Skip to content

A bare delimiter pair is the empty reference in every implementation - #289

Merged
konard merged 10 commits into
mainfrom
issue-288-cc91e23553e8
Aug 28, 2026
Merged

A bare delimiter pair is the empty reference in every implementation#289
konard merged 10 commits into
mainfrom
issue-288-cc91e23553e8

Conversation

@konard

@konard konard commented Aug 28, 2026

Copy link
Copy Markdown
Member

A bare delimiter pair is the empty reference

Fixes #288.

Measured against 0.14.0, an empty reference was read three different ways
depending on context. Every implementation now applies the rule the issue
proposes — give the shortest delimiter priority: a bare delimiter pair is the
empty reference, and a longer run is an n-quote delimiter as it is today.

Reproduction

$ node --input-type=module -e "import {Parser} from './js/src/index.js'; console.log(JSON.stringify(new Parser().parse('(a \"\" b)')))"
[{"id":null,"values":[{"id":"a","values":[]},{"id":"","values":[]},{"id":"b","values":[]}]}]

Before: (a "" b) held the two-character reference "", (a "" "" b) merged
into a single reference holding a space, and ("" ("" 1)) was a parse error.
After: they hold one empty reference, two empty references, and a nested pair of
empty references.

The rule

A maximal run of N identical delimiters (", ' or ` — all three behave
identically) is read as an n-quoted reference when it opens one; otherwise, if N
is even, the whole run is one empty reference. For an even run the n-quoted
reading is only taken when the body is substantive: it holds at least one
non-whitespace character and its parentheses are balanced. Odd runs are
completely unchanged.

