ClosedXML / ClosedXML/ClosedXML.Parser
IndexOutOfRangeException parsing a structured reference whose specifier list has no column (Table1[[#All]], Table1[[#Headers],[#Data]])
- Dominant language
- C#
- Stars
- 14
- Forks
- 8
- PR merge metrics
- No merged PRs in 30d
Description
A structured reference whose bracketed inner form ends with an item specifier and no column name - `Table1[[#All]]`, `Table1[[#Headers],[#Data]]` - throws `IndexOutOfRangeException` out of the tokenizer instead of parsing. Both are valid Excel.
It matters because the exception is reachable from file input: any workbook holding such a formula or defined name fails to load with a runtime exception rather than the library's own `ParsingException`, so a consumer cannot distinguish "malformed formula" from a bug and cannot catch it as a parse failure.
## Reproduction
Against `ClosedXML.Parser` 2.0.0 from NuGet, calling `CellFormulaA1` with a no-op factory so only the tokenizer is exercised:
| Formula | Result |
|---|---|
| `Sales[#All]` | ok |
| `Sales[#Headers]` | ok |
| `Sales[[#Headers],[Amount]]` | ok |
| `Sales[[#Headers],[#Data],[Amount]]` | ok |
| `Sales[[#Headers],[#Data],[Amount]:[Tax]]` | ok |
| `Sales[[#All]]` | **IndexOutOfRangeException** |
| `Sales[[#Headers]]` | **IndexOutOfRangeException** |
| `Sales[[#Headers],[#Data]]` | **IndexOutOfRangeException** |
| `Sales[[#Data],[#Totals]]` | **IndexOutOfRangeException** |
| `Sales[ [#Headers],[#Data] ]` | **IndexOutOfRangeException** |
| `Sales[[#All],[#Data]]` | ParsingException *(correct - illegal in Excel)* |
| `Sales[[#Headers],[#Totals]]` | ParsingException *(correct - not contiguous)* |
The last two rows are the right behaviour: Excel only permits the contiguous pairs `#Headers,#Data` and `#Data,#Totals`, and the tokenizer rejects the others properly. So the accepted grammar is correct; it just crashes on the legal inputs that end without a column.
The rule is not "two specifiers" - `Sales[[#Headers]]`, with one, fails too. It is: **in the `[[…]]` inner-reference form, a specifier list not followed by a column name crashes.** The unbracketed shorthand `Sales[#All]` is fine because it returns early on a different branch.
```
System.IndexOutOfRangeException: Index was outside the bounds of the array.
at ClosedXML.Parser.TokenParser.GetStructuredName(ReadOnlySpan`1 input, Int32 startIdx, String& columnName)
at ClosedXML.Parser.TokenParser.ParseIntraTableReference(ReadOnlySpan`1 input, StructuredReferenceArea& area, String& firstColumn, String& lastColumn)
at ClosedXML.Parser.FormulaParser`3.RefAtomExpression(Boolean replaceFirstAtom, TNode refAtom)
…
at ClosedXML.Parser.FormulaParser`3.CellFormulaA1(String formula, TContext context, IAstFactory`3 factory)
```
## Cause
`src/ClosedXML.Parser/TokenParser.cs`, `ParseIntraTableReference` (lines ~386–419 on `develop`). Each keyword block ends with an unconditional `SkipComma`, which assumes a column always follows the specifier list:
```csharp
if (input[i + 1] == '#')
{
var listItem = GetArea(input, ++i);
i += GetLength(listItem) + 1;
area |= listItem;
i = SkipComma(input, i); // <-- assumes a comma is next
}
// … second keyword block, same shape …
// After keyword list, we get either a COLUMN or a COLUMN:COLUMN
i = GetStructuredName(input, i, out firstColumn);
```
Tracing `Sales[[#Headers]]`, where the intra-table span is `[[#Headers]]` (length 12):
1. `i = 1`, `input[1] == '['`, so control reaches the inner-reference path.
2. First keyword block: `GetArea` returns `Headers`, then `i += GetLength(Headers) + 1` → `i = 2 + 8 + 1 = 11`, and `input[11] == ']'` - the reference is over.
3. `SkipComma(input, 11)` is called anyway. Its `Debug.Assert(input[i] == ',')` is exactly the violated invariant, but it compiles out in Release, so it does `i++` and returns `12 == input.Length`.
4. `GetStructuredName(input, 12, …)` reads `input[startIdx]` and throws.
`Sales[[#Headers],[#Data]]` reaches the same place through the second block: after `#Data`, `i` lands on the closing `]`, `SkipComma` walks past the end, and `GetStructuredName` indexes out of range.
That also explains why the three-part forms are fine - there really is a comma and a column after the list, so the assumption holds.
## Suggested fix
Only skip a comma when one is actually there, and treat a specifier list that ends at `]` as a complete reference with no column:
- after each keyword block, look at the next non-whitespace character;
- if it is `]`, set `firstColumn = lastColumn = null` and return;
- if it is `,`, consume it and carry on as now.
That keeps every currently-passing input on the same path and turns the four crashes into the areas they denote (`#All` → whole table, `#Headers`+`#Data` → header and data rows over the table's full width). It also leaves the `ParsingException` rejections untouched.
## Notes
- Reproduced against the 2.0.0 NuGet package; the `develop` source shows the same unconditional `SkipComma`, so it looks unfixed there too.
- I have not checked the R1C1 entry point, only `CellFormulaA1`.
- Downstream, `StructuredReferenceArea` is a `[Flags]` enum and consumers already handle `Headers | Data` and `Totals | Data`, so nothing on the AST side needs to change - only the tokenizer.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in src/ClosedXML.Parser/TokenParser.cs at ParseIntraTableReference and reproduce the cases through CellFormulaA1 with the no-op factory. Verify that legal structured references ending in a specifier list parse without IndexOutOfRangeException, while the listed invalid combinations still produce ParsingException.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 84/100