Decompiling C# range and index operators: folding Slice and GetSubArray back into a[^1] and a[1..^1]
The ^ and .. operators are pure compiler sugar. a[^1] lowers to arithmetic against the collection length, a[1..^1] builds a System.Range from two System.Index values and calls RuntimeHelpers.GetSubArray, a span range becomes Span<T>.Slice, and a string range becomes String.Substring. A naive decompiler shows the raw constructor calls and the Length minus n arithmetic; Glass.NET pattern-matches these shapes and folds them back into ^ and .. — including the subtle case where span.Slice(1, 2) and span[1..3] emit byte-identical IL, so Glass has to pick the reading that round-trips.
The ^ (index-from-end) and .. (range) operators are some of the most convenient syntax C# has added, and some of the most thoroughly erased by the time code reaches IL. There is no “range” opcode and no “from end” opcode. a[^1] is arithmetic against a length; a[1..^1] is a pair of constructor calls and a helper; a span slice is a method call; a string range is Substring. By the time a decompiler sees the assembly, the operators are gone and only their mechanical residue remains.
This post walks that residue for each shape, shows what a naive decompiler prints, and shows how Glass.NET folds it back into ^ and .. — including the genuinely ambiguous case where two different source forms compile to exactly the same bytes.
The source
Four small methods, one per lowering shape:
int Last(int[] a) => a[^1]; // index from end
int[] Middle(int[] a) => a[1..^1]; // array range -> GetSubArray
Span<int> Tail(Span<int> s) => s[1..^1]; // span range -> Slice
string Head(string s) => s[..5]; // string range -> Substring
Each compiles to something that mentions neither ^ nor ...
a[^1]: an index that never becomes an Index
The intuition is that ^1 constructs a System.Index with fromEnd: true. It can — but when the index is used directly on a type that has a length and an int indexer, the compiler skips the object entirely and lowers to plain arithmetic:
// int Last(int[] a) => a[^1];
IL_0000: ldarg.1 // a
IL_0001: ldarg.1 // a (again, to read its length)
IL_0002: ldlen
IL_0003: conv.i4
IL_0004: ldc.i4.1
IL_0005: sub // a.Length - 1
IL_0006: ldelem.i4
IL_0007: ret
No newobj, no System.Index. A naive decompiler reads this exactly as written and prints the arithmetic:
int Last(int[] a) => a[a.Length - 1];
That is correct, but it hides the operator. Glass recognises the shape — a length load, a subtraction by a constant, feeding the index of an element access on the same instance — and folds it to the surface form:
int Last(int[] a) => a[^1];
The System.Index constructor only survives when the end-relative value has to live as a value: var i = ^1; stored in a local, or an indexer whose parameter is typed System.Index. There you really do see ldc.i4.1; ldc.i4.1; newobj instance void System.Index::.ctor(int32, bool) — value 1, fromEnd: true — and Glass renders that local as Index i = ^1;.
a[1..^1]: a Range, two Indexes, and GetSubArray
Array ranges are the richest shape, because slicing an array allocates a new array, and that copy goes through a runtime helper:
// int[] Middle(int[] a) => a[1..^1];
IL_0000: ldarg.1
IL_0001: ldc.i4.1
IL_0002: ldc.i4.0
IL_0003: newobj instance void System.Index::.ctor(int32, bool) // Index(1, fromEnd: false)
IL_0008: ldc.i4.1
IL_0009: ldc.i4.1
IL_000a: newobj instance void System.Index::.ctor(int32, bool) // Index(1, fromEnd: true)
IL_000f: newobj instance void System.Range::.ctor(valuetype System.Index, valuetype System.Index)
IL_0014: call !!0[] [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::GetSubArray<int32>(!!0[], valuetype System.Range)
IL_0019: ret
Four allocations’ worth of ceremony encode 1..^1: a from-start Index(1, false), a from-end Index(1, true), a Range built from the two, and GetSubArray<int> which internally calls range.GetOffsetAndLength(a.Length) to convert the range into a concrete (start, length) before copying. A naive decompiler prints all of it:
int[] Middle(int[] a) =>
RuntimeHelpers.GetSubArray(a, new Range(new Index(1), new Index(1, true)));
Glass matches the whole pipeline — Range constructed from two Index values handed to GetSubArray — and collapses it to the operator, reading fromEnd: false as a plain offset and fromEnd: true as a ^:
int[] Middle(int[] a) => a[1..^1];
This shape is unambiguous: nothing but range sugar produces a GetSubArray call, so Glass can always fold it without second-guessing.
s[1..^1] on a span: Slice, no copy
A Span<T> range never allocates. The compiler lowers it to Slice(start, length), computing the offsets from the span’s length. For the from-end case you can see the length load and the subtraction inline:
// Span<int> Tail(Span<int> s) => s[1..^1];
IL_0000: ldarga.s s
IL_0002: call instance int32 valuetype [System.Runtime]System.Span`1<int32>::get_Length()
IL_0007: ldc.i4.1
IL_0008: sub // end = Length - 1
IL_0009: ldc.i4.1 // start = 1
IL_000a: sub // length = (Length - 1) - 1
IL_000b: ldarga.s s
IL_000d: ldc.i4.1 // start again
IL_000e: ??? call Slice(int32, int32)
In practice Roslyn hoists get_Length into a temporary and orders the operands so the final call is s.Slice(1, s.Length - 2). A naive decompiler prints that literally:
Span<int> Tail(Span<int> s) => s.Slice(1, s.Length - 1 - 1);
Glass recognises get_Length feeding Length - n into the length argument of a Slice as the from-end marker and restores both endpoints:
Span<int> Tail(Span<int> s) => s[1..^1];
When the range is a runtime value rather than a constant — s[r] where r is a Range local — the compiler does not inline the math; it calls r.GetOffsetAndLength(s.Length), which returns a (int, int) tuple, and feeds that into Slice. Glass folds that back to s[r].
s[..5] on a string: Substring
Strings get a third lowering. A string range becomes Substring(start, length):
// string Head(string s) => s[..5];
IL_0000: ldarg.1
IL_0001: ldc.i4.0 // start = 0 (open-ended start)
IL_0002: ldc.i4.5 // length = 5
IL_0003: callvirt instance string [System.Runtime]System.String::Substring(int32, int32)
IL_0008: ret
Naive output reads it straight: s.Substring(0, 5). Glass folds the (0, constant) shape to the open-start range s[..5].
The ambiguity: span.Slice(1, 2) and span[1..3] are the same bytes
Here is the subtlety that makes this more than a lookup table. Take a span range with constant endpoints, both from the start:
Span<int> a = span[1..3]; // range sugar
Span<int> b = span.Slice(1, 2); // explicit method call
The compiler folds 1..3 to start = 1, length = 3 - 1 = 2 at compile time, so both lines emit byte-identical IL:
ldarga.s span
ldc.i4.1
ldc.i4.2
call instance valuetype System.Span`1<int32> System.Span`1<int32>::Slice(int32, int32)
There is nothing in the assembly that says which form the author wrote. The same is true of s[1..6] versus s.Substring(1, 5) on a string. A decompiler that always “prefers the modern syntax” will rewrite every Slice(1, 2) as [1..3] and every Substring as a range — including the ones that were written as explicit method calls.
Glass does not guess. Its rule is round-trip fidelity: emit the reading that recompiles to the same IL and asserts nothing the bytes do not require. For the bare Slice(const, const) / Substring(const, const) shape, that reading is the method call, so Glass prints span.Slice(1, 2) and s.Substring(1, 5) as written. It only emits .. and ^ when the IL carries evidence that only range sugar produces:
- a
System.Range::.ctorcall (the general range path), - a
RuntimeHelpers.GetSubArraycall (array ranges), - a
Range.GetOffsetAndLengthcall (runtime-valued ranges), - or
get_Length/ldlenarithmetic of the formLength - nfeeding an element access or aSlicelength (from-end^).
Every one of those shapes can be recompiled to byte-identical IL, so the fold is provably safe. The bare Slice cannot be folded without asserting a source form the IL does not demand — so Glass leaves it alone. The result reads honestly: ranges appear where ranges were provably used, and Slice appears where the author may simply have called Slice.
Trying it
Point the CLI at the type and read the method bodies back:
glass decompile MyApp.dll --type MyApp.Buffers
glass decompile MyApp.dll --type MyApp.Buffers --member Tail
Last, Middle, and Tail come back as a[^1], a[1..^1], and s[1..^1]; a genuine span.Slice(1, 2) comes back as the method call. The same reconstruction drives the Avalonia UI, so the folded ^/.. is what you read when you open the assembly in Glass.
The takeaway
Range and index are sugar that dissolves completely into IL: ^1 into Length - 1, 1..^1 into a System.Range of two System.Index values handed to GetSubArray or Slice, and a string range into Substring. A naive decompiler shows the residue — constructor calls, GetOffsetAndLength, length arithmetic — which is correct but unreadable. Glass pattern-matches each shape and folds it back to ^ and .., while refusing to fold the one shape it cannot prove: a bare constant Slice or Substring, which range sugar and an explicit call compile to identically. The guiding rule is that every fold must round-trip, so the surface syntax Glass shows is never a guess dressed up as a fact.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.