Source Reads as Changed?
(a " " b) a, a reference holding one space, b no
(a "" b) a, the empty reference, b yes
(a "" "" b) a, two empty references, b yes
(a ""x"" b) a, x written with a 2-quote run, b no
(a """" b) a, one empty reference, b yes
(x "" " "") x, a reference holding " no
("" ("" 1)) the empty reference linked to ("" 1) yes (was a parse error)
("" ('' 1)) the same no

The formatters were fixed to match: they used to drop a reference that held
nothing or only whitespace, which silently deleted it from the document. The
empty reference is now written as "" and Ref(" ") as ' ', so both read
back as themselves.

Landed in every supported language implementation

Language Parser Formatter Conformance tests
Rust rust/links-notation/src/parser.rs rust/links-notation/src/lib.rs tests/empty_reference_tests.rs
JavaScript js/src/grammar.pegjs (+ regenerated parser-generated.js) js/src/Link.js js/tests/EmptyReference.test.js
Python python/links_notation/parser.py python/links_notation/link.py python/tests/test_empty_reference.py
Go go/parser.go go/lino.go go/empty_reference_test.go
C# csharp/.../Parser.peg csharp/.../Link.cs csharp/.../EmptyReferenceTests.cs
Java java/.../Parser.java java/.../Link.java java/.../EmptyReferenceTest.java
PHP php/src/Parser.php php/src/Link.php php/tests/EmptyReferenceTest.php

Each suite asserts the same table, rendered the same way (every reference in
angle brackets, so an empty one is visible as <>), plus a round-trip check.
experiments/issue-288/ holds one runnable ground-truth script per language;
all seven print an identical rendering for the 24 cases from the issue.

The readers in lino-objects-codec are a separate repository and are not
touched here.

Tests

  • Rust: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test — all green
  • JavaScript: bun run lint, bun test — 204 pass
  • Python: black --check, isort --check-only, flake8, pytest — 193 passed, 1 skipped
  • Go: gofmt -l, go vet ./..., go test ./... — green
  • C#: dotnet test — 196 passed
  • Java: mvn test (133 tests), mvn spotless:check — green
  • PHP: phpunit — 183 tests, 497 assertions; phpcs — green

Three pre-existing tests encoded the old behaviour and were updated to the new
rule: MultiQuoteParserTest.testEmptyQuotes / testEmptyDoubleQuotes in Java
(which asserted that '' and "" are literal two-character strings) and
LinkTest::testLinkEscapeReferenceKeepsZero in PHP (which asserted that a
whitespace-only reference is written as nothing).

Documented trade-off

An even run whose body is whitespace-only or contains unbalanced parentheses now
reads as empty references rather than as an n-quote delimiter, so ""a ( b""
changes meaning. Such content stays expressible with a different delimiter or an
odd-length run: 'a ( b' or """a ( b""".

Also in this PR

  • docs/grammar/: GRAMMAR.md, links-notation.ebnf, grammar.lino and
    syntax-diagrams.md now specify references as delimited_reference
    (n_quoted_reference or empty_reference) or simple_reference. N-quoting
    and the backtick delimiter were previously undocumented.
  • CHANGELOG.md entries under ## [Unreleased].
  • Version bumps so the release workflows publish the fix: Rust/JS/Python/Go/C#
    0.14.0 → 0.15.0, Java 0.2.0 → 0.3.0, PHP 0.1.0 → 0.2.0, grammar spec
    0.14.0 → 0.15.0.
  • js/examples/empty_reference.js: the document lino-objects-codec writes for
    {"": {"": 1}}, parsing and round-tripping.

Out of scope, reported for the record

For an unterminated odd delimiter run the implementations already disagree,
and this PR does not change that: Rust, JavaScript and C# read (a " b) as
a, ", b, while Python, Go, Java and PHP read it as a, " b. The shared
conformance table only covers rows that agree everywhere.

Adding .gitkeep for PR creation (default mode).
This file will be removed when the task is complete.

Issue: #288
@konard konard self-assigned this Aug 28, 2026
konard added 8 commits August 28, 2026 06:26
A run of delimiters that encloses nothing was read as literal text, so
`(a "" b)` decoded to the two-character text `""`, two empties in a row
merged into a space, and `("" ("" 1))` failed to parse.

An even run of delimiters now takes the n-quote reading only when its body
is substantive - at least one visible character and balanced parentheses.
Otherwise the run is a delimiter pair enclosing nothing: the empty
reference. Odd runs and every existing n-quote meaning are unchanged.

The formatter writes the empty reference as `""` instead of nothing, so a
document holding one reads back as itself.

Refs #288
Mirrors the Rust rule: an even run of delimiters takes the n-quote
reading only when its body is substantive, and is otherwise a delimiter
pair enclosing nothing. The formatter writes the empty reference as `""`,
and the parser keeps an empty id instead of dropping it to null.

Refs #288
Ports the rule already landed in Rust and JavaScript: a run of an even
number of delimiters that does not enclose a substantive body is the empty
reference, and every longer n-quote run keeps the meaning it already had.
The formatters write the empty reference as a bare delimiter pair so that a
document round trips instead of losing the reference.

Adds the shared conformance suite to both implementations.

Drops the two unterminated-odd-run cases from the Rust and JavaScript
suites: Rust and JavaScript read `(a " b)` as three references while Python
and Go read it as two, a pre-existing divergence unrelated to the empty
reference.
Replaces the split N=1 / N=2 / N>=3 quoting rules with the universal
procedural parser the other implementations use: a run of an even number of
delimiters that does not enclose a substantive body is the empty reference,
and every longer n-quote run keeps the meaning it already had.

EscapeReference writes the empty reference as a bare delimiter pair so that a
document round trips instead of losing the reference.

Adds the shared conformance suite.
Replaces the three separate quote scanners with one universal procedural
parser, matching the other implementations: a run of an even number of
delimiters that does not enclose a substantive body is the empty reference,
and every longer n-quote run keeps the meaning it already had.

escapeReference writes the empty reference as a bare delimiter pair so that a
document round trips instead of losing the reference.

Adds the shared conformance suite and updates the two multi-quote tests that
asserted the old reading of a bare pair as literal text.
A run of an even number of delimiters that does not enclose a substantive
body is now the empty reference, so (a "" b) holds an empty reference,
two in a row stay separate and ("" ("" 1)) parses.

The formatter writes the empty reference as "" and a whitespace-only
reference as it is, so neither disappears from the document.

Part of #288
Describe references as delimited or simple: an n-quoted reference opens
with a run of N delimiters and closes with a run of exactly N, and an even
run that does not enclose a substantive body is the empty reference. The
three delimiters ", ' and ` are documented as equivalent.

Bump the package versions so the release workflows publish the fix.

Part of #288
Show the document lino-objects-codec writes for an object with empty
keys, (o: ("" (o: ("" 1)))), parsing and round tripping.

Part of #288
@konard konard changed the title [WIP] An empty reference is read three ways depending on context: "" keeps its delimiters, two in a row merge into a space, and nesting is a parse error A bare delimiter pair is the empty reference in every implementation Aug 28, 2026
@konard
konard marked this pull request as ready for review August 28, 2026 07:03
@konard

konard commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Working session summary

All CI runs on HEAD c712a02 finished green, and PR #289 is now out of draft.

Final state

Workflow Conclusion (on c712a02)
rust, js, python, go, csharp, java, php, bom-check success
auto-merge skipped (expected, no merge label)

What landed — the rule from the issue ("a bare delimiter pair is the empty reference, a longer run stays an n-quote delimiter") is implemented in all seven in-repo implementations (Rust, JS, Python, Go, C#, Java, PHP): for a maximal run of N delimiters with N even, the n-quote reading is taken only when the body is substantive (has a non-whitespace character and balanced parens); otherwise the run is consumed as the empty reference. Odd runs are unchanged. Formatters now write the empty reference as "" and quote whitespace-only references instead of dropping them. Each language has an EmptyReference test file covering the same 24-case table, plus per-language ground-truth scripts in experiments/issue-288/ that produce byte-identical output, a js/examples/empty_reference.js example for the downstream (o: ("" (o: ("" 1)))) document, updated grammar docs (EBNF, grammar.lino, GRAMMAR.md, syntax diagrams) at 0.15.0, CHANGELOG entries, and version bumps across all packages.

Two things the PR body flags for the reviewer: even-run n-quote strings with a whitespace-only or paren-unbalanced body change meaning (still writable as 'a ( b' or """a ( b"""), and the pre-existing, out-of-scope divergence on unterminated odd runs ((a " b) reads differently in Rust/JS/C# than in Python/Go/Java/PHP). The lino-objects-codec readers named in the issue live in a separate repo and are not part of this PR.

#289


This summary was automatically extracted from the AI working session output.

@konard

konard commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

🤖 Solution Draft Log

This log file contains the complete execution trace of the AI solution draft process.

💰 Cost: $20.067079

📊 Context and tokens usage:

Claude Opus 5: (5 sub-sessions)

  1. 116.0K / 1M (12%) input tokens, 45.2K / 128K (35%) output tokens
  2. 115.1K / 1M (12%) input tokens, 41.3K / 128K (32%) output tokens
  3. 116.3K / 1M (12%) input tokens, 33.9K / 128K (26%) output tokens
  4. 115.4K / 1M (12%) input tokens, 41.0K / 128K (32%) output tokens
  5. 41.3K / 1M (4%) input tokens, 1.7K / 128K (1%) output tokens

Total: (10.8K new + 403.5K cache writes + 21.6M cache reads) input tokens, 206.2K output tokens, $20.067079 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Thinking level: high (~23999 tokens)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (6835KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard
konard merged commit 4958f3c into main Aug 28, 2026
45 checks passed
@konard

konard commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

🎉 Auto-merged

This pull request has been automatically merged by hive-mind.

  • All CI checks have passed

Auto-merged by hive-mind with --auto-merge flag

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An empty reference is read three ways depending on context: "" keeps its delimiters, two in a row merge into a space, and nesting is a parse error

1 participant