Skip to content

Read a # line as a comment in every parser, and write a leading # quoted - #304

Merged
konard merged 20 commits into
mainfrom
issue-301-6d74023563c6
Sep 5, 2026
Merged

Read a # line as a comment in every parser, and write a leading # quoted#304
konard merged 20 commits into
mainfrom
issue-301-6d74023563c6

Conversation

@konard

@konard konard commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes #301# opens a line comment in all seven implementations, on by default, and can be turned off.

The document from the issue is prose that a parser used to read as a link:

parse_lino("# a b")     # (# a b)   — read as three references, not as prose
parse_lino("# a: b")    # the same prose, with a colon: a syntax error

Now both are comments and hold no links, and a document a formatter writes reads back as itself.

The issue asked whether comments should exist at all. The decision on it — "We should have an option to disable comments in parsers, and by default we should support comments with #. As it is a single character. That also will be an advantage over JSON." — is what this PR implements: comments are read by default everywhere, and a parser can be asked to treat # as an ordinary character again.

Root cause

The notation had no comment syntax, so nothing ever skipped a line of prose. # was simply an ordinary character in a reference, which is why # a b parsed by accident — as the three references #, a and b — and why one more character broke it. The four hand-written parsers were even quieter about it: they read # a: b as ('# a': b), silently turning the prose into a link identifier.

| document           | Rust, JavaScript, C#              | Python, Go, Java, PHP |
| ------------------ | --------------------------------- | --------------------- |
| `# a b\n`          | `(# a b)`                         | `(# a b)`             |
| `# a: b\n`         | syntax error at line 1, column 4  | `('# a': b)`          |

So the bug was not in how the colon was parsed. It was that prose was being parsed at all.

The rule

A # opens a comment when it stands at the start of the document or after a space, a tab or a line break. The comment runs to the end of its line. Everywhere else a # is content:

document reads as
# a: b no links
a: b # why (a: b)
issue#1047 issue#1047 — a # inside a token
a: b#c (a: b#c) — a # that opens a token
"# not a comment" a two references, # not a comment and a — a # inside a delimited reference
parent\n # what the child is for\n child parent with the single child child

