diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 228aaeed5..d514b9a5d 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -52,10 +52,10 @@ not shrink with compiler work — they are the shape of the target. | `EQ2001` | A C# construct with no runtime equivalent in the browser (typed-reference intrinsics: `__makeref`, `__reftype`, `__refvalue`, `stackalloc`, pointers). | Restructure without it. | | `EQ2002` | `goto`. | Restructure with loops and conditionals, or a labelled `break`/`continue` (those DO translate). | | `EQ2003` | `new T()` on a type parameter — generic arguments are erased at runtime in JavaScript, so the concrete type is unknown. | Pass a factory (`Func`) or the constructed value in. | -| `EQ2004` | A member declared outside the compilation that no strategy translates, so nothing emits it: a BCL member with no JavaScript form (`Convert.FromBase64String`, `DateOnly.ParseExact`), written qualified or reached bare through `using static` (`WriteLine` under `using static System.Console`), an extension method whose declaring class is not part of this compilation, or a `System.Range` value stored rather than indexed with. | Use a member that translates (index with the range directly, `text[a..b]`), move the call behind a `[ServerAction]`, or bring the declaring source into the compilation. | +| `EQ2004` | A member declared outside the compilation that no strategy translates, so nothing emits it: a BCL member with no JavaScript form (`Convert.FromBase64String`, `DateOnly.ParseExact`), written qualified or reached bare through `using static` (`WriteLine` under `using static System.Console`), an extension method whose declaring class is not part of this compilation, or a `System.Range` value stored rather than indexed with, or handed to an indexer that takes the `Range` itself (`this[Range r]`). | Use a member that translates (index with the range directly, `text[a..b]`, or give the type a `Length` and a `Slice(int start, int length)`, which a range calls), move the call behind a `[ServerAction]`, or bring the declaring source into the compilation. | | `EQ2005` | An infinite iterator. Iterators are MATERIALISED into an array, so the loop would run forever instead of yielding lazily. | Give the loop an end (a bound, a `yield break`), or take what you need inside the method. | | `EQ2006` | The member does not bind in the semantic model, so any translation would be a guess. | Either the code does not compile, or eqc is missing references / generated sources. Never guessed — see [Compiler](https://github.com/equantic/equantic-ui/wiki/Compiler). | -| `EQ2007` | A comparer that changes what a collection considers equal or in order: a collection expression's `with(…)` argument beyond a capacity hint, a comparer handed to a collection's constructor (`new Dictionary(StringComparer.OrdinalIgnoreCase)`) or to `ToHashSet`. The runtime's collections find an element by its type's default equality, and take no other comparer. The default comparers and `StringComparer.Ordinal` ask for exactly that, and pass. A sort or a binary search (`List.Sort`, `Array.Sort`, `BinarySearch`) takes more: the type's default comparer, a `Comparison`, a `StringComparer`'s six, `Comparer.Create` and a comparer the app writes cross, and only a comparer no twin carries (a `StringComparer` made for a culture, a comparer a library compiled) is refused. | Drop the comparer, or normalize the keys yourself (lower-case them on the way in and on every lookup). For a sort, pass a `Comparison`. | +| `EQ2007` | A comparer that changes what a collection considers equal or in order: a collection expression's `with(…)` argument beyond a capacity hint, a comparer handed to a collection's constructor (`new Dictionary(StringComparer.OrdinalIgnoreCase)`) or to a LINQ operator that finds its keys or its elements by it (`ToHashSet`, `ToDictionary`, `ToLookup`, `GroupBy`, `Distinct`). The runtime's collections find an element by its type's default equality, and take no other comparer. The default comparers and `StringComparer.Ordinal` ask for exactly that, and pass. A sort or a binary search (`List.Sort`, `Array.Sort`, `BinarySearch`) takes more: the type's default comparer, a `Comparison`, a `StringComparer`'s six, `Comparer.Create` and a comparer the app writes cross, and only a comparer no twin carries (a `StringComparer` made for a culture, a comparer a library compiled) is refused. | Drop the comparer, or normalize the keys yourself (lower-case them on the way in and on every lookup). For a sort, pass a `Comparison`. | | `EQ2008` | Query syntax using `join`, `let`, a second `from`, or `into` — its C# translation runs through compiler-generated transparent identifiers. Also the fenced initializer forms. | Rewrite in method syntax, where every operator is supported. | | `EQ2009` | A component is declared more than once in one file (partial declarations). eqc emits one module per declaration and cannot merge them. | Combine the members into a single declaration. | | `EQ2011` | A member that would take a name the runtime's component already uses. C# keeps a primary-constructor parameter, a field and a property apart, and spells them differently from the runtime's camelCase; JavaScript folds all of it onto `this.`, so the assignment replaces the runtime's member and the page fails only in the browser. The list is read from the live runtime (`core/runtime-members.spec.ts` → `Resources/runtime-members.txt`), never typed. | Rename the member. An `override` is exempt — a component's `Build` is meant to replace the runtime's `build`, and replacing a base member is what one is for — but a plain method is not: `public void Mount() { }` overrides nothing and still emits `mount()` over the runtime's own, so the component never mounts. | diff --git a/docs/LEDGER.md b/docs/LEDGER.md index f24cb9d7d..b9f20dcb3 100644 --- a/docs/LEDGER.md +++ b/docs/LEDGER.md @@ -2217,6 +2217,36 @@ record of a release, the wiki's Upgrading page is the distillate. class asked the browser for a module named by its simple name, which nothing writes any more, and one inside an owner that never crosses has no route and cannot be mapped. Proposed and archived through OpenSpec (`openspec/specs/transpiler-classes`). +- **2026-10-07 · A collection and an indexer answer as .NET's do**: a list built with an argument and + an initializer took one or the other, `new List(10) { 1, 2 }` empty and + `new List(source) { 3 }` a second declarator + ([#564](https://github.com/eQuantic/equantic-ui/issues/564)); + `ToDictionary` refused every comparer, `StringComparer.Ordinal` included, `GroupBy` too, `ToLookup` + called one as an element selector and `Distinct` dropped whatever it was handed + ([#578](https://github.com/eQuantic/equantic-ui/issues/578)); a range over a type with a + `Slice(int start, int length)` called JavaScript's `slice(start, end)` on the twin, three elements + where .NET slices two ([#585](https://github.com/eQuantic/equantic-ui/issues/585)); a twin read + through `IReadOnlyList` or `IList` read a subscript and a `length`, null where .NET says 19 + ([#586](https://github.com/eQuantic/equantic-ui/issues/586)); and `ICollection`'s `Add` and + `Clear` were an array's `push` and `splice`, which a set, a linked list and a dictionary's pairs + lack ([#593](https://github.com/eQuantic/equantic-ui/issues/593)). A list is one array, what its + constructor copies and then its elements; a LINQ operator's comparer passes the collection fence + (EQ2007), dropped where it asks for the default; a range over a twin calls its `Slice` with a + length, in the order C# reads its parts, and one handed to an indexer over `Range` fails the build + (EQ2004); a list's face reads, writes and counts through the runtime's `item`, `setItem` and + `count`, which answer an array and a twin alike, a twin counted by its own `Count` before a `Length` + beside it; and `ICollection`'s `Add` and `Clear` reach the runtime's `add` and `clear`. The + review before the pull request opened found a complex element initializer written as an empty object + and an indexer over `Range?` taking the slice path, Copilot's first round a read through a null face + answering 0 and GroupBy's named arguments read by their position, and its second a list's capacity + dropped unread, which never ran its call and built a list where a negative one throws, all fixed; a + `T[]` behind the faces grows where .NET refuses, as it did before, + [#711](https://github.com/eQuantic/equantic-ui/issues/711). 97 of the 115 new conformance cases fail + on main, and 14 of the 16 new Compiler cases; all of them pass here. One gap of the null-argument + baseline closes with it, ToLookup's null comparer, 402 entries to 401. The served runtime grows 686 + gzipped bytes over what main serves, the twins' reads through a list's face and the runtime's + helpers behind it. Proposed and archived through OpenSpec (`openspec/specs/transpiler-bcl`, + `transpiler-sequences` and `transpiler-expressions`). ## Retired documents diff --git a/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/.openspec.yaml b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/.openspec.yaml new file mode 100644 index 000000000..a26662f7a --- /dev/null +++ b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/proposal.md b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/proposal.md new file mode 100644 index 000000000..2da65fec8 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/proposal.md @@ -0,0 +1,78 @@ +# Proposal + +Closes #564, #578, #585, #586 and #593, sub-issues of #565 (the transpiler's fences hold on every +path, continued). Each is a collection or an indexer that answers in the browser differently from +.NET, with a green build. + +## Why + +Measured on main (fc8f0fdc), both sides executed by the conformance suite: + +- A list built with a constructor argument and a collection initializer takes one or the other + (#564): `new List(10) { 1, 2 }` is `[]`, and `new List(source) { 3 }` is + `source, [3]`, a second declarator in a declaration and a second argument in a call. The + target-typed form keeps the elements and drops the source, and `List c = new(source)` is empty. +- `ToDictionary` refuses every comparer, `StringComparer.Ordinal` included, which the comparer fence + every collection's construction passes lets through (#578). `GroupBy` refuses every one too, + `ToLookup(k, comparer)` calls the comparer as an element selector, and `Distinct` drops whatever + comparer it is handed, so `Distinct(StringComparer.OrdinalIgnoreCase)` keeps "a" and "A". +- A range over a type with a `Length` and a `Slice(int start, int length)` calls JavaScript's + `slice(start, end)` on the twin, whose `slice` is that `Slice` (#585): `new Strip()[1..3]` is two + elements in .NET and three in the browser, and `[3..1]` slices where .NET's `Slice(3, -2)` throws. +- A twin read through `IReadOnlyList` or `IList` reads a JavaScript subscript and a `length`, + which a twin does not answer (#586): `r[0] + r[2] + r.Count` over a twin is 19 in .NET and null in + the browser. +- `ICollection`'s `Add` and `Clear` are an array's `push` and `splice`, which a `HashSet`, a + `LinkedList`, a `SortedSet` and a dictionary's pairs behind the interface lack (#593): `c.Add(2)` + throws `c.push is not a function`. + +## What Changes + +- A `List` construction is one array, as C# builds it: what its constructor copies, spread, then + each element of its collection initializer in order; an object initializer is applied to it once + built. The explicit and the target-typed forms share it. +- `ToDictionary`, `ToLookup`, `GroupBy` and `Distinct` hand their comparer to the fence a collection's + construction passes (EQ2007): one that asks for the key type's own equality, ordinal strings or null + is dropped, since the lowering already finds the keys that way, and any other fails the build. +- A range over a type eqc writes calls the member the bound tree names: its `Slice` with the start and + the length C# computes, its receiver evaluated once, then its endpoints in order, then its `Length` or + `Count`, read only where an endpoint counts from the end or the end is open. A range handed to an + indexer that takes the `Range` itself fails the build (EQ2004), since a System.Range value has no + translation. A range over a string, an array or a list keeps JavaScript's `slice`. +- An access through a list's face (`IList`, `IReadOnlyList`, `IList`) reads and writes through + the runtime's `$eq.collections.item` and `setItem`, which answer an array's subscript and a twin's + `item` and `setItem` alike, from the end too, and its `Count` through `$eq.collections.count`, which + reads a twin's own `count` before a `size` or a `length` it may also declare. A receiver typed as an + array or a list keeps its subscript. +- `ICollection`'s own `Add` and `Clear` go through the runtime's `$eq.collections.add` and `clear`, + which add and empty as the collection behind the interface does, a member's collection initializer + included. +- No break for an app: its C# compiles to the new forms. The public surface of `eQuantic.UI.Compiler` + gains four `Eq` constants and `RangeIndexerStrategy` moves to the IR (`ConvertIr` in place of + `Convert`); the developer surface does not move. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `transpiler-bcl`: a list built with an argument and an initializer, a list's face, and + `ICollection`'s `Add` and `Clear`. +- `transpiler-sequences`: a LINQ operator handed a comparer. +- `transpiler-expressions`: a range over a type with a `Slice`. + +## Impact + +- eqc: `ObjectCreationStrategy` (the list), `LinqTableStrategy`, `GroupByStrategy`, `DistinctStrategy` + and the collection fence (comparers), `RangeIndexerStrategy` and `RangeExpressionStrategy` (ranges), + `Indexer`, `Place`, `CountSpelling` and `IndexFromEndStrategy` (the list's face), and + `ListMethodStrategy` and `ObjectInitializer` (`ICollection`). +- The runtime: `add`, `clear`, `item` and `setItem` in `utils/collections.ts`, exposed on + `$eq.collections`, and `count` reading a twin's `count`. The runtime's transpiled components + regenerate where they read a list through its face. +- Tests: conformance cases for each issue, both sides executed, the twins through the module graph; + Compiler tests for the comparers EQ2007 refuses and the range EQ2004 refuses; runtime specs. +- Docs: docs/DIAGNOSTICS.md for EQ2004 and EQ2007, and one docs/LEDGER.md line. diff --git a/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-bcl/spec.md b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-bcl/spec.md new file mode 100644 index 000000000..d4dbadc35 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-bcl/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: A list built with an argument and an initializer holds both + +A `List` constructed with an argument and a collection initializer SHALL hold what .NET's holds: +a copy of the source, never the source itself, or nothing for a capacity, then each element of the +initializer in order, in the explicit and the target-typed forms alike, the source evaluated before the +elements. A target-typed list built from a source with no initializer SHALL be a copy of the source. + +#### Scenario: A capacity and an initializer + +- **WHEN** browser-side code builds `new List(10) { 1, 2 }` and joins it +- **THEN** the answer is `1,2|2` with its count, as in .NET + +#### Scenario: A source and an initializer + +- **WHEN** browser-side code builds `var b = new List(source) { 3 }` over `source = new List { 1 }`, then runs `b.Add(4)` +- **THEN** `b` holds `1,3,4` and `source` still holds `1`, as in .NET + +#### Scenario: A target-typed copy + +- **WHEN** browser-side code builds `List c = new(source)` over `source = new List { 1 }` and runs `c.Add(9)` +- **THEN** `c` holds `1,9` and `source` one element, as in .NET + +#### Scenario: A capacity is evaluated first + +- **WHEN** browser-side code builds `new List(Cap()) { At("a", 1), At("b", 2) }`, each call logging its step, and `new List(-1)` +- **THEN** the log is `cab` before the list holds `1,2`, and the second throws an ArgumentOutOfRangeException, "Non-negative number required. (Parameter 'capacity')", as in .NET + +### Requirement: A list's face reads and writes the list behind it + +An element read or written through the indexer of `IList`, `IReadOnlyList` or `IList`, and a +`Count` read through such a face, SHALL answer for whatever the face holds when it runs: an array's or +a list's elements, and a twin's own indexer and count. A read, a write, a compound, a step, a +null-conditional read, an object initializer's entry and a key from the end SHALL each reach it, the +receiver and the key evaluated once and in C#'s order, and a write SHALL answer the value it wrote. + +#### Scenario: A twin behind IReadOnlyList + +- **WHEN** browser-side code computes `r[0] + r[2] + r.Count` over `IReadOnlyList r` holding a twin of `{ 7, 8, 9 }` +- **THEN** the answer is `19`, as in .NET + +#### Scenario: A twin behind IList + +- **WHEN** browser-side code runs `l[0] = 5; l[1] += 2; l[2]++;` over `IList l` holding a twin of `{ 1, 2, 3 }` +- **THEN** `l[0]`, `l[1]` and `l[2]` are `5`, `4` and `4`, as in .NET + +#### Scenario: A twin with a Length beside its Count + +- **WHEN** browser-side code reads `p.Count` and `p[^1]` over `IReadOnlyList p` holding a twin of `{ 3, 4 }` that also declares `Length => 12.5` and `Size => 7` +- **THEN** the answers are `2` and `4`, as in .NET + +#### Scenario: A face that holds null + +- **WHEN** browser-side code reads `r.Count`, `r[0]` and `r?.Count` over `IReadOnlyList r = null` +- **THEN** the first two throw a NullReferenceException, "Object reference not set to an instance of an object.", and the null-conditional answers null, as in .NET + +### Requirement: ICollection's Add and Clear answer for the collection behind it + +`ICollection.Add` and `ICollection.Clear` SHALL add and empty as the collection the interface +holds when the call runs does in .NET: a list appends, a set adds only a value it does not hold, a +linked list adds last, a dictionary adds the pair and refuses a key already there with .NET's message, +and a twin of the app's own runs its own `Add` and `Clear`. A collection initializer that adds to a +member typed as the interface SHALL add the same way. + +#### Scenario: A set behind ICollection + +- **WHEN** browser-side code runs `c.Add(2); c.Add(1);` over `ICollection c = new HashSet { 1 }` and reads `c.Count` +- **THEN** the count is `2`, as in .NET, where the call threw `c.push is not a function` + +#### Scenario: A dictionary's pairs behind ICollection + +- **WHEN** browser-side code adds the pair `("a", 2)` to `ICollection>` holding a dictionary with the key `a` +- **THEN** it throws `An item with the same key has already been added. Key: a`, as in .NET diff --git a/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-expressions/spec.md b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-expressions/spec.md new file mode 100644 index 000000000..6dd143246 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-expressions/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: A range over a type with a Slice calls its Slice + +A range used as an index over a type eqc writes, with a `Length` or a `Count` and a +`Slice(int start, int length)`, SHALL call that `Slice` as C# lowers the range: with the start and the +length computed from the endpoints and the count, the receiver evaluated once, then the endpoints in +the order they are written, then the count, read only where an endpoint counts from the end or the end +is left open. A range handed to an indexer that takes the `Range` itself SHALL fail the build with +EQ2004, since a System.Range value has no translation. A range over a string, an array or a list SHALL +keep answering as it does. + +#### Scenario: A range over a type with Slice + +- **WHEN** browser-side code computes `new Strip()[1..3]` over a type whose `Slice(start, length)` copies five elements +- **THEN** the slice holds 2 elements, `2,3`, as in .NET, where `slice(1, 3)` held three + +#### Scenario: The order a range is read in + +- **WHEN** browser-side code computes `Strip.R(s)[^Strip.At("A", 3)..^Strip.At("B", 1)]`, each call logging its step and `Length` logging `L` +- **THEN** the log is `RABL` before the slice, as in .NET + +#### Scenario: An indexer over Range + +- **WHEN** a component computes `new Ranged()[1..3]` over a type that declares `this[Range r]` +- **THEN** the build fails with EQ2004 at the range diff --git a/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-sequences/spec.md b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-sequences/spec.md new file mode 100644 index 000000000..6ab46d744 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/specs/transpiler-sequences/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: A LINQ operator takes a comparer the collection fence passes + +A comparer handed to `ToDictionary`, `ToLookup`, `GroupBy`, `Distinct` or `ToHashSet` SHALL pass the +fence a collection's construction passes: one that asks for the key type's default equality +(`EqualityComparer.Default`, a null comparer, or `StringComparer.Ordinal` for a string) SHALL answer +as the operator does without it, its selectors in their parameters whether named or not, and any other +SHALL fail the build with EQ2007, never be dropped or called as a selector. + +#### Scenario: An ordinal dictionary + +- **WHEN** browser-side code computes `new[] { "a", "bb" }.ToDictionary(w => w, StringComparer.Ordinal)` and reads its `"bb"` entry and its count +- **THEN** the answer is `bb|2`, as in .NET + +#### Scenario: A lookup with a comparer + +- **WHEN** browser-side code computes `new[] { "a", "bb", "cc" }.ToLookup(w => w.Length, EqualityComparer.Default)` and joins its group of 2 +- **THEN** the answer is `bb,cc`, as in .NET + +#### Scenario: A comparer that changes equality + +- **WHEN** a component computes `words.Distinct(StringComparer.OrdinalIgnoreCase)` +- **THEN** the build fails with EQ2007 at the comparer + +#### Scenario: Arguments named out of order + +- **WHEN** browser-side code computes `new[] { "a", "bb", "cc" }.GroupBy(comparer: EqualityComparer.Default, keySelector: w => w.Length)` and joins each key with its count +- **THEN** the answer is `1:1,2:2`, as in .NET diff --git a/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/tasks.md b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/tasks.md new file mode 100644 index 000000000..d940cc7ca --- /dev/null +++ b/openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does/tasks.md @@ -0,0 +1,56 @@ +# Tasks + +## 1. A list built with an argument and an initializer (#564) + +- [x] 1.1 One array for a `List` construction, the explicit and the target-typed forms alike: the + source copied or a capacity ignored, then the initializer's elements, an object initializer applied + once built. Verified by conformance cases (a capacity, a source, the target-typed forms, an argument + position, a set, a dictionary and a string as the source, the order), failing on main and green here + +## 2. A LINQ operator's comparer (#578) + +- [x] 2.1 `ToDictionary` and `ToLookup` judge their key comparer by the collection fence and take the + shape of the arguments left, named ones included; `GroupBy` and `Distinct` judge theirs by the same + fence. Verified by conformance cases for each operator and shape, failing on main and green here, and + by Compiler tests for the comparers EQ2007 refuses and the ones it passes + +## 3. A range over a type with a Slice (#585) + +- [x] 3.1 `RangeIndexerStrategy` on the IR, reading the member the bound tree names: a twin's `Slice` + with the start and the length C# computes, in .NET's order, and EQ2004 for an indexer over `Range`. + Verified by conformance cases through the module graph (the row of #585, from the end, open ends, a + zero from the end, named endpoints, the order measured on .NET, a reversed range, a type that slices + into itself, a string, an array and a list), failing on main and green here, and a Compiler test for + the refusal + +## 4. A list's face (#586) + +- [x] 4.1 The runtime's `item` and `setItem`, and `count` reading a twin's own `count` before a `size` or + a `length` beside it; the `Place` of a list face's indexer reads and writes through them, from the end + too, and a `Count` through the face counts through the runtime. Verified by conformance cases through + the module graph (the row of #586, from the end, a parameter, a write, a compound, a step, the order of + a write and of a compound, a null-conditional read, an object initializer's entry, an array and a list + behind the faces, a twin with a `Length` and a `Size` beside its `Count`), failing on main and green + here, and by runtime specs + +## 5. ICollection's Add and Clear (#593) + +- [x] 5.1 The runtime's `add` and `clear`, and the lowering of `ICollection`'s own `Add` and `Clear` + through them, a member's collection initializer included. Verified by conformance cases (a set, a + linked list, a sorted set, a dictionary's pairs, a sorted dictionary and list, a list and a set + behind their faces, a class of the app's own, a member's initializer), failing on main and green + here, and by runtime specs + +## 6. The real thing + +- [x] 6.1 Regenerate the runtime's transpiled components (`EQ_UPDATE_TRANSPILED=1`) and run the Runtime + suite (`-t:TestRuntime`), the Server suite (the served runtime's budget), the Web suite, the Compiler + suite and the Conformance suite + +## 7. Documentation and archive + +- [x] 7.1 docs/DIAGNOSTICS.md for EQ2004 (a range handed to an indexer over `Range`) and EQ2007 (a LINQ + operator's comparer), the wiki's Supported Features, Compiler and Diagnostics pages in English and + Portuguese, on the wiki branch of the same name, and one docs/LEDGER.md line citing the five issues + with the A/B counts +- [x] 7.2 `openspec archive a-collection-and-an-indexer-answer-as-dotnet-does --yes` diff --git a/openspec/specs/transpiler-bcl/spec.md b/openspec/specs/transpiler-bcl/spec.md index f3de7d6b7..2b92fded2 100644 --- a/openspec/specs/transpiler-bcl/spec.md +++ b/openspec/specs/transpiler-bcl/spec.md @@ -687,3 +687,76 @@ browser's string can hold SHALL be .NET's `OutOfMemoryException`, the builder un - **WHEN** an append makes a text longer than the engine's longest string - **THEN** it throws .NET's `OutOfMemoryException` and the builder is unchanged, where JavaScript's `RangeError` escaped every `catch (OutOfMemoryException)` + +### Requirement: A list built with an argument and an initializer holds both + +A `List` constructed with an argument and a collection initializer SHALL hold what .NET's holds: +a copy of the source, never the source itself, or nothing for a capacity, then each element of the +initializer in order, in the explicit and the target-typed forms alike, the source evaluated before the +elements. A target-typed list built from a source with no initializer SHALL be a copy of the source. + +#### Scenario: A capacity and an initializer + +- **WHEN** browser-side code builds `new List(10) { 1, 2 }` and joins it +- **THEN** the answer is `1,2|2` with its count, as in .NET + +#### Scenario: A source and an initializer + +- **WHEN** browser-side code builds `var b = new List(source) { 3 }` over `source = new List { 1 }`, then runs `b.Add(4)` +- **THEN** `b` holds `1,3,4` and `source` still holds `1`, as in .NET + +#### Scenario: A target-typed copy + +- **WHEN** browser-side code builds `List c = new(source)` over `source = new List { 1 }` and runs `c.Add(9)` +- **THEN** `c` holds `1,9` and `source` one element, as in .NET + +#### Scenario: A capacity is evaluated first + +- **WHEN** browser-side code builds `new List(Cap()) { At("a", 1), At("b", 2) }`, each call logging its step, and `new List(-1)` +- **THEN** the log is `cab` before the list holds `1,2`, and the second throws an ArgumentOutOfRangeException, "Non-negative number required. (Parameter 'capacity')", as in .NET + +### Requirement: A list's face reads and writes the list behind it + +An element read or written through the indexer of `IList`, `IReadOnlyList` or `IList`, and a +`Count` read through such a face, SHALL answer for whatever the face holds when it runs: an array's or +a list's elements, and a twin's own indexer and count. A read, a write, a compound, a step, a +null-conditional read, an object initializer's entry and a key from the end SHALL each reach it, the +receiver and the key evaluated once and in C#'s order, and a write SHALL answer the value it wrote. + +#### Scenario: A twin behind IReadOnlyList + +- **WHEN** browser-side code computes `r[0] + r[2] + r.Count` over `IReadOnlyList r` holding a twin of `{ 7, 8, 9 }` +- **THEN** the answer is `19`, as in .NET + +#### Scenario: A twin behind IList + +- **WHEN** browser-side code runs `l[0] = 5; l[1] += 2; l[2]++;` over `IList l` holding a twin of `{ 1, 2, 3 }` +- **THEN** `l[0]`, `l[1]` and `l[2]` are `5`, `4` and `4`, as in .NET + +#### Scenario: A twin with a Length beside its Count + +- **WHEN** browser-side code reads `p.Count` and `p[^1]` over `IReadOnlyList p` holding a twin of `{ 3, 4 }` that also declares `Length => 12.5` and `Size => 7` +- **THEN** the answers are `2` and `4`, as in .NET + +#### Scenario: A face that holds null + +- **WHEN** browser-side code reads `r.Count`, `r[0]` and `r?.Count` over `IReadOnlyList r = null` +- **THEN** the first two throw a NullReferenceException, "Object reference not set to an instance of an object.", and the null-conditional answers null, as in .NET + +### Requirement: ICollection's Add and Clear answer for the collection behind it + +`ICollection.Add` and `ICollection.Clear` SHALL add and empty as the collection the interface +holds when the call runs does in .NET: a list appends, a set adds only a value it does not hold, a +linked list adds last, a dictionary adds the pair and refuses a key already there with .NET's message, +and a twin of the app's own runs its own `Add` and `Clear`. A collection initializer that adds to a +member typed as the interface SHALL add the same way. + +#### Scenario: A set behind ICollection + +- **WHEN** browser-side code runs `c.Add(2); c.Add(1);` over `ICollection c = new HashSet { 1 }` and reads `c.Count` +- **THEN** the count is `2`, as in .NET, where the call threw `c.push is not a function` + +#### Scenario: A dictionary's pairs behind ICollection + +- **WHEN** browser-side code adds the pair `("a", 2)` to `ICollection>` holding a dictionary with the key `a` +- **THEN** it throws `An item with the same key has already been added. Key: a`, as in .NET diff --git a/openspec/specs/transpiler-expressions/spec.md b/openspec/specs/transpiler-expressions/spec.md index b9353dddf..0bf244d0b 100644 --- a/openspec/specs/transpiler-expressions/spec.md +++ b/openspec/specs/transpiler-expressions/spec.md @@ -390,3 +390,28 @@ be bound. - **WHEN** `Func any = list.Any` is made over the BCL's `Enumerable.Any` - **THEN** the build fails with EQ2004 + +### Requirement: A range over a type with a Slice calls its Slice + +A range used as an index over a type eqc writes, with a `Length` or a `Count` and a +`Slice(int start, int length)`, SHALL call that `Slice` as C# lowers the range: with the start and the +length computed from the endpoints and the count, the receiver evaluated once, then the endpoints in +the order they are written, then the count, read only where an endpoint counts from the end or the end +is left open. A range handed to an indexer that takes the `Range` itself SHALL fail the build with +EQ2004, since a System.Range value has no translation. A range over a string, an array or a list SHALL +keep answering as it does. + +#### Scenario: A range over a type with Slice + +- **WHEN** browser-side code computes `new Strip()[1..3]` over a type whose `Slice(start, length)` copies five elements +- **THEN** the slice holds 2 elements, `2,3`, as in .NET, where `slice(1, 3)` held three + +#### Scenario: The order a range is read in + +- **WHEN** browser-side code computes `Strip.R(s)[^Strip.At("A", 3)..^Strip.At("B", 1)]`, each call logging its step and `Length` logging `L` +- **THEN** the log is `RABL` before the slice, as in .NET + +#### Scenario: An indexer over Range + +- **WHEN** a component computes `new Ranged()[1..3]` over a type that declares `this[Range r]` +- **THEN** the build fails with EQ2004 at the range diff --git a/openspec/specs/transpiler-sequences/spec.md b/openspec/specs/transpiler-sequences/spec.md index 2eb2590c5..460dd154c 100644 --- a/openspec/specs/transpiler-sequences/spec.md +++ b/openspec/specs/transpiler-sequences/spec.md @@ -103,3 +103,31 @@ The source of a LINQ call and each of its arguments SHALL be evaluated once, in - **WHEN** a shared component computes `Items().Average()`, where `Items()` answers a `List` - **THEN** the module eqc writes passes the runtime's strict tsc: the source is bound bare, typed by the list, so the callback the lowering hands it is typed too + +### Requirement: A LINQ operator takes a comparer the collection fence passes + +A comparer handed to `ToDictionary`, `ToLookup`, `GroupBy`, `Distinct` or `ToHashSet` SHALL pass the +fence a collection's construction passes: one that asks for the key type's default equality +(`EqualityComparer.Default`, a null comparer, or `StringComparer.Ordinal` for a string) SHALL answer +as the operator does without it, its selectors in their parameters whether named or not, and any other +SHALL fail the build with EQ2007, never be dropped or called as a selector. + +#### Scenario: An ordinal dictionary + +- **WHEN** browser-side code computes `new[] { "a", "bb" }.ToDictionary(w => w, StringComparer.Ordinal)` and reads its `"bb"` entry and its count +- **THEN** the answer is `bb|2`, as in .NET + +#### Scenario: A lookup with a comparer + +- **WHEN** browser-side code computes `new[] { "a", "bb", "cc" }.ToLookup(w => w.Length, EqualityComparer.Default)` and joins its group of 2 +- **THEN** the answer is `bb,cc`, as in .NET + +#### Scenario: A comparer that changes equality + +- **WHEN** a component computes `words.Distinct(StringComparer.OrdinalIgnoreCase)` +- **THEN** the build fails with EQ2007 at the comparer + +#### Scenario: Arguments named out of order + +- **WHEN** browser-side code computes `new[] { "a", "bb", "cc" }.GroupBy(comparer: EqualityComparer.Default, keySelector: w => w.Length)` and joins each key with its count +- **THEN** the answer is `1:1,2:2`, as in .NET diff --git a/src/eQuantic.UI.Compiler/CodeGen/Eq.cs b/src/eQuantic.UI.Compiler/CodeGen/Eq.cs index 50ad38512..081b60937 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Eq.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Eq.cs @@ -424,6 +424,27 @@ public static class Eq /// finds equal to the value, and answers whether there was one (#400). public const string ListRemove = "$eq.collections.remove"; + /// ICollection<T>.Add, as the collection the interface holds when the call runs + /// adds: an array appends, a set adds a value it lacks, a linked list adds last, a dictionary adds the + /// pair, and a twin calls its own add (#593). + public const string CollectionAdd = "$eq.collections.add"; + + /// ICollection<T>.Clear, as the collection the interface holds when the call runs + /// empties itself (#593). + public const string CollectionClear = "$eq.collections.clear"; + + /// A list face's indexer read (IList<T>, IReadOnlyList<T>), for the + /// list the face holds when it runs: an array's subscript, a twin's item (#586). + public const string ListItem = "$eq.collections.item"; + + /// A list face's indexer write, an array's subscript or a twin's setItem, which answers + /// the value written, as C#'s assignment does (#586). + public const string ListSetItem = "$eq.collections.setItem"; + + /// new List<T>(capacity)'s check, which refuses a negative capacity in .NET's words: + /// the capacity is evaluated where C# evaluates it, before the list's elements, and sizes nothing. + public const string ListCapacity = "$eq.collections.listCapacity"; + /// EqualityComparer<T>.Default for a type compared by reference or by its own /// Equals: identity, NaN equal to NaN, and a twin's own equals. public const string SameItem = "$eq.collections.sameItem"; diff --git a/src/eQuantic.UI.Compiler/CodeGen/Extensions/CollectionComparerExtensions.cs b/src/eQuantic.UI.Compiler/CodeGen/Extensions/CollectionComparerExtensions.cs index db7bfbfe3..9b41b3874 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Extensions/CollectionComparerExtensions.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Extensions/CollectionComparerExtensions.cs @@ -52,16 +52,21 @@ public static void ReportUntranslatableComparer(this BaseObjectCreationExpressio } /// - /// Reports EQ2007 at an equality comparer handed to a call that builds a collection from it - /// (ToHashSet(comparer)), where it has no JavaScript translation, and answers whether it - /// reported. The comparers that ask for the default pass, as they do for a constructor. + /// Reports EQ2007 at an equality comparer handed to a LINQ operator that finds its elements or its + /// keys by it (ToHashSet, Distinct, ToDictionary, ToLookup, GroupBy), + /// where it has no JavaScript translation, and answers whether it reported. The comparers that ask + /// for the default pass, as they do for a constructor, and the operator's lowering, which already + /// finds them as the default does, drops them: each operator once kept a stricter copy of this test + /// of its own, or none, and refused StringComparer.Ordinal or dropped + /// StringComparer.OrdinalIgnoreCase (#578). /// internal static bool RefusesAsUntranslatable(this IOperation comparer, string what, ConversionContext context) { if (AsksForTheDefault(comparer)) return false; context.Report(comparer.Syntax, ConversionSeverity.Error, "EQ2007", - $"'{what}' with a comparer has no JavaScript translation: the set it builds finds its elements as the " - + "element type's default comparer does, and takes no other. Normalize the elements yourself, or drop the comparer."); + $"'{what}' with a comparer has no JavaScript translation: what it builds finds its elements and its keys as " + + "their type's default comparer does, and takes no other. Normalize them yourself (in the key selector), " + + "or drop the comparer."); return true; } @@ -133,6 +138,11 @@ when IsNamed(home, "Comparer`1") && create.Arguments.Length == 1: internal static bool IsCollectionComparer(this ITypeSymbol? type) => type is INamedTypeSymbol named && (IsNamed(named, "IEqualityComparer`1") || IsNamed(named, "IComparer`1")); + /// Whether is an IEqualityComparer<T>: the parameter a + /// LINQ operator finds its keys or its elements by. + internal static bool IsEqualityComparer(this ITypeSymbol? type) => + type is INamedTypeSymbol named && IsNamed(named, "IEqualityComparer`1"); + /// /// Whether the comparer asks for nothing the lowering does not already do: null (the /// constructor's own default), EqualityComparer<T>.Default or diff --git a/src/eQuantic.UI.Compiler/CodeGen/Extensions/TypeSymbolExtensions.cs b/src/eQuantic.UI.Compiler/CodeGen/Extensions/TypeSymbolExtensions.cs index 5acb60cfe..10606ca8a 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Extensions/TypeSymbolExtensions.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Extensions/TypeSymbolExtensions.cs @@ -324,6 +324,16 @@ public static bool HasOpenCollectionShape(this ITypeSymbol? type) || def.StartsWith("System.Collections.Generic.HashSet"); } + /// + /// Whether is a LIST'S FACE: IList<T>, IReadOnlyList<T> + /// or the non-generic IList. An array and a list are arrays on this side and answer one through + /// a subscript and a length, but a twin of the app's own implements the face as readily, and + /// answers through its item, its setItem and its count (#586). + /// + internal static bool IsListFace(this ITypeSymbol? type) => + type?.OriginalDefinition.ToDisplayString() is "System.Collections.Generic.IList" + or "System.Collections.Generic.IReadOnlyList" or "System.Collections.IList"; + /// The element type of an array or IEnumerable<T>, or null. public static ITypeSymbol? GetEnumerableElementType(this ITypeSymbol? collectionType) { diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/CountSpelling.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/CountSpelling.cs index 4d3fd3410..6bf670f16 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/CountSpelling.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/CountSpelling.cs @@ -7,10 +7,10 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies; /// /// A Count read, as the browser's form of its receiver answers it: a dictionary's and a set's /// size, the runtime queue's, stack's, linked list's and sorted set's count, the helper -/// that reads either an array or a Set for a face both answer to (ICollection), the own -/// count of a type the app or a library it references declares, and an array's length -/// for .NET's own types, which the browser holds as one (a list, a lookup), and for a receiver the -/// model cannot type. +/// that reads an array, a Set or a twin for a face more than one of them answers to (ICollection, +/// IReadOnlyList), the own count of a type the app or a library it references declares, +/// and an array's length for .NET's own types, which the browser holds as one (a list, a +/// lookup), and for a receiver the model cannot type. /// /// ONE table, for a member access and a property pattern alike. Each kept its own, and a pattern /// counted a set by length while the member read beside it said size, so @@ -26,8 +26,9 @@ public static JsExpr Read(JsExpr receiver, ITypeSymbol? type, ConversionContext // A receiver typed only as a collection may be a Set at run time, whose count is `size`. // `.length` on one is undefined, and `undefined > 0` is false, so the header checkbox - // simply never noticed a selection. - if (type.HasOpenCollectionShape()) + // simply never noticed a selection. One typed as a list's face may be a twin of the app's own, + // whose count is its `count` (#586): an IReadOnlyList over one counted undefined. + if (type.HasOpenCollectionShape() || type.IsListFace()) { context.UsedHelpers.Add(Eq.Import); return JsExpr.Call(JsExpr.Identifier(Eq.Count), receiver); diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ElementAccessStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ElementAccessStrategy.cs index 59d9d6801..b60e75da5 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ElementAccessStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ElementAccessStrategy.cs @@ -36,7 +36,9 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) } // An INSTANCE indexer a twin carries (#427) reads through its getter, `receiver.item(keys)`: - // a subscript read a property named after the key, which no twin had. A DICTIONARY READ fails + // a subscript read a property named after the key, which no twin had. A list face's indexer + // (IList, IReadOnlyList) reads through the runtime, an array's subscript and a twin's + // getter alike, whichever the face holds (#586). A DICTIONARY READ fails // for a key that is not there, where the class's own `get` answers `undefined`, so the absence // would spread through the program instead of stopping it where .NET stops it. Only a READ: the // same syntax on the left of an assignment is how a key is ADDED. Both are a Place's read. diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/IndexFromEndStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/IndexFromEndStrategy.cs index 9b9b188c1..108cff140 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/IndexFromEndStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/IndexFromEndStrategy.cs @@ -13,7 +13,8 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies.Expressions; /// An indexer a twin carries is not here: ring[^1] over a type that counts its elements is its /// item at the count the bound tree names (), and a type that declares /// this[Index] takes the index itself. This read ring.length, which no twin has, and -/// wrote the bare index into setItem. +/// wrote the bare index into setItem. Nor is a list face's (IReadOnlyList<T>, +/// IList<T>), which counts and reads whichever list it holds through the runtime (#586). /// /// /// A ^n that is no array's, list's or string's index is a System.Index VALUE (Index i = @@ -34,7 +35,7 @@ public bool CanConvert(SyntaxNode node, ConversionContext context) // Handle element access with ^n index: array[^1]. A dictionary keyed by Index is not one: // `d[^1]` looks the key up (DictionaryEntry), where this counted back from a length a map - // does not have. Nor is an indexer a twin carries. + // does not have. Nor is an indexer a twin carries, or a list face's. if (node is ElementAccessExpressionSyntax elementAccess && DictionaryEntry.Of(elementAccess, context) is null && Indexer.LoweredAt(elementAccess, context) is null) diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/Indexer.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/Indexer.cs index 885bf0902..b2a65da4d 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/Indexer.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/Indexer.cs @@ -84,15 +84,26 @@ public static bool IsLowered(IPropertySymbol? indexer) || Types.StringBuilderStrategy.CarriesIndexer(indexer); } + /// + /// Whether an indexer is a list FACE's (IList<T>, IReadOnlyList<T>, + /// IList), which the interface reaches on whatever implements it when the access runs: an + /// array's subscript and a twin's item alike, through the runtime (#586). It was a subscript, + /// which an array answers and a twin does not, so a twin read through the face was undefined. + /// + public static bool IsListFace(IPropertySymbol? indexer) => + indexer is { IsIndexer: true, IsStatic: false } && indexer.ContainingType.IsListFace(); + /// The indexer this lowering carries that the bound tree binds an element access to, or /// null: an access written with its receiver, a null-conditional's binding or an object /// initializer's entry, and a from-the-end key over a type that counts its elements, which C# - /// binds to its this[int] (ring[^1] is ring[ring.Count - 1]). + /// binds to its this[int] (ring[^1] is ring[ring.Count - 1]). A twin's own + /// indexer, and a list face's, which the reads through the runtime. public static IPropertySymbol? LoweredAt(SyntaxNode access, ConversionContext context) => context.SemanticHelper.GetOperation(access) switch { - IPropertyReferenceOperation { Property: var property } when IsLowered(property) => property, - IImplicitIndexerReferenceOperation { IndexerSymbol: IPropertySymbol property } when IsLowered(property) => property, + IPropertyReferenceOperation { Property: var property } when IsLowered(property) || IsListFace(property) => property, + IImplicitIndexerReferenceOperation { IndexerSymbol: IPropertySymbol property } + when IsLowered(property) || IsListFace(property) => property, _ => null, }; } diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectCreationStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectCreationStrategy.cs index f050a8a08..7a5aabb01 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectCreationStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectCreationStrategy.cs @@ -273,9 +273,11 @@ private JsExpr ConvertExplicit(ObjectCreationExpressionSyntax creation, Conversi // `Add` (ObjectInitializer), over the list the constructor built: as the list's literal, every // element was the list's own, 1 where .NET holds "#1". if (ListAddedByAnExtension(creation.Initializer, createdType, context) is { } listed) - return ObjectInitializer.Apply( - arguments.Count == 0 || IsCapacityArgument(creation, context) ? JsExpr.Array([]) : JsExpr.Array([JsExpr.Spread(arguments[0])]), - listed, context); + return ObjectInitializer.Apply(Listed(creation, arguments, [], context), listed, context); + + // A list is the array it holds: what its constructor copies, then its initializer's elements. + if (IsList(createdType) || createdType is null or IErrorTypeSymbol && IsListTypeName(typeName)) + return ListLiteral(creation, arguments, context); // An object initializer that ADDS to what a member holds (`Items = { 1, 2 }`) or writes an // entry (`[k] = v`) is applied once the object exists (#462): a config object can only @@ -325,23 +327,6 @@ private JsExpr ConvertExplicit(ObjectCreationExpressionSyntax creation, Conversi } } - // Special handling for Collections (handle both short and fully-qualified names). - // - // The single argument means one of TWO opposite things, and only the semantic model can say - // which: `new List(capacity)` is an empty list sized ahead, `new List(source)` is a - // copy. Passing it straight through emitted the capacity AS the list — `var lines = 7;` - // followed by `lines.push(...)`, which throws — and nothing said so at build time. - if (typeName.StartsWith("List<") || typeName.Contains(".List<") - || typeName.StartsWith("IEnumerable<") || typeName.Contains(".IEnumerable<")) - { - if (arguments.Count == 0 || arguments is [JsObject { Properties.Count: 0 }]) return JsExpr.Array([]); - if (IsCapacityArgument(creation, context)) return JsExpr.Array([]); - // A copy of the source, not an alias of it; a dictionary spreads into its pairs. - if (creation.Initializer == null && creation.ArgumentList?.Arguments.Count == 1) - return JsExpr.Array([JsExpr.Spread(arguments[0])]); - return Spliced(arguments); - } - // `new string(c, count)` — the padding idiom (`new string(' ', indentWidth)`). There is no // String constructor in JS that means this; `repeat` is what it means. if (typeName == "string" && creation.ArgumentList?.Arguments.Count == 2) @@ -390,11 +375,83 @@ private JsExpr ConvertExplicit(ObjectCreationExpressionSyntax creation, Conversi ? initializer : null; + /// Whether is List<T>, which this side holds as the + /// array of its elements. + private static bool IsList(ITypeSymbol? type) => + type?.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.List"; + + /// A list's type as it is written, where no model can say what it binds to. + private static bool IsListTypeName(string typeName) => typeName.StartsWith("List<") || typeName.Contains(".List<"); + /// - /// Values that stand where ONE expression is read, as they always stood: the only one, or, where - /// a creation's arguments and its initializer meet in a collection's literal, all of them - /// spliced in as text. That second shape is a comma expression and was never right: kept as it - /// was, for a change that is about where lines map rather than what a collection holds. + /// A list as C# builds it, as one array: what its constructor copies (), then + /// each element of its initializer, in order, as its Add appends them (#564). A capacity + /// dropped the elements, [], and a source was joined to them with a comma, source, [3], + /// a second declarator in a declaration and a second argument in a call; the target-typed form kept + /// the elements and dropped the source, and with no initializer built an empty list where a copy + /// was asked for. + /// + private static JsExpr ListLiteral(BaseObjectCreationExpressionSyntax creation, IReadOnlyList arguments, + ConversionContext context) => creation.Initializer switch + { + // An object initializer assigns the list's members (`{ Capacity = 8 }`), once it is built. + { RawKind: (int)SyntaxKind.ObjectInitializerExpression, Expressions.Count: > 0 } members => + ObjectInitializer.Apply(Listed(creation, arguments, [], context), members, context), + null => Listed(creation, arguments, [], context), + var initializer => Listed(creation, arguments, + [.. initializer.Expressions.Select(element => context.Converter.ConvertIr(Added(element)))], context), + }; + + /// + /// What an initializer's element hands the list's Add: the element itself, or the one + /// expression of a complex element initializer, { 1 }, which C# hands Add as its + /// argument. Converted as an initializer, new List<int> { { 1 }, 2 } held an empty + /// object where .NET holds 1. + /// + private static ExpressionSyntax Added(ExpressionSyntax element) => + element is InitializerExpressionSyntax { RawKind: (int)SyntaxKind.ComplexElementInitializerExpression, Expressions: [var only] } + ? only + : element; + + /// + /// A list's array as its constructor builds it, then : a COPY of the + /// source, spread, never an alias of it (a dictionary spreads into its pairs, a string into its + /// chars), and nothing for no argument at all. The single argument means one of two opposite things, + /// and only the bound constructor can say which: new List<T>(capacity) is an empty list + /// sized ahead, where passing it through made the capacity the list, var lines = 7; followed + /// by lines.push(…). + /// + /// A capacity puts nothing in the list, but C# evaluates it before the list's elements and the + /// constructor refuses a negative one, so a capacity that is not a constant the list takes reaches + /// the runtime first (), and the array after it. It was dropped unread: + /// new List<int>(Capacity()) { Item() } never called Capacity, and a negative one + /// built a list where .NET throws. + /// + /// + private static JsExpr Listed(BaseObjectCreationExpressionSyntax creation, IReadOnlyList arguments, + IReadOnlyList elements, ConversionContext context) + { + if (arguments.Count != 1) return JsExpr.Array(elements); + if (!IsCapacityArgument(creation, context)) return JsExpr.Array([JsExpr.Spread(arguments[0]), .. elements]); + if (IsCapacityTaken(creation, context)) return JsExpr.Array(elements); + context.UsedHelpers.Add(Eq.Import); + return JsExpr.Template("({0}, {1})", [JsExpr.Call(JsExpr.Identifier(Eq.ListCapacity), arguments[0]), JsExpr.Array(elements)]); + } + + /// Whether a list's capacity is a constant its constructor takes: nothing to evaluate, + /// nothing to refuse. Read from the bound tree, or from an integer literal where no model binds. + private static bool IsCapacityTaken(BaseObjectCreationExpressionSyntax creation, ConversionContext context) => + creation.ArgumentList?.Arguments is [{ Expression: var capacity }] + && (context.SemanticHelper.GetOperation(capacity)?.ConstantValue is { HasValue: true, Value: int and >= 0 } + || capacity is LiteralExpressionSyntax { Token.Value: int and >= 0 }); + + /// A target-typed list's constructor arguments, converted in its parameters' order. + private static IReadOnlyList ListArguments(BaseObjectCreationExpressionSyntax creation, ConversionContext context) => + creation.ArgumentList is { Arguments.Count: > 0 } ? OrderedArguments(creation, context) : []; + + /// + /// Values that stand where ONE expression is read, as they always stood: the only one, or all of + /// them spliced in as text, a comma expression, for a node constructed with arguments. /// private static JsExpr Spliced(IReadOnlyList values) => values.Count == 1 ? values[0] : JsExpr.Opaque(string.Join(", ", values.Select(JsExprWriter.Write))); @@ -875,17 +932,19 @@ private JsExpr ConvertImplicit(ImplicitObjectCreationExpressionSyntax creation, // A list one of whose elements an extension adds, as the explicit form applies it. if (ListAddedByAnExtension(creation.Initializer, target, context) is { } listed) - return ObjectInitializer.Apply( - creation.ArgumentList is { Arguments.Count: 1 } && !IsCapacityArgument(creation, context) - ? JsExpr.Array([JsExpr.Spread(context.Converter.ConvertIr(creation.ArgumentList.Arguments[0].Expression))]) - : JsExpr.Array([]), - listed, context); + return ObjectInitializer.Apply(Listed(creation, ListArguments(creation, context), [], context), listed, context); + + // A list, as the explicit form builds one: what its constructor copies, then its elements. + if (IsList(target)) return ListLiteral(creation, ListArguments(creation, context), context); // `new() { … }` on a collection (or with no resolvable named target) → the initializer IS // the value. A dictionary target is DictionaryStrategy's. return context.Converter.ConvertIr(creation.Initializer); } + // A list with no initializer: the copy of its source, or an empty one. + if (IsList(ms?.ContainingType)) return ListLiteral(creation, ListArguments(creation, context), context); + // Collection target with no initializer → empty literal. if (typeDisplay.Contains("List<") || typeDisplay.Contains("IEnumerable<") || typeDisplay.Contains("Collection<") || typeDisplay.TrimEnd('?').EndsWith("[]")) diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectInitializer.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectInitializer.cs index d40227db6..cf0b37326 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectInitializer.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectInitializer.cs @@ -158,8 +158,8 @@ when key.ArgumentList.Arguments.Any(argument => /// One element added to the collection a member holds, as every call to the Add the bound /// tree binds it to lowers: an EXTENSION's through its home's static, with the collection first /// (), a dictionary's pair through the lowering every - /// call to its Add has, a set's value through its add, a list's or a collection - /// interface's through the array's push, as Items.Add(1) lowers + /// call to its Add has, a set's value through its add, a list's through the array's + /// push and a collection interface's through the runtime's, as Items.Add(1) lowers /// (), and the Add of a type whose twin eqc /// writes, or of a vocabulary node, through the method itself. Each collection's own lowering /// applies only to its own Add: an extension Add(this List<string>, int) was @@ -190,7 +190,7 @@ when key.ArgumentList.Arguments.Any(argument => or "System.Collections.Generic.SortedSet") return arguments.Count == 1 ? JsExpr.Call(JsExpr.Member(receiver, "add"), Part(arguments[0])) : null; if (Primitives.ListMethodStrategy.Lowers(add)) - return Primitives.ListMethodStrategy.Add(receiver, [.. arguments.Select(Part)]); + return Primitives.ListMethodStrategy.Add(add, receiver, [.. arguments.Select(Part)], context); if (declaring is not null && TwinCarries(declaring)) return JsExpr.Call(JsExpr.Member(receiver, add!.Name.ToCamelCase()), [.. arguments.Select(Part)]); diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeExpressionStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeExpressionStrategy.cs index b66a8d242..c759ac763 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeExpressionStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeExpressionStrategy.cs @@ -7,7 +7,8 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies.Expressions; /// A range as a VALUE — `var r = 1..5;` — rather than as an index. Indexing with one is a slice and /// belongs to ; a range that is stored, passed, or returned has /// nothing on the other side to receive it, and emitting a `{ start, end }` object for it only made -/// the failure silent. It is reported instead: slice at the point of use. +/// the failure silent. It is reported instead: slice at the point of use. A range handed to an +/// indexer over System.Range is the same value, and is refused here too (). /// public class RangeExpressionStrategy : IConversionStrategy { @@ -26,5 +27,17 @@ public string Convert(SyntaxNode node, ConversionContext context) return $"{{ start: {left}, end: {right} }}"; } + /// + /// Reports a range handed to an indexer that takes the Range itself (this[Range r]): it + /// would cross as a System.Range value, which has no translation here, into a twin whose indexer + /// reads members of a value this side never builds (#585). It was written as a call of a + /// slice the twin does not have, or of a Slice that takes a length beside it. + /// + internal static void RefuseAsAKey(RangeExpressionSyntax range, IPropertySymbol indexer, ConversionContext context) => + context.Report(range, ConversionSeverity.Error, "EQ2004", + $"`{range}` is handed to {indexer.ContainingType.Name}'s indexer over System.Range as a Range value, which has " + + "no JavaScript translation. Give the type a Length (or a Count) and a Slice(int start, int length) instead: " + + "a range over those becomes a call of that Slice."); + public int Priority => 10; } diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeIndexerStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeIndexerStrategy.cs index 4257a2bcb..e675b5ba9 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeIndexerStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/RangeIndexerStrategy.cs @@ -1,6 +1,9 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using eQuantic.UI.Compiler.CodeGen.Extensions; +using eQuantic.UI.Compiler.CodeGen.Ir; namespace eQuantic.UI.Compiler.CodeGen.Strategies.Expressions; @@ -20,8 +23,19 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies.Expressions; /// positive literal after ^ therefore goes through $eq.slice, which resolves both /// endpoints against the length exactly as Index.GetOffset does. /// +/// +/// A range over a type eqc writes goes through the member the bound tree names (#585). Its +/// Slice(int start, int length) takes a LENGTH where JavaScript's slice takes an end, and +/// the twin's slice is that member, so x[1..3] called Slice(1, 3), three elements +/// where .NET answers two: it is called as C# lowers the range, its receiver once, then the endpoints in +/// their order, then the Length or Count the bound tree names, read only where an endpoint +/// counts from the end or the end is left open, then Slice(start, end - start). An indexer that +/// takes the Range itself would be handed a System.Range value, which has no translation +/// (), and is refused where it was a call of a slice the twin +/// does not have. +/// /// -public class RangeIndexerStrategy : IConversionStrategy +public class RangeIndexerStrategy : IExpressionIrStrategy { // A dictionary keyed by Range is not a slice: `d[1..2]` looks the key up (DictionaryEntry), and // a Range key is a Range VALUE, which RangeExpressionStrategy fences (EQ2004), so the build says @@ -30,43 +44,118 @@ public bool CanConvert(SyntaxNode node, ConversionContext context) => node is ElementAccessExpressionSyntax { ArgumentList.Arguments: [{ Expression: RangeExpressionSyntax }] } access && DictionaryEntry.Of(access, context) is null; - public string Convert(SyntaxNode node, ConversionContext context) + public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) { var access = (ElementAccessExpressionSyntax)node; var range = (RangeExpressionSyntax)access.ArgumentList.Arguments[0].Expression; - var receiver = context.Converter.ConvertExpression(access.Expression); + switch (context.SemanticHelper.GetOperation(access)) + { + case IImplicitIndexerReferenceOperation { IndexerSymbol: IMethodSymbol slice, LengthSymbol: IPropertySymbol length } + when ObjectCreationStrategy.TwinIsWritten(slice.ContainingType): + return Sliced(access, range, slice, length, context); + // Over `Range?` too, which a range reaches by its implicit conversion. + case IPropertyReferenceOperation { Property: { IsIndexer: true, Parameters: [{ Type: var key }] } indexer } + when key.IsNamed("System.Range"): + RangeExpressionStrategy.RefuseAsAKey(range, indexer, context); + return JsExpr.Opaque(access.ToString()); + } + + var receiver = context.Converter.ConvertIr(access.Expression); var start = Endpoint(range.LeftOperand, context, isStart: true); var end = Endpoint(range.RightOperand, context, isStart: false); if (start.Direct is { } from && end.Direct is { } to) - return $"{receiver}.slice({from}, {to})"; + return JsExpr.Call(JsExpr.Member(receiver, "slice"), from, to); if (start.Direct is { } only && end.Omitted) - return $"{receiver}.slice({only})"; + return JsExpr.Call(JsExpr.Member(receiver, "slice"), only); // The endpoint could be zero-from-the-end, where a negative index would mean the opposite. - return $"$eq.slice({receiver}, {start.Value}, {start.FromEnd.ToString().ToLowerInvariant()}, " - + $"{end.Value}, {end.FromEnd.ToString().ToLowerInvariant()})"; + context.UsedHelpers.Add(Eq.Import); + return JsExpr.Call(JsExpr.Identifier("$eq.slice"), receiver, start.Value, JsExpr.Literal(start.FromEnd ? "true" : "false"), + end.Value, JsExpr.Literal(end.FromEnd ? "true" : "false")); + } + + /// + /// x[a..b] over a twin with a Slice(start, length), as C# lowers it: the receiver, then + /// each endpoint written, then the count only where one is needed, then the slice. Where no endpoint + /// needs the count, a template over the parts: x.slice(a, b - a), the start bound once. Where + /// one does, a function handed the receiver and the endpoints in that order, which reads the count + /// once, after them, as C# reads it. + /// + private static JsExpr Sliced(ElementAccessExpressionSyntax access, RangeExpressionSyntax range, IMethodSymbol slice, + IPropertySymbol length, ConversionContext context) + { + var receiver = context.Converter.ConvertIr(access.Expression); + var member = slice.Name.ToCamelCase(); + var (start, startFromEnd) = Operand(range.LeftOperand, context); + var (end, endFromEnd) = Operand(range.RightOperand, context); + + if (!startFromEnd && !endFromEnd && end is not null) + { + return start is null + ? JsExpr.Template($"{{0}}.{member}(0, {{1}})", [receiver, end]) + : JsExpr.Template($"{{0}}.{member}({{1}}, {{2}} - {{1}})", [receiver, start, end]); + } + + // `this` and `super` are read where they stand, an arrow taking them from its function: `super` + // is no value an arrow could be handed. + var fixedReceiver = receiver is JsIdentifier { Name: "this" or "super" }; + var self = fixedReceiver ? receiver : JsExpr.Identifier("$r"); + var count = JsExpr.Identifier("$n"); + var from = start is null ? JsExpr.Literal("0") : startFromEnd ? JsExpr.Binary(count, "-", JsExpr.Identifier("$s")) : JsExpr.Identifier("$s"); + var to = end is null ? count : endFromEnd ? JsExpr.Binary(count, "-", JsExpr.Identifier("$e")) : JsExpr.Identifier("$e"); + var annotated = context.TypeAnnotations; + List parameters = fixedReceiver ? [] : [annotated ? "$r: any" : "$r"]; + List arguments = fixedReceiver ? [] : [receiver]; + if (start is not null) + { + parameters.Add(annotated ? "$s: number" : "$s"); + arguments.Add(start); + } + if (end is not null) + { + parameters.Add(annotated ? "$e: number" : "$e"); + arguments.Add(end); + } + var body = JsStatement.Block([ + JsStatement.Const("$n", JsExpr.Member(self, length.Name.ToCamelCase())), + JsStatement.Return(JsExpr.Call(JsExpr.Member(self, member), from, JsExpr.Binary(to, "-", from))), + ]); + return JsExpr.Call(JsExpr.ArrowBlock(string.Join(", ", parameters), body, context.Layout, context.Depth), arguments); } + /// An endpoint of a range as C# evaluates it: its value, without the ^, and whether it + /// counts from the end; null for one left out. + private static (JsExpr? Value, bool FromEnd) Operand(ExpressionSyntax? operand, ConversionContext context) => operand switch + { + null => (null, false), + PrefixUnaryExpressionSyntax hat when hat.IsKind(SyntaxKind.IndexExpression) => (context.Converter.ConvertIr(hat.Operand), true), + _ => (context.Converter.ConvertIr(operand), false), + }; + /// One end of the range: what it is, and whether `.slice` can take it as it stands. - private readonly record struct RangeEnd(string Value, bool FromEnd, string? Direct, bool Omitted); + private readonly record struct RangeEnd(JsExpr Value, bool FromEnd, JsExpr? Direct, bool Omitted); private static RangeEnd Endpoint(ExpressionSyntax? operand, ConversionContext context, bool isStart) { // Omitted: `..end` starts at 0, `start..` runs to the end. if (operand is null) - return isStart ? new RangeEnd("0", false, "0", false) : new RangeEnd("null", false, null, true); + return isStart + ? new RangeEnd(JsExpr.Literal("0"), false, JsExpr.Literal("0"), false) + : new RangeEnd(JsExpr.Literal("null"), false, null, true); if (operand is not PrefixUnaryExpressionSyntax hat || !hat.IsKind(SyntaxKind.IndexExpression)) { - var value = context.Converter.ConvertExpression(operand); + var value = context.Converter.ConvertIr(operand); return new RangeEnd(value, false, value, false); } - var offset = context.Converter.ConvertExpression(hat.Operand); + var offset = context.Converter.ConvertIr(hat.Operand); // `^3` is `-3` to slice — but only once we know it is not `^0`, which slice reads as 0. - var direct = hat.Operand is LiteralExpressionSyntax { Token.Value: int and > 0 } ? $"-{offset}" : null; + var direct = hat.Operand is LiteralExpressionSyntax { Token.Value: int and > 0 } + ? JsExpr.Literal($"-{JsExprWriter.Write(offset)}") + : null; return new RangeEnd(offset, true, direct, false); } diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/DistinctStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/DistinctStrategy.cs index 567b60630..9050c2e1b 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/DistinctStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/DistinctStrategy.cs @@ -1,6 +1,7 @@ using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using eQuantic.UI.Compiler.CodeGen.Extensions; using eQuantic.UI.Compiler.CodeGen.Ir; namespace eQuantic.UI.Compiler.CodeGen.Strategies.Linq; @@ -43,6 +44,17 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) var invocation = (InvocationExpressionSyntax)node; var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression; + // A comparer is the collection fence's to judge (#578): one that asks for the element type's own + // equality, which the shapes below already are, is dropped, and any other is refused, the call + // written as its own C# text, as ToHashSet's is. It was dropped whatever it asked for, so + // `Distinct(StringComparer.OrdinalIgnoreCase)` kept "a" and "A" in the browser alone. + foreach (var argument in invocation.ArgumentList.Arguments) + { + if (context.SemanticHelper.GetOperation(argument.Expression) is not { } comparer) + return JsExpr.Opaque(context.Unhandled(invocation, "Distinct with a comparer")); + if (comparer.RefusesAsUntranslatable("Distinct", context)) return JsExpr.Opaque(invocation.ToString()); + } + var source = LinqSource.Ir(memberAccess.Expression, context); // JS Set dedups by SameValueZero, which matches C# Distinct for primitives, strings, diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/GroupByStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/GroupByStrategy.cs index fa30b0aae..abf7be2a9 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/GroupByStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/GroupByStrategy.cs @@ -1,6 +1,7 @@ -using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using eQuantic.UI.Compiler.CodeGen.Extensions; using eQuantic.UI.Compiler.CodeGen.Ir; namespace eQuantic.UI.Compiler.CodeGen.Strategies.Linq; @@ -11,10 +12,11 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies.Linq; /// with a key property attached, so a group works as a sequence (iterate, g.Select(…), /// g.Count()) AND exposes g.Key — matching .NET; groups stay in first-occurrence key order, as /// LINQ's do. The element selector transforms what goes INTO a group; the result selector maps -/// each finished group through (key, group). Which role an argument plays is read from the -/// bound overload's parameter names, and from lambda arity where nothing binds. Keys group by the -/// key type's equality (). A custom key comparer has no translation and is -/// fenced, never dropped. +/// each finished group through (key, group). Which role an argument plays is the parameter the +/// bound tree hands it to, named or not, and from lambda arity where nothing binds. Keys group by the +/// key type's equality (). A key comparer is the collection fence's to judge: +/// one that asks for that equality is dropped, and any other has no translation and is refused (EQ2007), +/// never dropped. /// /// The element selector used to be silently ignored — GroupBy(w => w.Length, w => w.ToUpper()) /// grouped the raw words — which the query-syntax differential (group w.ToUpper() by w.Length @@ -42,17 +44,23 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) if (args.Count == 0) return source; - // {0} is the source and the selectors follow it, in the order C# evaluates them. - var parts = new List { source, context.Converter.ConvertIr(args[0].Expression) }; + // {0} is the source and the selectors follow it, in the order C# evaluates them: the order they + // are written, whatever part each plays. + var parts = new List { source }; + int? keySelector = null; int? elementSelector = null; int? resultSelector = null; var bound = context.SemanticHelper.GetSymbol(invocation) as IMethodSymbol; - var parameters = bound?.Parameters; - for (var i = 1; i < args.Count; i++) + var roles = Roles(invocation, context); + for (var i = 0; i < args.Count; i++) { - switch (Role(parameters, args.Count, i, args[i].Expression)) + switch (roles[i]) { + case "keySelector": + keySelector = parts.Count; + parts.Add(context.Converter.ConvertIr(args[i].Expression)); + break; case "elementSelector": elementSelector = parts.Count; parts.Add(context.Converter.ConvertIr(args[i].Expression)); @@ -62,13 +70,18 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) parts.Add(context.Converter.ConvertIr(args[i].Expression)); break; default: - context.Report(args[i], ConversionSeverity.Error, "EQ2008", - "GroupBy with a custom key comparer has no JavaScript translation — keys " - + "group by === here. Drop the comparer, or normalize the key inside the " - + "key selector."); + // A key comparer is the collection fence's to judge (#578): one that asks for the + // key type's own equality, which the groups below are found by, is dropped, and any + // other is refused, as a ToDictionary's is. Every comparer was refused here, + // StringComparer.Ordinal included. + if (context.SemanticHelper.GetOperation(args[i].Expression) is not { } comparer) + return JsExpr.Opaque(context.Unhandled(invocation, "GroupBy with a comparer")); + if (comparer.RefusesAsUntranslatable("GroupBy", context)) return JsExpr.Opaque(invocation.ToString()); break; } } + if (keySelector is not { } keyAt) + return JsExpr.Opaque(context.Unhandled(invocation, "GroupBy without a key selector")); var pushed = elementSelector is { } element ? $"({{{element}}})($item)" : "$item"; // A key that is an object here (a record, a date, a decimal) groups by its VALUE, as .NET's @@ -76,7 +89,7 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) var key = bound is { TypeArguments.Length: > 1 } ? bound.TypeArguments[1] : null; if (LinqKeys.ComparesByValue(key)) context.UsedHelpers.Add(Eq.Import); var grouped = "{0}.reduce(($groups, $item) => { " + - "const $key = ({1})($item); " + + $"const $key = ({{{keyAt}}})($item); " + $"let $g = $groups.find(($x) => {LinqKeys.Matches(key, "$x.key", "$key")}); " + "if (!$g) { $g = []; $g.key = $key; $groups.push($g); } " + $"$g.push({pushed}); return $groups; }}, [])"; @@ -87,26 +100,40 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) parts); } - /// The role of the argument after the key selector. The bound overload names it; - /// without a binding the lambda's arity does — (key, group) is a result selector, a - /// one-parameter lambda an element selector, anything else a comparer. - private static string Role(ImmutableArray? parameters, int argCount, int index, - ExpressionSyntax argument) + /// + /// The role of each argument, by the position it is written at: the parameter the bound tree hands + /// it to, so a named argument written out of order plays its own part, + /// GroupBy(comparer: c, keySelector: k) included (#578). Read by position among the arguments + /// the bound tree names, as reads a key comparer, so a call a + /// null-conditional rebuilt, whose arguments are copies, keeps its roles. The first argument was + /// always the key selector and the rest were matched to the parameters from the end, so a named + /// comparer was converted as the key selector and the key selector refused as a comparer. Without a + /// binding, the first is the key selector and the lambda's arity names the rest: (key, group) + /// is a result selector, a one-parameter lambda an element selector, anything else a comparer. + /// + private static IReadOnlyList Roles(InvocationExpressionSyntax invocation, ConversionContext context) { - // Aligned from the END, so the reduced (receiver-less) and the static forms both map. - if (parameters is { } bound && bound.Length >= argCount - && bound[bound.Length - argCount + index].Name is ("elementSelector" or "resultSelector" or "comparer") and var name) + var args = invocation.ArgumentList.Arguments; + if (context.SemanticHelper.GetOperation(invocation) is IInvocationOperation call) { - return name; + var written = (context.SemanticHelper.Original(invocation) as InvocationExpressionSyntax)?.ArgumentList.Arguments ?? args; + var roles = new string?[args.Count]; + foreach (var argument in call.Arguments) + { + if (argument is { Syntax: ArgumentSyntax syntax, Parameter.Name: var name } + && written.IndexOf(syntax) is var at && at >= 0 && at < roles.Length) + roles[at] = name; + } + if (roles.All(role => role is not null)) return Array.ConvertAll(roles, role => role!); } - return argument switch + return [.. args.Select((argument, at) => at == 0 ? "keySelector" : argument.Expression switch { SimpleLambdaExpressionSyntax => "elementSelector", ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: 1 } => "elementSelector", ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: 2 } => "resultSelector", _ => "comparer", - }; + })]; } public int Priority => 10; diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/LinqTableStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/LinqTableStrategy.cs index 21104c28b..814f89881 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/LinqTableStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Linq/LinqTableStrategy.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; using eQuantic.UI.Compiler.CodeGen.Extensions; using eQuantic.UI.Compiler.CodeGen.Ir; using eQuantic.UI.Compiler.CodeGen.Strategies.Types; @@ -30,10 +31,12 @@ public bool CanConvert(SyntaxNode node, ConversionContext context) { if (node is not InvocationExpressionSyntax invocation) return false; if (!invocation.TryGetInstanceCall(out _, out var name)) return false; - // A ToDictionary of three arguments has no shape of its own: its third is a comparer, which - // ConvertIr refuses in the words it refuses the shorter overloads with. - if (Template(name.Identifier.Text, invocation.ArgumentList.Arguments.Count) is null - && !IsToDictionaryWithAComparer(name.Identifier.Text, invocation.ArgumentList.Arguments.Count) + // The shape is the one of the arguments the template takes, a key comparer the fence passes + // being dropped. A ToDictionary of three arguments no model binds has no shape of its own: its + // third is a comparer, which nothing can judge without a model, and ConvertIr refuses it. + var shaped = Shaped(name.Identifier.Text, invocation, context).Count; + if (Template(name.Identifier.Text, shaped) is null + && !IsToDictionaryWithAComparer(name.Identifier.Text, shaped) && name.Identifier.Text != "ToHashSet") return false; // The SYMBOL decides when there is one; a NAME may decide only where the model cannot be @@ -51,19 +54,27 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) if (name.Identifier.Text == "ToHashSet") return ToHashSet(invocation, receiverSyntax, context); + // A key comparer is the collection fence's to judge (#578): one that asks for what the shape + // already does is dropped, and any other is refused, the call written as its own C# text, as + // ToHashSet's is. ToDictionary refused every comparer, StringComparer.Ordinal included, and + // ToLookup took one for an element selector and called it. + foreach (var comparer in KeyComparers(name.Identifier.Text, invocation, context)) + if (comparer.RefusesAsUntranslatable(name.Identifier.Text, context)) return JsExpr.Opaque(invocation.ToString()); + var shaped = Shaped(name.Identifier.Text, invocation, context); + var receiver = LinqSource.Ir(receiverSyntax, context); - var args = invocation.ArgumentList.Arguments + var args = shaped .Select(a => LinqSource.Argument(a, invocation, context)) .ToArray(); var template = Template(name.Identifier.Text, args.Length); - if (name.Identifier.Text == "ToDictionary" && ToDictionary(invocation, context) is var (dictionary, refusal)) + if (name.Identifier.Text == "ToDictionary" && ToDictionary(invocation, args.Length, context) is var (dictionary, refusal)) { if (refusal is not null) return JsExpr.Opaque(context.Unhandled(invocation, refusal)); template = dictionary; } - // Only a ToDictionary of three arguments reaches here without a shape, and its third is a - // comparer: a call no model binds has nothing to refuse it with but its count. + // Only a ToDictionary of three arguments no model binds reaches here without a shape, and its + // third is a comparer: a call no model binds has nothing to judge it by but its count. if (template is null) return JsExpr.Opaque(context.Unhandled(invocation, "ToDictionary with a comparer")); // A lookup groups by the key type's equality, as GroupBy does: by === two equal records // were two groups. @@ -75,21 +86,56 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) if (template.Contains("$eq.")) context.UsedHelpers.Add(Eq.Import); // {0} is the receiver; {1}… the arguments. The writer binds whatever is reused. - return JsExpr.Template(BindNamedArguments(template, invocation, context), + return JsExpr.Template(BindNamedArguments(template, shaped, invocation, context), new[] { receiver }.Concat(args).ToArray()); } + /// The operators of the table that take a key COMPARER beside their selectors, whose + /// overloads with one share their argument counts with the ones with an element selector. + private static bool TakesAKeyComparer(string name) => name is "ToDictionary" or "ToLookup"; + + /// The key comparers a call hands an operator that takes one, as the bound tree passes + /// them: each argument that fills an IEqualityComparer<TKey>. + private static IEnumerable KeyComparers(string name, InvocationExpressionSyntax invocation, ConversionContext context) => + TakesAKeyComparer(name) && context.SemanticHelper.GetOperation(invocation) is IInvocationOperation call + ? call.Arguments + .Where(argument => argument.ArgumentKind != ArgumentKind.DefaultValue && argument.Parameter?.Type.IsEqualityComparer() == true) + .Select(argument => argument.Value) + : []; + + /// + /// The arguments the template takes, in the order they are written: every one but the key comparer + /// of an operator that takes one, which the fence has passed () and which + /// asks for what the template already does. ToDictionary(k, comparer) is + /// ToDictionary(k)'s shape, where its count made it the shape of ToDictionary(k, e). + /// The comparer is found by its POSITION among the arguments the bound tree names, so a call a + /// strategy rebuilt (a ?.'s, which copies its arguments position by position) drops its own. + /// + private static IReadOnlyList Shaped(string name, InvocationExpressionSyntax invocation, ConversionContext context) + { + var arguments = invocation.ArgumentList.Arguments; + if (!TakesAKeyComparer(name) || context.SemanticHelper.GetOperation(invocation) is not IInvocationOperation call) return arguments; + var written = (context.SemanticHelper.Original(invocation) as InvocationExpressionSyntax)?.ArgumentList.Arguments ?? arguments; + var comparers = call.Arguments + .Where(argument => argument.Parameter?.Type.IsEqualityComparer() == true && argument.Syntax is ArgumentSyntax) + .Select(argument => written.IndexOf((ArgumentSyntax)argument.Syntax)) + .ToHashSet(); + return arguments.Where((_, at) => !comparers.Contains(at)).ToList(); + } + /// /// The table's holes past {0} are the call's PARAMETERS in order, and the arguments arrive /// in the order they were WRITTEN: the same thing until one is named, when /// Aggregate(func: f, seed: s) reduced with the seed as the function and /// ToDictionary(elementSelector: e, keySelector: k) keyed by the element. Each parameter /// hole is pointed at the argument that fills it, and the arguments stay in the order C# - /// evaluates them, which the template writer keeps. + /// evaluates them, which the template writer keeps. are the ones the + /// template takes (), a dropped comparer, which is always the last parameter, + /// filling no hole. /// - private static string BindNamedArguments(string template, InvocationExpressionSyntax invocation, ConversionContext context) + private static string BindNamedArguments(string template, IReadOnlyList arguments, + InvocationExpressionSyntax invocation, ConversionContext context) { - var arguments = invocation.ArgumentList.Arguments; if (arguments.All(argument => argument.NameColon is null)) return template; if (context.SemanticHelper.GetSymbol(invocation) is not IMethodSymbol { MethodKind: MethodKind.ReducedExtension } method) return template; @@ -117,24 +163,23 @@ private static string BindNamedArguments(string template, InvocationExpressionSy /// /// ToDictionary by the runtime, which refuses a null key and a key twice as .NET does, into /// the dictionary class a constructed one is, its keys found by value where the key type's default - /// comparer finds them so (). A comparer has no form - /// here and is refused, and so is an enum with aliases, whose two names for one value are two keys - /// on this side. Null is a call no model binds, which keeps the table's shape. + /// comparer finds them so (). A comparer reaches here only when it asks + /// for that, the fence having refused any other, and is dropped (#578); an enum with aliases is + /// refused, whose two names for one value are two keys on this side. is + /// how many selectors the call passes. Null is a call no model binds, which keeps the table's shape. /// - private static (string? Template, string? Refusal)? ToDictionary(InvocationExpressionSyntax invocation, + private static (string? Template, string? Refusal)? ToDictionary(InvocationExpressionSyntax invocation, int selectors, ConversionContext context) { - if (context.SemanticHelper.GetSymbol(invocation) is not IMethodSymbol { TypeArguments: [_, var key, ..] } method) + if (context.SemanticHelper.GetSymbol(invocation) is not IMethodSymbol { TypeArguments: [_, var key, ..] }) return null; - if (method.Parameters.Any(parameter => parameter.Type.Name == "IEqualityComparer")) - return (null, "ToDictionary with a comparer"); if ((key.UnwrapNullable() ?? key) is INamedTypeSymbol { TypeKind: TypeKind.Enum } keyEnum && LinqKeys.HasAliases(keyEnum)) return (null, $"ToDictionary keyed by {key.ToDisplayString()}, an enum with aliases"); - var selectors = invocation.ArgumentList.Arguments.Count == 2 ? "{1}, {2}" : "{1}"; + var holes = selectors == 2 ? "{1}, {2}" : "{1}"; var byValue = ElementEquality.Of(key) is { } equality - ? invocation.ArgumentList.Arguments.Count == 2 ? $", {equality}" : $", null, {equality}" + ? selectors == 2 ? $", {equality}" : $", null, {equality}" : ""; - return ($"{Eq.LinqToDictionary}({{0}}, {selectors}{byValue})", null); + return ($"{Eq.LinqToDictionary}({{0}}, {holes}{byValue})", null); } private static bool IsToDictionaryWithAComparer(string name, int argCount) => name == "ToDictionary" && argCount == 3; diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Place.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Place.cs index 7c198429a..3d4e9eafd 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Place.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Place.cs @@ -10,11 +10,14 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies; /// /// A PLACE JavaScript cannot assign, because it is read and written through calls: a dictionary's /// entry, read by $eq.mapGet and written by $eq.mapSet (), -/// and an entry of an indexer a twin carries, read by its item and written by its -/// setItem (). It is resolved here, once, and every writer takes it from -/// here: the assignment, the compound, the step, the coalescing assignment, a bool's compound, the -/// null-conditional assignment, the object initializer's entry and the deconstruction. Each of them -/// spelled the two kinds beside each other, so a rule of either had a writer for each to be taught in. +/// an entry of an indexer a twin carries, read by its item and written by its +/// setItem (), and an entry of a list's face (IList<T>, +/// IReadOnlyList<T>), read and written by the runtime's item and setItem, which +/// answer an array's subscript and a twin's indexer alike (#586). It is resolved here, once, and every +/// writer takes it from here: the assignment, the compound, the step, the coalescing assignment, a +/// bool's compound, the null-conditional assignment, the object initializer's entry and the +/// deconstruction. Each of them spelled the kinds beside each other, so a rule of any had a writer for +/// each to be taught in. /// /// Its parts are what C# evaluates, in the order it evaluates them: the receiver, then the keys as they /// are written, and after them the constants the call passes without evaluating anything. A writer @@ -24,7 +27,21 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies; /// internal sealed class Place { - private readonly bool _entry; + /// What a place is an entry of, which says how it is read and written. + private enum Kind + { + /// A dictionary's, through $eq.mapGet and $eq.mapSet. + Entry, + + /// An indexer's a twin carries, through its own item and setItem. + Indexer, + + /// A list face's, through the runtime's item and setItem, handed the + /// receiver, whichever list the face holds. + Face, + } + + private readonly Kind _kind; private readonly IReadOnlyList _parts; private readonly int _evaluated; private readonly IReadOnlyList _keys; @@ -32,7 +49,7 @@ internal sealed class Place private readonly (string Get, string Set) _names; private readonly ConversionContext _context; - /// A dictionary's entry, or else an indexer's. + /// What the place is an entry of. /// The receiver, the keys C# evaluates, then the constants the call passes. /// How many of the parts C# evaluates; the rest are constants. /// The keys the call passes, in its parameters' order, as template text over the @@ -41,10 +58,10 @@ internal sealed class Place /// position). /// An indexer's getter and setter on the twin (). /// The conversion the writers build in. - private Place(bool entry, IReadOnlyList parts, int evaluated, IReadOnlyList keys, bool computed, + private Place(Kind kind, IReadOnlyList parts, int evaluated, IReadOnlyList keys, bool computed, (string Get, string Set) names, ConversionContext context) { - _entry = entry; + _kind = kind; _parts = parts; _evaluated = evaluated; _keys = keys; @@ -54,8 +71,8 @@ private Place(bool entry, IReadOnlyList parts, int evaluated, IReadOnlyL } /// The place names, through its parentheses: a dictionary's - /// entry or an entry of an indexer a twin carries; null for any other target, whose writers keep - /// JavaScript's own assignment. + /// entry, an entry of an indexer a twin carries, or a list face's; null for any other target, whose + /// writers keep JavaScript's own assignment. public static Place? Of(ExpressionSyntax target, ConversionContext context) { var node = target; @@ -95,9 +112,9 @@ private Place(bool entry, IReadOnlyList parts, int evaluated, IReadOnlyL /// kept: where the keys were computed, the receiver and the keys are read back from the one value. public Place Over(IReadOnlyList evaluated) { - if (!_computed) return new(_entry, [.. evaluated, .. _parts.Skip(_evaluated)], _evaluated, _keys, false, _names, _context); + if (!_computed) return new(_kind, [.. evaluated, .. _parts.Skip(_evaluated)], _evaluated, _keys, false, _names, _context); List held = [.. Enumerable.Range(0, _keys.Count + 1).Select(i => JsExpr.Index(evaluated[0], JsExpr.Literal(i.ToString())))]; - return new(_entry, held, held.Count, [.. _keys.Select((_, i) => Hole(i + 1))], false, _names, _context); + return new(_kind, held, held.Count, [.. _keys.Select((_, i) => Hole(i + 1))], false, _names, _context); } /// The read. @@ -127,11 +144,19 @@ public JsExpr Coalesce(JsExpr value) => private static string Hole(int index) => "{" + index + "}"; - private string ReadText(IReadOnlyList keys) => - _entry ? DictionaryEntry.Read("{0}", keys[0]) : $"{{0}}.{_names.Get}({string.Join(", ", keys)})"; + private string ReadText(IReadOnlyList keys) => _kind switch + { + Kind.Entry => DictionaryEntry.Read("{0}", keys[0]), + Kind.Face => $"{Eq.ListItem}({string.Join(", ", keys.Prepend("{0}"))})", + _ => $"{{0}}.{_names.Get}({string.Join(", ", keys)})", + }; - private string WriteText(IReadOnlyList keys, string value) => - _entry ? DictionaryEntry.Write("{0}", keys[0], value) : $"{{0}}.{_names.Set}({string.Join(", ", keys.Append(value))})"; + private string WriteText(IReadOnlyList keys, string value) => _kind switch + { + Kind.Entry => DictionaryEntry.Write("{0}", keys[0], value), + Kind.Face => $"{Eq.ListSetItem}({string.Join(", ", keys.Prepend("{0}").Append(value))})", + _ => $"{{0}}.{_names.Set}({string.Join(", ", keys.Append(value))})", + }; private JsExpr Template(string text, IReadOnlyList parts) => JsExpr.Template(text, parts); @@ -139,7 +164,7 @@ private JsExpr Template(string text, IReadOnlyList parts) => private static Place Entry(JsExpr receiver, ExpressionSyntax key, ConversionContext context) { context.UsedHelpers.Add(Eq.Import); - return new Place(entry: true, [receiver, context.Converter.ConvertIr(key)], evaluated: 2, ["{1}"], computed: false, + return new Place(Kind.Entry, [receiver, context.Converter.ConvertIr(key)], evaluated: 2, ["{1}"], computed: false, (Indexer.Get, Indexer.Set), context); } @@ -194,7 +219,7 @@ private static Place Entry(JsExpr receiver, ExpressionSyntax key, ConversionCont } return keys.Any(key => key is null) ? null - : Made(parts, evaluated, keys!, computed: false, Indexer.NamesOf(indexer), access, context); + : Made(KindOf(indexer, context), parts, evaluated, keys!, computed: false, Indexer.NamesOf(indexer), access, context); } /// @@ -218,18 +243,21 @@ private static Place Entry(JsExpr receiver, ExpressionSyntax key, ConversionCont return null; } var offset = context.Converter.ConvertIr(hat.Operand); - var counted = $"{{0}}.{count.Name.ToCamelCase()}"; + // A list face counts whichever list it holds through the runtime, an array's length and a twin's + // count alike (#586); a twin's own indexer counts by the member the bound tree names. + var kind = KindOf((IPropertySymbol)index.IndexerSymbol, context); + var counted = kind == Kind.Face ? $"{Eq.Count}({{0}})" : $"{{0}}.{count.Name.ToCamelCase()}"; // A literal offset reads nothing, so the count may come first. Any other is read first, and a // receiver read by its name is then bound once, as C# spills it, where an offset that has an // effect could reassign the name between the two reads (and the template's writer, seeing a name // read after a call, would bind the value of the write ahead of the count). var names = Indexer.NamesOf((IPropertySymbol)index.IndexerSymbol); if (offset is JsLiteral) - return Made([receiver, offset], evaluated: 2, [$"{counted} - {{1}}"], computed: true, names, access, context); + return Made(kind, [receiver, offset], evaluated: 2, [$"{counted} - {{1}}"], computed: true, names, access, context); var once = receiver is JsIdentifier { Name: not ("this" or "super") } && !JsExprWriter.IsInlinable(offset) ? JsExpr.Group(receiver) : receiver; - return Made([once, offset], evaluated: 2, [$"-{{1}} + {counted}"], computed: true, names, access, context); + return Made(kind, [once, offset], evaluated: 2, [$"-{{1}} + {counted}"], computed: true, names, access, context); } /// @@ -249,12 +277,21 @@ private static JsExpr Default(IArgumentOperation omitted, ConversionContext cont /// private const int MostParts = 9; + /// Whether an indexer's place is read through the twin's own members or, for a list face's, + /// through the runtime's, which the module then imports. + private static Kind KindOf(IPropertySymbol indexer, ConversionContext context) + { + if (!Indexer.IsListFace(indexer)) return Kind.Indexer; + context.UsedHelpers.Add(Eq.Import); + return Kind.Face; + } + /// An indexer's place, unless it takes more parts than a template holds, which is refused /// (EQ1004) rather than written into holes no template fills. - private static Place? Made(List parts, int evaluated, IReadOnlyList keys, bool computed, + private static Place? Made(Kind kind, List parts, int evaluated, IReadOnlyList keys, bool computed, (string Get, string Set) names, SyntaxNode at, ConversionContext context) { - if (parts.Count <= MostParts) return new Place(entry: false, parts, evaluated, keys, computed, names, context); + if (parts.Count <= MostParts) return new Place(kind, parts, evaluated, keys, computed, names, context); context.Unhandled(at, $"indexer's (it passes {parts.Count - 1} keys, and one read and write holds {MostParts - 1})"); return null; } diff --git a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Primitives/ListMethodStrategy.cs b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Primitives/ListMethodStrategy.cs index a1fa30e71..635e3c5fe 100644 --- a/src/eQuantic.UI.Compiler/CodeGen/Strategies/Primitives/ListMethodStrategy.cs +++ b/src/eQuantic.UI.Compiler/CodeGen/Strategies/Primitives/ListMethodStrategy.cs @@ -9,6 +9,12 @@ namespace eQuantic.UI.Compiler.CodeGen.Strategies.Primitives; /// List<T>'s members, and those of the faces a list answers to (IList<T>, /// ICollection<T>), over the array a list is on this side. /// +/// ICollection<T>'s own Add and Clear are the exception: an API takes that face +/// when it promises no order, and a set, a linked list or a dictionary's pairs may stand behind it when +/// the call runs, so the runtime asks the value what it is (#593), as it does for Contains, +/// Remove and CopyTo. They were an array's push and splice, which none of those has. +/// +/// /// A member is the array's own method only where the two answer alike: Add is push, /// FindAll filter, Exists some, TrueForAll every, /// ForEach forEach, and IndexOf indexOf for an element compared by identity @@ -119,7 +125,7 @@ public JsExpr ConvertIr(SyntaxNode node, ConversionContext context) } /// Whether is an Add this strategy lowers: a list's, or a - /// list or collection interface's, which every call to it lowers to the array's push. + /// list or collection interface's (). internal static bool Lowers(IMethodSymbol? add) => add is { Name: "Add", ContainingType: { } declaring } && OfAList(declaring); /// A list, or a list or collection interface, whose methods this strategy lowers. @@ -128,9 +134,23 @@ private static bool OfAList(INamedTypeSymbol type) => && (name.StartsWith("System.Collections.Generic.List<") || name.StartsWith("System.Collections.Generic.IList<") || name.StartsWith("System.Collections.Generic.ICollection<")); - /// A list's Add, as every call to it lowers: the array's push. An object - /// initializer's element applied to a list a member holds is a call to it. - internal static JsExpr Add(JsExpr list, IReadOnlyList items) => Method(list, "push", items); + /// + /// An Add this strategy lowers, as every call to it lowers: a list's is the array's push, + /// and ICollection<T>'s asks the runtime, which adds as the collection the interface holds + /// when the call runs adds (#593). An object initializer's element applied to a collection a member + /// holds is a call to it. + /// + internal static JsExpr Add(IMethodSymbol? add, JsExpr list, IReadOnlyList items, ConversionContext context) + { + if (add?.ContainingType is not { } declaring || !IsCollectionInterface(declaring)) return Method(list, "push", items); + context.UsedHelpers.Add(Eq.Import); + return JsExpr.Call(JsExpr.Identifier(Eq.CollectionAdd), [list, .. items]); + } + + /// Whether is ICollection<T>, the face a set, a linked + /// list and a dictionary's pairs answer to as readily as a list. + private static bool IsCollectionInterface(INamedTypeSymbol type) => + type.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.ICollection"; /// The lowering of a call the model binds, or null for the shapes every model answers alike. private static JsExpr? Bound(string name, InvocationExpressionSyntax invocation, IMethodSymbol method, JsExpr list, @@ -139,6 +159,11 @@ private static bool OfAList(INamedTypeSymbol type) => var count = method.Parameters.Length; switch (name) { + // ICollection's own, for whichever collection the interface holds when the call runs (#593). + case "Add" or "Clear" when method.ContainingType is { } declaring && IsCollectionInterface(declaring): + context.UsedHelpers.Add(Eq.Import); + return ParameterTemplate.Call(name == "Add" ? $"{Eq.CollectionAdd}({{0}}, {{1}})" : $"{Eq.CollectionClear}({{0}})", + list, invocation, method, context); case "IndexOf" or "LastIndexOf": { var equality = ElementEquality.Of(element); diff --git a/src/eQuantic.UI.Compiler/PublicAPI.Unshipped.txt b/src/eQuantic.UI.Compiler/PublicAPI.Unshipped.txt index 975b75e21..d2ed33b57 100644 --- a/src/eQuantic.UI.Compiler/PublicAPI.Unshipped.txt +++ b/src/eQuantic.UI.Compiler/PublicAPI.Unshipped.txt @@ -58,6 +58,13 @@ eQuantic.UI.Compiler.CodeGen.ValueMember.ValueMember(string! Display, string! Js eQuantic.UI.Compiler.CodeGen.ValueMember.Store.get -> string! eQuantic.UI.Compiler.CodeGen.ValueMember.Store.init -> void eQuantic.UI.Compiler.Services.TypeScriptCodeBuilder.ClassBuilder.State(string! name, string? type, Microsoft.CodeAnalysis.SyntaxNode? sourceNode = null) -> void +const eQuantic.UI.Compiler.CodeGen.Eq.CollectionAdd = "$eq.collections.add" -> string! +const eQuantic.UI.Compiler.CodeGen.Eq.CollectionClear = "$eq.collections.clear" -> string! +const eQuantic.UI.Compiler.CodeGen.Eq.ListItem = "$eq.collections.item" -> string! +const eQuantic.UI.Compiler.CodeGen.Eq.ListSetItem = "$eq.collections.setItem" -> string! +const eQuantic.UI.Compiler.CodeGen.Eq.ListCapacity = "$eq.collections.listCapacity" -> string! +eQuantic.UI.Compiler.CodeGen.Strategies.Expressions.RangeIndexerStrategy.ConvertIr(Microsoft.CodeAnalysis.SyntaxNode! node, eQuantic.UI.Compiler.CodeGen.ConversionContext! context) -> eQuantic.UI.Compiler.CodeGen.Ir.JsExpr! +*REMOVED*eQuantic.UI.Compiler.CodeGen.Strategies.Expressions.RangeIndexerStrategy.Convert(Microsoft.CodeAnalysis.SyntaxNode! node, eQuantic.UI.Compiler.CodeGen.ConversionContext! context) -> string! *REMOVED*static eQuantic.UI.Compiler.CodeGen.Strategies.UserDefinedOperators.Binary(Microsoft.CodeAnalysis.IMethodSymbol! method, string! token, string! left, string! right) -> eQuantic.UI.Compiler.CodeGen.Ir.JsExpr? *REMOVED*static eQuantic.UI.Compiler.CodeGen.Strategies.UserDefinedOperators.Unary(Microsoft.CodeAnalysis.IMethodSymbol! method, string! token, string! operand) -> eQuantic.UI.Compiler.CodeGen.Ir.JsExpr? static eQuantic.UI.Compiler.CodeGen.TypeSymbolExtensions.IntroduceTwin(this Microsoft.CodeAnalysis.INamedTypeSymbol! type, eQuantic.UI.Compiler.CodeGen.ConversionContext! context) -> string! diff --git a/src/eQuantic.UI.Runtime/src/eq.spec.ts b/src/eQuantic.UI.Runtime/src/eq.spec.ts index f62af2e05..52ebf4d57 100644 --- a/src/eQuantic.UI.Runtime/src/eq.spec.ts +++ b/src/eQuantic.UI.Runtime/src/eq.spec.ts @@ -58,7 +58,8 @@ describe('$eq namespace', () => { expect($eq.sameClosure(copy, doubles)).toBe(false); expect($eq.sameClosure(copy, ints)).toBe(true); expect($eq.sameClosure($eq.withPatch(ints, { a: 2 }), doubles)).toBe(false); - // An unmarked source makes an unmarked copy, which is not taken for another type. + // An unmarked source makes an unmarked copy, and an unmarked value compares as any closed type: + // only two values marked differently are of different types. expect($eq.sameClosure($eq.closingLike(new Pair(1), new Pair(1)), doubles)).toBe(true); }); diff --git a/src/eQuantic.UI.Runtime/src/eq.ts b/src/eQuantic.UI.Runtime/src/eq.ts index c84268105..292b61855 100644 --- a/src/eQuantic.UI.Runtime/src/eq.ts +++ b/src/eQuantic.UI.Runtime/src/eq.ts @@ -119,6 +119,10 @@ import { linkedList, contains, remove, + add as collectionAdd, + clear as collectionClear, + item as listItem, + setItem as listSetItem, sameItem, pairComparer, count, @@ -147,6 +151,7 @@ import { findLastIndex, indexOf as listIndexOf, lastIndexOf as listLastIndexOf, + listCapacity, listSort, listSortBy, order, @@ -454,6 +459,12 @@ export const $eq = { count, setAdd, remove, + /** `ICollection`'s `Add` and `Clear`, for whichever collection the interface holds (#593). */ + add: collectionAdd, + clear: collectionClear, + /** A list face's indexer, read and written, for an array and a twin alike (#586). */ + item: listItem, + setItem: listSetItem, sameItem, pairComparer, /** `new HashSet(…)`, and a set an initializer or a collection expression builds. */ @@ -488,6 +499,8 @@ export const $eq = { arraySort, arraySortBy, binarySearch, + /** `new List(capacity)`'s refusal of a negative capacity, evaluated before the elements. */ + listCapacity, /** `new KeyValuePair(key, value)` and `KeyValuePair.Create`: the shape a dictionary yields. */ pair, }, diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Accordion.ts b/src/eQuantic.UI.Runtime/src/shared/components/Accordion.ts index e148d3855..85e42eae2 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Accordion.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Accordion.ts @@ -19,8 +19,8 @@ export class Accordion extends StatefulComponent { build(context: BuildContext) { let theme = context.theme; let column = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = 0; i < this.items.length; i++) { - let item = this.items[i]; + for (let i = 0; i < $eq.collections.count(this.items); i++) { + let item = $eq.collections.item(this.items, i); let index = i; let open = this.isOpen(i); let header = new Row(8, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: SizeValue.fill }); @@ -32,7 +32,7 @@ export class Accordion extends StatefulComponent { if (open && (content = item.content) != null) { column.add(new Box(new BoxStyle({ width: SizeValue.fill, padding: new EdgeInsets(12, 0, 12, 12) }), content)); } - if (i < this.items.length - 1) column.add(new Divider()); + if (i < $eq.collections.count(this.items) - 1) column.add(new Divider()); } return column; } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/AppBar.ts b/src/eQuantic.UI.Runtime/src/shared/components/AppBar.ts index 09d56e0fb..7f41379b9 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/AppBar.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/AppBar.ts @@ -11,7 +11,7 @@ export class AppBar extends StatelessComponent { } set actions(value) { - this.$actions = (value != null && value.length > 3) ? $eq.exceptions.raise($eq.exceptions.create(['System.ArgumentException', 'System.SystemException', 'System.Exception'], 'AppBar takes at most 3 actions (spec B3) — overflow belongs in an ActionSheet.', { paramName: 'Actions' })) : value; + this.$actions = (value != null && $eq.collections.count(value) > 3) ? $eq.exceptions.raise($eq.exceptions.create(['System.ArgumentException', 'System.SystemException', 'System.Exception'], 'AppBar takes at most 3 actions (spec B3) — overflow belongs in an ActionSheet.', { paramName: 'Actions' })) : value; } declare scrolled: boolean; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/BarChart.ts b/src/eQuantic.UI.Runtime/src/shared/components/BarChart.ts index 51a1513ee..fa752d134 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/BarChart.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/BarChart.ts @@ -1,4 +1,4 @@ -import { BarChartLayout, Box, BoxStyle, BuildContext, Button, Canvas, CanvasPointer, CategoryAxis, ChartSeries, Column, CornerRadii, DataColumn, DataRow, DataTable, EdgeInsets, Flexible, GridTrack, Point, Positioned, Pressable, Rect, Row, SdkStrings, SizeValue, Stack, StatefulComponent, Text, UiComponent, ValueAxis, ValueTicks, VisualNode } from "../runtime-exports"; +import { $eq, BarChartLayout, Box, BoxStyle, BuildContext, Button, Canvas, CanvasPointer, CategoryAxis, ChartSeries, Column, CornerRadii, DataColumn, DataRow, DataTable, EdgeInsets, Flexible, GridTrack, Point, Positioned, Pressable, Rect, Row, SdkStrings, SizeValue, Stack, StatefulComponent, Text, UiComponent, ValueAxis, ValueTicks, VisualNode } from "../runtime-exports"; export class BarChart extends StatefulComponent { static $typeId = 'eQuantic.UI.Charts.BarChart'; @@ -32,7 +32,7 @@ export class BarChart extends StatefulComponent { this._title = title; this._subtitle = subtitle; this._plotHeight = plotHeight; - this._hidden = BarChart.hidden(series.length); + this._hidden = BarChart.hidden($eq.collections.count(series)); if (props && typeof props === 'object') Object.assign(this, props); } @@ -42,7 +42,7 @@ export class BarChart extends StatefulComponent { let root = new Column(8, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); if (this._title != null) root.add(new Text(this._title, 'title', theme.textPrimary, 1)); if (this._subtitle != null) root.add(new Text(this._subtitle, 'caption', theme.textMuted, 2)); - if (this._series.length > 1) root.add(this.legend(theme)); + if ($eq.collections.count(this._series) > 1) root.add(this.legend(theme)); root.add(this._table ? BarChart.table(this._series, this._categories, this._values, theme) : this.plot(context)); root.add(this.footer()); return root; @@ -51,7 +51,7 @@ export class BarChart extends StatefulComponent { adoptConfig(next: UiComponent) { let fresh: any; if (!((next instanceof BarChart && (fresh = next, true)))) return; - let reshaped = fresh._series.length !== this._series.length || fresh._categories.categories.length !== this._categories.categories.length; + let reshaped = $eq.collections.count(fresh._series) !== $eq.collections.count(this._series) || $eq.collections.count(fresh._categories.categories) !== $eq.collections.count(this._categories.categories); this._series = fresh._series; this._categories = fresh._categories; this._values = fresh._values; @@ -60,7 +60,7 @@ export class BarChart extends StatefulComponent { this._title = fresh._title; this._subtitle = fresh._subtitle; this._plotHeight = fresh._plotHeight; - if (this._hidden.length !== this._series.length) this._hidden = BarChart.hidden(this._series.length); + if (this._hidden.length !== $eq.collections.count(this._series)) this._hidden = BarChart.hidden($eq.collections.count(this._series)); if (reshaped) this._hover = -1; } @@ -69,13 +69,13 @@ export class BarChart extends StatefulComponent { columns.push(new DataColumn(categories.title ?? '', GridTrack.flex(2))); for (const s of series) columns.push(new DataColumn(s.name, GridTrack.flex(1), 'end')); let rows: DataRow[] = []; - for (let c = 0; c < categories.categories.length; c++) { + for (let c = 0; c < $eq.collections.count(categories.categories); c++) { let cells: VisualNode[] = []; - cells.push(new Text(categories.categories[c], 'bodyM', theme.textPrimary, 1)); + cells.push(new Text($eq.collections.item(categories.categories, c), 'bodyM', theme.textPrimary, 1)); for (const s of series) { cells.push(new Text(values.label(s.at(c)), 'bodyM', theme.textPrimary, 1, 'end', false, true)); } - rows.push(new DataRow(categories.categories[c], cells)); + rows.push(new DataRow($eq.collections.item(categories.categories, c), cells)); } return new DataTable(columns, rows); } @@ -93,19 +93,19 @@ export class BarChart extends StatefulComponent { } seriesColor(theme: any, index: number) { - return theme.data.seriesColor(this._series[index].slotAt(index)); + return theme.data.seriesColor($eq.collections.item(this._series, index).slotAt(index)); } legend(theme: any) { let row = new Row(12, 'start', 'center', true, 4); - for (let i = 0; i < this._series.length; i++) { + for (let i = 0; i < $eq.collections.count(this._series); i++) { let index = i; let hidden = this._hidden[i]; let swatch = new Box(new BoxStyle({ width: SizeValue.fixed(12), height: SizeValue.fixed(12), background: hidden ? theme.border : this.seriesColor(theme, i), cornerRadius: new CornerRadii(2) })); let entry = new Row(4, 'start', 'center'); entry.add(swatch); - entry.add(new Text(this._series[i].name, 'labelSmall', hidden ? theme.textMuted : theme.textSecondary, 1)); - row.add(new Pressable(entry, () => this.setState(() => this.isolate(index)), { label: this._series[i].name })); + entry.add(new Text($eq.collections.item(this._series, i).name, 'labelSmall', hidden ? theme.textMuted : theme.textSecondary, 1)); + row.add(new Pressable(entry, () => this.setState(() => this.isolate(index)), { label: $eq.collections.item(this._series, i).name })); } return row; } @@ -142,7 +142,7 @@ export class BarChart extends StatefulComponent { let plotRow = new Row(0, 'start', 'stretch'); let axisRow = new Row(0, 'start', 'start'); if (vertical) { - let ticks = BarChartLayout.ticks(this._series, this.visible(), this._categories.categories.length, this._layout, this._values); + let ticks = BarChartLayout.ticks(this._series, this.visible(), $eq.collections.count(this._categories.categories), this._layout, this._values); let band = BarChart.valueBandWidth(this._values, ticks); plotRow.add(this.valueLabelsBeside(context, ticks, band)); plotRow.add(new Flexible(stack)); @@ -182,19 +182,19 @@ export class BarChart extends StatefulComponent { categoryLabelsBeside(context: BuildContext) { let theme = context.theme; - let count = this._categories.categories.length; + let count = $eq.collections.count(this._categories.categories); let slot = count === 0 ? this._plotHeight : Math.fround(this._plotHeight / Math.fround(count)); let lineHeight = Math.fround(theme.type('caption').lineHeight * context.typeScale); let stack = new Stack('topStart', { width: SizeValue.fixed(BarChart.categoryAxisWidth), height: SizeValue.fixed(this._plotHeight) }); for (let c = 0; c < count; c++) { - let label = new Box(new BoxStyle({ width: SizeValue.fixed(Math.fround(BarChart.categoryAxisWidth - 8)) }), new Text(this._categories.categories[c], 'caption', theme.textMuted, 1, 'end')); + let label = new Box(new BoxStyle({ width: SizeValue.fixed(Math.fround(BarChart.categoryAxisWidth - 8)) }), new Text($eq.collections.item(this._categories.categories, c), 'caption', theme.textMuted, 1, 'end')); stack.add(new Positioned(label, Math.fround(Math.fround(Math.fround(Math.fround(c) * slot) + Math.fround(slot / 2)) - Math.fround(lineHeight / 2)), null, null, 0)); } return stack; } valueLabelsBelow(theme: any) { - let ticks = BarChartLayout.ticks(this._series, this.visible(), this._categories.categories.length, this._layout, this._values); + let ticks = BarChartLayout.ticks(this._series, this.visible(), $eq.collections.count(this._categories.categories), this._layout, this._values); let row = new Row(0, 'spaceBetween'); for (let i = 0; i < ticks.count; i++) { row.add(new Text(this._values.label(ticks.at(i)), 'caption', theme.textMuted, 1, 'start', false, true)); @@ -215,15 +215,15 @@ export class BarChart extends StatefulComponent { let theme = this._theme; if (theme == null) return; let vertical = this._orientation === 'vertical'; - let geometry = BarChartLayout.solve(this._series, this.visible(), this._categories.categories.length, this._layout, this._orientation, this._values, p.size.width, p.size.height); + let geometry = BarChartLayout.solve(this._series, this.visible(), $eq.collections.count(this._categories.categories), this._layout, this._orientation, this._values, p.size.width, p.size.height); this._geometry = geometry; for (let i = 0; i < geometry.ticks.count; i++) { let at = geometry.tickPosition(i); if (vertical) p.line(new Point(0, at), new Point(p.size.width, at), theme.border, 1); else p.line(new Point(at, 0), new Point(at, p.size.height), theme.border, 1); } if (vertical) p.line(new Point(0, geometry.baseline), new Point(p.size.width, geometry.baseline), theme.borderStrong, 1); else p.line(new Point(geometry.baseline, 0), new Point(geometry.baseline, p.size.height), theme.borderStrong, 1); - for (let i = 0; i < geometry.bars.length; i++) { - let b = geometry.bars[i]; + for (let i = 0; i < $eq.collections.count(geometry.bars); i++) { + let b = $eq.collections.item(geometry.bars, i); if (b.box.isEmpty) continue; let color = this.seriesColor(theme, b.series); if (i === this._hover) color = color.withOpacity(Math.fround(0.8)); @@ -257,11 +257,11 @@ export class BarChart extends StatefulComponent { tooltip(theme: any) { let geometry = this._geometry; - if (this._hover < 0 || geometry == null || this._hover >= geometry.bars.length) return null; - let bar = geometry.bars[this._hover]; - let series = this._series[bar.series]; + if (this._hover < 0 || geometry == null || this._hover >= $eq.collections.count(geometry.bars)) return null; + let bar = $eq.collections.item(geometry.bars, this._hover); + let series = $eq.collections.item(this._series, bar.series); let card = new Column(4); - card.add(new Text(this._categories.categories[bar.category], 'caption', theme.textMuted, 1)); + card.add(new Text($eq.collections.item(this._categories.categories, bar.category), 'caption', theme.textMuted, 1)); let line = new Row(8, 'start', 'center'); line.add(new Box(new BoxStyle({ width: SizeValue.fixed(8), height: SizeValue.fixed(8), background: this.seriesColor(theme, bar.series), cornerRadius: new CornerRadii(2) }))); line.add(new Text(this._values.label(series.at(bar.category)), 'label', theme.textPrimary, 1, 'start', false, true)); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/BarChartLayout.ts b/src/eQuantic.UI.Runtime/src/shared/components/BarChartLayout.ts index 74a69b2f9..9bc433b97 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/BarChartLayout.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/BarChartLayout.ts @@ -1,4 +1,4 @@ -import { BarChartGeometry, BarRect, ChartSeries, Rect, ValueAxis, ValueScale, ValueTicks } from "../runtime-exports"; +import { $eq, BarChartGeometry, BarRect, ChartSeries, Rect, ValueAxis, ValueScale, ValueTicks } from "../runtime-exports"; export class BarChartLayout { static maxThickness: number = 24; @@ -17,9 +17,9 @@ export class BarChartLayout { for (let c = 0; c < categoryCount; c++) { let positive = 0; let negative = 0; - for (let s = 0; s < series.length; s++) { - if (!visible[s]) continue; - let v = series[s].at(c); + for (let s = 0; s < $eq.collections.count(series); s++) { + if (!$eq.collections.item(visible, s)) continue; + let v = $eq.collections.item(series, s).at(c); if (layout === 'stacked') { if (v >= 0) positive += v; else negative += v; } else { @@ -45,8 +45,8 @@ export class BarChartLayout { let baseValue = ticks.min > 0 ? ticks.min : ticks.max < 0 ? ticks.max : 0; let baseline = BarChartLayout.offset(ticks, baseValue, across); let shown: number[] = []; - for (let s = 0; s < series.length; s++) { - if (visible[s]) shown.push(s); + for (let s = 0; s < $eq.collections.count(series); s++) { + if ($eq.collections.item(visible, s)) shown.push(s); } let slot = categoryCount === 0 ? along : Math.fround(along / Math.fround(categoryCount)); let bars: BarRect[] = []; @@ -61,7 +61,7 @@ export class BarChartLayout { let start = Math.fround(categoryStart + Math.fround(Math.fround(slot - group) / 2)); for (let k = 0; k < n; k++) { let s = shown[k]; - let v = series[s].at(c); + let v = $eq.collections.item(series, s).at(c); let from = BarChartLayout.offset(ticks, baseValue, across); let to = BarChartLayout.offset(ticks, v, across); bars.push(BarChartLayout.rect(vertical, across, c, s, Math.fround(start + Math.fround(Math.fround(k) * Math.fround(thickness + BarChartLayout.gap))), thickness, Math.min(from, to), Math.max(from, to), v < baseValue, true)); @@ -73,12 +73,12 @@ export class BarChartLayout { let lastPositive = -1; let lastNegative = -1; for (const s of shown) { - if (series[s].at(c) >= 0) lastPositive = s; else lastNegative = s; + if ($eq.collections.item(series, s).at(c) >= 0) lastPositive = s; else lastNegative = s; } let positiveTop = baseValue; let negativeBottom = baseValue; for (const s of shown) { - let v = series[s].at(c); + let v = $eq.collections.item(series, s).at(c); let from = null; let to = null; let dataEnd = null; @@ -108,8 +108,8 @@ export class BarChartLayout { static hitTest(geometry: BarChartGeometry, x: number, y: number) { let bars = geometry.bars; - for (let i = 0; i < bars.length; i++) { - let box = bars[i].box; + for (let i = 0; i < $eq.collections.count(bars); i++) { + let box = $eq.collections.item(bars, i).box; if (x >= Math.fround(box.left - BarChartLayout.hitSlack) && x <= Math.fround(box.right + BarChartLayout.hitSlack) && y >= Math.fround(box.top - BarChartLayout.hitSlack) && y <= Math.fround(box.bottom + BarChartLayout.hitSlack)) return i; } return -1; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/BottomNavigation.ts b/src/eQuantic.UI.Runtime/src/shared/components/BottomNavigation.ts index fef2c68cb..bb1c75978 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/BottomNavigation.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/BottomNavigation.ts @@ -9,7 +9,7 @@ export class BottomNavigation extends StatelessComponent { } set items(value) { - this.$items = (value.length < 3 || value.length > 5) ? $eq.exceptions.raise($eq.exceptions.create(['System.ArgumentException', 'System.SystemException', 'System.Exception'], 'BottomNavigation takes 3-5 destinations (spec B4): 2 → Tabs, 6+ → Drawer.', { paramName: 'Items' })) : value; + this.$items = (($v1760) => ($v1760 < 3 || $v1760 > 5))($eq.collections.count(value)) ? $eq.exceptions.raise($eq.exceptions.create(['System.ArgumentException', 'System.SystemException', 'System.Exception'], 'BottomNavigation takes 3-5 destinations (spec B4): 2 → Tabs, 6+ → Drawer.', { paramName: 'Items' })) : value; } declare selected: number; @@ -31,8 +31,8 @@ export class BottomNavigation extends StatelessComponent { let theme = context.theme; let primary = theme.colors('primary'); let row = new Row(0, 'start', 'center', false, null, null, { width: SizeValue.fill, height: SizeValue.fill }); - for (let i = 0; i < this.items.length; i++) { - let item = this.items[i]; + for (let i = 0; i < $eq.collections.count(this.items); i++) { + let item = $eq.collections.item(this.items, i); let isActive = i === this.selected; let index = i; let filled: any; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Breadcrumb.ts b/src/eQuantic.UI.Runtime/src/shared/components/Breadcrumb.ts index 0131feccf..f1cd08dff 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Breadcrumb.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Breadcrumb.ts @@ -1,4 +1,4 @@ -import { BuildContext, Crumb, Icon, IconGlyph, Link, Row, StatelessComponent, Text } from "../runtime-exports"; +import { $eq, BuildContext, Crumb, Icon, IconGlyph, Link, Row, StatelessComponent, Text } from "../runtime-exports"; export class Breadcrumb extends StatelessComponent { static $typeId = 'eQuantic.UI.Components.Breadcrumb'; @@ -14,9 +14,9 @@ export class Breadcrumb extends StatelessComponent { build(context: BuildContext) { let theme = context.theme; let row = new Row(4, 'start', 'center', false, null, null, { cross: 'center' }); - for (let i = 0; i < this.crumbs.length; i++) { - let crumb = this.crumbs[i]; - let last = i === this.crumbs.length - 1; + for (let i = 0; i < $eq.collections.count(this.crumbs); i++) { + let crumb = $eq.collections.item(this.crumbs, i); + let last = i === $eq.collections.count(this.crumbs) - 1; if (i > 0) row.add(new Icon(IconGlyph.fromIcons('chevronRight'), 16, theme.borderStrong)); let text = new Text(crumb.label, 'caption', last ? theme.textPrimary : theme.textSecondary, 1); let destination: any; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Calendar.ts b/src/eQuantic.UI.Runtime/src/shared/components/Calendar.ts index 6ff562d6e..0a4175d6b 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Calendar.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Calendar.ts @@ -29,7 +29,7 @@ export class Calendar extends StatefulComponent { build(context: BuildContext) { let theme = context.theme; let first = CalendarNames.firstDayOfWeek; - let monthTitle = `${CalendarNames.monthNames[this._month.month - 1]} ${this._month.year}`; + let monthTitle = `${$eq.collections.item(CalendarNames.monthNames, this._month.month - 1)} ${this._month.year}`; let header = new Row(4, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill }); header.add(new Text(this.label ?? monthTitle, 'titleSmall', theme.textPrimary, 1)); header.add(new Flexible(new Spacer())); @@ -38,7 +38,7 @@ export class Calendar extends StatefulComponent { let names = CalendarNames.dayNamesShort; let dayRow = new Row(0, 'start', 'center', false, null, null, { width: SizeValue.fill }); for (let column = 0; column < 7; column++) { - dayRow.add(new Box(new BoxStyle({ width: SizeValue.fixed(Calendar.cellSize), height: SizeValue.fixed(Calendar.headerHeight) }), VisualNodeExtensions.centered(new Text(names[(first + column) % 7], 'caption', theme.textMuted, 1)))); + dayRow.add(new Box(new BoxStyle({ width: SizeValue.fixed(Calendar.cellSize), height: SizeValue.fixed(Calendar.headerHeight) }), VisualNodeExtensions.centered(new Text($eq.collections.item(names, (first + column) % 7), 'caption', theme.textMuted, 1)))); } let rows = [dayRow]; let start = Calendar.gridStart(this._month, first); @@ -87,7 +87,7 @@ export class Calendar extends StatefulComponent { } static spoken(day: DateOnly) { - return `${CalendarNames.dayNamesLong[Calendar.sundayIndex(day)]}, ${day.day} ${CalendarNames.monthNames[day.month - 1]} ${day.year}`; + return `${$eq.collections.item(CalendarNames.dayNamesLong, Calendar.sundayIndex(day))}, ${day.day} ${$eq.collections.item(CalendarNames.monthNames, day.month - 1)} ${day.year}`; } static sundayIndex(day: DateOnly) { diff --git a/src/eQuantic.UI.Runtime/src/shared/components/ChartSeries.ts b/src/eQuantic.UI.Runtime/src/shared/components/ChartSeries.ts index 396a1a3df..09af77339 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/ChartSeries.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/ChartSeries.ts @@ -1,3 +1,3 @@ import { $eq } from "../runtime-exports"; -export class ChartSeries { declare name: string; declare values: number[]; declare slot: number; constructor(name: any = null, values: any = null, slot: any = -1) {this.name = name;this.values = values;this.slot = slot;} equals(o: unknown) { return o instanceof ChartSeries && o.constructor === this.constructor && $eq.equals(this.name, o.name) && $eq.collections.sameItem(this.values, o.values) && $eq.equals(this.slot, o.slot); } with(patch: any): ChartSeries { return $eq.withPatch(this, patch); } static $record = true; getHashCode(): number { return $eq.hash.combine(this.name, this.values, this.slot); } slotAt(position: number) {return this.slot >= 0 ? this.slot : position;} at(category: number) {return category < this.values.length ? this.values[category] : 0;} toString() { return `ChartSeries { Name = ${this.name}, Values = ${this.values}, Slot = ${this.slot} }`; } } +export class ChartSeries { declare name: string; declare values: number[]; declare slot: number; constructor(name: any = null, values: any = null, slot: any = -1) {this.name = name;this.values = values;this.slot = slot;} equals(o: unknown) { return o instanceof ChartSeries && o.constructor === this.constructor && $eq.equals(this.name, o.name) && $eq.collections.sameItem(this.values, o.values) && $eq.equals(this.slot, o.slot); } with(patch: any): ChartSeries { return $eq.withPatch(this, patch); } static $record = true; getHashCode(): number { return $eq.hash.combine(this.name, this.values, this.slot); } slotAt(position: number) {return this.slot >= 0 ? this.slot : position;} at(category: number) {return category < $eq.collections.count(this.values) ? $eq.collections.item(this.values, category) : 0;} toString() { return `ChartSeries { Name = ${this.name}, Values = ${this.values}, Slot = ${this.slot} }`; } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeBlock.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeBlock.ts index 94f90c15a..c6b77d740 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeBlock.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeBlock.ts @@ -159,7 +159,7 @@ export class CodeBlock extends StatelessComponent { marks.add(new Positioned(new Box(new BoxStyle({ width: width, height: lineHeight, background: this.inverse ? CodeBlock.codeSlabActive : theme.colors('primary').subtle })), Math.fround(metrics.contentTop + Math.fround(Math.fround(this.rowOf(activeLine)) * lineHeight)), null, null, 0)); } this.addMarks(marks, CodeBlock.highlightPass, metrics, theme, first, last, firstLine, lastLine, width); - if (this.selectionBands.length > 0) { + if ($eq.collections.count(this.selectionBands) > 0) { let band = CodeBlock.selectionFor(this.inverse, theme).withOpacity(CodeBlock.selectionAlpha); let windowTop = Math.fround(metrics.contentTop + Math.fround(Math.fround(first) * lineHeight)); let windowBottom = Math.fround(metrics.contentTop + Math.fround(Math.fround(last + 1) * lineHeight)); @@ -357,7 +357,7 @@ export class CodeBlock extends StatelessComponent { for (const mark of this.marks(decoration, this.document, (line: number) => this.cellsOf(line), (line: number) => this.shows(line) ? this.rowOf(line) : -1, firstLine, lastLine, metrics, theme, width)) marks.add(mark); } let fillers: any; - if (!((fillers = this.fillerDocument) != null) || this.fillerDecorations.length === 0) return; + if (!((fillers = this.fillerDocument) != null) || $eq.collections.count(this.fillerDecorations) === 0) return; let sources: number[] = []; let rows: number[] = []; let lowest = 2147483647; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletion.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletion.ts index 631f55fb6..16bc15246 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletion.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletion.ts @@ -72,14 +72,14 @@ export class CodeCompletion { } invoke() { - if (this._editor.readOnly || this.providers.length === 0) return false; + if (this._editor.readOnly || $eq.collections.count(this.providers) === 0) return false; this.open(this.wordStart(this._editor.caret)); this.ask('invoked', null, this.providers.slice()); return true; } typed(typed: string) { - if (this._editor.readOnly || this._active || this.providers.length === 0 || !this._editor.selection.isEmpty) return; + if (this._editor.readOnly || this._active || $eq.collections.count(this.providers) === 0 || !this._editor.selection.isEmpty) return; let triggered: any[] = []; for (const provider of this.providers) { if (provider.triggerCharacters.includes(typed)) triggered.push(provider); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletionView.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletionView.ts index 36e5147e4..70358da34 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletionView.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeCompletionView.ts @@ -94,13 +94,13 @@ export class CodeCompletionView { let items = completion.items; let columns = (Math.trunc(Math.floor(Math.fround(Math.fround(width - CodeCompletionView.widthOf(metrics, 0)) / metrics.columnWidth))) | 0); let page = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = top; i < top + rows && i < items.length; i++) { + for (let i = top; i < top + rows && i < $eq.collections.count(items); i++) { let index = i; - page.add(CodeCompletionView.option(theme, metrics, items[i], i === completion.selected, columns, () => pick(index))); + page.add(CodeCompletionView.option(theme, metrics, $eq.collections.item(items, i), i === completion.selected, columns, () => pick(index))); } let paged = new Row(4, 'start', 'start', false, null, null, { width: SizeValue.fill }); paged.add(new Flexible(page)); - paged.add(CodeCompletionView.pageMark(theme, metrics, top, rows, items.length)); + paged.add(CodeCompletionView.pageMark(theme, metrics, top, rows, $eq.collections.count(items))); let list = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); let documented = CodeCompletionView.documentation(context, metrics, documentation, documentationLines, above); if (above && !(documented == null)) list.add(documented); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiff.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiff.ts index ac42b1cc8..48afc8fa9 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiff.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiff.ts @@ -233,8 +233,8 @@ export class CodeDiff extends StatefulComponent { } startAtTheFirstChange(source: CodeDiffSource) { - if (source.changes.length === 0) return; - let change = source.changes[0]; + if ($eq.collections.count(source.changes) === 0) return; + let change = $eq.collections.item(source.changes, 0); (this._modified!).selection = new CodeRange(new CodePosition(Math.min(change.modifiedStart, this._modified.document.lineCount - 1), 0)); (this._original!).selection = new CodeRange(new CodePosition(Math.min(change.originalStart, this._original.document.lineCount - 1), 0)); } @@ -262,7 +262,7 @@ export class CodeDiff extends StatefulComponent { bar.add(new Flexible(new Text(title ?? '', 'label', theme.textSecondary, 1, 'start', false, false, null, 0, { mono: true }))); bar.add(new Text(`+${addedLines}`, 'labelSmall', theme.colors('success').base, 1, 'start', false, false, null, 0, { tabular: true })); bar.add(new Text(`−${removedLines}`, 'labelSmall', theme.colors('destructive').base, 1, 'start', false, false, null, 0, { tabular: true })); - let none = source.changes.length === 0; + let none = $eq.collections.count(source.changes) === 0; bar.add(new IconButton(new Icon(IconGlyph.fromIcons('chevronUp')), SdkStrings.previousChange, 'standard', 'medium', null, { size: 'small', disabled: none, onPressed: () => this.stepTo(false) })); bar.add(new IconButton(new Icon(IconGlyph.fromIcons('chevronDown')), SdkStrings.nextChange, 'standard', 'medium', null, { size: 'small', disabled: none, onPressed: () => this.stepTo(true) })); bar.add(new Button(inline ? SdkStrings.showSideBySide : SdkStrings.showInline, 'ghost', 'small', null, { onPressed: () => this.setState(() => { @@ -277,9 +277,9 @@ export class CodeDiff extends StatefulComponent { return Math.min(change.modifiedStart, modified.document.lineCount - 1); }; let source: any; let modified: any; let original: any; - if (!(((this._source != null && this._source.changes != null && this._source.changes.length > 0) && (source = this._source, true))) || !((modified = this._modified) != null) || !((original = this._original) != null)) return; + if (!(((this._source != null && this._source.changes != null && $eq.collections.count(this._source.changes) > 0) && (source = this._source, true))) || !((modified = this._modified) != null) || !((original = this._original) != null)) return; let caret = modified.caret.line; - let target = forward ? source.changes[0] : source.changes[source.changes.length - 1]; + let target = forward ? $eq.collections.item(source.changes, 0) : $eq.collections.item(source.changes, $eq.collections.count(source.changes) - 1); if (forward) { for (const change of source.changes) { if (lineOf(change) <= caret) continue; @@ -287,9 +287,9 @@ export class CodeDiff extends StatefulComponent { break; } } else { - for (let i = source.changes.length - 1; i >= 0; i--) { - if (lineOf(source.changes[i]) >= caret) continue; - target = source.changes[i]; + for (let i = $eq.collections.count(source.changes) - 1; i >= 0; i--) { + if (lineOf($eq.collections.item(source.changes, i)) >= caret) continue; + target = $eq.collections.item(source.changes, i); break; } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffLayout.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffLayout.ts index 4730a0ec4..196f0cee8 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffLayout.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffLayout.ts @@ -1,3 +1,3 @@ import { $eq, CodeCollapse, CodeDiffFold, CodeDiffGap, CodeFiller, CodeLineChange, CodeRows } from "../runtime-exports"; -export class CodeDiffLayout { declare original: CodeRows; declare modified: CodeRows; declare folds: CodeDiffFold[]; constructor(original: any = null, modified: any = null, folds: any = null) {this.original = original;this.modified = modified;this.folds = folds;} equals(o: unknown) { return o instanceof CodeDiffLayout && o.constructor === this.constructor && $eq.collections.sameItem(this.original, o.original) && $eq.collections.sameItem(this.modified, o.modified) && $eq.collections.sameItem(this.folds, o.folds); } with(patch: any): CodeDiffLayout { return $eq.withPatch(this, patch); } static $record = true; getHashCode(): number { return $eq.hash.combine(this.original, this.modified, this.folds); } static get $hydration() { return { folds: [CodeDiffFold] }; } static sideBySide(changes: CodeLineChange[], originalLines: number, modifiedLines: number, context: number = CodeDiffLayout.defaultContext, expanded: number[] | null = null, gaps: CodeDiffGap[] | null = null, foldLabel: ((value: number) => string) | null = null) {let originalFillers: CodeFiller[] = [];let modifiedFillers: CodeFiller[] = [];let gap = 0;for (const change of changes) {gap = CodeDiffLayout.addGapsBefore(change, gaps, gap, originalFillers, modifiedFillers);let difference = change.modifiedCount - change.originalCount;if (difference > 0) originalFillers.push(new CodeFiller(change.originalStart + change.originalCount, difference)); else if (difference < 0) modifiedFillers.push(new CodeFiller(change.modifiedStart + change.modifiedCount, -difference));}CodeDiffLayout.addGapsBefore(null, gaps, gap, originalFillers, modifiedFillers);let [originalRuns, modifiedRuns, folds] = CodeDiffLayout.unchangedRuns(changes, originalLines, modifiedLines, context, expanded, gaps, foldLabel);return new CodeDiffLayout(new CodeRows(originalLines, originalFillers, originalRuns), new CodeRows(modifiedLines, modifiedFillers, modifiedRuns), folds);} static inline(changes: CodeLineChange[], originalLines: number, modifiedLines: number, context: number = CodeDiffLayout.defaultContext, expanded: number[] | null = null, gaps: CodeDiffGap[] | null = null, foldLabel: ((value: number) => string) | null = null) {let originalGaps: CodeFiller[] = [];let removed: CodeFiller[] = [];let gap = 0;for (const change of changes) {gap = CodeDiffLayout.addGapsBefore(change, gaps, gap, originalGaps, removed);if (change.originalCount > 0) removed.push(new CodeFiller(change.modifiedStart, change.originalCount, change.originalStart));}CodeDiffLayout.addGapsBefore(null, gaps, gap, originalGaps, removed);let [originalRuns, modifiedRuns, folds] = CodeDiffLayout.unchangedRuns(changes, originalLines, modifiedLines, context, expanded, gaps, foldLabel);return new CodeDiffLayout(new CodeRows(originalLines, originalGaps, originalRuns), new CodeRows(modifiedLines, removed, modifiedRuns), folds);} static addGapsBefore(change: CodeLineChange | null, gaps: CodeDiffGap[] | null, next: number, original: CodeFiller[], modified: CodeFiller[]) {if (gaps == null) return next;while (next < gaps.length && (change == null || gaps[next].originalLine <= change.originalStart && gaps[next].modifiedLine <= change.modifiedStart)) {original.push(new CodeFiller(gaps[next].originalLine, 1, undefined, gaps[next].header));modified.push(new CodeFiller(gaps[next].modifiedLine, 1, undefined, gaps[next].header));next++;}return next;} static unchangedRuns(changes: CodeLineChange[], originalLines: number, modifiedLines: number, context: number, expanded: number[] | null, gaps: CodeDiffGap[] | null, foldLabel: ((value: number) => string) | null): [CodeCollapse[], CodeCollapse[], CodeDiffFold[]] {let original: CodeCollapse[] = [];let modified: CodeCollapse[] = [];let folds: CodeDiffFold[] = [];let originalAt = 0;let modifiedAt = 0;for (let i = 0; i <= changes.length; i++) {let originalEnd = i < changes.length ? changes[i].originalStart : originalLines;let modifiedEnd = i < changes.length ? changes[i].modifiedStart : modifiedLines;let length = Math.min(originalEnd - originalAt, modifiedEnd - modifiedAt);let before = i > 0 ? context : 0;let after = i < changes.length ? context : 0;let hidden = length - before - after;if (hidden >= CodeDiffLayout.fewestFolded && !((expanded == null ? null : $eq.collections.contains(expanded, originalAt + before)) ?? false) && !CodeDiffLayout.crossesAGap(gaps, originalAt + before, originalAt + before + hidden)) {let label = (foldLabel?.(hidden) ?? null);original.push(new CodeCollapse(originalAt + before, originalAt + before + hidden - 1, undefined, label));modified.push(new CodeCollapse(modifiedAt + before, modifiedAt + before + hidden - 1, undefined, label));folds.push(new CodeDiffFold(originalAt + before, modifiedAt + before, hidden));}if (i < changes.length) {originalAt = changes[i].originalStart + changes[i].originalCount;modifiedAt = changes[i].modifiedStart + changes[i].modifiedCount;}}return [original, modified, folds];} foldOfModified(line: number) {for (const fold of this.folds) if (line >= fold.modifiedLine && line < fold.modifiedLine + fold.count) return fold;return null;} foldOfOriginal(line: number) {for (const fold of this.folds) if (line >= fold.originalLine && line < fold.originalLine + fold.count) return fold;return null;} static crossesAGap(gaps: CodeDiffGap[] | null, from: number, to: number) {if (gaps == null) return false;for (const gap of gaps) if (gap.originalLine > from && gap.originalLine <= to) return true;return false;} static defaultContext = 3; static fewestFolded = 3; toString() { return `CodeDiffLayout { Original = ${this.original}, Modified = ${this.modified}, Folds = ${this.folds} }`; } } +export class CodeDiffLayout { declare original: CodeRows; declare modified: CodeRows; declare folds: CodeDiffFold[]; constructor(original: any = null, modified: any = null, folds: any = null) {this.original = original;this.modified = modified;this.folds = folds;} equals(o: unknown) { return o instanceof CodeDiffLayout && o.constructor === this.constructor && $eq.collections.sameItem(this.original, o.original) && $eq.collections.sameItem(this.modified, o.modified) && $eq.collections.sameItem(this.folds, o.folds); } with(patch: any): CodeDiffLayout { return $eq.withPatch(this, patch); } static $record = true; getHashCode(): number { return $eq.hash.combine(this.original, this.modified, this.folds); } static get $hydration() { return { folds: [CodeDiffFold] }; } static sideBySide(changes: CodeLineChange[], originalLines: number, modifiedLines: number, context: number = CodeDiffLayout.defaultContext, expanded: number[] | null = null, gaps: CodeDiffGap[] | null = null, foldLabel: ((value: number) => string) | null = null) {let originalFillers: CodeFiller[] = [];let modifiedFillers: CodeFiller[] = [];let gap = 0;for (const change of changes) {gap = CodeDiffLayout.addGapsBefore(change, gaps, gap, originalFillers, modifiedFillers);let difference = change.modifiedCount - change.originalCount;if (difference > 0) originalFillers.push(new CodeFiller(change.originalStart + change.originalCount, difference)); else if (difference < 0) modifiedFillers.push(new CodeFiller(change.modifiedStart + change.modifiedCount, -difference));}CodeDiffLayout.addGapsBefore(null, gaps, gap, originalFillers, modifiedFillers);let [originalRuns, modifiedRuns, folds] = CodeDiffLayout.unchangedRuns(changes, originalLines, modifiedLines, context, expanded, gaps, foldLabel);return new CodeDiffLayout(new CodeRows(originalLines, originalFillers, originalRuns), new CodeRows(modifiedLines, modifiedFillers, modifiedRuns), folds);} static inline(changes: CodeLineChange[], originalLines: number, modifiedLines: number, context: number = CodeDiffLayout.defaultContext, expanded: number[] | null = null, gaps: CodeDiffGap[] | null = null, foldLabel: ((value: number) => string) | null = null) {let originalGaps: CodeFiller[] = [];let removed: CodeFiller[] = [];let gap = 0;for (const change of changes) {gap = CodeDiffLayout.addGapsBefore(change, gaps, gap, originalGaps, removed);if (change.originalCount > 0) removed.push(new CodeFiller(change.modifiedStart, change.originalCount, change.originalStart));}CodeDiffLayout.addGapsBefore(null, gaps, gap, originalGaps, removed);let [originalRuns, modifiedRuns, folds] = CodeDiffLayout.unchangedRuns(changes, originalLines, modifiedLines, context, expanded, gaps, foldLabel);return new CodeDiffLayout(new CodeRows(originalLines, originalGaps, originalRuns), new CodeRows(modifiedLines, removed, modifiedRuns), folds);} static addGapsBefore(change: CodeLineChange | null, gaps: CodeDiffGap[] | null, next: number, original: CodeFiller[], modified: CodeFiller[]) {if (gaps == null) return next;while (next < $eq.collections.count(gaps) && (change == null || $eq.collections.item(gaps, next).originalLine <= change.originalStart && $eq.collections.item(gaps, next).modifiedLine <= change.modifiedStart)) {original.push(new CodeFiller($eq.collections.item(gaps, next).originalLine, 1, undefined, $eq.collections.item(gaps, next).header));modified.push(new CodeFiller($eq.collections.item(gaps, next).modifiedLine, 1, undefined, $eq.collections.item(gaps, next).header));next++;}return next;} static unchangedRuns(changes: CodeLineChange[], originalLines: number, modifiedLines: number, context: number, expanded: number[] | null, gaps: CodeDiffGap[] | null, foldLabel: ((value: number) => string) | null): [CodeCollapse[], CodeCollapse[], CodeDiffFold[]] {let original: CodeCollapse[] = [];let modified: CodeCollapse[] = [];let folds: CodeDiffFold[] = [];let originalAt = 0;let modifiedAt = 0;for (let i = 0; i <= $eq.collections.count(changes); i++) {let originalEnd = i < $eq.collections.count(changes) ? $eq.collections.item(changes, i).originalStart : originalLines;let modifiedEnd = i < $eq.collections.count(changes) ? $eq.collections.item(changes, i).modifiedStart : modifiedLines;let length = Math.min(originalEnd - originalAt, modifiedEnd - modifiedAt);let before = i > 0 ? context : 0;let after = i < $eq.collections.count(changes) ? context : 0;let hidden = length - before - after;if (hidden >= CodeDiffLayout.fewestFolded && !((expanded == null ? null : $eq.collections.contains(expanded, originalAt + before)) ?? false) && !CodeDiffLayout.crossesAGap(gaps, originalAt + before, originalAt + before + hidden)) {let label = (foldLabel?.(hidden) ?? null);original.push(new CodeCollapse(originalAt + before, originalAt + before + hidden - 1, undefined, label));modified.push(new CodeCollapse(modifiedAt + before, modifiedAt + before + hidden - 1, undefined, label));folds.push(new CodeDiffFold(originalAt + before, modifiedAt + before, hidden));}if (i < $eq.collections.count(changes)) {originalAt = $eq.collections.item(changes, i).originalStart + $eq.collections.item(changes, i).originalCount;modifiedAt = $eq.collections.item(changes, i).modifiedStart + $eq.collections.item(changes, i).modifiedCount;}}return [original, modified, folds];} foldOfModified(line: number) {for (const fold of this.folds) if (line >= fold.modifiedLine && line < fold.modifiedLine + fold.count) return fold;return null;} foldOfOriginal(line: number) {for (const fold of this.folds) if (line >= fold.originalLine && line < fold.originalLine + fold.count) return fold;return null;} static crossesAGap(gaps: CodeDiffGap[] | null, from: number, to: number) {if (gaps == null) return false;for (const gap of gaps) if (gap.originalLine > from && gap.originalLine <= to) return true;return false;} static defaultContext = 3; static fewestFolded = 3; toString() { return `CodeDiffLayout { Original = ${this.original}, Modified = ${this.modified}, Folds = ${this.folds} }`; } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffSource.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffSource.ts index 1f8fdf1eb..fd1fa9469 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffSource.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffSource.ts @@ -1,4 +1,4 @@ -import { CodeDiffer, CodeDiffGap, CodeDocument, CodeLineChange, CodePatchFile } from "../runtime-exports"; +import { $eq, CodeDiffer, CodeDiffGap, CodeDocument, CodeLineChange, CodePatchFile } from "../runtime-exports"; export class CodeDiffSource { constructor(original: CodeDocument, originalLineCount: number, modified: CodeDocument, modifiedLineCount: number, changes: CodeLineChange[], originalNumbers: number[] | null, modifiedNumbers: number[] | null, gaps: CodeDiffGap[]) { @@ -31,12 +31,12 @@ export class CodeDiffSource { originalNumber(line: number) { let numbers: any; - return (numbers = this._originalNumbers) != null ? numbers[line] : line + 1; + return (numbers = this._originalNumbers) != null ? $eq.collections.item(numbers, line) : line + 1; } modifiedNumber(line: number) { let numbers: any; - return (numbers = this._modifiedNumbers) != null ? numbers[line] : line + 1; + return (numbers = this._modifiedNumbers) != null ? $eq.collections.item(numbers, line) : line + 1; } static fromTexts(original: string, modified: string) { @@ -49,17 +49,17 @@ export class CodeDiffSource { originalLineOf(modifiedLine: number) { let low = 0; - let high = this.changes.length - 1; + let high = $eq.collections.count(this.changes) - 1; let found = -1; while (low <= high) { let middle = Math.trunc((low + high) / 2); - if (this.changes[middle].modifiedStart <= modifiedLine) { + if ($eq.collections.item(this.changes, middle).modifiedStart <= modifiedLine) { found = middle; low = middle + 1; } else high = middle - 1; } if (found < 0) return modifiedLine; - let change = this.changes[found]; + let change = $eq.collections.item(this.changes, found); if (modifiedLine < change.modifiedStart + change.modifiedCount) return -1; return change.originalStart + change.originalCount + (modifiedLine - change.modifiedStart - change.modifiedCount); } @@ -80,23 +80,23 @@ export class CodeDiffSource { let originalNumber = hunk.originalStart; let modifiedNumber = hunk.modifiedStart; let i = 0; - while (i < hunk.lines.length) { - if (hunk.lines[i].kind === 'context') { - originalLines.push(hunk.lines[i].text); + while (i < $eq.collections.count(hunk.lines)) { + if ($eq.collections.item(hunk.lines, i).kind === 'context') { + originalLines.push($eq.collections.item(hunk.lines, i).text); originalNumbers.push(++originalNumber); - modifiedLines.push(hunk.lines[i].text); + modifiedLines.push($eq.collections.item(hunk.lines, i).text); modifiedNumbers.push(++modifiedNumber); i++; continue; } let originalStart = originalLines.length; let modifiedStart = modifiedLines.length; - while (i < hunk.lines.length && hunk.lines[i].kind !== 'context') { - if (hunk.lines[i].kind === 'removed') { - originalLines.push(hunk.lines[i].text); + while (i < $eq.collections.count(hunk.lines) && $eq.collections.item(hunk.lines, i).kind !== 'context') { + if ($eq.collections.item(hunk.lines, i).kind === 'removed') { + originalLines.push($eq.collections.item(hunk.lines, i).text); originalNumbers.push(++originalNumber); } else { - modifiedLines.push(hunk.lines[i].text); + modifiedLines.push($eq.collections.item(hunk.lines, i).text); modifiedNumbers.push(++modifiedNumber); } i++; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffer.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffer.ts index 03dbe4873..46c424cb5 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffer.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeDiffer.ts @@ -35,13 +35,13 @@ export class CodeDiffer { } static idsOf(items: string[], ids: any) { - let result = new Array(items.length).fill(0); + let result = new Array($eq.collections.count(items)).fill(0); let next = ids.size; - for (let i = 0; i < items.length; i++) { + for (let i = 0; i < $eq.collections.count(items); i++) { let id: any; - if (!(($0, $1) => ($0.has($1) ? ((id = $0.get($1)), true) : ((id = 0), false)))(ids, items[i])) { + if (!(($0, $1) => ($0.has($1) ? ((id = $0.get($1)), true) : ((id = 0), false)))(ids, $eq.collections.item(items, i))) { id = next++; - $eq.mapSet(ids, items[i], id); + $eq.mapSet(ids, $eq.collections.item(items, i), id); } result[i] = id; } @@ -158,8 +158,8 @@ export class CodeDiffer { let removed = new Array(a.length).fill(false); let added = new Array(b.length).fill(false); CodeDiffer.diff(a, 0, a.length, b, 0, b.length, removed, added); - let originalEnd = new CodePosition(originalStart + originalCount - 1, original[originalStart + originalCount - 1].length); - let modifiedEnd = new CodePosition(modifiedStart + modifiedCount - 1, modified[modifiedStart + modifiedCount - 1].length); + let originalEnd = new CodePosition(originalStart + originalCount - 1, $eq.collections.item(original, originalStart + originalCount - 1).length); + let modifiedEnd = new CodePosition(modifiedStart + modifiedCount - 1, $eq.collections.item(modified, modifiedStart + modifiedCount - 1).length); let inner: CodeInnerChange[] = []; let i = 0; let j = 0; @@ -184,10 +184,10 @@ export class CodeDiffer { if (line > start) { texts.push('\n'); tokenLines.push(line - 1); - tokenColumns.push(lines[line - 1].length); + tokenColumns.push($eq.collections.item(lines, line - 1).length); if (texts.length > CodeDiffer.innerTokenLimit) return false; } - let text = lines[line]; + let text = $eq.collections.item(lines, line); let column = 0; while (column < text.length) { let begin = column; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeDocument.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeDocument.ts index 59d88e7e2..3ebfdcd1c 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeDocument.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeDocument.ts @@ -57,7 +57,7 @@ export class CodeDocument { } static fromLines(lines: string[]) { - return new CodeDocument(lines.length === 0 ? [''] : lines.slice()); + return new CodeDocument($eq.collections.count(lines) === 0 ? [''] : lines.slice()); } static fromText(text: string) { @@ -132,7 +132,7 @@ export class CodeDocument { let start = this.clamp(range.start); let end = this.clamp(range.end); let inserted = CodeDocument.fromText(text); - let lines: string[] = []; + let lines: string[] = ($eq.collections.listCapacity(this._lines.length + inserted.lineCount), []); for (let i = 0; i < start.line; i++) lines.push(this._lines[i]); let head = this._lines[start.line].slice(0, start.column); let tail = this._lines[end.line].slice(end.column); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeEdit.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeEdit.ts index ca6a049cf..955c0b6c8 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeEdit.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeEdit.ts @@ -1,3 +1,3 @@ import { $eq, CodeDocument, CodePosition, CodeRange } from "../runtime-exports"; -export class CodeEdit { declare range: CodeRange; declare removedText: string; declare insertedText: string; declare selectionBefore: CodeRange; declare selectionAfter: CodeRange; declare typed: boolean; constructor(range: any = CodeRange.$zero(), removedText: any = null, insertedText: any = null, selectionBefore: any = CodeRange.$zero(), selectionAfter: any = CodeRange.$zero(), typed: any = false) {this.range = range;this.removedText = removedText;this.insertedText = insertedText;this.selectionBefore = selectionBefore;this.selectionAfter = selectionAfter;this.typed = typed;} equals(o: unknown) { return o instanceof CodeEdit && o.constructor === this.constructor && $eq.equals(this.range, o.range) && $eq.equals(this.removedText, o.removedText) && $eq.equals(this.insertedText, o.insertedText) && $eq.equals(this.selectionBefore, o.selectionBefore) && $eq.equals(this.selectionAfter, o.selectionAfter) && $eq.equals(this.typed, o.typed); } with(patch: any): CodeEdit { return $eq.withPatch(this, patch); } static $record = true; getHashCode(): number { return $eq.hash.combine(this.range, this.removedText, this.insertedText, this.selectionBefore, this.selectionAfter, this.typed); } static get $hydration() { return { range: CodeRange, selectionBefore: CodeRange, selectionAfter: CodeRange }; } get insertedRange() {let start = this.range.start;let lines = CodeDocument.fromText(this.insertedText).lines;let end = lines.length === 1 ? new CodePosition(start.line, start.column + lines[0].length) : new CodePosition(start.line + lines.length - 1, lines[lines.length - 1].length);return new CodeRange(start, end);} get isSimpleInsert() {return this.removedText.length === 0 && !this.breaksLine;} get breaksLine() {return this.insertedText.includes('\n') || this.insertedText.includes('\r');} toString() { return `CodeEdit { Range = ${this.range}, RemovedText = ${this.removedText}, InsertedText = ${this.insertedText}, SelectionBefore = ${this.selectionBefore}, SelectionAfter = ${this.selectionAfter}, Typed = ${this.typed}, InsertedRange = ${this.insertedRange}, IsSimpleInsert = ${this.isSimpleInsert} }`; } } +export class CodeEdit { declare range: CodeRange; declare removedText: string; declare insertedText: string; declare selectionBefore: CodeRange; declare selectionAfter: CodeRange; declare typed: boolean; constructor(range: any = CodeRange.$zero(), removedText: any = null, insertedText: any = null, selectionBefore: any = CodeRange.$zero(), selectionAfter: any = CodeRange.$zero(), typed: any = false) {this.range = range;this.removedText = removedText;this.insertedText = insertedText;this.selectionBefore = selectionBefore;this.selectionAfter = selectionAfter;this.typed = typed;} equals(o: unknown) { return o instanceof CodeEdit && o.constructor === this.constructor && $eq.equals(this.range, o.range) && $eq.equals(this.removedText, o.removedText) && $eq.equals(this.insertedText, o.insertedText) && $eq.equals(this.selectionBefore, o.selectionBefore) && $eq.equals(this.selectionAfter, o.selectionAfter) && $eq.equals(this.typed, o.typed); } with(patch: any): CodeEdit { return $eq.withPatch(this, patch); } static $record = true; getHashCode(): number { return $eq.hash.combine(this.range, this.removedText, this.insertedText, this.selectionBefore, this.selectionAfter, this.typed); } static get $hydration() { return { range: CodeRange, selectionBefore: CodeRange, selectionAfter: CodeRange }; } get insertedRange() {let start = this.range.start;let lines = CodeDocument.fromText(this.insertedText).lines;let end = $eq.collections.count(lines) === 1 ? new CodePosition(start.line, start.column + $eq.collections.item(lines, 0).length) : new CodePosition(start.line + $eq.collections.count(lines) - 1, $eq.collections.item(lines, $eq.collections.count(lines) - 1).length);return new CodeRange(start, end);} get isSimpleInsert() {return this.removedText.length === 0 && !this.breaksLine;} get breaksLine() {return this.insertedText.includes('\n') || this.insertedText.includes('\r');} toString() { return `CodeEdit { Range = ${this.range}, RemovedText = ${this.removedText}, InsertedText = ${this.insertedText}, SelectionBefore = ${this.selectionBefore}, SelectionAfter = ${this.selectionAfter}, Typed = ${this.typed}, InsertedRange = ${this.insertedRange}, IsSimpleInsert = ${this.isSimpleInsert} }`; } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeEditor.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeEditor.ts index b70089ed0..9a4157ab3 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeEditor.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeEditor.ts @@ -95,20 +95,20 @@ export class CodeEditor extends StatefulComponent { this._measured = items; this._listColumns = Math.max(this._listColumns, CodeCompletionView.columnsOf(items)); } - this._listColumns = Math.max(this._listColumns, CodeCompletionView.entryColumns(items[completion.selected].item)); + this._listColumns = Math.max(this._listColumns, CodeCompletionView.entryColumns($eq.collections.item(items, completion.selected).item)); let width = CodeCompletionView.widthOf(metrics, this._listColumns); if (this._viewportWidth > 0) width = Math.min(width, this._viewportWidth); - let documentation = items[completion.selected].item.documentation; + let documentation = $eq.collections.item(items, completion.selected).item.documentation; let documentationLines = (documentation != null && documentation.length > 0) ? CodeCompletionView.documentationLinesOf(context, metrics, documentation, width) : 0; let codeHeight = Math.fround(Math.fround(2 * metrics.contentTop) + Math.fround(Math.fround(editor.document.lineCount) * metrics.lineHeight)); let surfaceHeight = bounded ? Math.max(codeHeight, this._viewport) : codeHeight; let viewTop = windowed ? this._offset : 0; let viewBottom = windowed && this._viewport > 0 ? Math.min(Math.fround(this._offset + this._viewport), surfaceHeight) : surfaceHeight; - let [x, y, rows, above, documentationShown] = CodeCompletionView.place(metrics, editor.caretRect(completion.start), viewTop, viewBottom, this._scrollX, this._viewportWidth, Math.min(12, items.length), width, documentationLines, CodeCompletionView.documentationLineOf(context, metrics)); + let [x, y, rows, above, documentationShown] = CodeCompletionView.place(metrics, editor.caretRect(completion.start), viewTop, viewBottom, this._scrollX, this._viewportWidth, Math.min(12, $eq.collections.count(items)), width, documentationLines, CodeCompletionView.documentationLineOf(context, metrics)); let selected = completion.selected; if (selected < this._listTop) this._listTop = selected; if (selected >= this._listTop + rows) this._listTop = selected - rows + 1; - this._listTop = Math.max(0, Math.min(this._listTop, items.length - rows)); + this._listTop = Math.max(0, Math.min(this._listTop, $eq.collections.count(items) - rows)); completion.pageSize = rows; offered = CodeCompletionView.build(context, completion, metrics, this._listTop, rows, width, above, documentation, documentationShown, (index: number) => this.pick(editor, index)); offeredAt = new Point(x, y); @@ -172,12 +172,12 @@ export class CodeEditor extends StatefulComponent { for (const provider of this._put) CodeEditor.removeInstance(providers, provider); this._put = this.completions == null ? [new CodeKeywordCompletionProvider(), new CodeWordCompletionProvider()] : [...this.completions]; this._handed = this.completions == null ? null : this._put; - for (const provider of this._put) providers.push(provider); + for (const provider of this._put) $eq.collections.add(providers, provider); } static removeInstance(providers: any[], provider: any) { - for (let i = 0; i < providers.length; i++) { - if (!(providers[i] === provider)) continue; + for (let i = 0; i < $eq.collections.count(providers); i++) { + if (!($eq.collections.item(providers, i) === provider)) continue; providers.splice(i, 1); return; } @@ -185,9 +185,9 @@ export class CodeEditor extends StatefulComponent { static sameProviders(one: any, other: any) { if (one == null || other == null) return one == null && other == null; - if (one.length !== other.length) return false; - for (let i = 0; i < one.length; i++) { - if (!(one[i] === other[i])) return false; + if ($eq.collections.count(one) !== $eq.collections.count(other)) return false; + for (let i = 0; i < $eq.collections.count(one); i++) { + if (!($eq.collections.item(one, i) === $eq.collections.item(other, i))) return false; } return true; } @@ -211,14 +211,14 @@ export class CodeEditor extends StatefulComponent { } marks(editor: CodeEditorController, matches: CodeRange[], first: number, last: number) { - if (matches.length === 0 && !this.matchBrackets && editor.composition == null) return this.decorations; + if ($eq.collections.count(matches) === 0 && !this.matchBrackets && editor.composition == null) return this.decorations; let marks: CodeDecoration[] = [...this.decorations]; let composition: any; if ((composition = editor.composition) != null) marks.push(new CodeDecoration(composition, 'underline')); - if (matches.length > 0) { + if ($eq.collections.count(matches) > 0) { let current = editor.selection; - for (let i = CodeEditor.firstEndingOnOrAfter(matches, first); i < matches.length && matches[i].start.line <= last; i++) { - let match = matches[i]; + for (let i = CodeEditor.firstEndingOnOrAfter(matches, first); i < $eq.collections.count(matches) && $eq.collections.item(matches, i).start.line <= last; i++) { + let match = $eq.collections.item(matches, i); marks.push(new CodeDecoration(match, $eq.equals(match.start, current.start) && $eq.equals(match.end, current.end) ? 'outline' : 'highlight')); } } @@ -267,10 +267,10 @@ export class CodeEditor extends StatefulComponent { static firstEndingOnOrAfter(matches: CodeRange[], line: number) { let low = 0; - let high = matches.length; + let high = $eq.collections.count(matches); while (low < high) { let middle = Math.trunc((low + high) / 2); - if (matches[middle].end.line < line) low = middle + 1; else high = middle; + if ($eq.collections.item(matches, middle).end.line < line) low = middle + 1; else high = middle; } return low; } @@ -297,15 +297,15 @@ export class CodeEditor extends StatefulComponent { let index = 0; let current = editor.selection.start; let low = 0; - let high = matches.length; + let high = $eq.collections.count(matches); while (low < high) { let middle = Math.trunc((low + high) / 2); - if (matches[middle].start.compareTo(current) < 0) low = middle + 1; else high = middle; + if ($eq.collections.item(matches, middle).start.compareTo(current) < 0) low = middle + 1; else high = middle; } - if (low < matches.length && $eq.equals(matches[low].start, current)) index = low + 1; + if (low < $eq.collections.count(matches) && $eq.equals($eq.collections.item(matches, low).start, current)) index = low + 1; let row = new Row(8, 'start', 'center', false, null, null, { cross: 'center' }); row.add(new Box(new BoxStyle({ width: 168 }), new TextEntry(this._findText, (value: string) => this.setState(() => this._findText = value), { placeholder: SdkStrings.find, label: SdkStrings.find, autofocus: true, onSubmit: () => step(true) }))); - row.add(new Text(matches.length === 0 ? this._findText.length === 0 ? '' : '0' : `${index}/${matches.length}`, 'labelSmall', theme.textMuted, 1, 'start', false, false, null, 0, { tabular: true })); + row.add(new Text($eq.collections.count(matches) === 0 ? this._findText.length === 0 ? '' : '0' : `${index}/${$eq.collections.count(matches)}`, 'labelSmall', theme.textMuted, 1, 'start', false, false, null, 0, { tabular: true })); row.add(new IconButton(new Icon(IconGlyph.fromIcons('chevronUp')), SdkStrings.previousMatch, 'standard', 'medium', null, { size: 'small', onPressed: () => step(false) })); row.add(new IconButton(new Icon(IconGlyph.fromIcons('chevronDown')), SdkStrings.nextMatch, 'standard', 'medium', null, { size: 'small', onPressed: () => step(true) })); row.add(new IconButton(new Icon(IconGlyph.fromIcons('close')), SdkStrings.closeFind, 'standard', 'medium', null, { size: 'small', onPressed: () => this.closeFind(editor) })); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodeEditorController.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodeEditorController.ts index fca8da098..e372a56c8 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodeEditorController.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodeEditorController.ts @@ -98,7 +98,7 @@ export class CodeEditorController { get widestLine(): number { let tabSize = this.rules.indentWidth; if (this._widths == null || this._widthsTabs !== tabSize) { - this._widths = []; + this._widths = ($eq.collections.listCapacity(this._document.lineCount), []); for (let line = 0; line < this._document.lineCount; line++) this._widths.push(CodeLineCells.widthOf(this._document.line(line), tabSize)); this._widthsTabs = tabSize; this._widest = CodeEditorController.widest(this._widths); @@ -151,7 +151,7 @@ export class CodeEditorController { if (lostTheWidest && widestHere < this._widest) this._widest = CodeEditorController.widest(old); else if (widestHere > this._widest) this._widest = widestHere; return; } - let next: number[] = []; + let next: number[] = ($eq.collections.listCapacity(old.length - gone + linesInserted + 1), []); for (let i = 0; i < line; i++) next.push(old[i]); let measuredWidest = 0; for (let i = line; i <= line + linesInserted; i++) { @@ -792,21 +792,21 @@ export class CodeEditorController { } nextOf(matches: CodeRange[], backward: boolean = false) { - if (matches.length === 0) return null; + if ($eq.collections.count(matches) === 0) return null; let low = 0; - let high = matches.length; + let high = $eq.collections.count(matches); if (backward) { while (low < high) { let middle = Math.trunc((low + high) / 2); - if (CodePosition.opLessOrEqual(matches[middle].end, this._selection.start)) low = middle + 1; else high = middle; + if (CodePosition.opLessOrEqual($eq.collections.item(matches, middle).end, this._selection.start)) low = middle + 1; else high = middle; } - return low > 0 ? matches[low - 1] : matches[matches.length - 1]; + return low > 0 ? $eq.collections.item(matches, low - 1) : $eq.collections.item(matches, $eq.collections.count(matches) - 1); } while (low < high) { let middle = Math.trunc((low + high) / 2); - if (CodePosition.opLessThan(matches[middle].start, this._selection.end)) low = middle + 1; else high = middle; + if (CodePosition.opLessThan($eq.collections.item(matches, middle).start, this._selection.end)) low = middle + 1; else high = middle; } - return low < matches.length ? matches[low] : matches[0]; + return low < $eq.collections.count(matches) ? $eq.collections.item(matches, low) : $eq.collections.item(matches, 0); } bracketAtCaret(): [CodePosition, CodePosition] | null { diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CodePatch.ts b/src/eQuantic.UI.Runtime/src/shared/components/CodePatch.ts index d51cd24ba..5453ede4b 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CodePatch.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CodePatch.ts @@ -58,14 +58,14 @@ export class CodePatch { if (parts.length !== 2 || !parts[0].startsWith('-') || !parts[1].startsWith('+')) return null; let original: any; let modified: any; if (!((original = CodePatch.range(parts[0].slice(1))) != null) || !((modified = CodePatch.range(parts[1].slice(1))) != null)) return null; - let section = close + 3 < line.length ? $eq.text.trimStart(line.slice((close + 3))) : ''; + let section = close + 3 < line.length ? $eq.text.trimStart(line.slice(close + 3)) : ''; return [original[0], original[1], modified[0], modified[1], section]; } static range(text: string): [number, number] | null { let comma = text.indexOf(','); let lineText = comma < 0 ? text : text.slice(0, comma); - let countText = comma < 0 ? '1' : text.slice((comma + 1)); + let countText = comma < 0 ? '1' : text.slice(comma + 1); let line: any; let count: any; if (!((line = $eq.num.intTryParse(lineText, 'int')) !== undefined || ((line = 0), false)) || !((count = $eq.num.intTryParse(countText, 'int')) !== undefined || ((count = 0), false))) return null; return [line, count]; @@ -114,7 +114,7 @@ export class CodePatch { static gitHeaderPaths(text: string): [string | null, string | null] { let split = $eq.text.lastIndexOf(text, ' b/', 'ordinal'); if (split < 0) return [null, null]; - return [CodePatch.pathOf(text.slice(0, split)), CodePatch.pathOf(text.slice((split + 1)))]; + return [CodePatch.pathOf(text.slice(0, split)), CodePatch.pathOf(text.slice(split + 1))]; } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/CultureSwitcher.ts b/src/eQuantic.UI.Runtime/src/shared/components/CultureSwitcher.ts index 0f46e5ade..defb67483 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/CultureSwitcher.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/CultureSwitcher.ts @@ -1,4 +1,4 @@ -import { Box, BuildContext, Button, CultureOption, Menu, MenuItem, SegmentedControl, SizeVariantValue, StatelessComponent } from "../runtime-exports"; +import { $eq, Box, BuildContext, Button, CultureOption, Menu, MenuItem, SegmentedControl, SizeVariantValue, StatelessComponent } from "../runtime-exports"; export class CultureSwitcher extends StatelessComponent { static $typeId = 'eQuantic.UI.Components.CultureSwitcher'; @@ -18,18 +18,18 @@ export class CultureSwitcher extends StatelessComponent { } build(context: BuildContext) { - if (this.options.length === 0) return new Box(); + if ($eq.collections.count(this.options) === 0) return new Box(); let controller = context.getService('ICultureController'); let current = controller?.uICulture ?? ''; let selected = 0; - for (let i = 0; i < this.options.length; i++) { - if (this.options[i].name === current) { + for (let i = 0; i < $eq.collections.count(this.options); i++) { + if ($eq.collections.item(this.options, i).name === current) { selected = i; break; } - if (CultureSwitcher.languageOf(this.options[i].name) === CultureSwitcher.languageOf(current)) selected = i; + if (CultureSwitcher.languageOf($eq.collections.item(this.options, i).name) === CultureSwitcher.languageOf(current)) selected = i; } - if (this.shape !== 'menu' && (this.shape === 'segments' || this.options.length <= 3)) { + if (this.shape !== 'menu' && (this.shape === 'segments' || $eq.collections.count(this.options) <= 3)) { let labels: string[] = []; for (const option of this.options) labels.push(option.label); return new SegmentedControl(labels, selected, (index: number) => this.switch(controller, index), { size: this.size, stretch: false }); @@ -39,14 +39,14 @@ export class CultureSwitcher extends StatelessComponent { let flag: any; items.push(new MenuItem(((option.flag != null && option.flag.length > 0) && (flag = option.flag, true)) ? `${flag} ${option.label}` : option.label)); } - let chosen = this.options[selected]; + let chosen = $eq.collections.item(this.options, selected); let code: any; return new Menu(new Button(((chosen.short != null && chosen.short.length > 0) && (code = chosen.short, true)) ? code : chosen.label, 'ghost', this.size, null, { leading: this.icon }), items, (index: number) => this.switch(controller, index)); } switch(controller: any, index: number) { - if (index < 0 || index >= this.options.length) return; - let option = this.options[index]; + if (index < 0 || index >= $eq.collections.count(this.options)) return; + let option = $eq.collections.item(this.options, index); controller?.apply(option.name, option.name); this.onChanged?.(option.name); } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/DataTable.ts b/src/eQuantic.UI.Runtime/src/shared/components/DataTable.ts index 019e6f4c7..7084297ba 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/DataTable.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/DataTable.ts @@ -38,7 +38,7 @@ export class DataTable extends StatelessComponent { let table = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); table.add(this.header(theme, tracks)); let empty: any; - if (this.rows.length === 0 && this.pendingRows === 0 && (empty = this.empty) != null) { + if ($eq.collections.count(this.rows) === 0 && this.pendingRows === 0 && (empty = this.empty) != null) { table.add(empty); return table; } @@ -48,10 +48,10 @@ export class DataTable extends StatelessComponent { } tracks() { - let tracks = new Array(this.columns.length + (this.selectable ? 1 : 0)).fill(undefined); + let tracks = new Array($eq.collections.count(this.columns) + (this.selectable ? 1 : 0)).fill(undefined); let offset = 0; if (this.selectable) tracks[offset++] = GridTrack.fixed(DataTable.checkboxTrack); - for (let i = 0; i < this.columns.length; i++) tracks[offset + i] = this.columns[i].track; + for (let i = 0; i < $eq.collections.count(this.columns); i++) tracks[offset + i] = $eq.collections.item(this.columns, i).track; return tracks; } @@ -59,10 +59,10 @@ export class DataTable extends StatelessComponent { let grid = new Grid(tracks, 0, null, { width: SizeValue.fill }); if (this.selectable) { let anySelected = $eq.collections.count(this.selection!) > 0; - grid.add(DataTable.cell(new Box(new BoxStyle(), new Checkbox(anySelected, this.onToggleAll, null, { indeterminate: anySelected && $eq.collections.count(this.selection) < this.rows.length })), 'center')); + grid.add(DataTable.cell(new Box(new BoxStyle(), new Checkbox(anySelected, this.onToggleAll, null, { indeterminate: anySelected && $eq.collections.count(this.selection) < $eq.collections.count(this.rows) })), 'center')); } - for (let i = 0; i < this.columns.length; i++) { - let column = this.columns[i]; + for (let i = 0; i < $eq.collections.count(this.columns); i++) { + let column = $eq.collections.item(this.columns, i); let sorted = i === this.sortColumn && this.sortDirection !== 'none'; let label = new Row(4, 'start', 'center', false, null, null, { width: SizeValue.fill, cross: 'center', main: column.align === 'start' ? 'start' : 'end' }); label.add(new Text(column.header.toUpperCase(), 'caption', sorted ? theme.textPrimary : theme.textMuted, 1)); @@ -83,7 +83,7 @@ export class DataTable extends StatelessComponent { let toggle = this.onToggleRow; grid.add(DataTable.cell(new Box(new BoxStyle(), new Checkbox(selected, () => toggle?.(row), null, { disabled: toggle == null })), 'center')); } - for (let i = 0; i < this.columns.length && i < row.cells.length; i++) grid.add(DataTable.cell(row.cells[i], this.columns[i].align)); + for (let i = 0; i < $eq.collections.count(this.columns) && i < $eq.collections.count(row.cells); i++) grid.add(DataTable.cell($eq.collections.item(row.cells, i), $eq.collections.item(this.columns, i).align)); let box = new Box(new BoxStyle({ width: SizeValue.fill, minHeight: DataTable.rowHeight, background: selected ? theme.colors('primary').subtle : null, borderWidth: 1, borderColor: theme.border, hover: new StyleDiff({ background: theme.surfaceSubtle }), transition: TransitionSpec.of(1, Motion.press) }), grid, { key: row.key }); return this.onRowPressed == null ? box : new Pressable(box, () => this.onRowPressed!(row)); } @@ -91,8 +91,8 @@ export class DataTable extends StatelessComponent { pending(theme: any, tracks: GridTrack[], index: number) { let grid = new Grid(tracks, 0, null, { width: SizeValue.fill }); if (this.selectable) grid.add(DataTable.cell(new Box(new BoxStyle(), new Skeleton('block', 16, 16)), 'center')); - for (let i = 0; i < this.columns.length; i++) { - grid.add(DataTable.cell(new Box(new BoxStyle(), new Skeleton('line', i % 2 === 0 ? 96 : 64)), this.columns[i].align)); + for (let i = 0; i < $eq.collections.count(this.columns); i++) { + grid.add(DataTable.cell(new Box(new BoxStyle(), new Skeleton('line', i % 2 === 0 ? 96 : 64)), $eq.collections.item(this.columns, i).align)); } return new Box(new BoxStyle({ width: SizeValue.fill, minHeight: DataTable.rowHeight, borderWidth: 1, borderColor: theme.border }), grid, { key: `pending-${index}` }); } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/List.ts b/src/eQuantic.UI.Runtime/src/shared/components/List.ts index 6422eec33..c234549b9 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/List.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/List.ts @@ -1,4 +1,4 @@ -import { BuildContext, Column, Divider, ListItem, SizeValue, StatelessComponent } from "../runtime-exports"; +import { $eq, BuildContext, Column, Divider, ListItem, SizeValue, StatelessComponent } from "../runtime-exports"; export class List extends StatelessComponent { static $typeId = 'eQuantic.UI.Components.List'; @@ -17,9 +17,9 @@ export class List extends StatelessComponent { build(_context: BuildContext) { let column = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = 0; i < this.items.length; i++) { - column.add(this.items[i]); - if (this.dividers && i < this.items.length - 1) column.add(new Divider('leading', 'horizontal', { leadingInset: this.items[i].contentInset })); + for (let i = 0; i < $eq.collections.count(this.items); i++) { + column.add($eq.collections.item(this.items, i)); + if (this.dividers && i < $eq.collections.count(this.items) - 1) column.add(new Divider('leading', 'horizontal', { leadingInset: $eq.collections.item(this.items, i).contentInset })); } return column; } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/MarkdownParser.ts b/src/eQuantic.UI.Runtime/src/shared/components/MarkdownParser.ts index 69a8759f0..eeeab5740 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/MarkdownParser.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/MarkdownParser.ts @@ -126,7 +126,7 @@ export class MarkdownParser { let ch = plain[i]; if ((/^\p{L}$/u.test(ch)) || (/^\p{Nd}$/u.test(ch))) slug += ch; else if (slug.length > 0 && slug[slug.length - 1] !== '-') slug += '-'; } - while (slug.length > 0 && slug[slug.length - 1] === '-') slug = slug.slice(0, (slug.length - 1)); + while (slug.length > 0 && slug[slug.length - 1] === '-') slug = slug.slice(0, slug.length - 1); return slug; } @@ -160,7 +160,7 @@ export class MarkdownParser { clean.push(''); continue; } - text = text.slice((close + 3)); + text = text.slice(close + 3); open = false; } while (true) { @@ -172,7 +172,7 @@ export class MarkdownParser { open = true; break; } - text = text.slice(0, start) + text.slice((end + 3)); + text = text.slice(0, start) + text.slice(end + 3); } clean.push($eq.text.trimEnd(text)); } @@ -194,7 +194,7 @@ export class MarkdownParser { let cells: string[] = []; let t = $eq.text.trim(line); if (t.length > 0 && t[0] === '|') t = t.slice(1); - if (t.length > 0 && t[t.length - 1] === '|') t = t.slice(0, (t.length - 1)); + if (t.length > 0 && t[t.length - 1] === '|') t = t.slice(0, t.length - 1); let cell = ''; let inCode = false; for (let i = 0; i < t.length; i++) { @@ -221,7 +221,7 @@ export class MarkdownParser { while (digits < t.length && (/^\p{Nd}$/u.test(t[digits]))) digits++; if (digits > 0 && digits + 1 < t.length && t[digits] === '.' && t[digits + 1] === ' ') { let $n12: any; - return ($n12 = new MarkdownBulletMatch(), $n12.marker = t.slice(0, digits) + '.', $n12.content = $eq.text.trim(t.slice((digits + 2))), $n12); + return ($n12 = new MarkdownBulletMatch(), $n12.marker = t.slice(0, digits) + '.', $n12.content = $eq.text.trim(t.slice(digits + 2)), $n12); } return null; } @@ -238,7 +238,7 @@ export class MarkdownParser { if (end > i) { buffer = MarkdownParser.flushText(runs, buffer); let $n13: any; - runs.push(($n13 = new MarkdownRun(), $n13.text = text.slice((i + 1), end), $n13.code = true, $n13)); + runs.push(($n13 = new MarkdownRun(), $n13.text = text.slice(i + 1, end), $n13.code = true, $n13)); i = end + 1; continue; } @@ -262,7 +262,7 @@ export class MarkdownParser { let end = $eq.text.indexOf(text, '**', i + 2, 'ordinal'); if (end > i) { buffer = MarkdownParser.flushText(runs, buffer); - for (const run of MarkdownParser.inline(text.slice((i + 2), end))) { + for (const run of MarkdownParser.inline(text.slice(i + 2, end))) { run.bold = true; runs.push(run); } @@ -282,7 +282,7 @@ export class MarkdownParser { } if (end > i + 1) { buffer = MarkdownParser.flushText(runs, buffer); - for (const run of MarkdownParser.inline(text.slice((i + 1), end))) { + for (const run of MarkdownParser.inline(text.slice(i + 1, end))) { run.italic = true; runs.push(run); } @@ -317,7 +317,7 @@ export class MarkdownParser { let hrefEnd = $eq.text.indexOfChar(text, ')', close + 2); if (hrefEnd <= close) return null; let $n15: any; - return ($n15 = new MarkdownLinkMatch(), $n15.label = text.slice((open + 1), close), $n15.href = text.slice((close + 2), hrefEnd), $n15.end = hrefEnd + 1, $n15); + return ($n15 = new MarkdownLinkMatch(), $n15.label = text.slice(open + 1, close), $n15.href = text.slice(close + 2, hrefEnd), $n15.end = hrefEnd + 1, $n15); } static addLinkRuns(runs: MarkdownRun[], label: string, href: string) { diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Menu.ts b/src/eQuantic.UI.Runtime/src/shared/components/Menu.ts index 2778117af..21237dd01 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Menu.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Menu.ts @@ -1,4 +1,4 @@ -import { Anchored, AnchorPlacementValue, Box, BoxStyle, BuildContext, Column, CornerRadii, EdgeInsets, Icon, IconGlyph, KeyChord, MenuItem, Pressable, Row, Shortcut, SizeValue, Sizing, StatefulComponent, StyleDiff, Text, UiComponent, VisualNode } from "../runtime-exports"; +import { $eq, Anchored, AnchorPlacementValue, Box, BoxStyle, BuildContext, Column, CornerRadii, EdgeInsets, Icon, IconGlyph, KeyChord, MenuItem, Pressable, Row, Shortcut, SizeValue, Sizing, StatefulComponent, StyleDiff, Text, UiComponent, VisualNode } from "../runtime-exports"; export class Menu extends StatefulComponent { static $typeId = 'eQuantic.UI.Components.Menu'; @@ -24,8 +24,8 @@ export class Menu extends StatefulComponent { build(context: BuildContext) { let theme = context.theme; let list = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = 0; i < this.items.length; i++) { - let item = this.items[i]; + for (let i = 0; i < $eq.collections.count(this.items); i++) { + let item = $eq.collections.item(this.items, i); let index = i; let row = new Row(8, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: SizeValue.fill }); let glyph: any; @@ -36,7 +36,7 @@ export class Menu extends StatefulComponent { } let panel = new Box(new BoxStyle({ minWidth: 180, background: theme.surface, cornerRadius: new CornerRadii(theme.shape('medium')), borderWidth: 1, borderColor: theme.border, elevation: 2, padding: EdgeInsets.symmetric(0, 4), clip: true }), list); let menu: VisualNode = new Anchored(new Pressable(this.trigger, this.toggle.bind(this), { expanded: this._open }), panel, { placement: this.placement, open: this._open, onDismiss: () => this.setState(() => this._open = false), panelRole: 'menu', activeIndex: this._open ? this._highlight : -1 }); - let live = this._open && this.items.length > 0; + let live = this._open && $eq.collections.count(this.items) > 0; menu = new Shortcut(menu, KeyChord.arrowDown, () => this.setState(() => this._highlight = this.step(+1)), { enabled: live }); menu = new Shortcut(menu, KeyChord.arrowUp, () => this.setState(() => this._highlight = this.step(-1)), { enabled: live }); menu = new Shortcut(menu, KeyChord.enter, () => this.choose(this._highlight), { enabled: live }); @@ -59,18 +59,18 @@ export class Menu extends StatefulComponent { } choose(index: number) { - if (index < 0 || index >= this.items.length || this.items[index].disabled) return; + if (index < 0 || index >= $eq.collections.count(this.items) || $eq.collections.item(this.items, index).disabled) return; this.onSelect?.(index); this.setState(() => this._open = false); } firstEnabled() { - for (let i = 0; i < this.items.length; i++) if (!this.items[i].disabled) return i; + for (let i = 0; i < $eq.collections.count(this.items); i++) if (!$eq.collections.item(this.items, i).disabled) return i; return 0; } step(direction: number) { - for (let i = this._highlight + direction; i >= 0 && i < this.items.length; i += direction) if (!this.items[i].disabled) return i; + for (let i = this._highlight + direction; i >= 0 && i < $eq.collections.count(this.items); i += direction) if (!$eq.collections.item(this.items, i).disabled) return i; return this._highlight; } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/MermaidParser.ts b/src/eQuantic.UI.Runtime/src/shared/components/MermaidParser.ts index a5a0678d5..6525a1263 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/MermaidParser.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/MermaidParser.ts @@ -86,7 +86,7 @@ export class MermaidParser { static parseFlowchartLine(graph: MermaidGraph, line: string) { for (const skip of MermaidParser.skipWords) if (line === skip || $eq.text.startsWith(line, skip + ' ', 'ordinal')) return; - let text = $eq.text.endsWith(line, ';', 'ordinal') ? line.slice(0, (line.length - 1)) : line; + let text = $eq.text.endsWith(line, ';', 'ordinal') ? line.slice(0, line.length - 1) : line; let from = MermaidParser.nodeRefAt(text, 0); if (from == null) return; MermaidParser.declare(graph, from); @@ -152,7 +152,7 @@ export class MermaidParser { let close = $eq.text.indexOf(text, closer, from, 'ordinal'); if (close < 0) return null; let label = $eq.text.trim(text.slice(from, close)); - if ($eq.text.startsWith(label, '"', 'ordinal') && $eq.text.endsWith(label, '"', 'ordinal') && label.length >= 2) label = label.slice(1, (label.length - 1)); + if ($eq.text.startsWith(label, '"', 'ordinal') && $eq.text.endsWith(label, '"', 'ordinal') && label.length >= 2) label = label.slice(1, label.length - 1); node.label = label; node.shape = shape; node.shaped = true; @@ -176,7 +176,7 @@ export class MermaidParser { if (after < text.length && text[after] === '|') { let close = $eq.text.indexOfChar(text, '|', after + 1); if (close > after) { - edge.label = $eq.text.trim(text.slice((after + 1), close)); + edge.label = $eq.text.trim(text.slice(after + 1, close)); edge.end = close + 1; } } @@ -185,13 +185,13 @@ export class MermaidParser { static parseSequenceLine(graph: MermaidGraph, line: string) { if ($eq.text.startsWith(line, 'participant ', 'ordinal') || $eq.text.startsWith(line, 'actor ', 'ordinal')) { - let rest = $eq.text.trim(line.slice((line.indexOf(' ') + 1))); + let rest = $eq.text.trim(line.slice(line.indexOf(' ') + 1)); let alias = rest; let display = rest; let asAt = $eq.text.indexOf(rest, ' as ', 'ordinal'); if (asAt > 0) { alias = $eq.text.trim(rest.slice(0, asAt)); - display = $eq.text.trim(rest.slice((asAt + 4))); + display = $eq.text.trim(rest.slice(asAt + 4)); } MermaidParser.declareParticipant(graph, alias, display); return; @@ -199,12 +199,12 @@ export class MermaidParser { let colon = line.indexOf(':'); if (colon <= 0) return; let head = $eq.text.trim(line.slice(0, colon)); - let label = $eq.text.trim(line.slice((colon + 1))); + let label = $eq.text.trim(line.slice(colon + 1)); for (const arrow of MermaidParser.messageArrows) { let at = $eq.text.indexOf(head, arrow, 'ordinal'); if (at <= 0) continue; let from = $eq.text.trim(head.slice(0, at)); - let to = $eq.text.trim(head.slice((at + arrow.length))); + let to = $eq.text.trim(head.slice(at + arrow.length)); if (from.length === 0 || to.length === 0) return; MermaidParser.declareParticipant(graph, from, from); MermaidParser.declareParticipant(graph, to, to); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/NavigationRail.ts b/src/eQuantic.UI.Runtime/src/shared/components/NavigationRail.ts index 03280f2fc..eb9c82bc8 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/NavigationRail.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/NavigationRail.ts @@ -9,7 +9,7 @@ export class NavigationRail extends StatelessComponent { } set items(value) { - this.$items = (value.length < 3 || value.length > 7) ? $eq.exceptions.raise($eq.exceptions.create(['System.ArgumentException', 'System.SystemException', 'System.Exception'], 'NavigationRail takes 3-7 destinations (spec B4): 2 → Tabs, 8+ → Drawer.', { paramName: 'Items' })) : value; + this.$items = (($v2470) => ($v2470 < 3 || $v2470 > 7))($eq.collections.count(value)) ? $eq.exceptions.raise($eq.exceptions.create(['System.ArgumentException', 'System.SystemException', 'System.Exception'], 'NavigationRail takes 3-7 destinations (spec B4): 2 → Tabs, 8+ → Drawer.', { paramName: 'Items' })) : value; } declare selected: number; @@ -34,8 +34,8 @@ export class NavigationRail extends StatelessComponent { let theme = context.theme; let primary = theme.colors('primary'); let destinations = new Column(8, 'start', 'stretch', false, null, null, { width: SizeValue.fill, cross: 'center' }); - for (let i = 0; i < this.items.length; i++) { - let item = this.items[i]; + for (let i = 0; i < $eq.collections.count(this.items); i++) { + let item = $eq.collections.item(this.items, i); let isActive = i === this.selected; let index = i; let filled: any; diff --git a/src/eQuantic.UI.Runtime/src/shared/components/RadioGroup.ts b/src/eQuantic.UI.Runtime/src/shared/components/RadioGroup.ts index 87390a589..1cd306e6a 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/RadioGroup.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/RadioGroup.ts @@ -27,19 +27,19 @@ export class RadioGroup extends StatelessComponent { let theme = context.theme; let primary = theme.colors('primary'); let options = new Column(4, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = 0; i < this.options.length; i++) { + for (let i = 0; i < $eq.collections.count(this.options); i++) { let isSelected = i === this.selected; let index = i; let circleContent = isSelected ? VisualNodeExtensions.centered(new Box(new BoxStyle({ width: Sizing.radioDot(context.density), height: Sizing.radioDot(context.density), background: primary.base, cornerRadius: new CornerRadii(theme.shape('full')) }))) : null; let circle = new Box(new BoxStyle({ width: Sizing.selectionBox(context.density), height: Sizing.selectionBox(context.density), cornerRadius: new CornerRadii(theme.shape('full')), borderWidth: 2, borderColor: isSelected ? primary.base : theme.borderStrong }), circleContent); let row = new Row(12, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: 44 }); row.add(circle); - row.add(new Text(this.options[i], 'bodyM', this.disabled ? theme.textMuted : theme.textPrimary, 1)); - options.add(new Pressable(row, this.disabled || this.onChanged == null || isSelected ? null : () => this.onChanged!(index), { disabled: this.disabled, label: this.options[i], role: 'radio', selected: isSelected })); + row.add(new Text($eq.collections.item(this.options, i), 'bodyM', this.disabled ? theme.textMuted : theme.textPrimary, 1)); + options.add(new Pressable(row, this.disabled || this.onChanged == null || isSelected ? null : () => this.onChanged!(index), { disabled: this.disabled, label: $eq.collections.item(this.options, i), role: 'radio', selected: isSelected })); } let group: VisualNode = options; - if (!this.disabled && !(this.onChanged == null) && this.options.length > 0) { - let count = this.options.length; + if (!this.disabled && !(this.onChanged == null) && $eq.collections.count(this.options) > 0) { + let count = $eq.collections.count(this.options); group = new Adjustable(options, (direction: number) => this.onChanged!($eq.num.intRem(this.selected + direction + count, count)), { role: 'radiogroup', label: this.label ?? '' }); } let column = new Column(4, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Rules.ts b/src/eQuantic.UI.Runtime/src/shared/components/Rules.ts index fef9017ee..0877cc841 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Rules.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Rules.ts @@ -18,7 +18,7 @@ export class Rules { if (value.length === 0) return true; let at = value.indexOf('@'); if (at <= 0 || at !== value.lastIndexOf('@')) return false; - let domain = value.slice((at + 1)); + let domain = value.slice(at + 1); let dot = domain.indexOf('.'); return dot > 0 && dot < domain.length - 1 && !domain.includes(' ') && !value.slice(0, at).includes(' '); }); diff --git a/src/eQuantic.UI.Runtime/src/shared/components/SegmentedControl.ts b/src/eQuantic.UI.Runtime/src/shared/components/SegmentedControl.ts index 6511090ce..36b51b8c9 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/SegmentedControl.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/SegmentedControl.ts @@ -30,18 +30,18 @@ export class SegmentedControl extends StatelessComponent { let inset = 3; let trackRadius = Sizing.radius(this.size); let row = new Row(0, 'start', 'center', false, null, null, { width: this.stretch ? SizeValue.fill : SizeValue.hug, height: SizeValue.fill, cross: 'stretch' }); - for (let i = 0; i < this.segments.length; i++) { + for (let i = 0; i < $eq.collections.count(this.segments); i++) { let index = i; let selected = index === this.selectedIndex; let label = new Row(0, 'start', 'center', false, null, null, { width: this.stretch ? SizeValue.fill : SizeValue.hug, height: SizeValue.fill, main: 'center', cross: 'center' }); - label.add(new Text(this.segments[index], 'label', selected ? theme.textPrimary : theme.textSecondary, 1, 'start', false, false, null, 0, { styleOverride: theme.type('label').withSize(Sizing.labelSize(this.size, context.density)), transition: TransitionSpec.of(1, Motion.press) })); + label.add(new Text($eq.collections.item(this.segments, index), 'label', selected ? theme.textPrimary : theme.textSecondary, 1, 'start', false, false, null, 0, { styleOverride: theme.type('label').withSize(Sizing.labelSize(this.size, context.density)), transition: TransitionSpec.of(1, Motion.press) })); let segment = new Box(new BoxStyle({ width: this.stretch ? SizeValue.fill : SizeValue.hug, height: SizeValue.fill, padding: this.stretch ? undefined : EdgeInsets.symmetric(Sizing.paddingX(this.size, context.density), 0), background: selected ? theme.surface : null, cornerRadius: new CornerRadii(Math.fround(trackRadius - inset)), elevation: selected ? 1 : 0, transition: TransitionSpec.of(1 | 8, Motion.press) }), label); - let press = new Pressable(segment, this.disabled || selected ? null : () => this.onChanged?.(index), { disabled: this.disabled, label: this.segments[index], role: 'radio', selected: selected }); + let press = new Pressable(segment, this.disabled || selected ? null : () => this.onChanged?.(index), { disabled: this.disabled, label: $eq.collections.item(this.segments, index), role: 'radio', selected: selected }); row.add(this.stretch ? new Flexible(press, 1) : press); } let track = new Box(new BoxStyle({ width: this.stretch ? SizeValue.fill : SizeValue.hug, height: height, padding: EdgeInsets.all(inset), background: theme.surfaceSubtle, cornerRadius: new CornerRadii(trackRadius), opacity: this.disabled ? theme.disabledOpacity : 1 }), row); - if (this.disabled || this.onChanged == null || this.segments.length === 0) return track; - let count = this.segments.length; + if (this.disabled || this.onChanged == null || $eq.collections.count(this.segments) === 0) return track; + let count = $eq.collections.count(this.segments); return new Adjustable(track, (direction: number) => this.onChanged!($eq.num.intRem(this.selectedIndex + direction + count, count)), { role: 'radiogroup' }); } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Select.ts b/src/eQuantic.UI.Runtime/src/shared/components/Select.ts index 8b8a5f2d0..99e9da54d 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Select.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Select.ts @@ -1,4 +1,4 @@ -import { Anchored, Box, BoxStyle, BuildContext, Column, CornerRadii, EdgeInsets, Flexible, Icon, IconGlyph, KeyChord, Pressable, Row, Shortcut, SizeValue, Sizing, Spacer, StatefulComponent, StyleDiff, Text, UiComponent, VisualNode } from "../runtime-exports"; +import { $eq, Anchored, Box, BoxStyle, BuildContext, Column, CornerRadii, EdgeInsets, Flexible, Icon, IconGlyph, KeyChord, Pressable, Row, Shortcut, SizeValue, Sizing, Spacer, StatefulComponent, StyleDiff, Text, UiComponent, VisualNode } from "../runtime-exports"; export class Select extends StatefulComponent { static $typeId = 'eQuantic.UI.Components.Select'; @@ -27,18 +27,18 @@ export class Select extends StatefulComponent { build(context: BuildContext) { let theme = context.theme; - let hasValue = this.selectedIndex >= 0 && this.selectedIndex < this.options.length; + let hasValue = this.selectedIndex >= 0 && this.selectedIndex < $eq.collections.count(this.options); let fieldRow = new Row(8, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: SizeValue.fill }); - fieldRow.add(new Text(hasValue ? this.options[this.selectedIndex] : this.placeholder ?? 'Select…', 'bodyM', hasValue ? theme.textPrimary : theme.textMuted, 1)); + fieldRow.add(new Text(hasValue ? $eq.collections.item(this.options, this.selectedIndex) : this.placeholder ?? 'Select…', 'bodyM', hasValue ? theme.textPrimary : theme.textMuted, 1)); fieldRow.add(new Flexible(new Spacer())); fieldRow.add(new Icon(IconGlyph.fromIcons('chevronDown'), 16, theme.textSecondary)); let field = new Box(new BoxStyle({ height: Sizing.height('medium', context.density), width: SizeValue.fill, padding: EdgeInsets.symmetric(12, 0), background: theme.surface, cornerRadius: new CornerRadii(theme.shape('medium')), borderWidth: 1, borderColor: theme.borderStrong, opacity: this.disabled ? theme.disabledOpacity : null, hover: this.disabled ? null : new StyleDiff({ borderColor: theme.colors('primary').base }) }), fieldRow); let list = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = 0; i < this.options.length; i++) { + for (let i = 0; i < $eq.collections.count(this.options); i++) { let index = i; let selected = i === this.selectedIndex; let row = new Row(8, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: SizeValue.fill }); - row.add(new Text(this.options[i], 'bodyM', selected ? theme.colors('primary').onSubtle : theme.textPrimary, 1)); + row.add(new Text($eq.collections.item(this.options, i), 'bodyM', selected ? theme.colors('primary').onSubtle : theme.textPrimary, 1)); if (selected) { row.add(new Flexible(new Spacer())); row.add(new Icon(IconGlyph.fromIcons('check'), 16, theme.colors('primary').onSubtle)); @@ -49,8 +49,8 @@ export class Select extends StatefulComponent { let panel = new Box(new BoxStyle({ background: theme.surface, cornerRadius: new CornerRadii(theme.shape('medium')), borderWidth: 1, borderColor: theme.border, elevation: 2, padding: EdgeInsets.symmetric(0, 4), clip: true }), list); let trigger = new Pressable(field, this.disabled ? null : this.toggle.bind(this), { disabled: this.disabled, expanded: this._open && !this.disabled }); let select: VisualNode = new Anchored(trigger, panel, { open: this._open && !this.disabled, onDismiss: () => this.setState(() => this._open = false), matchAnchorWidth: true, panelRole: 'listbox', activeIndex: this._open ? this._highlight : -1 }); - let live = this._open && !this.disabled && this.options.length > 0; - select = new Shortcut(select, KeyChord.arrowDown, () => this.setState(() => this._highlight = Math.min(this.options.length - 1, this._highlight + 1)), { enabled: live }); + let live = this._open && !this.disabled && $eq.collections.count(this.options) > 0; + select = new Shortcut(select, KeyChord.arrowDown, () => this.setState(() => this._highlight = Math.min($eq.collections.count(this.options) - 1, this._highlight + 1)), { enabled: live }); select = new Shortcut(select, KeyChord.arrowUp, () => this.setState(() => this._highlight = Math.max(0, this._highlight - 1)), { enabled: live }); select = new Shortcut(select, KeyChord.enter, () => this.choose(this._highlight), { enabled: live }); return select; @@ -68,12 +68,12 @@ export class Select extends StatefulComponent { toggle() { return this.setState(() => { this._open = !this._open; - if (this._open) this._highlight = this.selectedIndex >= 0 && this.selectedIndex < this.options.length ? this.selectedIndex : 0; + if (this._open) this._highlight = this.selectedIndex >= 0 && this.selectedIndex < $eq.collections.count(this.options) ? this.selectedIndex : 0; }); } choose(index: number) { - if (index < 0 || index >= this.options.length) return; + if (index < 0 || index >= $eq.collections.count(this.options)) return; this.onChanged?.(index); this.setState(() => this._open = false); } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Table.ts b/src/eQuantic.UI.Runtime/src/shared/components/Table.ts index 6aa15b825..a053af7b3 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Table.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Table.ts @@ -1,4 +1,4 @@ -import { Box, BoxStyle, BuildContext, Column, Divider, EdgeInsets, Grid, GridTrack, SizeValue, StatelessComponent, StyleDiff, Text } from "../runtime-exports"; +import { $eq, Box, BoxStyle, BuildContext, Column, Divider, EdgeInsets, Grid, GridTrack, SizeValue, StatelessComponent, StyleDiff, Text } from "../runtime-exports"; export class Table extends StatelessComponent { static $typeId = 'eQuantic.UI.Components.Table'; @@ -16,18 +16,18 @@ export class Table extends StatelessComponent { build(context: BuildContext) { let theme = context.theme; - let tracks = GridTrack.repeat(this.columns.length, GridTrack.flex()); + let tracks = GridTrack.repeat($eq.collections.count(this.columns), GridTrack.flex()); let header = new Grid(tracks, 12, null, { width: SizeValue.fill }); for (const column of this.columns) header.add(new Text(column, 'label', theme.textSecondary, 1)); let table = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); table.add(new Box(new BoxStyle({ width: SizeValue.fill, padding: EdgeInsets.symmetric(12, 8), borderWidth: 0 }), header)); table.add(new Divider()); - for (let r = 0; r < this.rows.length; r++) { + for (let r = 0; r < $eq.collections.count(this.rows); r++) { let row = new Grid(tracks, 12, null, { width: SizeValue.fill }); - let cells = this.rows[r]; - for (let c = 0; c < this.columns.length; c++) row.add(new Text(c < cells.length ? cells[c] : '', 'bodyM', null, 1)); + let cells = $eq.collections.item(this.rows, r); + for (let c = 0; c < $eq.collections.count(this.columns); c++) row.add(new Text(c < $eq.collections.count(cells) ? $eq.collections.item(cells, c) : '', 'bodyM', null, 1)); table.add(new Box(new BoxStyle({ width: SizeValue.fill, minHeight: 44, padding: EdgeInsets.symmetric(12, 8), hover: new StyleDiff({ background: theme.surfaceSubtle }) }), row)); - if (r < this.rows.length - 1) table.add(new Divider()); + if (r < $eq.collections.count(this.rows) - 1) table.add(new Divider()); } return table; } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/Tabs.ts b/src/eQuantic.UI.Runtime/src/shared/components/Tabs.ts index 7ccfbe7d4..f3f05d70c 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/Tabs.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/Tabs.ts @@ -22,19 +22,19 @@ export class Tabs extends StatelessComponent { let theme = context.theme; let primary = theme.colors('primary'); let row = new Row(0, 'start', 'center', false, null, null, { width: SizeValue.fill, height: 48 }); - for (let i = 0; i < this.labels.length; i++) { + for (let i = 0; i < $eq.collections.count(this.labels); i++) { let isActive = i === this.selected; let index = i; - let label = new Text(this.labels[i], 'caption', isActive ? primary.base : theme.textMuted, 1, 'start', false, false, null, 0, { styleOverride: new TypeStyle(14, 18, isActive ? 'bold' : 'semiBold', Math.fround(0.1), Math.fround(1.3)) }); + let label = new Text($eq.collections.item(this.labels, i), 'caption', isActive ? primary.base : theme.textMuted, 1, 'start', false, false, null, 0, { styleOverride: new TypeStyle(14, 18, isActive ? 'bold' : 'semiBold', Math.fround(0.1), Math.fround(1.3)) }); let labelRow = new Row(0, 'start', 'center', false, null, null, { main: 'center', height: SizeValue.fill }); labelRow.add(label); let cell = new Column(0, 'start', 'stretch', false, null, null, { height: SizeValue.fill }); cell.add(new Flexible(labelRow)); cell.add(new Box(new BoxStyle({ width: SizeValue.fill, height: 3, padding: EdgeInsets.symmetric(16, 0) }), isActive ? new Box(new BoxStyle({ width: SizeValue.fill, height: 3, background: primary.base, cornerRadius: new CornerRadii(2, 2, 0, 0) })) : null)); - row.add(new Flexible(new Pressable(cell, this.onSelect == null ? null : () => this.onSelect!(index), { label: this.labels[i], pressedBackground: theme.surfaceSubtle, role: 'tab', selected: isActive }))); + row.add(new Flexible(new Pressable(cell, this.onSelect == null ? null : () => this.onSelect!(index), { label: $eq.collections.item(this.labels, i), pressedBackground: theme.surfaceSubtle, role: 'tab', selected: isActive }))); } - if (this.onSelect == null || this.labels.length === 0) return row; - let count = this.labels.length; + if (this.onSelect == null || $eq.collections.count(this.labels) === 0) return row; + let count = $eq.collections.count(this.labels); return new Adjustable(row, (direction: number) => this.onSelect!($eq.num.intRem(this.selected + direction + count, count)), { role: 'tablist' }); } } diff --git a/src/eQuantic.UI.Runtime/src/shared/components/TimePicker.ts b/src/eQuantic.UI.Runtime/src/shared/components/TimePicker.ts index 0bc9a9651..561960dd3 100644 --- a/src/eQuantic.UI.Runtime/src/shared/components/TimePicker.ts +++ b/src/eQuantic.UI.Runtime/src/shared/components/TimePicker.ts @@ -36,7 +36,7 @@ export class TimePicker extends StatefulComponent { let theme = context.theme; let open = this._open && !this.disabled; let times = open ? this.slots() : []; - let highlight = times.length > 0 ? Math.min(this._highlight, times.length - 1) : -1; + let highlight = $eq.collections.count(times) > 0 ? Math.min(this._highlight, $eq.collections.count(times) - 1) : -1; let field = new Row(8, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: SizeValue.fill }); field.add(new Icon(IconGlyph.fromIcons('clock'), 20, theme.textMuted)); let value: any; @@ -45,8 +45,8 @@ export class TimePicker extends StatefulComponent { field.add(new Icon(IconGlyph.fromIcons('chevronDown'), 16, theme.textSecondary)); let box = new Box(new BoxStyle({ height: Sizing.height('medium', context.density), width: SizeValue.fill, padding: EdgeInsets.symmetric(12, 0), background: theme.surface, cornerRadius: new CornerRadii(theme.shape('medium')), borderWidth: 1, borderColor: theme.borderStrong, opacity: this.disabled ? theme.disabledOpacity : null, hover: this.disabled ? null : new StyleDiff({ borderColor: theme.colors('primary').base }) }), field); let list = new Column(0, 'start', 'stretch', false, null, null, { width: SizeValue.fill }); - for (let i = 0; i < times.length; i++) { - let slot = times[i]; + for (let i = 0; i < $eq.collections.count(times); i++) { + let slot = $eq.collections.item(times, i); let picked = $eq.equals(this.selected, slot); let highlighted = i === highlight; let row = new Row(8, 'start', 'center', false, null, null, { cross: 'center', width: SizeValue.fill, height: SizeValue.fill }); @@ -57,9 +57,9 @@ export class TimePicker extends StatefulComponent { let picker: VisualNode = new Anchored(new Pressable(box, this.disabled ? null : this.toggle.bind(this), { disabled: this.disabled, label: this.label.length > 0 ? this.label : SdkStrings.chooseTime, expanded: this._open && !this.disabled }), panel, { open: open, onDismiss: this.close.bind(this), matchAnchorWidth: true, panelRole: 'listbox', activeIndex: open ? highlight : -1 }); let moving = open && highlight >= 0; picker = new Shortcut(picker, KeyChord.escape, this.close.bind(this), { enabled: open }); - picker = new Shortcut(picker, KeyChord.arrowDown, () => this.setState(() => this._highlight = Math.min(times.length - 1, highlight + 1)), { enabled: moving }); + picker = new Shortcut(picker, KeyChord.arrowDown, () => this.setState(() => this._highlight = Math.min($eq.collections.count(times) - 1, highlight + 1)), { enabled: moving }); picker = new Shortcut(picker, KeyChord.arrowUp, () => this.setState(() => this._highlight = Math.max(0, highlight - 1)), { enabled: moving }); - picker = new Shortcut(picker, KeyChord.enter, () => this.pick(times[highlight]), { enabled: moving }); + picker = new Shortcut(picker, KeyChord.enter, () => this.pick($eq.collections.item(times, highlight)), { enabled: moving }); return picker; } @@ -106,8 +106,8 @@ export class TimePicker extends StatefulComponent { slotOf(value: TimeOnly) { let times = this.slots(); - for (let i = 0; i < times.length; i++) { - if (times[i].equals(value)) return i; + for (let i = 0; i < $eq.collections.count(times); i++) { + if ($eq.collections.item(times, i).equals(value)) return i; } return 0; } diff --git a/src/eQuantic.UI.Runtime/src/utils/collections.spec.ts b/src/eQuantic.UI.Runtime/src/utils/collections.spec.ts index f2d6af7a5..edb8c0e01 100644 --- a/src/eQuantic.UI.Runtime/src/utils/collections.spec.ts +++ b/src/eQuantic.UI.Runtime/src/utils/collections.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { sortedDictionary } from './sorted'; -import { dictionary } from './dictionary'; +import { sortedDictionary, sortedList, sortedSet } from './sorted'; +import { dictionary, pair } from './dictionary'; +import { hashSetOf } from './hash-set'; import { Queue, queue, @@ -9,6 +10,11 @@ import { LinkedList, linkedList, remove, + add, + clear, + count, + item, + setItem, } from './collections'; describe('Queue — FIFO', () => { @@ -171,3 +177,120 @@ describe('remove over a dictionary, as ICollection> removes', expect(sorted.has(2)).toBe(false); }); }); + +// ICollection's Add and Clear, which reach the runtime with whichever collection the interface holds +// when the call runs (#593): an array's push and splice were all they had. +describe("ICollection's Add and Clear, as the collection behind the interface answers them", () => { + it('adds as each collection adds its own: appended, a value a set holds ignored, a linked list last', () => { + const list = [1]; + add(list, 2); + expect(list).toEqual([1, 2]); + const set = hashSetOf([1]); + add(set, 2); + add(set, 1); + expect([...set]).toEqual([1, 2]); + const linked = linkedList([1]); + add(linked, 2); + expect(linked.toArray()).toEqual([1, 2]); + const sorted = sortedSet([3]); + add(sorted, 1); + add(sorted, 3); + expect(sorted.toArray()).toEqual([1, 3]); + }); + + it("adds a dictionary's pair, and refuses a key already there in each dictionary's words", () => { + const dict = dictionary(); + add(dict, pair('a', 1)); + expect(dict.get('a')).toBe(1); + expect(() => add(dict, pair('a', 2))).toThrow('An item with the same key has already been added. Key: a'); + const sorted = sortedDictionary(); + add(sorted, pair(2, 'b')); + add(sorted, pair(1, 'a')); + expect(sorted.keys()).toEqual([1, 2]); + expect(() => add(sortedList([[2, 'b']]), pair(2, 'c'))).toThrow("Key: 2 (Parameter 'key')"); + }); + + it('clears each in place, and a twin through its own members', () => { + const list = [1, 2]; + clear(list); + expect(list).toEqual([]); + const set = hashSetOf([1]); + clear(set); + expect(set.size).toBe(0); + const linked = linkedList([1, 2]); + clear(linked); + expect(linked.count).toBe(0); + const held: number[] = []; + const twin = { + add: (item: number) => held.push(item), + clear: () => (held.length = 0), + get count() { + return held.length; + }, + }; + add(twin, 7); + expect(count(twin)).toBe(1); + clear(twin); + expect(count(twin)).toBe(0); + }); +}); + +// A list face's indexer (IList, IReadOnlyList), which reaches the runtime with whichever list the +// face holds when it runs (#586): a subscript was all it had, which a twin does not answer. +describe("a list face's indexer, as the list behind the face answers it", () => { + it('reads and writes an array or an array-like by subscript, a twin by item and setItem', () => { + const array = [1, 2, 3]; + expect(item(array, 1)).toBe(2); + expect(setItem(array, 1, 5)).toBe(5); + expect(array).toEqual([1, 5, 3]); + const held = [7, 8, 9]; + const twin = { + item: (index: number) => held[index], + setItem: (index: number, value: number) => { + held[index] = Math.min(value, 10); + }, + get count() { + return held.length; + }, + }; + expect(item(twin, 2)).toBe(9); + expect(setItem(twin, 0, 42)).toBe(42); + expect(held[0]).toBe(10); + expect(count(twin)).toBe(3); + const typed = new Float64Array([1, 2]); + expect(item(typed, 1)).toBe(2); + expect(setItem(typed, 0, 3)).toBe(3); + expect(typed[0]).toBe(3); + }); + + it('counts a twin by its own count, never by a length or a size beside it', () => { + const polyline = { + item: (index: number) => [3, 4][index], + get length() { + return 12.5; + }, + get size() { + return 7; + }, + get count() { + return 2; + }, + }; + expect(count(polyline)).toBe(2); + expect(count(new Float64Array([1, 2, 3]))).toBe(3); + expect(count(new Set([1, 2]))).toBe(2); + }); + + // A read or a call through null throws .NET's NullReferenceException in its words: the count of null + // was none, 0 where `xs.Count` throws, and the others threw JavaScript's own TypeError. A + // null-conditional and a pattern test the receiver before they reach any of them. + it('refuses a null as .NET does, for a count, a read, a write, an Add and a Clear', () => { + const message = 'Object reference not set to an instance of an object.'; + expect(() => count(null)).toThrow(message); + expect(() => count(undefined)).toThrow(message); + expect(() => item(null as unknown as number[], 0)).toThrow(message); + expect(() => setItem(null as unknown as number[], 0, 1)).toThrow(message); + expect(() => add(null, 1)).toThrow(message); + expect(() => clear(null)).toThrow(message); + }); +}); diff --git a/src/eQuantic.UI.Runtime/src/utils/collections.ts b/src/eQuantic.UI.Runtime/src/utils/collections.ts index 7af5da9a4..aac2d03a7 100644 --- a/src/eQuantic.UI.Runtime/src/utils/collections.ts +++ b/src/eQuantic.UI.Runtime/src/utils/collections.ts @@ -7,10 +7,12 @@ * `new Queue(...)` / `new Stack(...)` and maps the instance methods to camelCase. */ +import { Dictionary as RuntimeDictionary } from './dictionary'; import { equals } from './equals'; import { exception } from './exceptions'; import { HashSet } from './hash-set'; import { sameBy, sameItem, type KeyEquality } from './key-equality'; +import { SortedMap } from './sorted'; export { pairComparer, sameItem } from './key-equality'; @@ -361,11 +363,82 @@ export function remove( return false; } +/** What .NET throws for a member read or a call through null, in its words. */ +function nullReference(): Error { + return exception('System.NullReferenceException', 'Object reference not set to an instance of an object.'); +} + /** - * `HashSet.Add` — which answers whether the value was NEW, and is the whole reason - * `if (!set.Add(x)) set.Remove(x)` toggles. A JS `Set.add` returns the set itself, always truthy, - * so that idiom silently became "add, and never remove". + * `ICollection.Add`, for the collection the interface holds when the call runs, each adding as its + * own `ICollection.Add` does in .NET (#593): an array (a `List`) appends; a set adds a value it + * does not hold and ignores one it does, a `HashSet` and a `SortedSet` alike; a linked list adds LAST; + * a dictionary (`ICollection>`) adds the pair's key and value and refuses a key + * already there in its own words, the runtime's `Dictionary` and a sorted map alike; and a twin calls + * its own `add`. It was an array's `push`, which none of the others has. An array stands for a `T[]` + * as well as a `List`, so a fixed-size array behind the face grows here where .NET refuses the call + * (NotSupportedException): the value carries nothing that tells the two apart (#711). A null throws the + * NullReferenceException a call through null throws in .NET. + */ +export function add(collection: unknown, item: T): void { + if (collection == null) throw nullReference(); + if (Array.isArray(collection)) collection.push(item); + else if (collection instanceof LinkedList) collection.addLast(item); + else if (collection instanceof RuntimeDictionary || collection instanceof SortedMap) { + const { key, value } = item as { key: unknown; value: unknown }; + collection.add(key, value); + } else (collection as { add(item: T): unknown }).add(item); +} + +/** + * `ICollection.Clear`, for the collection the interface holds when the call runs: an array (a + * `List`) is emptied in place, and every other collection, the runtime's and a twin, by its own + * `clear` (#593). It was an array's `splice`, which a set, a linked list and a dictionary lack. A null + * throws as {@link add} does. + */ +export function clear(collection: unknown): void { + if (collection == null) throw nullReference(); + if (Array.isArray(collection)) collection.length = 0; + else (collection as { clear(): void }).clear(); +} + +/** + * What may stand behind a list's face: an array or another array-like, or a twin, whose indexer is + * `item` and `setItem`. Typed by its element, so a twin's TypeScript reads what the face holds, and + * the element is `any` where the receiver is untyped, as `mapGet`'s value is. + */ +type Indexed = ArrayLike | { item(index: number): T }; + +/** What a list's face may be written through: an array's subscript, or a twin's `setItem`. */ +type WritablyIndexed = { [index: number]: T } | { setItem(index: number, value: T): unknown }; + +/** + * An element read through a list's face (`IList`, `IReadOnlyList`), for whichever list the face + * holds when the read runs (#586): an array, which stands for a `List` and a `T[]`, by its subscript, + * and a twin of the app's own by its indexer's getter, `item` (#427). The subscript alone read a + * property named after the index, which no twin has. Anything else indexed, a typed array a hand-written + * caller handed over, is read by its subscript as before. A null throws as {@link add} does, where a + * read of a member through it threw JavaScript's own TypeError, in its own words. + */ +export function item(list: Indexed, index: number): T { + if (list == null) throw nullReference(); + if (Array.isArray(list)) return list[index] as T; + const twin = list as ArrayLike & { item?: (index: number) => T }; + return typeof twin.item === 'function' ? twin.item(index) : twin[index]; +} + +/** + * The write beside {@link item}: an array's subscript, or a twin's indexer setter, `setItem`. It answers + * the value written, as C#'s assignment does, whatever the setter does with its own copy. A null throws + * as {@link add} does. */ +export function setItem(list: WritablyIndexed, index: number, value: T): T { + if (list == null) throw nullReference(); + const target = list as { [index: number]: T } & { setItem?: (index: number, value: T) => unknown }; + if (!Array.isArray(list) && typeof target.setItem === 'function') target.setItem(index, value); + else target[index] = value; + return value; +} + /** * LINQ's Zip: pairs run out with the SHORTER sequence. A `map` over the receiver instead walks the * longer one and hands the selector `undefined` for the missing partner, which for numbers is a @@ -382,6 +455,11 @@ export function zip( return result; } +/** + * `HashSet.Add` — which answers whether the value was NEW, and is the whole reason + * `if (!set.Add(x)) set.Remove(x)` toggles. A JS `Set.add` returns the set itself, always truthy, + * so that idiom silently became "add, and never remove". + */ export function setAdd(set: Set, value: T): boolean { if (set instanceof HashSet) return set.tryAdd(value); if (set.has(value)) return false; @@ -390,14 +468,21 @@ export function setAdd(set: Set, value: T): boolean { } /** - * How many a collection holds — `length`, `size`, or a walk. Same reason as {@link contains}: a C# - * receiver typed as a collection may be an array or a Set here, and each keeps its count under a - * different name. Null counts as none, so a guarded `xs?.Count` needs no guard at all. + * How many a collection holds — `length`, `count`, `size`, or a walk. Same reason as {@link contains}: + * a C# receiver typed as a collection may be an array or a Set here, and each keeps its count under a + * different name. The runtime's linked list, queue, stack and sorted set, and a twin of the app's own + * behind an `ICollection` or an `IReadOnlyList`, keep it as `count`, which a twin that cannot be + * walked answers alone (#586, #593). It is asked before a `size` or a `length`, which are a twin's own + * members when it has them: a polyline's `Length` is how long it is, and its `Count` how many points + * it holds. A null throws the NullReferenceException `xs.Count` throws in .NET: a null-conditional + * `xs?.Count` and a property pattern test the receiver before they count, so only a read through null + * reaches here, and counting it as none answered 0 where .NET throws. */ export function count(collection: unknown): number { - if (collection == null) return 0; + if (collection == null) throw nullReference(); if (Array.isArray(collection) || typeof collection === 'string') return collection.length; - const sized = collection as { size?: unknown; length?: unknown }; + const sized = collection as { size?: unknown; length?: unknown; count?: unknown }; + if (typeof sized.count === 'number') return sized.count; if (typeof sized.size === 'number') return sized.size; if (typeof sized.length === 'number') return sized.length; let total = 0; diff --git a/src/eQuantic.UI.Runtime/src/utils/list.spec.ts b/src/eQuantic.UI.Runtime/src/utils/list.spec.ts index 40d2789a1..9aee7adee 100644 --- a/src/eQuantic.UI.Runtime/src/utils/list.spec.ts +++ b/src/eQuantic.UI.Runtime/src/utils/list.spec.ts @@ -13,6 +13,7 @@ import { findLastIndex, indexOf, lastIndexOf, + listCapacity, listSort, listSortBy, order, @@ -216,4 +217,12 @@ describe("List's Sort and BinarySearch, by the comparer the compiler names (# "Non-negative number required. (Parameter 'count')", ); }); + + // `new List(capacity)` hands its capacity here before the list's elements: one the constructor + // takes passes, a negative one is refused in .NET's words. + it("refuses a negative capacity as List's constructor does", () => { + expect(() => listCapacity(0)).not.toThrow(); + expect(() => listCapacity(16)).not.toThrow(); + expect(fails(() => listCapacity(-1))).toBe("Non-negative number required. (Parameter 'capacity')"); + }); }); diff --git a/src/eQuantic.UI.Runtime/src/utils/list.ts b/src/eQuantic.UI.Runtime/src/utils/list.ts index e8e6008ed..344e96201 100644 --- a/src/eQuantic.UI.Runtime/src/utils/list.ts +++ b/src/eQuantic.UI.Runtime/src/utils/list.ts @@ -61,6 +61,18 @@ function requireAtLeastZero(parameter: string, value: number): void { } } +// ---- a list's capacity -------------------------------------------------------------------------- + +/** + * `new List(capacity)`: an array sizes nothing ahead, but .NET's constructor refuses a negative + * capacity, and the compiler hands it here before the list's elements are evaluated, as C# evaluates + * the constructor's argument first. The capacity was dropped unread, so a call that made it never ran + * and a negative one built a list. + */ +export function listCapacity(capacity: number): void { + if (capacity < 0) throw outOfRange('capacity', NON_NEGATIVE); +} + // ---- the comparers a sort or a search is handed -------------------------------------------------- const orders = new Map>(); diff --git a/tests/eQuantic.UI.Compiler.Tests/CollectionComparerFenceTests.cs b/tests/eQuantic.UI.Compiler.Tests/CollectionComparerFenceTests.cs index 1b260b7bf..5a040d58c 100644 --- a/tests/eQuantic.UI.Compiler.Tests/CollectionComparerFenceTests.cs +++ b/tests/eQuantic.UI.Compiler.Tests/CollectionComparerFenceTests.cs @@ -12,7 +12,8 @@ namespace eQuantic.UI.Compiler.Tests; /// collections lower to compares with === and ordinal strings, and the comparer used to /// vanish: CodeLanguages.For("CSharp") answered C# natively and plain text in the browser, /// and new Dictionary<string, int>(comparer) with no initializer became the comparer -/// itself (found in review, #359). +/// itself (found in review, #359). A LINQ operator that finds its keys or its elements by a comparer +/// (ToDictionary, ToLookup, GroupBy, Distinct) passes the same fence (#578). /// public class CollectionComparerFenceTests { @@ -22,6 +23,17 @@ public class CollectionComparerFenceTests [InlineData("var s = new HashSet(StringComparer.InvariantCultureIgnoreCase);")] // A sorted dictionary is built by another strategy (a runtime map): the fence is not theirs. [InlineData("var m = new SortedDictionary(StringComparer.OrdinalIgnoreCase);")] + // A LINQ operator that finds its keys or its elements by a comparer passes the same fence (#578): + // ToDictionary refused every comparer under EQ1004, GroupBy under EQ2008, and Distinct and ToLookup + // took this one without a word. + [InlineData("var d = new[] { \"a\" }.ToDictionary(w => w, StringComparer.OrdinalIgnoreCase);")] + [InlineData("var d = new[] { \"a\" }.ToDictionary(w => w, w => w.Length, StringComparer.OrdinalIgnoreCase);")] + [InlineData("var l = new[] { \"a\" }.ToLookup(w => w, StringComparer.OrdinalIgnoreCase);")] + [InlineData("var l = new[] { \"a\" }.ToLookup(w => w, w => w.Length, StringComparer.OrdinalIgnoreCase);")] + [InlineData("var g = new[] { \"a\" }.GroupBy(w => w, StringComparer.OrdinalIgnoreCase);")] + [InlineData("var x = new[] { \"a\" }.Distinct(StringComparer.OrdinalIgnoreCase);")] + // Named and written before the key selector, where it is the comparer that is refused (see below). + [InlineData("var g = new[] { \"a\" }.GroupBy(comparer: StringComparer.OrdinalIgnoreCase, keySelector: w => w);")] public void AComparerThatChangesEqualityIsRefused(string statement) { // The FENCE's refusal, and only it: a strategy that refuses the same construction again says the @@ -43,6 +55,14 @@ public void AComparerThatChangesEqualityIsRefused(string statement) [InlineData("var d = new Dictionary(4, StringComparer.Ordinal);")] [InlineData("var m = new SortedDictionary(StringComparer.Ordinal);")] [InlineData("var m = new SortedList(Comparer.Default);")] + // A LINQ operator's, which the lowering drops as it finds its keys that way already (#578). + [InlineData("var d = new[] { \"a\" }.ToDictionary(w => w, StringComparer.Ordinal);")] + [InlineData("var d = new[] { \"a\" }.ToDictionary(w => w, w => w.Length, EqualityComparer.Default);")] + [InlineData("var l = new[] { \"a\" }.ToLookup(w => w.Length, EqualityComparer.Default);")] + [InlineData("var g = new[] { \"a\" }.GroupBy(w => w, w => w.Length, (k, n) => k, StringComparer.Ordinal);")] + [InlineData("var x = new[] { \"a\" }.Distinct((IEqualityComparer)null);")] + // GroupBy's comparer named and written first: the key selector was refused as a comparer. + [InlineData("var g = new[] { \"a\" }.GroupBy(comparer: StringComparer.Ordinal, keySelector: w => w);")] public void AComparerThatAsksForWhatTheLoweringDoesPasses(string statement) { // No error at all, not merely no EQ2007: #443's refusal was EQ1004, which a check for the @@ -50,13 +70,39 @@ public void AComparerThatAsksForWhatTheLoweringDoesPasses(string statement) ErrorsOf(statement).Should().BeEmpty(); } + /// + /// A comparer named and written before GroupBy's key selector is refused where it is written, and the + /// key selector is not. The arguments after the first were matched to the parameters from the end, so + /// the key selector was the one refused as a comparer, and the comparer was converted as the key + /// selector (#578). + /// + [Fact] + public void ANamedComparerWrittenFirst_IsRefusedWhereItIsWritten() + { + var refusals = ErrorsAt("var g = new[] { \"a\" }.GroupBy(comparer: StringComparer.OrdinalIgnoreCase, keySelector: w => w);"); + + refusals.Should().ContainSingle().Which.Should().StartWith("EQ2007 at StringComparer.OrdinalIgnoreCase"); + } + /// Compiled with the framework referenced, so the constructor BINDS: the fence reads the /// parameter a comparer lands in, which a standalone parse cannot see. - private static IReadOnlyList ErrorsOf(string statement) + private static IReadOnlyList ErrorsOf(string statement) => + Compile(statement).Errors.Select(error => error.Code).ToList(); + + /// Each error as its code and the source it is reported at, to the end of that line. + private static IReadOnlyList ErrorsAt(string statement) + { + var (source, errors) = Compile(statement); + var lines = source.Split('\n'); + return errors.Select(error => $"{error.Code} at {lines[error.Line - 1][(error.Column - 1)..]}").ToList(); + } + + private static (string Source, IReadOnlyList Errors) Compile(string statement) { var source = $$""" using System; using System.Collections.Generic; + using System.Linq; public sealed class Probe { @@ -76,9 +122,6 @@ public void Run() new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var compiler = new ComponentCompiler(); compiler.SetProjectCompilation(compilation); - return compiler.CompileSource(source, "Probe.cs") - .SelectMany(result => result.Errors) - .Select(error => error.Code) - .ToList(); + return (source, compiler.CompileSource(source, "Probe.cs").SelectMany(result => result.Errors).ToList()); } } diff --git a/tests/eQuantic.UI.Compiler.Tests/Coverage/diagnostics.baseline.txt b/tests/eQuantic.UI.Compiler.Tests/Coverage/diagnostics.baseline.txt index 50ccb779a..9cb88e840 100644 --- a/tests/eQuantic.UI.Compiler.Tests/Coverage/diagnostics.baseline.txt +++ b/tests/eQuantic.UI.Compiler.Tests/Coverage/diagnostics.baseline.txt @@ -17,7 +17,7 @@ EQ2004 eQuantic.UI.Code/Marks/CodeDiagnostic.cs eQuantic.UI.Compiler/CodeGen/Ext EQ2005 eQuantic.UI.Compiler/CodeGen/MethodLowering.cs EQ2006 eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ConditionalAccessStrategy.cs eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/InvocationStrategy.cs eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/MemberAccessStrategy.cs eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectCreationStrategy.cs EQ2007 eQuantic.UI.Compiler/CodeGen/Extensions/CollectionComparerExtensions.cs eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/CollectionExpressionStrategy.cs -EQ2008 eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/InitializerExpressionStrategy.cs eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectInitializer.cs eQuantic.UI.Compiler/CodeGen/Strategies/Linq/GroupByStrategy.cs eQuantic.UI.Compiler/CodeGen/Strategies/Linq/QueryExpressionStrategy.cs eQuantic.UI.Compiler/CodeGen/TypeScriptEmitter.cs eQuantic.UI.Compiler/ComponentCompiler.cs +EQ2008 eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/InitializerExpressionStrategy.cs eQuantic.UI.Compiler/CodeGen/Strategies/Expressions/ObjectInitializer.cs eQuantic.UI.Compiler/CodeGen/Strategies/Linq/QueryExpressionStrategy.cs eQuantic.UI.Compiler/CodeGen/TypeScriptEmitter.cs eQuantic.UI.Compiler/ComponentCompiler.cs EQ2009 eQuantic.UI.Compiler/ComponentCompiler.cs EQ2010 eQuantic.UI.Compiler/CodeGen/Extensions/HostOnlySymbolExtensions.cs eQuantic.UI.Compiler/CodeGen/RecordTypeEmitter.cs eQuantic.UI.Compiler/CodeGen/TypeScriptEmitter.cs EQ2011 eQuantic.UI.Compiler/Services/ShadowedRuntimeMembers.cs diff --git a/tests/eQuantic.UI.Compiler.Tests/Coverage/ir-migration.baseline.txt b/tests/eQuantic.UI.Compiler.Tests/Coverage/ir-migration.baseline.txt index 78af28330..028481d91 100644 --- a/tests/eQuantic.UI.Compiler.Tests/Coverage/ir-migration.baseline.txt +++ b/tests/eQuantic.UI.Compiler.Tests/Coverage/ir-migration.baseline.txt @@ -37,7 +37,6 @@ OfTypeStrategy QueryExpressionStrategy QueueStackStrategy RangeExpressionStrategy -RangeIndexerStrategy ReferenceEqualsStrategy RegexStrategy ResourceAccessorStrategy diff --git a/tests/eQuantic.UI.Compiler.Tests/Strategies/CollectionConstructionTests.cs b/tests/eQuantic.UI.Compiler.Tests/Strategies/CollectionConstructionTests.cs index 5edd2eb84..395cfe989 100644 --- a/tests/eQuantic.UI.Compiler.Tests/Strategies/CollectionConstructionTests.cs +++ b/tests/eQuantic.UI.Compiler.Tests/Strategies/CollectionConstructionTests.cs @@ -28,8 +28,11 @@ public void ACapacityMakesAnEMPTYCollection() var fixedSize = new List(16); """); - js.Should().Contain("let sized: string[] = [];", "a capacity is a hint, not the contents"); - js.Should().Contain("let fixedSize: string[] = [];"); + // A capacity is a hint, not the contents, but C# evaluates it and the constructor refuses a negative + // one, so one that is not a constant the list takes is handed to the runtime first. + js.Should().Contain("let sized: string[] = ($eq.collections.listCapacity(source.length + 1), []);", + "a capacity is a hint, not the contents, and is evaluated and checked as the constructor does"); + js.Should().Contain("let fixedSize: string[] = [];", "a constant the list takes has nothing to evaluate or refuse"); } [Fact] diff --git a/tests/eQuantic.UI.Compiler.Tests/Strategies/IListAddRepro.cs b/tests/eQuantic.UI.Compiler.Tests/Strategies/IListAddRepro.cs index 3e02bfff8..46e407a97 100644 --- a/tests/eQuantic.UI.Compiler.Tests/Strategies/IListAddRepro.cs +++ b/tests/eQuantic.UI.Compiler.Tests/Strategies/IListAddRepro.cs @@ -10,11 +10,12 @@ namespace eQuantic.UI.Compiler.Tests.Strategies; /// /// Regression guard: with a real semantic model, calling Add() on an interface-typed collection -/// (IList<T>/ICollection<T>, where Add is inherited from ICollection<T>) must map to the -/// JS array push — not degrade to a naive camel-cased .add(). This mirrors HtmlElement.Children -/// (IList<IComponent>), which broke SPA boot when the eqc semantic model lacked the eQuantic references -/// and could not resolve the receiver type. The plain ListStrategyTests only cover the no-semantic-info -/// fallback, so they never exercised this path. +/// (IList<T>/ICollection<T>, where Add is inherited from ICollection<T>) must reach the +/// runtime's $eq.collections.add, which pushes onto an array and adds to a set, a linked list or a +/// twin through its own member (#593), and never degrade to a naive camel-cased .add(), which an +/// array lacks. This mirrors HtmlElement.Children (IList<IComponent>), which broke SPA boot when the +/// eqc semantic model lacked the eQuantic references and could not resolve the receiver type. The plain +/// ListStrategyTests only cover the no-semantic-info fallback, so they never exercised this path. /// public class IListAddRepro { @@ -60,8 +61,8 @@ public void Method() {{ [Theory] [InlineData("Box")] // concrete receiver, Children typed IList [InlineData("IComponent")] // interface receiver, Children typed IList - public void ChildrenAdd_MapsToPush(string receiverType) + public void ChildrenAdd_ReachesTheCollectionBehindTheFace(string receiverType) { - ConvertAddOn(receiverType).Should().Be("target.children.push(child)"); + ConvertAddOn(receiverType).Should().Be("$eq.collections.add(target.children, child)"); } } diff --git a/tests/eQuantic.UI.Compiler.Tests/Strategies/LinqStrategyTests.cs b/tests/eQuantic.UI.Compiler.Tests/Strategies/LinqStrategyTests.cs index 9754d2e86..5a0a9789a 100644 --- a/tests/eQuantic.UI.Compiler.Tests/Strategies/LinqStrategyTests.cs +++ b/tests/eQuantic.UI.Compiler.Tests/Strategies/LinqStrategyTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using eQuantic.UI.Compiler.CodeGen; using Xunit; namespace eQuantic.UI.Compiler.Tests.Strategies; @@ -337,14 +338,23 @@ public void AnOperatorInItsStaticForm_IsRefused_ButMaxAndMin() } [Fact] - public void ToDictionary_WithAComparer_IsRefused() + public void ToDictionary_WithAComparerThatAsksForTheDefault_IsTheShapeWithoutOne() { - // The comparer was called as if it were the element selector. - TestHelper.DiagnosticsFor("var r = items.ToDictionary(x => x, (IEqualityComparer)null)") - .Should().Contain(d => d.Code == "EQ1004" && d.Message.Contains("ToDictionary with a comparer")); - TestHelper.DiagnosticsFor("var r = items.ToDictionary(x => x, x => x.Length, (IEqualityComparer)null)") - .Should().Contain(d => d.Code == "EQ1004" && d.Message.Contains("ToDictionary with a comparer"), - "the comparer beside an element selector is refused in the same words"); + // The comparer was once called as if it were the element selector it shares an argument count + // with (#390), and then every comparer was refused, the default's included. One the collection + // fence passes is dropped now, so the call is the one written without it (#578), and one that + // changes equality is the fence's to refuse (CollectionComparerFenceTests). + foreach (var (withComparer, without) in new[] + { + ("items.ToDictionary(x => x, (IEqualityComparer)null)", "items.ToDictionary(x => x)"), + ("items.ToDictionary(x => x, x => x.Length, (IEqualityComparer)null)", "items.ToDictionary(x => x, x => x.Length)"), + }) + { + TestHelper.DiagnosticsFor($"var r = {withComparer}") + .Should().NotContain(d => d.Severity == ConversionSeverity.Error, $"`{withComparer}` builds"); + TestHelper.ConvertExpression(withComparer).Should().Be(TestHelper.ConvertExpression(without), + "a comparer that asks for the default is dropped, never called as a selector"); + } TestHelper.DiagnosticsFor("var r = items.ToDictionary(x => x, x => x.Length)") .Should().NotContain(d => d.Code == "EQ1004", "an element selector is not a comparer"); } diff --git a/tests/eQuantic.UI.Compiler.Tests/Strategies/RangeIndexerTests.cs b/tests/eQuantic.UI.Compiler.Tests/Strategies/RangeIndexerTests.cs index 7ec70f92a..695b72c1a 100644 --- a/tests/eQuantic.UI.Compiler.Tests/Strategies/RangeIndexerTests.cs +++ b/tests/eQuantic.UI.Compiler.Tests/Strategies/RangeIndexerTests.cs @@ -1,4 +1,7 @@ +using eQuantic.UI.Compiler.Services; using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Xunit; namespace eQuantic.UI.Compiler.Tests.Strategies; @@ -49,4 +52,53 @@ public void TheReceiverIsEvaluatedONCE_HoweverComplexItIs() "a slice that called the method twice would do the work twice — and disagree with " + "itself when the method is not pure"); } + + /// + /// A range over an indexer that takes the Range itself hands it a System.Range value, which has + /// no translation here: it is refused (EQ2004) at the range, where it was a call of a slice + /// the twin does not have, or of a Slice that takes a length beside it (#585). + /// + [Theory] + [InlineData("System.Range")] + [InlineData("System.Range?")] // reached by the range's implicit conversion, and refused the same way + public void ARangeHandedToAnIndexerOverRange_IsRefused(string key) + { + var errors = ErrorsOf($$""" + public class Ranged + { + public int Count => 5; + public string this[{{key}} r] => "ranged"; + public int[] Slice(int start, int length) => new int[length]; + } + + public sealed class Probe + { + public object Run() => new Ranged()[1..3]; + } + """); + + // The strategy's own refusal, and not merely its code: EQ2004 is every untranslated member's, so a + // test of the code alone passes over any other refusal that reaches the same access. + errors.Should().Contain(error => error.StartsWith("EQ2004: `1..3` is handed to Ranged's indexer over System.Range")); + } + + /// Each error as its code and its message, compiled with the framework referenced, so the + /// access BINDS: the bound tree names the member the range reaches, which a standalone parse cannot. + private static IReadOnlyList ErrorsOf(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, ParseDefaults.Options, path: "Probe.cs"); + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(p => (MetadataReference)TestReferences.Of(p)) + .Append(TestReferences.Of(typeof(eQuantic.UI.Primitives.VisualNode).Assembly.Location)); + var compilation = CSharpCompilation.Create("Probe", [tree], references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var compiler = new ComponentCompiler(); + compiler.SetProjectCompilation(compilation); + return compiler.CompileSource(source, "Probe.cs") + .SelectMany(result => result.Errors) + .Select(error => $"{error.Code}: {error.Message}") + .ToList(); + } } diff --git a/tests/eQuantic.UI.Conformance.Tests/CollectionAndIndexerConformanceTests.cs b/tests/eQuantic.UI.Conformance.Tests/CollectionAndIndexerConformanceTests.cs new file mode 100644 index 000000000..88edf7ae0 --- /dev/null +++ b/tests/eQuantic.UI.Conformance.Tests/CollectionAndIndexerConformanceTests.cs @@ -0,0 +1,379 @@ +using eQuantic.UI.Conformance.Tests.Infrastructure; +using Xunit; + +namespace eQuantic.UI.Conformance.Tests; + +/// +/// A collection built, read, sliced and added to as .NET's is, on both sides, whatever stands behind +/// the face the code reaches it through: a list built with an argument and an initializer (#564), a +/// LINQ operator handed a comparer the collection fence passes (#578), a range over a type with a +/// Slice (#585), an indexer of a BCL interface over a twin (#586), and ICollection<T>'s +/// Add and Clear over a set, a linked list or a dictionary's pairs (#593). +/// +/// A case that needs a type of its own runs through the module graph an app's build writes, one case +/// at a time and in both the SDK's TypeScript and plain JavaScript, so a failure names its case. +/// +/// +public class CollectionAndIndexerConformanceTests +{ + /// + /// A list built with a constructor argument and a collection initializer holds both, as C# builds it: + /// the source copied (a capacity ignored), then each element added in order. The capacity's form + /// dropped the elements, [], and the source's joined the copy and the elements with a comma, + /// source, [3], which in a declaration is a second declarator and in an argument a second + /// argument; the target-typed form kept the elements and dropped the source (#564). + /// + [SkippableTheory] + // The rows of #564. + [InlineData("var a = new List(10) { 1, 2 }; return string.Join(\",\", a) + \"|\" + a.Count;")] // 1,2|2 + [InlineData("var source = new List { 1 }; var b = new List(source) { 3 }; b.Add(4); return string.Join(\",\", b) + \"|\" + string.Join(\",\", source);")] // 1,3,4|1 + // Target-typed, the same two. + [InlineData("List a = new(10) { 1, 2 }; return string.Join(\",\", a);")] // 1,2 + [InlineData("var source = new List { 1, 2 }; List c = new(source) { 3 }; c.Add(4); return string.Join(\",\", c) + \"|\" + source.Count;")] // 1,2,3,4|2 + // The target-typed copy with no element, which was an empty list, and an empty initializer after a source. + [InlineData("var source = new List { 1 }; List c = new(source); c.Add(9); return string.Join(\",\", c) + \"|\" + source.Count;")] // 1,9|1 + [InlineData("var source = new List { 1 }; var c = new List(source) { }; c.Add(9); return string.Join(\",\", c) + \"|\" + source.Count;")] // 1,9|1 + // In an argument, where the copy and the elements were two arguments, and with a named capacity. + [InlineData("return string.Join(\",\", new List(new[] { 1 }) { 2 });")] // 1,2 + // A complex element initializer hands its one expression to Add. + [InlineData("var a = new List { { 1 }, 2 }; List b = new() { { 3 } }; return string.Join(\",\", a) + \"|\" + (a[0] + 1) + \"|\" + b[0];")] // 1,2|2|3 + [InlineData("return new List(capacity: 4) { \"a\" }.Count;")] // 1 + // A source of any shape: a set, a dictionary's pairs, a string's chars. + [InlineData("var l = new List(new HashSet { 7, 8 }) { 9 }; return string.Join(\",\", l);")] // 7,8,9 + [InlineData("var d = new Dictionary { [\"a\"] = 1 }; var l = new List>(d) { new(\"b\", 2) }; return string.Join(\",\", l.Select(p => p.Key + p.Value));")] // a1,b2 + [InlineData("var l = new List(\"ab\") { 'c' }; return new string(l.ToArray());")] // abc + // The source is read first, then each element in its order. + [InlineData("var log = \"\"; List Src() { log += \"s\"; return new List { 0 }; } int At(string s, int v) { log += s; return v; } var l = new List(Src()) { At(\"a\", 1), At(\"b\", 2) }; return log + \"|\" + string.Join(\",\", l);")] // sab|0,1,2 + // A capacity is evaluated before the elements, though it puts nothing in the list, and a negative one + // is refused as the constructor refuses it: it was dropped unread. + [InlineData("var log = \"\"; int Cap() { log += \"c\"; return 4; } int At(string s, int v) { log += s; return v; } var l = new List(Cap()) { At(\"a\", 1), At(\"b\", 2) }; return log + \"|\" + string.Join(\",\", l);")] // cab|1,2 + [InlineData("var log = \"\"; int Cap() { log += \"c\"; return 4; } List l = new(Cap()) { 7 }; var m = new List(Cap()); m.Add(8); return log + \"|\" + l[0] + m[0];")] // cc|78 + [InlineData("try { var l = new List(-1) { 1 }; return \"built\"; } catch (ArgumentOutOfRangeException e) { return e.Message; }")] + [InlineData("int n = -2; try { List l = new(n); return \"built \" + l.Count; } catch (ArgumentOutOfRangeException e) { return e.Message; }")] + [InlineData("int Bad() => throw new InvalidOperationException(\"no capacity\"); try { var l = new List(Bad()) { 1 }; return \"built\"; } catch (InvalidOperationException e) { return e.Message; }")] + [InlineData("int n = 3; var l = new List(n) { 7 }; return string.Join(\",\", l) + \"|\" + new List(n).Count;")] // 7|0 + public void AListBuiltWithAnArgumentAndAnInitializer_HoldsBoth(string statements) + { + Skip.IfNot(JsExecutor.IsAvailable, "No JS engine available."); + ConformanceRunner.AssertStatementsSameAsDotNet(statements); + } + + /// + /// A comparer handed to a LINQ operator that builds a keyed result passes the collection fence's own + /// test: one that asks for the default (the key type's own, ordinal strings, null) answers as the + /// operator does without it. ToDictionary refused every comparer, StringComparer.Ordinal + /// included (EQ1004), GroupBy refused every one too (EQ2008), ToLookup took a comparer + /// for an element selector and called it, or had no form at all after one, and Distinct dropped + /// it, whatever it asked for (#578). + /// + [SkippableTheory] + // The row of #578, and the comparers the fence passes: the default's, ordinal strings, and null. + [InlineData("var d = new[] { \"a\", \"bb\" }.ToDictionary(w => w, StringComparer.Ordinal); return d[\"bb\"] + \"|\" + d.Count;")] // bb|2 + [InlineData("var d = new[] { \"a\", \"bb\" }.ToDictionary(w => w, w => w.Length, StringComparer.Ordinal); return d[\"bb\"] + d[\"a\"];")] // 3 + [InlineData("var d = new[] { \"a\", \"bb\" }.ToDictionary(w => w.Length, EqualityComparer.Default); return d[2];")] // bb + [InlineData("var d = new[] { \"a\", \"bb\" }.ToDictionary(w => w, (IEqualityComparer)null); return d.Count;")] // 2 + // Named and out of order: each selector in its parameter, the comparer dropped. + [InlineData("var d = new[] { \"a\", \"bb\" }.ToDictionary(comparer: StringComparer.Ordinal, keySelector: w => w + \"!\"); return d[\"bb!\"];")] // bb + [InlineData("var d = new[] { \"a\", \"bb\" }.ToDictionary(comparer: EqualityComparer.Default, elementSelector: w => w + \"?\", keySelector: w => w.Length); return d[2];")] // bb? + // A key twice is refused as it is without a comparer. + [InlineData("try { new[] { \"a\", \"a\" }.ToDictionary(w => w, StringComparer.Ordinal); return \"built\"; } catch (Exception e) { return e.Message; }")] + // After a null-conditional, whose call is rebuilt over its receiver, the comparer is still the one dropped. + [InlineData("string[] none = null; var words = new[] { \"a\", \"bb\" }; var d = none?.ToDictionary(w => w, StringComparer.Ordinal); var e = words?.ToDictionary(w => w.Length, EqualityComparer.Default); var l = words?.ToLookup(w => w.Length, EqualityComparer.Default); return (d == null) + \"|\" + e[2] + \"|\" + l[1].Count();")] // True|bb|1 + // ToLookup takes a comparer where it takes an element selector, and after one. + [InlineData("var l = new[] { \"a\", \"bb\", \"cc\" }.ToLookup(w => w.Length, EqualityComparer.Default); return string.Join(\",\", l[2]) + \"|\" + l.Count;")] // bb,cc|2 + [InlineData("var l = new[] { \"a\", \"A\", \"a\" }.ToLookup(w => w, StringComparer.Ordinal); return l.Count + \"|\" + l[\"a\"].Count();")] // 2|2 + [InlineData("var l = new[] { \"a\", \"bb\", \"cc\" }.ToLookup(w => w.Length, w => w.ToUpper(), EqualityComparer.Default); return string.Join(\",\", l[2]);")] // BB,CC + // Distinct, GroupBy in its four shapes, and ToHashSet alike. + [InlineData("return new[] { \"a\", \"A\", \"a\" }.Distinct(StringComparer.Ordinal).Count();")] // 2 + [InlineData("return string.Join(\",\", new[] { 3, 1, 3 }.Distinct(EqualityComparer.Default));")] // 3,1 + [InlineData("return string.Join(\",\", new[] { \"a\", \"A\", \"a\" }.GroupBy(w => w, StringComparer.Ordinal).Select(g => g.Key + g.Count()));")] // a2,A1 + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(w => w.Length, w => w.ToUpper(), EqualityComparer.Default).Select(g => string.Join(\"\", g)));")] // A,BBCC + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(w => w.Length, (k, g) => k + \":\" + g.Count(), EqualityComparer.Default));")] // 1:1,2:2 + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(w => w.Length, w => w[0], (k, g) => k + new string(g.ToArray()), EqualityComparer.Default));")] // 1a,2bc + // GroupBy's arguments named and out of order: each in its own parameter, the comparer dropped. The + // first argument was taken for the key selector and the rest matched from the end. + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(comparer: EqualityComparer.Default, keySelector: w => w.Length).Select(g => g.Key + \":\" + g.Count()));")] // 1:1,2:2 + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(elementSelector: w => w.ToUpper(), keySelector: w => w.Length).Select(g => string.Join(\"\", g)));")] // A,BBCC + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(resultSelector: (k, g) => k + \":\" + g.Count(), comparer: EqualityComparer.Default, keySelector: w => w.Length));")] // 1:1,2:2 + [InlineData("return string.Join(\",\", new[] { \"a\", \"bb\", \"cc\" }.GroupBy(comparer: EqualityComparer.Default, resultSelector: (k, g) => k + new string(g.ToArray()), elementSelector: w => w[0], keySelector: w => w.Length));")] // 1a,2bc + // After a null-conditional, which rebuilds the call with copies of its arguments. + [InlineData("string[] words = new[] { \"a\", \"bb\", \"cc\" }; var g = words?.GroupBy(comparer: EqualityComparer.Default, keySelector: w => w.Length); return string.Join(\",\", g.Select(x => x.Key + \":\" + x.Count()));")] // 1:1,2:2 + [InlineData("return new[] { \"a\", \"A\", \"a\" }.ToHashSet(StringComparer.Ordinal).Count;")] // 2 + public void ALinqOperatorHandedTheDefaultsComparer_AnswersAsItDoesWithoutOne(string statements) + { + Skip.IfNot(JsExecutor.IsAvailable, "No JS engine available."); + ConformanceRunner.AssertStatementsSameAsDotNet(statements); + } + + /// + /// A read or a call through a list's face, or a collection's, that holds null throws .NET's + /// NullReferenceException in its words, and a null-conditional one answers null. The runtime's count + /// counted a null as none, so r.Count answered 0 where .NET throws, and the element reads and + /// the calls threw JavaScript's own TypeError (#586, #593). + /// + [SkippableTheory] + [InlineData("IReadOnlyList r = null; try { return r.Count.ToString(); } catch (NullReferenceException e) { return e.Message; }")] + [InlineData("ICollection c = null; try { return c.Count.ToString(); } catch (NullReferenceException e) { return e.Message; }")] + [InlineData("IList l = null; try { return l[^1].ToString(); } catch (NullReferenceException e) { return e.Message; }")] + [InlineData("IReadOnlyList r = null; try { return r[0].ToString(); } catch (NullReferenceException e) { return e.Message; }")] + [InlineData("IList l = null; try { l[0] = 1; return \"written\"; } catch (NullReferenceException e) { return e.Message; }")] + [InlineData("ICollection c = null; try { c.Add(1); return \"added\"; } catch (NullReferenceException e) { return e.Message; }")] + [InlineData("ICollection c = null; try { c.Clear(); return \"cleared\"; } catch (NullReferenceException e) { return e.Message; }")] + // A null-conditional answers null, never 0, and counts what is there; a pattern tests for null first. + [InlineData("IReadOnlyList r = null; IReadOnlyList s = new List { 1, 2 }; var d = new Dictionary { [\"r\"] = r?.Count, [\"s\"] = s?.Count }; return d.ContainsValue(null) + \"|\" + d[\"s\"];")] // True|2 + [InlineData("ICollection c = null; IReadOnlyList r = null; return (c?.Count ?? -1) + \"|\" + (c?.Count > 0) + \"|\" + (r?[0] ?? -1) + \"|\" + (r is { Count: > 0 });")] // -1|False|-1|False + public void AReadThroughANullFace_ThrowsAsDotNetDoes(string statements) + { + Skip.IfNot(JsExecutor.IsAvailable, "No JS engine available."); + ConformanceRunner.AssertStatementsSameAsDotNet(statements); + } + + /// + /// ICollection<T>'s Add and Clear answer for the collection the interface + /// holds when the call runs, as Contains, Remove and CopyTo already did: a set + /// adds what it does not hold, a linked list adds last, a dictionary adds the pair's key and refuses + /// one already there. They were an array's push and splice, which none of those has + /// (#593). + /// + [SkippableTheory] + // The rows of #593. + [InlineData("ICollection c = new HashSet { 1 }; c.Add(2); c.Add(1); return c.Count + \"|\" + string.Join(\",\", c);")] // 2|1,2 + [InlineData("ICollection c = new LinkedList(new[] { 1 }); c.Add(2); return c.Count + \"|\" + string.Join(\",\", c);")] // 2|1,2 + [InlineData("ICollection c = new HashSet { 1 }; c.Clear(); return c.Count;")] // 0 + // The same two over a linked list, a sorted set and a dictionary's pairs. + [InlineData("ICollection c = new LinkedList(new[] { 1, 2 }); c.Clear(); c.Add(3); return c.Count + \"|\" + string.Join(\",\", c);")] // 1|3 + [InlineData("ICollection c = new SortedSet { 3 }; c.Add(1); c.Add(3); return string.Join(\",\", c);")] // 1,3 + [InlineData("ICollection c = new SortedSet { 3 }; c.Clear(); return c.Count;")] // 0 + [InlineData("ICollection> c = new Dictionary(); c.Add(new KeyValuePair(\"a\", 1)); return c.Count + \"|\" + string.Join(\",\", c.Select(p => p.Key + p.Value));")] // 1|a1 + [InlineData("ICollection> c = new Dictionary { [\"a\"] = 1 }; try { c.Add(new KeyValuePair(\"a\", 2)); return \"added\"; } catch (Exception e) { return e.Message; }")] + [InlineData("ICollection> c = new Dictionary { [\"a\"] = 1 }; c.Clear(); return c.Count;")] // 0 + [InlineData("ICollection> c = new SortedDictionary(); c.Add(new KeyValuePair(2, \"b\")); c.Add(new KeyValuePair(1, \"a\")); return string.Join(\",\", c.Select(p => p.Value));")] // a,b + [InlineData("ICollection> c = new SortedList { [2] = \"b\" }; try { c.Add(new KeyValuePair(2, \"c\")); return \"added\"; } catch (Exception e) { return e.Message; }")] + // A list behind the interface as before, and a set behind ISet, whose Clear is ICollection's. + [InlineData("ICollection c = new List { 1 }; c.Add(2); c.Clear(); c.Add(3); return string.Join(\",\", c);")] // 3 + [InlineData("IList l = new List { 1 }; l.Add(2); return string.Join(\",\", l) + \"|\" + l.Count;")] // 1,2|2 + [InlineData("ISet s = new HashSet { 1, 2 }; s.Clear(); return s.Count;")] // 0 + public void ICollectionsAddAndClear_AnswerForTheCollectionBehindIt(string statements) + { + Skip.IfNot(JsExecutor.IsAvailable, "No JS engine available."); + ConformanceRunner.AssertStatementsSameAsDotNet(statements); + } + + private const string Bags = """ + using System.Collections; + using System.Collections.Generic; + + public class Bag : ICollection + { + private readonly List _items = new(); + public int Adds; + public int Count => _items.Count; + public bool IsReadOnly => false; + public void Add(int item) { Adds++; _items.Add(item); } + public void Clear() => _items.Clear(); + public bool Contains(int item) => _items.Contains(item); + public void CopyTo(int[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public bool Remove(int item) => _items.Remove(item); + public IEnumerator GetEnumerator() { foreach (var item in _items) yield return item; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class Holder + { + public ICollection Items { get; } = new HashSet { 1 }; + public ICollection Bagged { get; } = new Bag(); + } + """; + + private static readonly (string Name, string Statements)[] BagCases = + [ + // "2|0|2" + ("a class of the app's own behind the interface", "ICollection c = new Bag(); c.Add(1); c.Add(2); var n = c.Count; c.Clear(); return n + \"|\" + c.Count + \"|\" + ((Bag)c).Adds;"), + // "2|1": a member's collection initializer adds through the interface's Add + ("a member's initializer adds through the interface", "var h = new Holder { Items = { 1, 2 }, Bagged = { 5 } }; return h.Items.Count + \"|\" + h.Bagged.Count;"), + ]; + + public static TheoryData BagCaseNames() => Each(BagCases); + + [SkippableTheory] + [MemberData(nameof(BagCaseNames))] + public void ICollectionsAddAndClear_ReachAClassOfTheAppsOwn(string name, bool typeAnnotations) => + ModuleGraph.AssertSameAsDotNet(Bags, typeAnnotations, BagCases.Single(c => c.Name == name)); + + private const string Slices = """ + using System.Collections.Generic; + + public class Strip + { + public static string Text = ""; + private readonly int[] _v = { 1, 2, 3, 4, 5 }; + public int Length { get { Text += "L"; return _v.Length; } } + public int this[int i] => _v[i]; + public int[] Slice(int start, int length) + { + Text += "S" + start + "," + length; + var r = new int[length]; + for (var i = 0; i < length; i++) r[i] = _v[start + i]; + return r; + } + public static Strip R(Strip s) { Text += "R"; return s; } + public static int At(string step, int value) { Text += step; return value; } + } + + public class Words + { + private readonly List _v; + public Words(params string[] v) { _v = new List(v); } + public int Count => _v.Count; + public string this[int i] => _v[i]; + public Words Slice(int start, int length) => new Words(_v.GetRange(start, length).ToArray()); + public override string ToString() => string.Join(",", _v); + } + """; + + private static readonly (string Name, string Statements)[] SliceCases = + [ + // "2|2,3": Slice(1, 2), where JavaScript's slice(1, 3) is three elements + ("the row of #585", "var part = new Strip()[1..3]; return part.Length + \"|\" + string.Join(\",\", part);"), + // "3,4" + ("a range from the end", "var p = new Strip()[^3..^1]; return string.Join(\",\", p);"), + // "1,2|4,5|1,2,3,4,5" + ("an open start, an open end and both", "var s = new Strip(); return string.Join(\",\", s[..2]) + \"|\" + string.Join(\",\", s[3..]) + \"|\" + string.Join(\",\", s[..]);"), + // "3,4,5|0" + ("an end from the end at zero", "var s = new Strip(); return string.Join(\",\", s[2..^0]) + \"|\" + s[^0..].Length;"), + // "2,3,4|2,3,4" + ("named endpoints", "int a = 1, b = 4; var s = new Strip(); return string.Join(\",\", s[a..b]) + \"|\" + string.Join(\",\", s[^b..^a]);"), + // the receiver once, then the endpoints in their order, then the length and the slice, as .NET reads them + ("the order a range is read in", "Strip.Text = \"\"; var s = new Strip(); var p = Strip.R(s)[Strip.At(\"A\", 1)..Strip.At(\"B\", 3)]; return Strip.Text + \"|\" + p.Length;"), + ("the order a range from the end is read in", "Strip.Text = \"\"; var s = new Strip(); var p = Strip.R(s)[^Strip.At(\"A\", 3)..^Strip.At(\"B\", 1)]; return Strip.Text + \"|\" + p.Length;"), + ("the order an open range is read in", "Strip.Text = \"\"; var s = new Strip(); var p = Strip.R(s)[Strip.At(\"A\", 1)..]; return Strip.Text + \"|\" + p.Length;"), + // "threw": Slice(3, -2) refuses a negative length, where slice(3, 1) answered one element + ("a range that ends before it starts", "try { var p = new Strip()[3..1]; return \"sliced \" + p.Length; } catch (System.Exception) { return \"threw\"; }"), + // "b,c|c,d": a type that counts and slices into itself + ("a type that counts and slices into itself", "return new Words(\"a\", \"b\", \"c\", \"d\")[1..^1].ToString() + \"|\" + new Words(\"a\", \"b\", \"c\", \"d\")[1..][1..].ToString();"), + // "2,3|2,3|bc|bc": a string, an array and a list keep JavaScript's slice, which answers alike there + ("a string, an array and a list keep their slice", "var arr = new[] { 1, 2, 3, 4 }; var l = new List { 1, 2, 3, 4 }; return string.Join(\",\", arr[1..3]) + \"|\" + string.Join(\",\", l[1..3]) + \"|\" + \"abcd\"[1..3] + \"|\" + \"abcd\"[^3..^1];"), + ]; + + public static TheoryData SliceCaseNames() => Each(SliceCases); + + /// + /// A range over a type eqc writes, with a Length or a Count and a + /// Slice(int start, int length), calls that Slice as C# lowers the range: the receiver + /// once, the start and the LENGTH computed from the endpoints and the count the bound tree names. It + /// called the twin's slice with the range's end where Slice takes a length (#585). + /// + [SkippableTheory] + [MemberData(nameof(SliceCaseNames))] + public void ARangeOverATypeWithSlice_CallsItsSliceWithALength(string name, bool typeAnnotations) => + ModuleGraph.AssertSameAsDotNet(Slices, typeAnnotations, SliceCases.Single(c => c.Name == name)); + + private const string Lists = """ + using System.Collections; + using System.Collections.Generic; + + public class Ring : IReadOnlyList + { + private readonly int[] _v = { 7, 8, 9 }; + public int Count => _v.Length; + public int this[int i] => _v[i]; + public IEnumerator GetEnumerator() { foreach (var v in _v) yield return v; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class Cells : IList + { + public static string Text = ""; + private readonly List _v = new() { 1, 2, 3 }; + public int Count => _v.Count; + public bool IsReadOnly => false; + public int this[int i] { get { Text += "g" + i; return _v[i]; } set { Text += "s" + i; _v[i] = value; } } + public int IndexOf(int item) => _v.IndexOf(item); + public void Insert(int index, int item) => _v.Insert(index, item); + public void RemoveAt(int index) => _v.RemoveAt(index); + public void Add(int item) => _v.Add(item); + public void Clear() => _v.Clear(); + public bool Contains(int item) => _v.Contains(item); + public void CopyTo(int[] array, int arrayIndex) => _v.CopyTo(array, arrayIndex); + public bool Remove(int item) => _v.Remove(item); + public IEnumerator GetEnumerator() { foreach (var v in _v) yield return v; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public static IList R(IList l) { Text += "R"; return l; } + public static int At(string step, int value) { Text += step; return value; } + } + + public class Polyline : IReadOnlyList + { + private readonly int[] _v = { 3, 4 }; + public double Length => 12.5; + public int Size => 7; + public int Count => _v.Length; + public int this[int i] => _v[i]; + public IEnumerator GetEnumerator() { foreach (var v in _v) yield return v; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class Shelf + { + public IList L { get; } = new Cells(); + } + + public static class Sums + { + public static int Of(IReadOnlyList xs) { var s = 0; for (var i = 0; i < xs.Count; i++) s += xs[i]; return s; } + } + """; + + private static readonly (string Name, string Statements)[] ListCases = + [ + // 19: the twin's item and count, where a subscript and a length answered undefined + ("the row of #586", "IReadOnlyList r = new Ring(); return r[0] + r[2] + r.Count;"), + // 97 + ("a read from the end", "IReadOnlyList r = new Ring(); return r[^1] * 10 + r[^3];"), + // 30: a twin, an array and a list through one parameter + ("a parameter typed as the interface", "return Sums.Of(new Ring()) + Sums.Of(new[] { 1, 2 }) + Sums.Of(new List { 3 });"), + // "2|4|7": the face counts by the twin's own Count, never by a Length or a Size beside it + ("a type of the app's own with a Length and a Size beside its count", "IReadOnlyList p = new Polyline(); return p.Count + \"|\" + p[^1] + \"|\" + Sums.Of(p);"), + // "5|4|4" + ("a read, a write, a compound and a step through IList", "IList l = new Cells(); l[0] = 5; l[1] += 2; l[2]++; return l[0] + \"|\" + l[1] + \"|\" + l[2];"), + // "7|7" + ("a write answers the value written", "IList l = new Cells(); var y = (l[0] = 7); return y + \"|\" + l[0];"), + // "3|6" + ("a write from the end", "IList l = new Cells(); l[^1] = 6; l[^2] += 1; return l[1] + \"|\" + l[2];"), + // "RKVs0": the receiver, the key, the value, then the setter + ("a write's order", "Cells.Text = \"\"; IList l = new Cells(); Cells.R(l)[Cells.At(\"K\", 0)] = Cells.At(\"V\", 4); return Cells.Text;"), + // "RKg1Vs1|4" + ("a compound's order", "Cells.Text = \"\"; IList l = new Cells(); Cells.R(l)[Cells.At(\"K\", 1)] += Cells.At(\"V\", 2); return Cells.Text + \"|\" + l[1];"), + // "8|-1" + ("a null-conditional read", "IReadOnlyList r = new Ring(); IReadOnlyList none = null; return (r?[1] ?? -1) + \"|\" + (none?[1] ?? -1);"), + // 9: an object initializer's entry written through the twin's setter, read back through its own indexer + ("an object initializer's entry", "var s = new Shelf { L = { [0] = 9 } }; return ((Cells)s.L)[0];"), + // 19: an array and a list behind the interfaces keep their subscript + ("an array and a list behind the interfaces", "IList l = new List { 1, 2 }; l[0] = 5; l[1] += 2; IReadOnlyList a = new[] { 1, 2, 3 }; return l[0] + l[1] + l[^1] + a[^1] + a.Count;"), + ]; + + public static TheoryData ListCaseNames() => Each(ListCases); + + /// + /// An access through the indexer of a BCL interface (IReadOnlyList<T>, IList<T>) + /// reaches a twin's item and setItem (#427), and an array's subscript, whichever the + /// interface holds when it runs, its count alike. It was a subscript, which a twin does not answer: + /// r[0] + r[2] + r.Count over a twin was null where .NET says 19 (#586). + /// + [SkippableTheory] + [MemberData(nameof(ListCaseNames))] + public void AnIndexerOfABclInterface_ReachesTheTwinBehindIt(string name, bool typeAnnotations) => + ModuleGraph.AssertSameAsDotNet(Lists, typeAnnotations, ListCases.Single(c => c.Name == name)); + + /// Each case by its name, in the SDK's TypeScript and in plain JavaScript. + private static TheoryData Each(IEnumerable<(string Name, string Statements)> cases) + { + var data = new TheoryData(); + foreach (var (name, _) in cases) + { + data.Add(name, true); + data.Add(name, false); + } + return data; + } +} diff --git a/tests/eQuantic.UI.Conformance.Tests/null-argument-gaps.baseline.txt b/tests/eQuantic.UI.Conformance.Tests/null-argument-gaps.baseline.txt index 64f6d0a73..6a8867ec9 100644 --- a/tests/eQuantic.UI.Conformance.Tests/null-argument-gaps.baseline.txt +++ b/tests/eQuantic.UI.Conformance.Tests/null-argument-gaps.baseline.txt @@ -304,7 +304,6 @@ Enumerable.ToLookup(IEnumerable,Func) source type LINQ Enumerable.ToLookup(IEnumerable,Func,Func) elementSelector type LINQ over a list is lowered to the array's own method, which reads a null source or delegate through null: refusing it by name is a guard at every call, which is a design decision Enumerable.ToLookup(IEnumerable,Func,Func) keySelector type LINQ over a list is lowered to the array's own method, which reads a null source or delegate through null: refusing it by name is a guard at every call, which is a design decision Enumerable.ToLookup(IEnumerable,Func,Func) source type LINQ over a list is lowered to the array's own method, which reads a null source or delegate through null: refusing it by name is a guard at every call, which is a design decision -Enumerable.ToLookup(IEnumerable,Func,IEqualityComparer) comparer type ToLookup with a comparer is lowered as the ToLookup with an element selector Enumerable.Union(IEnumerable,IEnumerable) first type LINQ over a list is lowered to the array's own method, which reads a null source or delegate through null: refusing it by name is a guard at every call, which is a design decision Enumerable.UnionBy(IEnumerable,IEnumerable,Func) first type LINQ over a list is lowered to the array's own method, which reads a null source or delegate through null: refusing it by name is a guard at every call, which is a design decision Enumerable.UnionBy(IEnumerable,IEnumerable,Func) keySelector type LINQ over a list is lowered to the array's own method, which reads a null source or delegate through null: refusing it by name is a guard at every call, which is a design decision diff --git a/tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json b/tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json index 782d17cc7..7ed816fe8 100644 --- a/tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json +++ b/tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json @@ -1,4 +1,4 @@ { - "gzipBytes": 218555, + "gzipBytes": 219241, "measuredWith": "gzip, CompressionLevel.SmallestSize" }