Reading a comment inside a delimited reference correctly means knowing how far that reference reaches, which is not something a regular expression can tell: any run of N delimiters opens and closes a reference and 2N of them stand for N delimiters written as content. So the scanner that reads a delimited reference was lifted out of each parser (Quotes in C#, quotes.py in Python, and their counterparts elsewhere) and is now shared with the comment stripper — the stripper reaches into a reference exactly as far as the parser does, by construction rather than by resemblance.

Comments are blanked, not removed

Each implementation replaces the characters of a comment with spaces instead of cutting them out, so every character that follows keeps the offset, line and column it was written at. That is what keeps the positions from #302 honest:

# a comment
stage: rust: nextest

still reports Syntax error at line 2, column 12, with the offending line quoted under a caret, and not a position shifted by the length of the comment. blanking a comment keeps the length of the document is a test in all seven suites.

A blanked comment leaves a line of spaces behind, so a line of spaces now separates links the way an empty line does — also a test in all seven suites.

Turning comments off

A document written before comments existed can still be read:

language how
Rust parse_lino_with_config(source, &ParserConfig::without_comments())
JavaScript new Parser({ comments: false })
Python Parser(comments=False)
Go p := NewParser(); p.Comments = false
Java new Parser(false)
C# new Parser(comments: false)
PHP new Parser(10 * 1024 * 1024, 1000, false)

rust/links-notation/examples/comments.rs shows both settings on one document.

Writing a document back

A parser that skips comments makes the formatter's job stricter: (a #tag) written unquoted no longer reads back as itself — #tag opens a comment, and reading the document again gives a alone, a syntax error or a different link, depending on the language. Every escaper now quotes a reference that begins with a #:

(a '#tag')        # written quoted, reads back as (a #tag)
issue#1047        # a # that cannot open a comment needs no quotes

This is the half of the change that is easy to miss, so it has its own tests in all seven suites (a reference that begins with a hash is written quoted, a hash that cannot open a comment is left unquoted). Note that Rust's Display for LiNo does not escape at all — a pre-existing asymmetry, not touched here — so the Rust test goes through format_links_with_config, which is the path that escapes.

Tests

A conformance suite shared by all seven implementations, written before the fix and watched failing:

Python JavaScript Rust C# Go Java PHP
comment tests 21 23 23 22 21 21 21

The three parsers that report positions also assert that a comment does not move a later error, and Rust and JavaScript assert that a parser without comments still rejects the document from the issue.

Local runs, all green: cargo test + cargo fmt --check + cargo clippy -D warnings, dotnet test --configuration Release (229/229) + dotnet format --verify-no-changes, bun test (237) + bun run lint + Prettier, pytest (214 passed, 1 skipped) + Black + isort + flake8, go test ./... + gofmt -l, mvn test + spotless:check. PHPUnit runs in CI only — composer requires PHP >= 8.4 and this machine has 8.3.

scripts/create-test-case-comparison.mjs matched def test_... only at the start of a line, so Python tests written as methods of a Test... class were invisible to it and whole files never reached the comparison. With that fixed the Python count in the README goes from 146 to 215 — the 21 new comment tests plus 48 class-based tests that had been hidden all along — and the comment suites of all seven languages now sit in one ## Comments category.

Documentation

Every README (8 languages × 2 translations), docs/grammar/GRAMMAR.md, docs/grammar/grammar.lino, docs/grammar/links-notation.ebnf, docs/grammar/syntax-diagrams.md and CHANGELOG.md describe comments, the option that turns them off, and the quoting rule the formatter follows.

Known divergence, left alone

With comments turned off, Python, Go, Java and PHP read # a: b as ('# a': b) while Rust, JavaScript and C# reject it. That is about whether an identifier may hold a space before a colon — it predates this PR and is the same looseness catalogued in #302 and #138. It is orthogonal to comments, so it is not fixed here; the shared suite asserts the rejection only in the two implementations that already reject.

How to reproduce and verify

./experiments/issue-301/run.sh

Asks all seven implementations about the same six documents and prints the answers next to each other; toolchains that are not installed are reported as skipped. experiments/issue-301/README.md records what the run says before and after.

Release

Every implementation is bumped to 0.19.0 (node scripts/version-consistency.mjs — "All 7 implementations declare 0.19.0"), so merging releases the change.

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

Issue: #301
@konard konard self-assigned this Sep 5, 2026
@konard

konard commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

🚨 Solution Draft Failed

The automated solution draft encountered an error:

CLAUDE stopped: Authentication expired — re-login required [authentication_failed] — Failed to authenticate: OAuth session expired and could not be refreshed

- Re-authenticate the tool: claude /login  (or set ANTHROPIC_API_KEY for API-key billing)
- Once access is restored, resume with the session ID printed above — no work is lost.

🤖 Models used:

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

📎 Failure log uploaded as Gist (8377KB)


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

@konard

konard commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

@konard

konard commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

🤖 AI Work Session Started

Starting automated work session at 2026-09-05T21:57:41.534Z

The PR has been converted to draft mode while work is in progress.

This comment marks the beginning of an AI work session. Please wait for the session to finish, and provide your feedback.

Comments are blanked before parsing, so every later character keeps its
offset and a parse error still points at the original document. A parser
can be told to treat # as an ordinary character with
ParserConfig::without_comments().

Part of #301.
The parser blanks comments before handing the document to the generated
parser, so a parse error still points at the position in the document the
caller wrote. A line holding nothing but spaces now separates links the
way an empty line does, which is what a blanked comment line leaves
behind. new Parser({ comments: false }) reads # as an ordinary character.

Part of #301.
The quote scanner moves into a module of its own so that the comment
stripper can ask it how far a delimited reference reaches. A line holding
nothing no longer closes an indented block, which is what a blanked
comment line leaves behind. Parser(comments=False) reads # as an ordinary
character.

Part of #301.
A formatter that writes `(a #tag)` writes a document that no longer reads
back as itself: the `#` opens a comment, so the second reference is gone
by the time the document is parsed again. A `#` anywhere else in a
reference cannot open a comment (`issue#1047`), so only the first
character decides.

All seven implementations now quote such a reference, each with a test
that fails without the fix.
The rule belongs next to the one it mirrors, so it is now in the Comments
section of all sixteen READMEs, in the grammar overview and in the
changelog.
A minor bump rather than a patch: every parser gains a `comments` option
and reads `#` comments by default, which is new public API in all seven
languages.

All seven declare 0.19.0, so scripts/version-consistency.mjs passes,
including the installation snippets in the Java and PHP READMEs. The
manifests that name the library by version rather than by path follow:
rust/links-notation-benchmark/Cargo.toml, benchmarks/java/pom.xml and the
private website package, and the benchmark report footer names the version
it measured.

Refs #301
The generated comparison groups tests by the name of the file they live in,
so the Rust suite, named `comment_tests.rs`, stood in a category of its own
while the other six shared `Comments`. Rename it, and give the tests that
differed by name alone the name their counterparts carry.

The Python extractor matched `def test_...` only at the start of a line, so
tests written as methods of a `Test...` class were invisible to it, and whole
files, the comment suite among them, never reached the comparison. Allow the
indentation a method carries.
@konard konard changed the title [WIP] Links Notation has no comment syntax, so # prose lines parse by accident and break on a bare colon Read a # line as a comment in every parser, and write a leading # quoted Sep 5, 2026
@konard
konard marked this pull request as ready for review September 5, 2026 23:18
Clippy 1.98, which the workflow runs, reads an array of byte characters as a
byte string written the long way (`clippy::byte_char_slices`) and the lint is
denied there.
@konard

konard commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Working session summary

PR #304 is finished and ready for review: #304

What landed since the last checkpoint:

  • Committed the parity/tooling work (353a17b): renamed rust/links-notation/tests/comment_tests.rscomments_tests.rs and aligned the differing test names, so the generated comparison shows a single ## Comments category covering all seven languages (Python 21, JavaScript 23, Rust 23, C# 22, Go 21, Java 21, PHP 21); fixed the Python extractor in scripts/create-test-case-comparison.mjs (/^[ \t]*def (test_\w+)/gm), which had hidden every class-based test — the README count for Python goes 146 → 215.
  • Confirmed the flake8 findings were an artifact of my invocation, not a real issue: CI runs flake8 from python/, where python/.flake8 ignores E203 and lists the three E402 files. Run that way, flake8 --max-line-length=120 . exits 0.
  • Ran the remaining gates: mvn spotless:check exit 0, bun run lint exit 0.
  • PR title and description rewritten (gh pr edit) in the style of the merged PRs, covering the root cause, the comment rule, why comments are blanked, the per-language opt-out, the formatter round-trip fix, the test table, the comparison-script fix, the 0.19.0 bump, and the one divergence left alone (comments=False on # a: b in the four hand-written parsers, which belongs to Double check that all language implementations have the same list of test cases tested #138/Rust parse errors carry no line or column, while the JavaScript port reports both #302). Marked ready with gh pr ready 304.
  • CI caught a real failure I could not reproduce locally and it is fixed: clippy 1.98 on the runner denies clippy::byte_char_slices, which my local clippy 0.1.96 does not have, so cargo clippy -D warnings failed on the three byte-set constants in rust/links-notation/src/comments.rs. Rewrote them as byte strings (*b"\"'"etc.) and pushed5c816c3`.

CI on 5c816c3 is fully green: rust, csharp, js, python, go, java, php, docs, links, pages, bom-check, release-audit, security, benchmarks all success (auto-merge skipped). Working tree is clean and origin/main is already an ancestor of the branch, so the PR is mergeable.

One note on my earlier local verification: rustup update stable failed here with a cross-device-link error and rolled back, so I still cannot run the CI's clippy version locally — the green rust job on 5c816c3 is the evidence for that lint, not a local run.


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

@konard

konard commented Sep 5, 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: $32.572985

📊 Context and tokens usage:

Claude Opus 5: (7 sub-sessions)

  1. 114.9K / 1M (11%) input tokens, 30.4K / 128K (24%) output tokens
  2. 116.1K / 1M (12%) input tokens, 42.1K / 128K (33%) output tokens
  3. 115.0K / 1M (12%) input tokens, 39.4K / 128K (31%) output tokens
  4. 115.6K / 1M (12%) input tokens, 39.1K / 128K (31%) output tokens
  5. 116.6K / 1M (12%) input tokens, 33.1K / 128K (26%) output tokens
  6. 116.6K / 1M (12%) input tokens, 25.6K / 128K (20%) output tokens
  7. 71.3K / 1M (7%) input tokens, 12.3K / 128K (10%) output tokens

Total: (17.8K new + 665.5K cache writes + 37.3M cache reads) input tokens, 286.4K output tokens, $32.572986 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 (11722KB)


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

@konard
konard merged commit 418d459 into main Sep 5, 2026
75 checks passed
@konard

konard commented Sep 5, 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.

Links Notation has no comment syntax, so # prose lines parse by accident and break on a bare colon

1 participant