diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..d2f5ff3 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-08-28T06:09:39.810Z for PR creation at branch issue-288-cc91e23553e8 for issue https://github.com/link-foundation/links-notation/issues/288 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e51684..72e5613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependabot for automated dependency updates ### Changed +- Grammar: a reference is a `delimited_reference` (`n_quoted_reference` or + `empty_reference`) or a `simple_reference`; the three delimiters `"`, `'` + and `` ` `` are documented as equivalent, and an even delimiter run that does + not open an n-quoted reference with a substantive body is the empty reference + ([#288](https://github.com/link-foundation/links-notation/issues/288)) - Grammar: `multiline_link`, `multiline_value_link` and `multiline_values` are replaced by `nested_group` and `nested_group_body`; `eol` now also matches the end of a nested group, and `ENTER_NESTED_CONTEXT`/`EXIT_NESTED_CONTEXT` were @@ -45,6 +50,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved quote escaping to handle edge cases in JavaScript ### Fixed +- A bare delimiter pair is now the empty reference in every implementation + (JavaScript, Python, Rust, Go, Java, C#, PHP): `(a "" b)` holds an empty + reference instead of the two-character text `""`, `(a "" "" b)` holds two + empty references instead of merging into one holding a space, and + `("" ("" 1))` parses instead of failing. A run of an even number of + delimiters keeps its n-quote meaning only when it encloses a substantive + body, so `(a ""x"" b)` and `(x "" " "")` are unchanged + ([#288](https://github.com/link-foundation/links-notation/issues/288)) +- Formatters no longer drop a reference that holds nothing or only whitespace: + the empty reference is written as `""` and `Ref(" ")` as `' '`, so both read + back as themselves + ([#288](https://github.com/link-foundation/links-notation/issues/288)) - Indentation inside `( )` was ignored, so a parenthesised group collapsed to one flat list of references and records such as `value (` / ` id "1"` / ` label "one"` / `)` lost their boundaries diff --git a/csharp/Link.Foundation.Links.Notation.Tests/EmptyReferenceTests.cs b/csharp/Link.Foundation.Links.Notation.Tests/EmptyReferenceTests.cs new file mode 100644 index 0000000..8710be9 --- /dev/null +++ b/csharp/Link.Foundation.Links.Notation.Tests/EmptyReferenceTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Link.Foundation.Links.Notation.Tests +{ + /// + /// Conformance tests for the empty reference. + /// + /// https://github.com/link-foundation/links-notation/issues/288 + /// + /// A bare delimiter pair is the empty reference. The three delimiters + /// ", ' and ` behave identically, and every longer + /// n-quote run keeps the meaning it already had. The table below is shared + /// with the Rust, JavaScript, Python, Go, Java and PHP suites, so a document + /// written by one implementation reads the same in all of them. + /// + public static class EmptyReferenceTests + { + /// + /// Renders a parsed node unambiguously: every reference is wrapped in + /// angle brackets so an empty one is visible as <>. + /// + private static string Render(Link node) + { + if (node.Values == null || node.Values.Count == 0) + { + return "<" + (node.Id ?? "") + ">"; + } + var head = node.Id == null ? "" : "<" + node.Id + ">: "; + return "(" + head + string.Join(" ", node.Values.Select(Render)) + ")"; + } + + private static string Rendered(string source) + { + var links = new Parser().Parse(source); + return string.Join("\n", links.Select(Render)); + } + + private static void AssertParsesAs(string source, string expected) + { + Assert.Equal(expected, Rendered(source)); + } + + [Fact] + public static void BareDelimiterPairIsTheEmptyReference() + { + AssertParsesAs("(a \"\" b)", "( <> )"); + } + + [Fact] + public static void EveryDelimiterStyleYieldsTheSameEmptyReference() + { + AssertParsesAs("(a \"\" b)", "( <> )"); + AssertParsesAs("(a '' b)", "( <> )"); + AssertParsesAs("(a `` b)", "( <> )"); + } + + [Fact] + public static void AdjacentEmptyReferencesStaySeparate() + { + AssertParsesAs("(a \"\" \"\" b)", "( <> <> )"); + AssertParsesAs("(a '' '' b)", "( <> <> )"); + AssertParsesAs("(a `` `` b)", "( <> <> )"); + AssertParsesAs("(a \"\" \"\" b)", "( <> <> )"); + } + + [Fact] + public static void NestedEmptyReferencesParse() + { + AssertParsesAs("(\"\" (\"\" 1))", "(<> (<> <1>))"); + AssertParsesAs("(\"\" ('' 1))", "(<> (<> <1>))"); + AssertParsesAs("(\"x\" (\"\" 1))", "( (<> <1>))"); + AssertParsesAs("(\"\" (\"x\" 1))", "(<> ( <1>))"); + AssertParsesAs("(\"\" x (\"\" 1))", "(<> (<> <1>))"); + AssertParsesAs("(\"\" 1 (\"\" 1))", "(<> <1> (<> <1>))"); + } + + [Fact] + public static void EmptyReferenceIsValidAsAnId() + { + AssertParsesAs("(\"\": 1)", "(<>: <1>)"); + AssertParsesAs("(o: (\"\" (o: (\"\" 1))))", "(: (<> (: (<> <1>))))"); + } + + [Fact] + public static void NQuoteDelimitedBodiesAreUnchanged() + { + // A run that encloses a substantive body keeps its n-quote meaning. + AssertParsesAs("(a \"\"x\"\" b)", "( )"); + AssertParsesAs("(x \"\" \" \"\")", "( < \" >)"); + AssertParsesAs("(x ' \" ')", "( < \" >)"); + // An n-quote-delimited empty is still empty. + AssertParsesAs("(a \"\"\"\" b)", "( <> )"); + } + + [Fact] + public static void ASingleSpaceStillReadsAsASpace() + { + AssertParsesAs("(a \" \" b)", "( < > )"); + } + + [Fact] + public static void EmptyReferenceSurvivesARoundTrip() + { + var sources = new[] + { + "(a \"\" b)", + "(a \"\" \"\" b)", + "(\"\" (\"\" 1))", + "(\"\": 1)", + "(o: (\"\" (o: (\"\" 1))))", + }; + foreach (var source in sources) + { + var formatted = ((IList>)new Parser().Parse(source)).Format(); + var reparsed = ((IList>)new Parser().Parse(formatted)).Format(); + Assert.Equal(formatted, reparsed); + } + } + + [Fact] + public static void EmptyReferenceIsWrittenAsADelimiterPair() + { + var links = (IList>)new Parser().Parse("(a \"\" b)"); + Assert.Equal("(a \"\" b)", links.Format()); + } + } +} diff --git a/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj b/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj index 059a03e..ff4b6ca 100644 --- a/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj +++ b/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj @@ -4,7 +4,7 @@ Link.Foundation's Platform.Protocols.Lino Class Library Konstantin Diachenko Link.Foundation.Links.Notation - 0.14.0 + 0.15.0 Konstantin Diachenko net8 Link.Foundation.Links.Notation diff --git a/csharp/Link.Foundation.Links.Notation/Link.cs b/csharp/Link.Foundation.Links.Notation/Link.cs index 7ec8037..e3c0e7f 100644 --- a/csharp/Link.Foundation.Links.Notation/Link.cs +++ b/csharp/Link.Foundation.Links.Notation/Link.cs @@ -138,10 +138,16 @@ public Link Simplify() /// The escaped reference string with appropriate quoting. public static string EscapeReference(string? reference) { - if (string.IsNullOrWhiteSpace(reference)) + if (reference == null) { return ""; } + // The empty reference is written as a bare delimiter pair, so that it reads + // back as itself instead of disappearing from the document. + if (reference.Length == 0) + { + return "\"\""; + } if ( reference.Contains(":") || reference.Contains("(") || diff --git a/csharp/Link.Foundation.Links.Notation/Parser.peg b/csharp/Link.Foundation.Links.Notation/Parser.peg index 024b48e..b23c516 100644 --- a/csharp/Link.Foundation.Links.Notation/Parser.peg +++ b/csharp/Link.Foundation.Links.Notation/Parser.peg @@ -3,75 +3,128 @@ @using System.Linq @members { - // Field to store parsed multi-quote value - private string _multiQuoteValue; + // Value and length of the reference parsed by ParseQuotedStringAt. + private string _quoteValue; + private int _quoteLength; /// - /// Parse a multi-quote string dynamically for N >= 3 quotes. - /// Uses a universal procedural algorithm that handles any N. - /// Stores result in _multiQuoteValue field. + /// A body written between an even run of delimiters is substantive when it + /// holds at least one visible character and does not straddle a parenthesis. + /// An even run can always be read as delimiter pairs enclosing nothing, so the + /// n-quote reading is only taken when it carries something the pairs cannot. /// - /// The raw string including opening and closing quotes - /// The quote character (", ', or `) - /// True if parsing succeeded and the result matches the input length - private bool ParseMultiQuoteString(string input, char quoteChar) + private static bool IsSubstantiveBody(string content) { - _multiQuoteValue = null; - if (string.IsNullOrEmpty(input)) return false; + int depth = 0; + bool hasVisible = false; + + foreach (var c in content) + { + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + if (depth < 0) return false; + } + + if (!char.IsWhiteSpace(c)) hasVisible = true; + } + + return hasVisible && depth == 0; + } + + /// + /// Universal procedural parser for N-quote references (any N >= 1), reading + /// from the given position of the subject. A run of an even number of + /// delimiters that does not open a reference with a substantive body is the + /// empty reference: the shortest reading, a bare delimiter pair enclosing + /// nothing, wins over a longer n-quote delimiter. + /// Stores the result in _quoteValue and its length in _quoteLength. + /// + /// The whole input being parsed + /// The position of the opening delimiter + /// The delimiter character (", ', or `) + /// True when a reference was read at that position + private bool ParseQuotedStringAt(string subject, int startPos, char quoteChar) + { + _quoteValue = null; + _quoteLength = 0; + if (subject == null || startPos >= subject.Length || subject[startPos] != quoteChar) return false; // Count opening quotes int quoteCount = 0; - while (quoteCount < input.Length && input[quoteCount] == quoteChar) + int pos = startPos; + while (pos < subject.Length && subject[pos] == quoteChar) { quoteCount++; + pos++; } - if (quoteCount < 3) return false; // Let explicit rules handle N=1 and N=2 + bool isEvenRun = quoteCount % 2 == 0; string openClose = new string(quoteChar, quoteCount); string escapeSeq = new string(quoteChar, quoteCount * 2); - string escapeVal = new string(quoteChar, quoteCount); - int pos = quoteCount; // Start after opening quotes var content = new System.Text.StringBuilder(); - - while (pos < input.Length) + while (pos < subject.Length) { // Check for escape sequence (2*N quotes) - if (pos + escapeSeq.Length <= input.Length && - input.Substring(pos, escapeSeq.Length) == escapeSeq) + if (pos + escapeSeq.Length <= subject.Length && + string.CompareOrdinal(subject, pos, escapeSeq, 0, escapeSeq.Length) == 0) { - content.Append(escapeVal); + content.Append(openClose); // 2*N quotes become N quotes pos += escapeSeq.Length; continue; } // Check for closing quotes (exactly N quotes, not more) - if (pos + quoteCount <= input.Length && - input.Substring(pos, quoteCount) == openClose) + if (pos + quoteCount <= subject.Length && + string.CompareOrdinal(subject, pos, openClose, 0, quoteCount) == 0) { - // Make sure it's exactly N quotes (not followed by more of the same quote) int afterClose = pos + quoteCount; - if (afterClose >= input.Length || input[afterClose] != quoteChar) + if (afterClose >= subject.Length || subject[afterClose] != quoteChar) { - // Found valid closing - check if we consumed the entire input - if (afterClose == input.Length) + // Found valid closing + if (isEvenRun && !IsSubstantiveBody(content.ToString())) { - _multiQuoteValue = content.ToString(); - return true; + return SetEmptyReference(isEvenRun, quoteCount); } - return false; + _quoteValue = content.ToString(); + _quoteLength = afterClose - startPos; + return true; } } // Take next character - content.Append(input[pos]); + content.Append(subject[pos]); pos++; } - // No closing quotes found - return false; + // No valid closing found + return SetEmptyReference(isEvenRun, quoteCount); + } + + private bool SetEmptyReference(bool isEvenRun, int quoteCount) + { + if (!isEvenRun) return false; + _quoteValue = string.Empty; + _quoteLength = quoteCount; + return true; } + + /// + /// Consumes one more character of the reference read by ParseQuotedStringAt. + /// + private bool ConsumeQuotedCharacter() + { + if (_quoteLength <= 1) return false; + _quoteLength--; + return true; + } + /// /// Saved indentation context of an enclosing scope. /// Every parenthesized group opens a nested context that starts fresh at @@ -202,55 +255,22 @@ singleLineLink > = __ id:(reference) __ ":" v:singleLineValues { ne singleLineValueLink > = v:singleLineValues { new Link(v) } indentedIdLink > = id:(reference) __ ":" eol { new Link(id) } -// Reference can be quoted (with any number of quotes) or simple unquoted -// Order: high quotes (3+) first, then double quotes (2), then single quotes (1), then simple -// This ordering ensures proper precedence for quote matching -reference = highQuotedReference / doubleQuotedReference / singleQuotedReference / simpleReference +// Reference can be quoted (with any number of delimiters N >= 1) or simple unquoted +// Universal approach: use procedural parsing for all delimiter styles and counts +reference = quotedReference / simpleReference simpleReference = "" referenceSymbol+ -// High quote references (N >= 3) - use universal procedural parsing -// Lookahead for 3+ quotes, then capture and validate with the procedural parser -highQuotedReference = &('"""' / "'''" / '```') raw:highQuoteCapture { raw } - -highQuoteCapture = raw:highQuoteDoubleRaw &{ ParseMultiQuoteString(raw, '"') } { _multiQuoteValue } -/ raw:highQuoteSingleRaw &{ ParseMultiQuoteString(raw, '\'') } { _multiQuoteValue } -/ raw:highQuoteBacktickRaw &{ ParseMultiQuoteString(raw, '`') } { _multiQuoteValue } - -// Raw capture for high quotes - greedily match quotes and content -highQuoteDoubleRaw = "" ('"'+ highQuoteDoubleContent* '"'+) -highQuoteSingleRaw = "" ("'"+ highQuoteSingleContent* "'"+) -highQuoteBacktickRaw = "" ('`'+ highQuoteBacktickContent* '`'+) - -// Content for high quotes: any char OR quote sequences followed by non-quote -highQuoteDoubleContent = [^"] / '"'+ &[^"] -highQuoteSingleContent = [^'] / "'"+ &[^'] -highQuoteBacktickContent = [^`] / '`'+ &[^`] - -// Double quotes (N=2) - explicit PEG rules for proper escape handling -doubleQuotedReference = doubleDoubleQuote / doubleSingleQuote / doubleBacktickQuote - -doubleDoubleQuote = '""' r:doubleDoubleContent* '""' { string.Join("", r) } -doubleDoubleContent = '""""' { "\"\"" } / !'""' c:. { c.ToString() } - -doubleSingleQuote = "''" r:doubleSingleContent* "''" { string.Join("", r) } -doubleSingleContent = "''''" { "''" } / !"''" c:. { c.ToString() } - -doubleBacktickQuote = '``' r:doubleBacktickContent* '``' { string.Join("", r) } -doubleBacktickContent = '````' { "``" } / !'``' c:. { c.ToString() } - -// Single quotes (N=1) - explicit PEG rules for proper disambiguation -// These are needed because single-quoted strings on the same line must be correctly parsed -singleQuotedReference = singleDoubleQuote / singleSingleQuote / singleBacktickQuote - -singleDoubleQuote = '"' r:singleDoubleContent* '"' { string.Join("", r) } -singleDoubleContent = '""' { "\"" } / c:[^"] { c.ToString() } +quotedReference = doubleQuotedUniversal / singleQuotedUniversal / backtickQuotedUniversal -singleSingleQuote = "'" r:singleSingleContent* "'" { string.Join("", r) } -singleSingleContent = "''" { "'" } / c:[^'] { c.ToString() } +// Peek at the subject, parse procedurally, then consume exactly as many +// characters as the procedural parser reported. +doubleQuotedUniversal = &'"' &{ ParseQuotedStringAt(state.Subject, state.Location, '"') } consumeQuoted { _quoteValue } +singleQuotedUniversal = &"'" &{ ParseQuotedStringAt(state.Subject, state.Location, '\'') } consumeQuoted { _quoteValue } +backtickQuotedUniversal = &'`' &{ ParseQuotedStringAt(state.Subject, state.Location, '`') } consumeQuoted { _quoteValue } -singleBacktickQuote = '`' r:singleBacktickContent* '`' { string.Join("", r) } -singleBacktickContent = '``' { "`" } / c:[^`] { c.ToString() } +consumeQuoted = "" (. consumeQuotedMore*) +consumeQuotedMore = &{ ConsumeQuotedCharacter() } . SET_BASE_INDENTATION = spaces:" "* #{ if ((int)state["BaseIndentation"] == -1) state["BaseIndentation"] = spaces.Count; } PUSH_INDENTATION = spaces:" "* #{ state["NormalizedIndent"] = spaces.Count - ((int)state["BaseIndentation"] == -1 ? 0 : (int)state["BaseIndentation"]); if ((int)state["NormalizedIndent"] < 0) state["NormalizedIndent"] = 0; } &{ (int)state["NormalizedIndent"] > (int)state["IndentationStack"].Peek() } #{ state["IndentationStack"].Push((int)state["NormalizedIndent"]); } diff --git a/docs/grammar/GRAMMAR.md b/docs/grammar/GRAMMAR.md index 2bc8767..e185e17 100644 --- a/docs/grammar/GRAMMAR.md +++ b/docs/grammar/GRAMMAR.md @@ -28,7 +28,7 @@ The following EBNF grammar formally defines the Links Notation syntax: ```ebnf (* Links Notation (Lino) Grammar - EBNF *) -(* Version: 0.14.0 *) +(* Version: 0.15.0 *) (* === Document Structure === *) document = skip_empty_lines, links, whitespace, EOF @@ -77,15 +77,32 @@ indented_id_link = reference, horizontal_whitespace, ":", eol ; reference_or_link = nested_group | reference ; -reference = double_quoted_reference - | single_quoted_reference +reference = delimited_reference | simple_reference ; simple_reference = reference_symbol, { reference_symbol } ; -double_quoted_reference = '"', { any_char - '"' }, '"' ; +(* The three delimiters behave identically; nothing else delimits *) +delimiter = '"' | "'" | "`" ; -single_quoted_reference = "'", { any_char - "'" }, "'" ; +delimiter_run = delimiter, { delimiter } ; + +(* The n-quoted reading is tried first; when it does not apply, an even run + of delimiters is the empty reference *) +delimited_reference = n_quoted_reference + | empty_reference ; + +(* A run of N delimiters, a body, then a run of exactly N of the same + delimiter. Conditions: both runs use the same delimiter and have the same + length N; the closing run is not followed by another delimiter; a run of + 2N delimiters in the body stands for N literal delimiters; and when N is + even the body is substantive, holding at least one non-whitespace + character with balanced parentheses *) +n_quoted_reference = delimiter_run, { any_char }, delimiter_run ; + +(* A bare delimiter pair is the empty reference, and so is any longer even + run of the same delimiter that does not open an n-quoted reference *) +empty_reference = delimiter, delimiter, { delimiter, delimiter } ; (* === Terminal Symbols === *) reference_symbol = any_char - whitespace_char - "(" - ":" - ")" ; @@ -195,17 +212,25 @@ grammar: (alternative nested_group reference) reference: - (alternative double_quoted_reference single_quoted_reference - simple_reference) + (alternative delimited_reference simple_reference) simple_reference: (one_or_more reference_symbol) - double_quoted_reference: - (sequence '"' (zero_or_more (not '"')) '"') + delimiter: + (alternative '"' "'" '`') + + delimiter_run: + (one_or_more delimiter) + + delimited_reference: + (alternative n_quoted_reference empty_reference) + + n_quoted_reference: + (sequence delimiter_run (zero_or_more any_char) delimiter_run) - single_quoted_reference: - (sequence "'" (zero_or_more (not "'")) "'") + empty_reference: + (one_or_more (sequence delimiter delimiter)) reference_symbol: (not (alternative whitespace_char "(" ":" ")")) @@ -249,20 +274,59 @@ Document ### References -References are the atomic building blocks of Links Notation. There are -three types: +References are the atomic building blocks of Links Notation. A reference is +either simple or delimited: -| Type | Syntax | Example | Description | -|---------------|--------------|-----------------|------------------------------| -| Simple | `identifier` | `papa`, `mama` | Alphanumeric and special | -| Double-quoted | `"text"` | `"hello world"` | Any characters except `"` | -| Single-quoted | `'text'` | `'hello world'` | Any characters except `'` | +| Type | Syntax | Example | Description | +|-----------|--------------|-----------------|-------------------------------------| +| Simple | `identifier` | `papa`, `mama` | Alphanumeric and special | +| Delimited | `"text"` | `"hello world"` | Any text between matching runs | +| Empty | `""` | `("" 1)` | A bare delimiter pair holds nothing | **Simple Reference Characters:** - Valid: Letters, digits, `-`, `_`, `.`, `!`, `?`, `@`, `#`, `$`, `%`, etc. - Invalid: Space, tab, newline, `(`, `:`, `)` +**Delimiters:** + +The three delimiters `"`, `'` and `` ` `` behave identically, so `"text"`, +`'text'` and `` `text` `` are the same reference. Nothing else delimits: `«`, +`[`, `|` and the like are ordinary characters of a simple reference. + +**N-Quoted References:** + +A reference may open with a run of N identical delimiters and close with a run +of exactly N of the same delimiter, which lets the body hold the delimiter +itself: + +```lino +(x "" " "") +``` + +reads as a single reference holding ` " `. Inside the body a run of 2N +delimiters stands for N literal delimiters. + +**The Empty Reference:** + +The shortest reading wins: a bare delimiter pair is the empty reference, and so +is any longer even run that does not open an n-quoted reference with a +substantive body. A body is substantive when it holds at least one +non-whitespace character and its parentheses are balanced. + +| Source | Reads as | +|---------------|------------------------------------------| +| `(a "" b)` | `a`, the empty reference, `b` | +| `(a "" "" b)` | `a`, two empty references, `b` | +| `(a """" b)` | `a`, one empty reference, `b` | +| `(a ""x"" b)` | `a`, `x` written with a 2-quote run, `b` | +| `(a " " b)` | `a`, a reference holding one space, `b` | +| `("" ("" 1))` | the empty reference linked to `("" 1)` | + +The empty reference is a reference like any other: it can be a value, an +identifier, and it nests. Formatters write it as `""` so it reads back as +itself instead of disappearing from the document. + ### Link Types #### 1. Single-Line Value Link @@ -413,11 +477,11 @@ Both produce equivalent structures. ```text ┌─────────────────────────┐ - ┌───────┤ double_quoted_ref ├───────┐ + ┌───────┤ n_quoted_reference ├───────┐ │ └─────────────────────────┘ │ │ │ ──────┼───────┌─────────────────────────┐───────┼──────▶ - │ │ single_quoted_ref │ │ + │ │ empty_reference │ │ │ └─────────────────────────┘ │ │ │ │ ┌─────────────────────────┐ │ @@ -436,17 +500,33 @@ Both produce equivalent structures. └──────────────────┘ ``` -### Double-Quoted Reference +### N-Quoted Reference ```text - ┌───┐ ┌──────────────┐ ┌───┐ -──────────────┤ " ├───┤ any char ├───┤ " ├──────▶ - └───┘ │ except " │ └───┘ - └──────┬───────┘ - │ ▲ - └─────┘ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +────────┤ N delimiters ├───┤ any char ├───┤ N delimiters ├──────▶ + └──────────────┘ └──────┬───────┘ └──────────────┘ + │ ▲ + └─────┘ ``` +Both runs use the same delimiter and the same length N, and the closing run is +not followed by another delimiter. A run of 2N delimiters inside the body +stands for N literal delimiters. + +### Empty Reference + +```text + ┌───┐ ┌───┐ +──────────────┤ d ├───┤ d ├──────┬──────▶ + └───┘ └───┘ │ + ▲ │ + └────────────────┘ +``` + +An even run of the same delimiter `d` that does not open an n-quoted reference +with a substantive body is the empty reference. + ### Any Link ```text @@ -619,6 +699,9 @@ document: "full name": "John Doe" 'greeting': 'Hello, World!' mixed: "can contain 'single' quotes" 'and "double" quotes' +backtick: `also a delimiter` +empty: "" +(nested: ("" ("" 1))) ``` ### Real-World Example @@ -665,3 +748,7 @@ for valid input. | | `multiline_value_link` and `multiline_values` replaced by | | | `nested_group` and `nested_group_body`; blank lines are | | | skipped between lines of a block | +| 0.15.0 | A reference is `delimited_reference` (`n_quoted_reference` | +| | or `empty_reference`) or `simple_reference`; a bare | +| | delimiter pair is the empty reference, and all three | +| | delimiters are documented | diff --git a/docs/grammar/grammar.lino b/docs/grammar/grammar.lino index 95995bd..a17a420 100644 --- a/docs/grammar/grammar.lino +++ b/docs/grammar/grammar.lino @@ -1,5 +1,5 @@ (grammar: links-notation - (version: 0.14.0) + (version: 0.15.0) (description: "Links Notation (Lino) grammar expressed in links-notation format") (document: @@ -60,16 +60,31 @@ (alternative nested_group reference)) (reference: - (alternative double_quoted_reference single_quoted_reference simple_reference)) + (alternative delimited_reference simple_reference)) (simple_reference: (one_or_more reference_symbol)) - (double_quoted_reference: - (sequence "\"" (zero_or_more (not "\"")) "\"")) + (delimiter: + (alternative "\"" "'" "`")) - (single_quoted_reference: - (sequence "'" (zero_or_more (not "'")) "'")) + (delimiter_run: + (one_or_more delimiter)) + + (delimited_reference: + (alternative n_quoted_reference empty_reference)) + + (n_quoted_reference: + (sequence delimiter_run (zero_or_more any_char) delimiter_run) + (condition: "Both runs use the same delimiter and have the same length N") + (condition: "The closing run is not followed by another delimiter") + (condition: "A run of 2N delimiters in the body stands for N literal delimiters") + (condition: "When N is even the body holds at least one non-whitespace character and balanced parentheses")) + + (empty_reference: + (one_or_more (sequence delimiter delimiter)) + (condition: "The whole run uses the same delimiter") + (condition: "An even run that does not open an n_quoted_reference is the empty reference")) (reference_symbol: (not (alternative whitespace_char "(" ":" ")"))) diff --git a/docs/grammar/links-notation.ebnf b/docs/grammar/links-notation.ebnf index d40c4c1..db4b4e6 100644 --- a/docs/grammar/links-notation.ebnf +++ b/docs/grammar/links-notation.ebnf @@ -1,5 +1,5 @@ (* Links Notation (Lino) Grammar - Extended Backus-Naur Form (EBNF) *) -(* Version: 0.14.0 *) +(* Version: 0.15.0 *) (* Repository: https://github.com/link-foundation/links-notation *) (* License: Unlicense *) @@ -90,19 +90,39 @@ indented_id_link = reference, horizontal_whitespace, ":", eol ; reference_or_link = nested_group | reference ; -(* A reference is either quoted or simple *) -reference = double_quoted_reference - | single_quoted_reference +(* A reference is either delimited or simple *) +reference = delimited_reference | simple_reference ; (* An unquoted reference (alphanumeric and special chars) *) simple_reference = reference_symbol, { reference_symbol } ; -(* A double-quoted reference: "any text except double quote" *) -double_quoted_reference = '"', { any_char - '"' }, '"' ; - -(* A single-quoted reference: 'any text except single quote' *) -single_quoted_reference = "'", { any_char - "'" }, "'" ; +(* The three delimiters behave identically; nothing else delimits *) +delimiter = '"' | "'" | "`" ; + +(* A run of one or more of the same delimiter *) +delimiter_run = delimiter, { delimiter } ; + +(* A delimited reference is either an n-quoted one or the empty reference. + The n-quoted reading is tried first; when it does not apply, an even run + of delimiters is the empty reference *) +delimited_reference = n_quoted_reference + | empty_reference ; + +(* A run of N delimiters, a body, then a run of exactly N of the same + delimiter. Inside the body a run of 2N delimiters stands for N literal + delimiters. N cannot be written in EBNF, so it is stated as a condition *) +n_quoted_reference = delimiter_run, { any_char }, delimiter_run ; +(* Conditions: both runs use the same delimiter and have the same length N; + the closing run is not followed by another delimiter; and when N is even + the body is substantive, meaning it holds at least one non-whitespace + character and its parentheses are balanced *) + +(* A bare delimiter pair is the empty reference, and so is any longer even + run that does not open an n-quoted reference: the shortest reading wins, + so "" is one empty reference and "" "" is two of them *) +empty_reference = delimiter, delimiter, { delimiter, delimiter } ; +(* Condition: the whole run uses the same delimiter *) (* ============================================================== *) (* TERMINAL SYMBOLS *) diff --git a/docs/grammar/syntax-diagrams.md b/docs/grammar/syntax-diagrams.md index 0955c92..fa37cf4 100644 --- a/docs/grammar/syntax-diagrams.md +++ b/docs/grammar/syntax-diagrams.md @@ -75,15 +75,15 @@ element ───┤ any_link ├───┬─────────── ## Reference -A reference can be quoted or unquoted. +A reference is either delimited or simple. ```text ┌────────────────────────────┐ - ┌────────┤ double_quoted_reference ├────────┐ + ┌────────┤ n_quoted_reference ├────────┐ │ └────────────────────────────┘ │ │ │ ─────┼────────┌────────────────────────────┐────────┼────▶ - │ │ single_quoted_reference │ │ + │ │ empty_reference │ │ │ └────────────────────────────┘ │ │ │ │ ┌────────────────────────────┐ │ @@ -91,6 +91,9 @@ A reference can be quoted or unquoted. └────────────────────────────┘ ``` +The three delimiters `"`, `'` and `` ` `` behave identically, and the n-quoted +reading is tried before the empty one. + ## Simple Reference One or more reference symbols (non-whitespace, non-special characters). @@ -104,24 +107,35 @@ simple_reference ──┤ reference_symbol ├────┬──── └────────────────┘ ``` -## Double-Quoted Reference +## N-Quoted Reference + +A run of N identical delimiters, a body, then a run of exactly N of the same +delimiter that is not followed by another one. A run of 2N delimiters inside +the body stands for N literal delimiters. ```text - ┌─────┐ ┌─────────────────┐ ┌─────┐ -double_quoted_ref ────┤ " ├───┤ any char ≠ " ├───┤ " ├───▶ - └─────┘ └────────┬────────┘ └─────┘ - │ ▲ - └──────┘ + ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +n_quoted_ref ─────────┤ N delimiters ├───┤ any char ├───┤ N delimiters ├───▶ + └──────────────┘ └────────┬────────┘ └──────────────┘ + │ ▲ + └──────┘ ``` -## Single-Quoted Reference +When N is even the body must be substantive: it holds at least one +non-whitespace character and its parentheses are balanced. Otherwise the run +reads as empty references instead. + +## Empty Reference + +An even run of the same delimiter that does not open an n-quoted reference: +`""` is one empty reference, `"" ""` is two of them. ```text - ┌─────┐ ┌─────────────────┐ ┌─────┐ -single_quoted_ref ────┤ ' ├───┤ any char ≠ ' ├───┤ ' ├───▶ - └─────┘ └────────┬────────┘ └─────┘ - │ ▲ - └──────┘ + ┌─────┐ ┌─────┐ +empty_reference ──────┤ d ├───┤ d ├──────┬─────▶ + └─────┘ └─────┘ │ + ▲ │ + └───────────────────┘ ``` ## Nested Group (Parenthesized) @@ -455,6 +469,8 @@ symbol ║ • Space ( ) ║ │ │ : - Separator between id and values ││ │ │ " - Double quote delimiter ││ │ │ ' - Single quote delimiter ││ +│ │ ` - Backtick delimiter ││ +│ │ "" - The empty reference (a bare delimiter pair) ││ │ │ ␣ - Space (value separator, indentation) ││ │ └────────────────────────────────────────────────────────────────────┘│ │ │ diff --git a/experiments/issue-288/csharp/GroundTruth.csproj b/experiments/issue-288/csharp/GroundTruth.csproj new file mode 100644 index 0000000..795856d --- /dev/null +++ b/experiments/issue-288/csharp/GroundTruth.csproj @@ -0,0 +1,12 @@ + + + Exe + net8 + disable + GroundTruth + GroundTruth + + + + + diff --git a/experiments/issue-288/csharp/Program.cs b/experiments/issue-288/csharp/Program.cs new file mode 100644 index 0000000..3b5c0d3 --- /dev/null +++ b/experiments/issue-288/csharp/Program.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Link.Foundation.Links.Notation; + +// Print the canonical rendering of every case in issue #288. +class Program +{ + static readonly string[] Cases = new[] + { + "(a \" \" b)", "(a \"\" b)", "(a '' b)", "(a `` b)", + "(a \"\" \"\" b)", "(a '' '' b)", "(a `` `` b)", + "(a \"\"x\"\" b)", "(a \"\"\"\" b)", "(x \"\" \" \"\")", "(x ' \" ')", + "(\"\" (\"\" 1))", "(\"\" ('' 1))", "(\"x\" (\"\" 1))", "(\"\" (\"x\" 1))", + "(\"\" x (\"\" 1))", "(\"\" 1 (\"\" 1))", "(o: (\"\" (o: (\"\" 1))))", + "(a \" b)", "(a \"\"\" b)", "(\"\")", "(\"\": 1)", "(a \"\" \"\" b)", "(\"\" \"\")", + }; + + static string Render(Link node) + { + if (node.Values == null || node.Values.Count == 0) + { + return "<" + (node.Id ?? "") + ">"; + } + var head = node.Id == null ? "" : "<" + node.Id + ">: "; + return "(" + head + string.Join(" ", node.Values.Select(Render)) + ")"; + } + + static void Main() + { + var parser = new Parser(); + foreach (var source in Cases) + { + try + { + var links = (IList>)parser.Parse(source); + Console.WriteLine($"{source,-24} => {string.Join("\n", links.Select(Render))}"); + } + catch (Exception e) + { + Console.WriteLine($"{source,-24} => Err({e.GetType().Name}: {e.Message.Split('\n')[0]})"); + } + } + } +} diff --git a/experiments/issue-288/go/go.mod b/experiments/issue-288/go/go.mod new file mode 100644 index 0000000..be43d69 --- /dev/null +++ b/experiments/issue-288/go/go.mod @@ -0,0 +1,7 @@ +module issue288 + +go 1.21 + +require github.com/link-foundation/links-notation/go v0.0.0 + +replace github.com/link-foundation/links-notation/go => ../../../go diff --git a/experiments/issue-288/go/main.go b/experiments/issue-288/go/main.go new file mode 100644 index 0000000..21916fe --- /dev/null +++ b/experiments/issue-288/go/main.go @@ -0,0 +1,51 @@ +// Prints the canonical rendering of every case in issue #288. +package main + +import ( + "fmt" + "strings" + + lino "github.com/link-foundation/links-notation/go" +) + +func render(n *lino.Link) string { + if len(n.Values) == 0 { + id := "" + if n.ID != nil { + id = *n.ID + } + return "<" + id + ">" + } + head := "" + if n.ID != nil { + head = "<" + *n.ID + ">: " + } + parts := make([]string, 0, len(n.Values)) + for _, v := range n.Values { + parts = append(parts, render(v)) + } + return "(" + head + strings.Join(parts, " ") + ")" +} + +func main() { + cases := []string{ + `(a " " b)`, `(a "" b)`, `(a '' b)`, "(a `` b)", + `(a "" "" b)`, `(a '' '' b)`, "(a `` `` b)", + `(a ""x"" b)`, `(a """" b)`, `(x "" " "")`, `(x ' " ')`, + `("" ("" 1))`, `("" ('' 1))`, `("x" ("" 1))`, `("" ("x" 1))`, + `("" x ("" 1))`, `("" 1 ("" 1))`, `(o: ("" (o: ("" 1))))`, + `(a " b)`, `(a """ b)`, `("")`, `("": 1)`, `(a "" "" b)`, `("" "")`, + } + for _, c := range cases { + links, err := lino.Parse(c) + if err != nil { + fmt.Printf("%-24s => Err(%v)\n", c, err) + continue + } + parts := make([]string, 0, len(links)) + for _, l := range links { + parts = append(parts, render(l)) + } + fmt.Printf("%-24s => %s\n", c, strings.Join(parts, "\n")) + } +} diff --git a/experiments/issue-288/ground_truth.mjs b/experiments/issue-288/ground_truth.mjs new file mode 100644 index 0000000..ce6e879 --- /dev/null +++ b/experiments/issue-288/ground_truth.mjs @@ -0,0 +1,22 @@ +import { Parser } from '../../js/src/Parser.js'; +const parser = new Parser(); +const render = (n) => + !n.values || n.values.length === 0 + ? `<${n.id ?? ''}>` + : `(${n.id !== null && n.id !== undefined ? `<${n.id}>: ` : ''}${n.values.map(render).join(' ')})`; +const cases = [ + '(a " " b)', '(a "" b)', "(a '' b)", '(a `` b)', + '(a "" "" b)', "(a '' '' b)", '(a `` `` b)', + '(a ""x"" b)', '(a """" b)', '(x "" " "")', `(x ' " ')`, + '("" ("" 1))', `("" ('' 1))`, '("x" ("" 1))', '("" ("x" 1))', + '("" x ("" 1))', '("" 1 ("" 1))', '(o: ("" (o: ("" 1))))', + '(a " b)', '(a """ b)', '("")', '("": 1)', '(a "" "" b)', '("" "")', +]; +for (const c of cases) { + try { + const r = parser.parse(c); + console.log(c.padEnd(24), '=>', r.map(render).join('\n')); + } catch (e) { + console.log(c.padEnd(24), '=> Err(' + e.message.split('\n')[0] + ')'); + } +} diff --git a/experiments/issue-288/ground_truth.py b/experiments/issue-288/ground_truth.py new file mode 100644 index 0000000..085fb84 --- /dev/null +++ b/experiments/issue-288/ground_truth.py @@ -0,0 +1,34 @@ +"""Print the canonical rendering of every case in issue #288.""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "python")) +from links_notation import Parser # noqa: E402 + +CASES = [ + '(a " " b)', '(a "" b)', "(a '' b)", '(a `` b)', + '(a "" "" b)', "(a '' '' b)", '(a `` `` b)', + '(a ""x"" b)', '(a """" b)', '(x "" " "")', '(x \' " \')', + '("" ("" 1))', '("" (\'\' 1))', '("x" ("" 1))', '("" ("x" 1))', + '("" x ("" 1))', '("" 1 ("" 1))', '(o: ("" (o: ("" 1))))', + '(a " b)', '(a """ b)', '("")', '("": 1)', '(a "" "" b)', '("" "")', +] + + +def render(node): + if not node.values: + return "<%s>" % ("" if node.id is None else node.id) + head = "" if node.id is None else "<%s>: " % node.id + return "(%s%s)" % (head, " ".join(render(v) for v in node.values)) + + +def main(): + parser = Parser() + for case in CASES: + try: + links = parser.parse(case) + print("%-24s => %s" % (case, "\n".join(render(l) for l in links))) + except Exception as e: # noqa: BLE001 + print("%-24s => Err(%s)" % (case, e)) + + +if __name__ == "__main__": + main() diff --git a/experiments/issue-288/ground_truth.rs b/experiments/issue-288/ground_truth.rs new file mode 100644 index 0000000..28cab70 --- /dev/null +++ b/experiments/issue-288/ground_truth.rs @@ -0,0 +1,55 @@ +//! Prints the canonical rendering of every case in issue #288, so the +//! conformance table can be checked against a real parse in every language. +use links_notation::{parse_lino_to_links, LiNo}; + +fn render(node: &LiNo) -> String { + match node { + LiNo::Ref(id) => format!("<{}>", id), + LiNo::Link { id, values } => { + let head = id + .as_ref() + .map(|id| format!("<{}>: ", id)) + .unwrap_or_default(); + let body = values.iter().map(render).collect::>().join(" "); + format!("({}{})", head, body) + } + } +} + +fn main() { + let cases: Vec<&str> = vec![ + r#"(a " " b)"#, + r#"(a "" b)"#, + r#"(a '' b)"#, + "(a `` b)", + r#"(a "" "" b)"#, + r#"(a '' '' b)"#, + "(a `` `` b)", + r#"(a ""x"" b)"#, + r#"(a """" b)"#, + r#"(x "" " "")"#, + r#"(x ' " ')"#, + r#"("" ("" 1))"#, + r#"("" ('' 1))"#, + r#"("x" ("" 1))"#, + r#"("" ("x" 1))"#, + r#"("" x ("" 1))"#, + r#"("" 1 ("" 1))"#, + r#"(o: ("" (o: ("" 1))))"#, + r#"(a " b)"#, + r#"(a """ b)"#, + r#"("")"#, + r#"("": 1)"#, + r#"(a "" "" b)"#, + r#"("" "")"#, + ]; + for case in cases { + match parse_lino_to_links(case) { + Ok(links) => { + let rendered = links.iter().map(render).collect::>().join("\n"); + println!("{:<24} => {}", case, rendered); + } + Err(e) => println!("{:<24} => Err({})", case, e), + } + } +} diff --git a/experiments/issue-288/java/GroundTruth.java b/experiments/issue-288/java/GroundTruth.java new file mode 100644 index 0000000..51141f1 --- /dev/null +++ b/experiments/issue-288/java/GroundTruth.java @@ -0,0 +1,41 @@ +import io.github.linkfoundation.linksnotation.Link; +import io.github.linkfoundation.linksnotation.Parser; +import java.util.List; +import java.util.stream.Collectors; + +/** Print the canonical rendering of every case in issue #288. */ +public final class GroundTruth { + private static final String[] CASES = { + "(a \" \" b)", "(a \"\" b)", "(a '' b)", "(a `` b)", + "(a \"\" \"\" b)", "(a '' '' b)", "(a `` `` b)", + "(a \"\"x\"\" b)", "(a \"\"\"\" b)", "(x \"\" \" \"\")", "(x ' \" ')", + "(\"\" (\"\" 1))", "(\"\" ('' 1))", "(\"x\" (\"\" 1))", "(\"\" (\"x\" 1))", + "(\"\" x (\"\" 1))", "(\"\" 1 (\"\" 1))", "(o: (\"\" (o: (\"\" 1))))", + "(a \" b)", "(a \"\"\" b)", "(\"\")", "(\"\": 1)", "(a \"\" \"\" b)", "(\"\" \"\")", + }; + + private static String render(Link node) { + if (node.getValues() == null || node.getValues().isEmpty()) { + return "<" + (node.getId() == null ? "" : node.getId()) + ">"; + } + String head = node.getId() == null ? "" : "<" + node.getId() + ">: "; + return "(" + + head + + node.getValues().stream().map(GroundTruth::render).collect(Collectors.joining(" ")) + + ")"; + } + + public static void main(String[] args) { + Parser parser = new Parser(); + for (String source : CASES) { + try { + List links = parser.parse(source); + System.out.printf( + "%-24s => %s%n", + source, links.stream().map(GroundTruth::render).collect(Collectors.joining("\n"))); + } catch (Exception e) { + System.out.printf("%-24s => Err(%s: %s)%n", source, e.getClass().getSimpleName(), e.getMessage()); + } + } + } +} diff --git a/experiments/issue-288/php/ground_truth.php b/experiments/issue-288/php/ground_truth.php new file mode 100644 index 0000000..ae5908b --- /dev/null +++ b/experiments/issue-288/php/ground_truth.php @@ -0,0 +1,49 @@ +values === null || $node->values === []) { + return '<' . ($node->id ?? '') . '>'; + } + $head = $node->id === null ? '' : '<' . $node->id . '>: '; + + return '(' . $head . implode(' ', array_map('render', $node->values)) . ')'; +} + +$parser = new Parser(); +foreach ($cases as $case) { + try { + $links = $parser->parse($case); + printf("%-24s => %s\n", $case, implode("\n", array_map('render', $links))); + } catch (Throwable $e) { + printf("%-24s => Err(%s: %s)\n", $case, get_class($e), $e->getMessage()); + } +} diff --git a/experiments/issue-288/roundtrip.rs b/experiments/issue-288/roundtrip.rs new file mode 100644 index 0000000..d45def6 --- /dev/null +++ b/experiments/issue-288/roundtrip.rs @@ -0,0 +1,22 @@ +use links_notation::{parse_lino, parse_lino_to_links, format_links}; + +fn main() { + let cases: Vec<&str> = vec![ + r#"(a "" b)"#, + r#"("")"#, + r#"("" ("" 1))"#, + r#"(o: ("" (o: ("" 1))))"#, + r#"(a " " b)"#, + r#"("": 1)"#, + ]; + for c in cases { + match parse_lino_to_links(c) { + Ok(links) => { + let formatted = format_links(&links); + let reparsed = parse_lino(&formatted); + println!("{:<26} => {:?}\n{:<26} reformat: {:?}\n{:<26} reparse: {:?}", c, links, "", formatted, "", reparsed); + } + Err(e) => println!("{:<26} => Err {:?}", c, e), + } + } +} diff --git a/go/VERSION b/go/VERSION index a803cc2..a551051 100644 --- a/go/VERSION +++ b/go/VERSION @@ -1 +1 @@ -0.14.0 +0.15.0 diff --git a/go/empty_reference_test.go b/go/empty_reference_test.go new file mode 100644 index 0000000..c33bcae --- /dev/null +++ b/go/empty_reference_test.go @@ -0,0 +1,130 @@ +package lino + +import ( + "strings" + "testing" +) + +// Conformance tests for the empty reference. +// +// https://github.com/link-foundation/links-notation/issues/288 +// +// A bare delimiter pair is the empty reference. The three delimiters `"`, `'` +// and `` ` `` behave identically, and every longer n-quote run keeps the +// meaning it already had. The table below is shared with the Rust, JavaScript, +// Python, C#, Java and PHP suites, so a document written by one implementation +// reads the same in all of them. + +// renderNode renders a parsed node unambiguously: every reference is wrapped in +// angle brackets so an empty one is visible as <>. +func renderNode(node *Link) string { + if len(node.Values) == 0 { + id := "" + if node.ID != nil { + id = *node.ID + } + return "<" + id + ">" + } + head := "" + if node.ID != nil { + head = "<" + *node.ID + ">: " + } + parts := make([]string, 0, len(node.Values)) + for _, value := range node.Values { + parts = append(parts, renderNode(value)) + } + return "(" + head + strings.Join(parts, " ") + ")" +} + +func assertParsesAs(t *testing.T, source, expected string) { + t.Helper() + links, err := Parse(source) + if err != nil { + t.Fatalf("Failed to parse %q: %v", source, err) + } + parts := make([]string, 0, len(links)) + for _, link := range links { + parts = append(parts, renderNode(link)) + } + if rendered := strings.Join(parts, "\n"); rendered != expected { + t.Errorf("Parsing %q:\nexpected %q\ngot %q", source, expected, rendered) + } +} + +func TestBareDelimiterPairIsTheEmptyReference(t *testing.T) { + assertParsesAs(t, `(a "" b)`, "( <> )") +} + +func TestEveryDelimiterStyleYieldsTheSameEmptyReference(t *testing.T) { + assertParsesAs(t, `(a "" b)`, "( <> )") + assertParsesAs(t, `(a '' b)`, "( <> )") + assertParsesAs(t, "(a `` b)", "( <> )") +} + +func TestAdjacentEmptyReferencesStaySeparate(t *testing.T) { + assertParsesAs(t, `(a "" "" b)`, "( <> <> )") + assertParsesAs(t, `(a '' '' b)`, "( <> <> )") + assertParsesAs(t, "(a `` `` b)", "( <> <> )") + assertParsesAs(t, `(a "" "" b)`, "( <> <> )") +} + +func TestNestedEmptyReferencesParse(t *testing.T) { + assertParsesAs(t, `("" ("" 1))`, "(<> (<> <1>))") + assertParsesAs(t, `("" ('' 1))`, "(<> (<> <1>))") + assertParsesAs(t, `("x" ("" 1))`, "( (<> <1>))") + assertParsesAs(t, `("" ("x" 1))`, "(<> ( <1>))") + assertParsesAs(t, `("" x ("" 1))`, "(<> (<> <1>))") + assertParsesAs(t, `("" 1 ("" 1))`, "(<> <1> (<> <1>))") +} + +func TestEmptyReferenceIsValidAsAnID(t *testing.T) { + assertParsesAs(t, `("": 1)`, "(<>: <1>)") + assertParsesAs(t, `(o: ("" (o: ("" 1))))`, "(: (<> (: (<> <1>))))") +} + +func TestNQuoteDelimitedBodiesAreUnchanged(t *testing.T) { + // A run that encloses a substantive body keeps its n-quote meaning. + assertParsesAs(t, `(a ""x"" b)`, "( )") + assertParsesAs(t, `(x "" " "")`, `( < " >)`) + assertParsesAs(t, `(x ' " ')`, `( < " >)`) + // An n-quote-delimited empty is still empty. + assertParsesAs(t, `(a """" b)`, "( <> )") +} + +func TestASingleSpaceStillReadsAsASpace(t *testing.T) { + assertParsesAs(t, `(a " " b)`, "( < > )") +} + +func TestEmptyReferenceSurvivesARoundTrip(t *testing.T) { + sources := []string{ + `(a "" b)`, + `(a "" "" b)`, + `("" ("" 1))`, + `("": 1)`, + `(o: ("" (o: ("" 1))))`, + } + for _, source := range sources { + links, err := Parse(source) + if err != nil { + t.Fatalf("Failed to parse %q: %v", source, err) + } + formatted := Format(links) + reparsed, err := Parse(formatted) + if err != nil { + t.Fatalf("Formatted %q did not parse: %v", formatted, err) + } + if Format(reparsed) != formatted { + t.Errorf("Round trip changed %q: %q became %q", source, formatted, Format(reparsed)) + } + } +} + +func TestEmptyReferenceIsWrittenAsADelimiterPair(t *testing.T) { + links, err := Parse(`(a "" b)`) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + if formatted := Format(links); formatted != `(a "" b)` { + t.Errorf("expected %q, got %q", `(a "" b)`, formatted) + } +} diff --git a/go/lino.go b/go/lino.go index 2110b22..0cabec6 100644 --- a/go/lino.go +++ b/go/lino.go @@ -276,8 +276,10 @@ func (l *Link) Equal(other *Link) bool { // escapeReference escapes a reference string by adding quotes if necessary. func escapeReference(reference string) string { - if reference == "" || strings.TrimSpace(reference) == "" { - return "" + // The empty reference is written as a bare delimiter pair, so that it reads + // back as itself instead of disappearing from the document. + if reference == "" { + return `""` } hasSingleQuote := strings.Contains(reference, "'") diff --git a/go/parser.go b/go/parser.go index 4412939..09f935a 100644 --- a/go/parser.go +++ b/go/parser.go @@ -3,6 +3,7 @@ package lino import ( "errors" "strings" + "unicode" ) // ParseError is returned when parsing fails. @@ -70,18 +71,51 @@ func (p *Parser) Parse(input string) ([]*Link, error) { return p.transformResult(rawResult), nil } -// skipQuotedString skips over the quoted string starting at start. +// isSubstantiveBody reports whether a body written between an even run of +// delimiters is substantive: it holds at least one visible character and does +// not straddle a parenthesis. An even run can always be read as delimiter pairs +// enclosing nothing, so the n-quote reading is only taken when it carries +// something the pairs cannot. +func isSubstantiveBody(content string) bool { + depth := 0 + hasVisible := false + + for _, c := range content { + switch c { + case '(': + depth++ + case ')': + depth-- + if depth < 0 { + return false + } + } + if !unicode.IsSpace(c) { + hasVisible = true + } + } + + return hasVisible && depth == 0 +} + +// parseQuotedStringAt parses the delimited reference starting at start. +// // Any number N of quotes opens and closes the string, 2*N quotes are an escaped -// quote sequence. It returns the position right after the closing quotes, or -1 -// when text does not start a terminated quoted string. -func (p *Parser) skipQuotedString(text string, start int) int { +// quote sequence. A run of an even number of delimiters that does not open a +// reference with a substantive body is the empty reference: the shortest +// reading, a bare delimiter pair enclosing nothing, wins over a longer n-quote +// delimiter. +// +// It returns the decoded value and the position right after the closing quotes, +// or ok == false when text does not start a delimited reference. +func parseQuotedStringAt(text string, start int) (value string, end int, ok bool) { if start >= len(text) { - return -1 + return "", 0, false } quoteChar := text[start] if quoteChar != '"' && quoteChar != '\'' && quoteChar != '`' { - return -1 + return "", 0, false } quoteCount := 0 @@ -91,24 +125,47 @@ func (p *Parser) skipQuotedString(text string, start int) int { pos++ } + isEvenRun := quoteCount%2 == 0 openClose := strings.Repeat(string(quoteChar), quoteCount) escapeSeq := strings.Repeat(string(quoteChar), quoteCount*2) + var content strings.Builder for pos < len(text) { if strings.HasPrefix(text[pos:], escapeSeq) { + content.WriteString(openClose) pos += len(escapeSeq) continue } if strings.HasPrefix(text[pos:], openClose) { afterClose := pos + quoteCount if afterClose >= len(text) || text[afterClose] != quoteChar { - return afterClose + body := content.String() + if isEvenRun && !isSubstantiveBody(body) { + return "", start + quoteCount, true + } + return body, afterClose, true } } + content.WriteByte(text[pos]) pos++ } - return -1 + if isEvenRun { + return "", start + quoteCount, true + } + + return "", 0, false +} + +// skipQuotedString skips over the quoted string starting at start. +// It returns the position right after the closing quotes, or -1 when text does +// not start a terminated quoted string. +func (p *Parser) skipQuotedString(text string, start int) int { + _, end, ok := parseQuotedStringAt(text, start) + if !ok { + return -1 + } + return end } // findMatchingParen finds the parenthesis closing the one at start. @@ -413,44 +470,10 @@ func (p *Parser) extractNextValue(text string, start int) (int, string) { return start, "" } - // Check if this starts with a multi-quote string - for _, quoteChar := range []byte{'"', '\'', '`'} { - if text[start] == quoteChar { - // Count opening quotes dynamically - quoteCount := 0 - pos := start - for pos < len(text) && text[pos] == quoteChar { - quoteCount++ - pos++ - } - - if quoteCount >= 1 { - remaining := text[start:] - openClose := strings.Repeat(string(quoteChar), quoteCount) - escapeSeq := strings.Repeat(string(quoteChar), quoteCount*2) - - innerPos := len(openClose) - for innerPos < len(remaining) { - // Check for escape sequence (2*N quotes) - if strings.HasPrefix(remaining[innerPos:], escapeSeq) { - innerPos += len(escapeSeq) - continue - } - // Check for closing quotes - if strings.HasPrefix(remaining[innerPos:], openClose) { - afterClosePos := innerPos + len(openClose) - // Make sure this is exactly N quotes (not more) - if afterClosePos >= len(remaining) || remaining[afterClosePos] != quoteChar { - return start + afterClosePos, remaining[:afterClosePos] - } - } - innerPos++ - } - - // No closing found, treat as regular text - break - } - } + // Check if this starts with a delimited reference (any N quotes, or a bare + // delimiter pair standing for the empty reference) + if _, end, ok := parseQuotedStringAt(text, start); ok { + return end, text[start:end] } // Check if this starts with a parenthesized expression @@ -507,69 +530,15 @@ func (p *Parser) parseValue(value string) *internalLink { func (p *Parser) extractReference(text string) string { text = strings.TrimSpace(text) - // Try multi-quote strings - for _, quoteChar := range []byte{'"', '\'', '`'} { - if len(text) > 0 && text[0] == quoteChar { - // Count opening quotes dynamically - quoteCount := 0 - for quoteCount < len(text) && text[quoteCount] == quoteChar { - quoteCount++ - } - - if quoteCount >= 1 && len(text) > quoteCount { - result := p.parseMultiQuoteString(text, quoteChar, quoteCount) - if result != nil { - return *result - } - } - } + // Try delimited references (any N quotes, or a bare delimiter pair) + if value, _, ok := parseQuotedStringAt(text, 0); ok { + return value } // Unquoted return text } -func (p *Parser) parseMultiQuoteString(text string, quoteChar byte, quoteCount int) *string { - openClose := strings.Repeat(string(quoteChar), quoteCount) - escapeSeq := strings.Repeat(string(quoteChar), quoteCount*2) - escapeVal := strings.Repeat(string(quoteChar), quoteCount) - - // Check for opening quotes - if !strings.HasPrefix(text, openClose) { - return nil - } - - remaining := text[len(openClose):] - var content strings.Builder - - for len(remaining) > 0 { - // Check for escape sequence (2*N quotes) - if strings.HasPrefix(remaining, escapeSeq) { - content.WriteString(escapeVal) - remaining = remaining[len(escapeSeq):] - continue - } - - // Check for closing quotes (N quotes not followed by more quotes) - if strings.HasPrefix(remaining, openClose) { - afterClose := remaining[len(openClose):] - // Make sure this is exactly N quotes (not more) - if afterClose == "" || afterClose[0] != quoteChar { - // Closing found - result := content.String() - return &result - } - } - - // Take the next character - content.WriteByte(remaining[0]) - remaining = remaining[1:] - } - - // No closing quotes found - return nil -} - func (p *Parser) transformResult(rawResult []*internalLink) []*Link { var links []*Link diff --git a/java/pom.xml b/java/pom.xml index 85812f4..a21e151 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ io.github.link-foundation links-notation - 0.2.0 + 0.3.0 jar Links Notation diff --git a/java/src/main/java/io/github/linkfoundation/linksnotation/Link.java b/java/src/main/java/io/github/linkfoundation/linksnotation/Link.java index a4722a1..3f37527 100644 --- a/java/src/main/java/io/github/linkfoundation/linksnotation/Link.java +++ b/java/src/main/java/io/github/linkfoundation/linksnotation/Link.java @@ -141,9 +141,14 @@ public static String getValueString(Link value) { * @return escaped reference */ public static String escapeReference(String reference) { - if (reference == null || reference.trim().isEmpty()) { + if (reference == null) { return ""; } + // The empty reference is written as a bare delimiter pair, so that it reads back as itself + // instead of disappearing from the document. + if (reference.isEmpty()) { + return "\"\""; + } boolean hasSingleQuote = reference.contains("'"); boolean hasDoubleQuote = reference.contains("\""); diff --git a/java/src/main/java/io/github/linkfoundation/linksnotation/Parser.java b/java/src/main/java/io/github/linkfoundation/linksnotation/Parser.java index 3aa9ccb..8b7643a 100644 --- a/java/src/main/java/io/github/linkfoundation/linksnotation/Parser.java +++ b/java/src/main/java/io/github/linkfoundation/linksnotation/Parser.java @@ -80,20 +80,63 @@ public List parse(String input) throws ParseException { } /** - * Skip over the quoted string starting at start. + * Report whether a body written between an even run of delimiters is substantive. + * + *

A substantive body holds at least one visible character and does not straddle a parenthesis. + * An even run can always be read as delimiter pairs enclosing nothing, so the n-quote reading is + * only taken when it carries something the pairs cannot. + */ + private static boolean isSubstantiveBody(String content) { + int depth = 0; + boolean hasVisible = false; + + for (int i = 0; i < content.length(); i++) { + char c = content.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth < 0) { + return false; + } + } + if (!Character.isWhitespace(c)) { + hasVisible = true; + } + } + + return hasVisible && depth == 0; + } + + /** The decoded value of a delimited reference and the position right after it. */ + private static final class QuotedString { + final String value; + final int end; + + QuotedString(String value, int end) { + this.value = value; + this.end = end; + } + } + + /** + * Parse the delimited reference starting at start. * *

Any number N of quotes opens and closes the string, 2*N quotes are an escaped quote - * sequence. Returns the position right after the closing quotes, or -1 when text does not start a - * terminated quoted string. + * sequence. A run of an even number of delimiters that does not open a reference with a + * substantive body is the empty reference: the shortest reading, a bare delimiter pair enclosing + * nothing, wins over a longer n-quote delimiter. + * + *

Returns null when text does not start a delimited reference. */ - private int skipQuotedString(String text, int start) { + private static QuotedString parseQuotedStringAt(String text, int start) { if (start >= text.length()) { - return -1; + return null; } char quoteChar = text.charAt(start); if (quoteChar != '"' && quoteChar != '\'' && quoteChar != '`') { - return -1; + return null; } int quoteCount = 0; @@ -103,24 +146,45 @@ private int skipQuotedString(String text, int start) { pos++; } + boolean isEvenRun = quoteCount % 2 == 0; + QuotedString emptyReference = isEvenRun ? new QuotedString("", start + quoteCount) : null; + String openClose = repeatChar(quoteChar, quoteCount); String escapeSeq = repeatChar(quoteChar, quoteCount * 2); + StringBuilder content = new StringBuilder(); while (pos < text.length()) { if (text.startsWith(escapeSeq, pos)) { + content.append(openClose); pos += escapeSeq.length(); continue; } if (text.startsWith(openClose, pos)) { int afterClose = pos + quoteCount; if (afterClose >= text.length() || text.charAt(afterClose) != quoteChar) { - return afterClose; + String value = content.toString(); + if (isEvenRun && !isSubstantiveBody(value)) { + return emptyReference; + } + return new QuotedString(value, afterClose); } } + content.append(text.charAt(pos)); pos++; } - return -1; + return emptyReference; + } + + /** + * Skip over the quoted string starting at start. + * + *

Returns the position right after the closing quotes, or -1 when text does not start a + * delimited reference. + */ + private int skipQuotedString(String text, int start) { + QuotedString parsed = parseQuotedStringAt(text, start); + return parsed == null ? -1 : parsed.end; } /** @@ -428,45 +492,10 @@ private int[] extractNextValue(String text, int start) { return new int[] {start}; } - // Check if this starts with a multi-quote string - char[] quoteChars = {'"', '\'', '`'}; - for (char quoteChar : quoteChars) { - if (text.charAt(start) == quoteChar) { - // Count opening quotes dynamically - int quoteCount = 0; - int pos = start; - while (pos < text.length() && text.charAt(pos) == quoteChar) { - quoteCount++; - pos++; - } - - if (quoteCount >= 1) { - // Parse this multi-quote string - String openClose = repeatChar(quoteChar, quoteCount); - String escapeSeq = repeatChar(quoteChar, quoteCount * 2); - - int innerPos = start + quoteCount; - while (innerPos < text.length()) { - // Check for escape sequence (2*N quotes) - if (text.substring(innerPos).startsWith(escapeSeq)) { - innerPos += escapeSeq.length(); - continue; - } - // Check for closing quotes - if (text.substring(innerPos).startsWith(openClose)) { - int afterClosePos = innerPos + quoteCount; - // Make sure this is exactly N quotes (not more) - if (afterClosePos >= text.length() || text.charAt(afterClosePos) != quoteChar) { - // Found the end - return new int[] {afterClosePos}; - } - } - innerPos++; - } - // No closing found, treat as regular text - break; - } - } + // Check if this starts with a delimited reference + QuotedString quoted = parseQuotedStringAt(text, start); + if (quoted != null) { + return new int[] {quoted.end}; } // Check if this starts with a parenthesized expression @@ -517,70 +546,16 @@ private Map parseValue(String value) throws ParseException { private String extractReference(String text) { text = text.trim(); - // Try multi-quote strings - char[] quoteChars = {'"', '\'', '`'}; - for (char quoteChar : quoteChars) { - if (!text.isEmpty() && text.charAt(0) == quoteChar) { - // Count opening quotes dynamically - int quoteCount = 0; - while (quoteCount < text.length() && text.charAt(quoteCount) == quoteChar) { - quoteCount++; - } - - if (quoteCount >= 1 && text.length() > quoteCount) { - String result = parseMultiQuoteString(text, quoteChar, quoteCount); - if (result != null) { - return result; - } - } - } + QuotedString quoted = parseQuotedStringAt(text, 0); + if (quoted != null) { + return quoted.value; } // Unquoted return text; } - /** Parse a multi-quote string. */ - private String parseMultiQuoteString(String text, char quoteChar, int quoteCount) { - String openClose = repeatChar(quoteChar, quoteCount); - String escapeSeq = repeatChar(quoteChar, quoteCount * 2); - String escapeVal = repeatChar(quoteChar, quoteCount); - - // Check for opening quotes - if (!text.startsWith(openClose)) { - return null; - } - - String remaining = text.substring(openClose.length()); - StringBuilder content = new StringBuilder(); - - while (!remaining.isEmpty()) { - // Check for escape sequence (2*N quotes) - if (remaining.startsWith(escapeSeq)) { - content.append(escapeVal); - remaining = remaining.substring(escapeSeq.length()); - continue; - } - - // Check for closing quotes (N quotes not followed by more quotes) - if (remaining.startsWith(openClose)) { - String afterClose = remaining.substring(openClose.length()); - // Make sure this is exactly N quotes (not more) - if (afterClose.isEmpty() || afterClose.charAt(0) != quoteChar) { - return content.toString(); - } - } - - // Take the next character - content.append(remaining.charAt(0)); - remaining = remaining.substring(1); - } - - // No closing quotes found - return null; - } - - private String repeatChar(char c, int count) { + private static String repeatChar(char c, int count) { StringBuilder sb = new StringBuilder(count); for (int i = 0; i < count; i++) { sb.append(c); diff --git a/java/src/test/java/io/github/linkfoundation/linksnotation/EmptyReferenceTest.java b/java/src/test/java/io/github/linkfoundation/linksnotation/EmptyReferenceTest.java new file mode 100644 index 0000000..7c4079b --- /dev/null +++ b/java/src/test/java/io/github/linkfoundation/linksnotation/EmptyReferenceTest.java @@ -0,0 +1,119 @@ +package io.github.linkfoundation.linksnotation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Conformance tests for the empty reference. + * + *

https://github.com/link-foundation/links-notation/issues/288 + * + *

A bare delimiter pair is the empty reference. The three delimiters {@code "}, {@code '} and + * {@code `} behave identically, and every longer n-quote run keeps the meaning it already had. The + * table below is shared with the Rust, JavaScript, Python, Go, C# and PHP suites, so a document + * written by one implementation reads the same in all of them. + */ +class EmptyReferenceTest { + + private Parser parser; + + @BeforeEach + void setUp() { + parser = new Parser(); + } + + /** + * Render a parsed node unambiguously: every reference is wrapped in angle brackets so an empty + * one is visible as {@code <>}. + */ + private static String render(Link node) { + if (node.getValues() == null || node.getValues().isEmpty()) { + return "<" + (node.getId() == null ? "" : node.getId()) + ">"; + } + String head = node.getId() == null ? "" : "<" + node.getId() + ">: "; + return "(" + + head + + node.getValues().stream().map(EmptyReferenceTest::render).collect(Collectors.joining(" ")) + + ")"; + } + + private String rendered(String source) throws ParseException { + List links = parser.parse(source); + return links.stream().map(EmptyReferenceTest::render).collect(Collectors.joining("\n")); + } + + private void assertParsesAs(String source, String expected) throws ParseException { + assertEquals(expected, rendered(source), "Parsing " + source); + } + + @Test + void bareDelimiterPairIsTheEmptyReference() throws ParseException { + assertParsesAs("(a \"\" b)", "( <> )"); + } + + @Test + void everyDelimiterStyleYieldsTheSameEmptyReference() throws ParseException { + assertParsesAs("(a \"\" b)", "( <> )"); + assertParsesAs("(a '' b)", "( <> )"); + assertParsesAs("(a `` b)", "( <> )"); + } + + @Test + void adjacentEmptyReferencesStaySeparate() throws ParseException { + assertParsesAs("(a \"\" \"\" b)", "( <> <> )"); + assertParsesAs("(a '' '' b)", "( <> <> )"); + assertParsesAs("(a `` `` b)", "( <> <> )"); + assertParsesAs("(a \"\" \"\" b)", "( <> <> )"); + } + + @Test + void nestedEmptyReferencesParse() throws ParseException { + assertParsesAs("(\"\" (\"\" 1))", "(<> (<> <1>))"); + assertParsesAs("(\"\" ('' 1))", "(<> (<> <1>))"); + assertParsesAs("(\"x\" (\"\" 1))", "( (<> <1>))"); + assertParsesAs("(\"\" (\"x\" 1))", "(<> ( <1>))"); + assertParsesAs("(\"\" x (\"\" 1))", "(<> (<> <1>))"); + assertParsesAs("(\"\" 1 (\"\" 1))", "(<> <1> (<> <1>))"); + } + + @Test + void emptyReferenceIsValidAsAnId() throws ParseException { + assertParsesAs("(\"\": 1)", "(<>: <1>)"); + assertParsesAs("(o: (\"\" (o: (\"\" 1))))", "(: (<> (: (<> <1>))))"); + } + + @Test + void nQuoteDelimitedBodiesAreUnchanged() throws ParseException { + // A run that encloses a substantive body keeps its n-quote meaning. + assertParsesAs("(a \"\"x\"\" b)", "( )"); + assertParsesAs("(x \"\" \" \"\")", "( < \" >)"); + assertParsesAs("(x ' \" ')", "( < \" >)"); + // An n-quote-delimited empty is still empty. + assertParsesAs("(a \"\"\"\" b)", "( <> )"); + } + + @Test + void aSingleSpaceStillReadsAsASpace() throws ParseException { + assertParsesAs("(a \" \" b)", "( < > )"); + } + + @Test + void emptyReferenceSurvivesARoundTrip() throws ParseException { + String[] sources = { + "(a \"\" b)", "(a \"\" \"\" b)", "(\"\" (\"\" 1))", "(\"\": 1)", "(o: (\"\" (o: (\"\" 1))))", + }; + for (String source : sources) { + String formatted = Link.formatLinks(parser.parse(source)); + assertEquals(formatted, Link.formatLinks(parser.parse(formatted)), source); + } + } + + @Test + void emptyReferenceIsWrittenAsADelimiterPair() throws ParseException { + assertEquals("(a \"\" b)", Link.formatLinks(parser.parse("(a \"\" b)"))); + } +} diff --git a/java/src/test/java/io/github/linkfoundation/linksnotation/MultiQuoteParserTest.java b/java/src/test/java/io/github/linkfoundation/linksnotation/MultiQuoteParserTest.java index 586827b..2e4ab60 100644 --- a/java/src/test/java/io/github/linkfoundation/linksnotation/MultiQuoteParserTest.java +++ b/java/src/test/java/io/github/linkfoundation/linksnotation/MultiQuoteParserTest.java @@ -127,19 +127,19 @@ void testQuotedWithParenthesesInside() throws ParseException { @Test void testEmptyQuotes() throws ParseException { - // Empty quotes are treated as literal strings (consistent with Python) + // A bare delimiter pair is the empty reference String input = "''"; List result = parser.parse(input); assertEquals(1, result.size()); - assertEquals("''", result.get(0).getValues().get(0).getId()); + assertEquals("", result.get(0).getValues().get(0).getId()); } @Test void testEmptyDoubleQuotes() throws ParseException { - // Empty quotes are treated as literal strings (consistent with Python) + // A bare delimiter pair is the empty reference String input = "\"\""; List result = parser.parse(input); assertEquals(1, result.size()); - assertEquals("\"\"", result.get(0).getValues().get(0).getId()); + assertEquals("", result.get(0).getValues().get(0).getId()); } } diff --git a/js/bun.lock b/js/bun.lock index 37c1c4a..1050f3d 100644 --- a/js/bun.lock +++ b/js/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@linksplatform/protocols-lino", diff --git a/js/examples/empty_reference.js b/js/examples/empty_reference.js new file mode 100644 index 0000000..a61c6e5 --- /dev/null +++ b/js/examples/empty_reference.js @@ -0,0 +1,43 @@ +// A document written for an object with empty keys, the case that motivated +// https://github.com/link-foundation/links-notation/issues/288 +// +// `{"": {"": 1}}` is written as `(o: ("" (o: ("" 1))))`. Every reference in it +// reads back as itself, so encoders that produce empty keys round trip. +// +// Usage: node js/examples/empty_reference.js + +import { Parser, formatLinks } from '../src/index.js'; + +const parser = new Parser(); + +const source = '(o: ("" (o: ("" 1))))'; + +console.log('Source: ', source); + +const links = parser.parse(source); + +// Show every reference between angle brackets, so an empty one stays visible. +function render(node) { + if (!node.values || node.values.length === 0) { + return `<${node.id ?? ''}>`; + } + const head = + node.id === null || node.id === undefined ? '' : `<${node.id}>: `; + return `(${head}${node.values.map(render).join(' ')})`; +} + +console.log('Parsed as: ', links.map(render).join('\n')); + +const formatted = formatLinks(links); +console.log('Formatted: ', formatted); +console.log( + 'Round trips: ', + formatLinks(parser.parse(formatted)) === formatted +); + +// A bare delimiter pair is one empty reference, and two in a row stay separate. +for (const example of ['(a "" b)', '(a "" "" b)', '(a " " b)', '(a ""x"" b)']) { + console.log( + `${example.padEnd(14)} => ${parser.parse(example).map(render).join(' ')}` + ); +} diff --git a/js/package-lock.json b/js/package-lock.json index 1146fe8..ba82373 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "links-notation", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "links-notation", - "version": "0.14.0", + "version": "0.15.0", "license": "Unlicense", "devDependencies": { "bun-types": "^1.3.14", diff --git a/js/package.json b/js/package.json index 03c9557..4646f31 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "links-notation", - "version": "0.14.0", + "version": "0.15.0", "description": "Links Notation parser for JavaScript", "main": "dist/index.js", "types": "index.d.ts", diff --git a/js/src/Link.js b/js/src/Link.js index 7f4421b..729665f 100644 --- a/js/src/Link.js +++ b/js/src/Link.js @@ -82,10 +82,16 @@ export class Link { * @returns {string} Escaped reference */ static escapeReference(reference) { - if (!reference || reference.trim() === '') { + if (reference === null || reference === undefined) { return ''; } + // The empty reference is written as a bare delimiter pair, so that it reads + // back as itself instead of disappearing from the document. + if (reference === '') { + return '""'; + } + const hasSingleQuote = reference.includes("'"); const hasDoubleQuote = reference.includes('"'); diff --git a/js/src/Parser.js b/js/src/Parser.js index 755c61d..db24243 100644 --- a/js/src/Parser.js +++ b/js/src/Parser.js @@ -64,7 +64,11 @@ export class Parser { if (item.children && item.children.length > 0) { // Special case: If this is an ID with empty values but has children, // the children should become the values of the link (indented ID syntax) - if (item.id && (!item.values || item.values.length === 0)) { + if ( + item.id !== undefined && + item.id !== null && + (!item.values || item.values.length === 0) + ) { const childValues = item.children.map((child) => { // For indented children, extract the actual reference from the child's values if (child.values && child.values.length === 1) { @@ -185,12 +189,12 @@ export class Parser { // For items with values, create a link with those values if (item.values && Array.isArray(item.values)) { // Create a link with id (if present) and transformed values - const link = new Link(item.id || null, []); + const link = new Link(item.id ?? null, []); link.values = item.values.map((v) => this.transformLink(v)); return link; } // Default case - return new Link(item.id || null, []); + return new Link(item.id ?? null, []); } } diff --git a/js/src/grammar.pegjs b/js/src/grammar.pegjs index cdbeeb8..a2b28d5 100644 --- a/js/src/grammar.pegjs +++ b/js/src/grammar.pegjs @@ -66,8 +66,36 @@ return indentationStack[indentationStack.length - 1]; } + // A body written between an even run of delimiters is substantive when it + // holds at least one visible character and does not straddle a parenthesis. + // An even run can always be read as delimiter pairs enclosing nothing, so the + // n-quote reading is only taken when it carries something the pairs cannot. + function isSubstantiveBody(content) { + let depth = 0; + let hasVisible = false; + + for (const c of content) { + if (c === '(') { + depth++; + } else if (c === ')') { + depth--; + if (depth < 0) { + return false; + } + } + if (!/\s/.test(c)) { + hasVisible = true; + } + } + + return hasVisible && depth === 0; + } + // Universal procedural parser for N-quote strings (any N >= 1) // Parses from the given position in the input string + // A run of an even number of delimiters that does not open a reference with a + // substantive body is the empty reference: the shortest reading, a bare + // delimiter pair enclosing nothing, wins over a longer n-quote delimiter. // Returns { value, length } or null function parseQuotedStringAt(inputStr, startPos, quoteChar) { if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) { @@ -82,6 +110,9 @@ pos++; } + const isEvenRun = quoteCount % 2 === 0; + const emptyReference = isEvenRun ? { value: '', length: quoteCount } : null; + const closeSeq = quoteChar.repeat(quoteCount); const escapeSeq = quoteChar.repeat(quoteCount * 2); @@ -100,6 +131,9 @@ const afterClose = pos + quoteCount; if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) { // Found valid closing + if (isEvenRun && !isSubstantiveBody(content)) { + return emptyReference; + } return { value: content, length: afterClose - startPos @@ -112,7 +146,7 @@ pos++; } - return null; // No valid closing found + return emptyReference; // No valid closing found } // Global state for passing parsed values between predicate and action diff --git a/js/src/parser-generated.js b/js/src/parser-generated.js index ee9e814..553a96a 100644 --- a/js/src/parser-generated.js +++ b/js/src/parser-generated.js @@ -1730,8 +1730,36 @@ function peg$parse(input, options) { return indentationStack[indentationStack.length - 1]; } + // A body written between an even run of delimiters is substantive when it + // holds at least one visible character and does not straddle a parenthesis. + // An even run can always be read as delimiter pairs enclosing nothing, so the + // n-quote reading is only taken when it carries something the pairs cannot. + function isSubstantiveBody(content) { + let depth = 0; + let hasVisible = false; + + for (const c of content) { + if (c === '(') { + depth++; + } else if (c === ')') { + depth--; + if (depth < 0) { + return false; + } + } + if (!/\s/.test(c)) { + hasVisible = true; + } + } + + return hasVisible && depth === 0; + } + // Universal procedural parser for N-quote strings (any N >= 1) // Parses from the given position in the input string + // A run of an even number of delimiters that does not open a reference with a + // substantive body is the empty reference: the shortest reading, a bare + // delimiter pair enclosing nothing, wins over a longer n-quote delimiter. // Returns { value, length } or null function parseQuotedStringAt(inputStr, startPos, quoteChar) { if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) { @@ -1746,6 +1774,9 @@ function peg$parse(input, options) { pos++; } + const isEvenRun = quoteCount % 2 === 0; + const emptyReference = isEvenRun ? { value: '', length: quoteCount } : null; + const closeSeq = quoteChar.repeat(quoteCount); const escapeSeq = quoteChar.repeat(quoteCount * 2); @@ -1764,6 +1795,9 @@ function peg$parse(input, options) { const afterClose = pos + quoteCount; if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) { // Found valid closing + if (isEvenRun && !isSubstantiveBody(content)) { + return emptyReference; + } return { value: content, length: afterClose - startPos @@ -1776,7 +1810,7 @@ function peg$parse(input, options) { pos++; } - return null; // No valid closing found + return emptyReference; // No valid closing found } // Global state for passing parsed values between predicate and action diff --git a/js/tests/EmptyReference.test.js b/js/tests/EmptyReference.test.js new file mode 100644 index 0000000..26fc830 --- /dev/null +++ b/js/tests/EmptyReference.test.js @@ -0,0 +1,91 @@ +// Conformance tests for the empty reference (issue #288). +// +// A bare delimiter pair is the empty reference. The three delimiters `"`, `'` +// and ` (backtick) behave identically, and every longer n-quote run keeps the +// meaning it already had. The table below is shared with the Rust, Python, C#, +// Go, Java and PHP suites, so a document written by one implementation reads +// the same in all of them. + +import { test, expect } from 'bun:test'; +import { Parser } from '../src/Parser.js'; +import { formatLinks } from '../src/Link.js'; + +const parser = new Parser(); + +// Render a parsed node unambiguously: every reference is wrapped in angle +// brackets so an empty one is visible as `<>`. +function render(node) { + if (!node.values || node.values.length === 0) { + return `<${node.id ?? ''}>`; + } + const head = + node.id === null || node.id === undefined ? '' : `<${node.id}>: `; + return `(${head}${node.values.map(render).join(' ')})`; +} + +function parsesAs(input) { + return parser.parse(input).map(render).join('\n'); +} + +test('TestBareDelimiterPairIsTheEmptyReference', () => { + expect(parsesAs('(a "" b)')).toBe('( <> )'); +}); + +test('TestEveryDelimiterStyleYieldsTheSameEmptyReference', () => { + expect(parsesAs('(a "" b)')).toBe('( <> )'); + expect(parsesAs("(a '' b)")).toBe('( <> )'); + expect(parsesAs('(a `` b)')).toBe('( <> )'); +}); + +test('TestAdjacentEmptyReferencesStaySeparate', () => { + expect(parsesAs('(a "" "" b)')).toBe('( <> <> )'); + expect(parsesAs("(a '' '' b)")).toBe('( <> <> )'); + expect(parsesAs('(a `` `` b)')).toBe('( <> <> )'); + expect(parsesAs('(a "" "" b)')).toBe('( <> <> )'); +}); + +test('TestNestedEmptyReferencesParse', () => { + expect(parsesAs('("" ("" 1))')).toBe('(<> (<> <1>))'); + expect(parsesAs('("" (\'\' 1))')).toBe('(<> (<> <1>))'); + expect(parsesAs('("x" ("" 1))')).toBe('( (<> <1>))'); + expect(parsesAs('("" ("x" 1))')).toBe('(<> ( <1>))'); + expect(parsesAs('("" x ("" 1))')).toBe('(<> (<> <1>))'); + expect(parsesAs('("" 1 ("" 1))')).toBe('(<> <1> (<> <1>))'); +}); + +test('TestEmptyReferenceIsValidAsAnId', () => { + expect(parsesAs('("": 1)')).toBe('(<>: <1>)'); + expect(parsesAs('(o: ("" (o: ("" 1))))')).toBe('(: (<> (: (<> <1>))))'); +}); + +test('TestNQuoteDelimitedBodiesAreUnchanged', () => { + // A run that encloses a substantive body keeps its n-quote meaning. + expect(parsesAs('(a ""x"" b)')).toBe('( )'); + expect(parsesAs('(x "" " "")')).toBe('( < " >)'); + expect(parsesAs("(x ' \" ')")).toBe('( < " >)'); + // An n-quote-delimited empty is still empty. + expect(parsesAs('(a """" b)')).toBe('( <> )'); +}); + +test('TestSingleSpaceStillReadsAsASpace', () => { + expect(parsesAs('(a " " b)')).toBe('( < > )'); +}); + +test('TestEmptyReferenceSurvivesARoundTrip', () => { + const inputs = [ + '(a "" b)', + '(a "" "" b)', + '("" ("" 1))', + '("": 1)', + '(o: ("" (o: ("" 1))))', + ]; + for (const input of inputs) { + const links = parser.parse(input); + const formatted = formatLinks(links); + expect(parser.parse(formatted)).toEqual(links); + } +}); + +test('TestEmptyReferenceIsWrittenAsADelimiterPair', () => { + expect(formatLinks(parser.parse('(a "" b)'))).toBe('(a "" b)'); +}); diff --git a/php/composer.json b/php/composer.json index 73d8959..b86fcd4 100644 --- a/php/composer.json +++ b/php/composer.json @@ -5,7 +5,7 @@ "keywords": ["links", "notation", "lino", "parser", "formatter", "links-platform"], "homepage": "https://github.com/link-foundation/links-notation", "license": "Unlicense", - "version": "0.1.0", + "version": "0.2.0", "authors": [ { "name": "Link Foundation", diff --git a/php/src/Link.php b/php/src/Link.php index cf338d0..e448d90 100644 --- a/php/src/Link.php +++ b/php/src/Link.php @@ -120,9 +120,14 @@ public static function getValueString(Link $value): string */ public static function escapeReference(?string $reference): string { - if ($reference === null || trim($reference) === '') { + if ($reference === null) { return ''; } + // The empty reference is written as a bare delimiter pair, so that it + // reads back as itself instead of disappearing from the document. + if ($reference === '') { + return '""'; + } // Check if single quotes are needed $needsSingleQuotes = false; diff --git a/php/src/Parser.php b/php/src/Parser.php index ce477ea..87261f5 100644 --- a/php/src/Parser.php +++ b/php/src/Parser.php @@ -80,22 +80,60 @@ public function parse(string $input): array } /** - * Skip over the quoted string starting at $start. + * Report whether a body written between an even run of delimiters is + * substantive: it holds at least one visible character and does not + * straddle a parenthesis. An even run can always be read as delimiter pairs + * enclosing nothing, so the n-quote reading is only taken when it carries + * something the pairs cannot. + */ + private function isSubstantiveBody(string $content): bool + { + $depth = 0; + $hasVisible = false; + + $length = strlen($content); + for ($i = 0; $i < $length; $i++) { + $char = $content[$i]; + if ($char === '(') { + $depth++; + } elseif ($char === ')') { + $depth--; + if ($depth < 0) { + return false; + } + } + if (trim($char) !== '') { + $hasVisible = true; + } + } + + return $hasVisible && $depth === 0; + } + + /** + * Parse the delimited reference starting at $start. * * Any number N of quotes opens and closes the string, 2*N quotes are an - * escaped quote sequence. Returns the position right after the closing - * quotes, or -1 when text does not start a terminated quoted string. + * escaped quote sequence. A run of an even number of delimiters that does + * not open a reference with a substantive body is the empty reference: the + * shortest reading, a bare delimiter pair enclosing nothing, wins over a + * longer n-quote delimiter. + * + * @return array{0: string, 1: int}|null The decoded value and the position + * right after the closing quotes, or + * null when $text does not start a + * delimited reference */ - private function skipQuotedString(string $text, int $start): int + private function parseQuotedStringAt(string $text, int $start): ?array { $length = strlen($text); if ($start >= $length) { - return -1; + return null; } $quoteChar = $text[$start]; if (!in_array($quoteChar, ['"', "'", '`'], true)) { - return -1; + return null; } $quoteCount = 0; @@ -105,24 +143,47 @@ private function skipQuotedString(string $text, int $start): int $pos++; } + $isEvenRun = $quoteCount % 2 === 0; + $emptyReference = $isEvenRun ? ['', $start + $quoteCount] : null; + $openClose = str_repeat($quoteChar, $quoteCount); $escapeSequence = str_repeat($quoteChar, $quoteCount * 2); + $content = ''; while ($pos < $length) { if (str_starts_with(substr($text, $pos), $escapeSequence)) { + $content .= $openClose; $pos += strlen($escapeSequence); continue; } if (str_starts_with(substr($text, $pos), $openClose)) { $afterClose = $pos + $quoteCount; if ($afterClose >= $length || $text[$afterClose] !== $quoteChar) { - return $afterClose; + if ($isEvenRun && !$this->isSubstantiveBody($content)) { + return $emptyReference; + } + + return [$content, $afterClose]; } } + $content .= $text[$pos]; $pos++; } - return -1; + return $emptyReference; + } + + /** + * Skip over the quoted string starting at $start. + * + * Returns the position right after the closing quotes, or -1 when text does + * not start a delimited reference. + */ + private function skipQuotedString(string $text, int $start): int + { + $parsed = $this->parseQuotedStringAt($text, $start); + + return $parsed === null ? -1 : $parsed[1]; } /** @@ -470,43 +531,12 @@ private function extractNextValue(string $text, int $start): array return [$start, '']; } - // Check if this starts with a multi-quote string (supports any N quotes) - $quoteChar = $text[$start]; - if (in_array($quoteChar, ['"', "'", '`'], true)) { - // Count opening quotes dynamically - $quoteCount = 0; - $pos = $start; - while ($pos < $length && $text[$pos] === $quoteChar) { - $quoteCount++; - $pos++; - } + // Check if this starts with a delimited reference + $quoted = $this->parseQuotedStringAt($text, $start); + if ($quoted !== null) { + [, $end] = $quoted; - // Parse this multi-quote string - $remaining = substr($text, $start); - $openClose = str_repeat($quoteChar, $quoteCount); - $escapeSequence = str_repeat($quoteChar, $quoteCount * 2); - $remainingLength = strlen($remaining); - - $innerPos = strlen($openClose); - while ($innerPos < $remainingLength) { - // Check for escape sequence (2*N quotes) - if (str_starts_with(substr($remaining, $innerPos), $escapeSequence)) { - $innerPos += strlen($escapeSequence); - continue; - } - // Check for closing quotes - if (str_starts_with(substr($remaining, $innerPos), $openClose)) { - $afterClosePos = $innerPos + strlen($openClose); - // Make sure this is exactly N quotes (not more) - if ($afterClosePos >= $remainingLength || $remaining[$afterClosePos] !== $quoteChar) { - // Found the end - return [$start + $afterClosePos, substr($remaining, 0, $afterClosePos)]; - } - } - $innerPos++; - } - - // No closing found, treat as regular text + return [$end, substr($text, $start, $end - $start)]; } // Check if this starts with a parenthesized expression @@ -565,76 +595,15 @@ private function extractReference(string $text): string { $text = trim($text); - // Try multi-quote strings (supports any N quotes) - foreach (['"', "'", '`'] as $quoteChar) { - if (str_starts_with($text, $quoteChar)) { - // Count opening quotes dynamically - $quoteCount = 0; - $length = strlen($text); - while ($quoteCount < $length && $text[$quoteCount] === $quoteChar) { - $quoteCount++; - } - - if ($length > $quoteCount) { - // Try to parse this multi-quote string - $result = $this->parseMultiQuoteString($text, $quoteChar, $quoteCount); - if ($result !== null) { - return $result; - } - } - } + $quoted = $this->parseQuotedStringAt($text, 0); + if ($quoted !== null) { + return $quoted[0]; } // Unquoted return $text; } - /** - * Parse a multi-quote string. - * - * For N quotes: opening = N quotes, closing = N quotes, escape = 2*N quotes -> N quotes - */ - private function parseMultiQuoteString(string $text, string $quoteChar, int $quoteCount): ?string - { - $openClose = str_repeat($quoteChar, $quoteCount); - $escapeSequence = str_repeat($quoteChar, $quoteCount * 2); - $escapeValue = str_repeat($quoteChar, $quoteCount); - - // Check for opening quotes - if (!str_starts_with($text, $openClose)) { - return null; - } - - $remaining = substr($text, strlen($openClose)); - $content = ''; - - while ($remaining !== '') { - // Check for escape sequence (2*N quotes) - if (str_starts_with($remaining, $escapeSequence)) { - $content .= $escapeValue; - $remaining = substr($remaining, strlen($escapeSequence)); - continue; - } - - // Check for closing quotes (N quotes not followed by more quotes) - if (str_starts_with($remaining, $openClose)) { - $afterClose = substr($remaining, strlen($openClose)); - // Make sure this is exactly N quotes (not more) - if ($afterClose === '' || !str_starts_with($afterClose, $quoteChar)) { - // Closing found: the text after it, if any, is kept out of the reference - return $content; - } - } - - // Take the next character - $content .= $remaining[0]; - $remaining = substr($remaining, 1); - } - - // No closing quotes found - return null; - } - /** * Transform raw parse result into Link objects. * diff --git a/php/tests/EmptyReferenceTest.php b/php/tests/EmptyReferenceTest.php new file mode 100644 index 0000000..3340443 --- /dev/null +++ b/php/tests/EmptyReferenceTest.php @@ -0,0 +1,132 @@ +parser = new Parser(); + } + + /** + * Render a parsed node unambiguously: every reference is wrapped in angle + * brackets so an empty one is visible as <>. + */ + private function render(Link $node): string + { + if (empty($node->values)) { + return '<' . ($node->id ?? '') . '>'; + } + $head = $node->id === null ? '' : '<' . $node->id . '>: '; + $values = array_map(fn (Link $value): string => $this->render($value), $node->values); + + return '(' . $head . implode(' ', $values) . ')'; + } + + /** + * @param \LinkFoundation\LinksNotation\Link[] $links + */ + private function rendered(array $links): string + { + return implode("\n", array_map(fn (Link $link): string => $this->render($link), $links)); + } + + private function assertParsesAs(string $expected, string $source): void + { + $this->assertSame($expected, $this->rendered($this->parser->parse($source)), "Parsing {$source}"); + } + + public function testBareDelimiterPairIsTheEmptyReference(): void + { + $this->assertParsesAs('( <> )', '(a "" b)'); + } + + public function testEveryDelimiterStyleYieldsTheSameEmptyReference(): void + { + $this->assertParsesAs('( <> )', '(a "" b)'); + $this->assertParsesAs('( <> )', "(a '' b)"); + $this->assertParsesAs('( <> )', '(a `` b)'); + } + + public function testAdjacentEmptyReferencesStaySeparate(): void + { + $this->assertParsesAs('( <> <> )', '(a "" "" b)'); + $this->assertParsesAs('( <> <> )', "(a '' '' b)"); + $this->assertParsesAs('( <> <> )', '(a `` `` b)'); + $this->assertParsesAs('( <> <> )', '(a "" "" b)'); + } + + public function testNestedEmptyReferencesParse(): void + { + $this->assertParsesAs('(<> (<> <1>))', '("" ("" 1))'); + $this->assertParsesAs('(<> (<> <1>))', '("" (\'\' 1))'); + $this->assertParsesAs('( (<> <1>))', '("x" ("" 1))'); + $this->assertParsesAs('(<> ( <1>))', '("" ("x" 1))'); + $this->assertParsesAs('(<> (<> <1>))', '("" x ("" 1))'); + $this->assertParsesAs('(<> <1> (<> <1>))', '("" 1 ("" 1))'); + } + + public function testEmptyReferenceIsValidAsAnId(): void + { + $this->assertParsesAs('(<>: <1>)', '("": 1)'); + $this->assertParsesAs('(: (<> (: (<> <1>))))', '(o: ("" (o: ("" 1))))'); + } + + public function testNQuoteDelimitedBodiesAreUnchanged(): void + { + // A run that encloses a substantive body keeps its n-quote meaning. + $this->assertParsesAs('( )', '(a ""x"" b)'); + $this->assertParsesAs('( < " >)', '(x "" " "")'); + $this->assertParsesAs('( < " >)', '(x \' " \')'); + // An n-quote-delimited empty is still empty. + $this->assertParsesAs('( <> )', '(a """" b)'); + } + + public function testASingleSpaceStillReadsAsASpace(): void + { + $this->assertParsesAs('( < > )', '(a " " b)'); + } + + public function testEmptyReferenceSurvivesARoundTrip(): void + { + $sources = [ + '(a "" b)', + '(a "" "" b)', + '("" ("" 1))', + '("": 1)', + '(o: ("" (o: ("" 1))))', + ]; + foreach ($sources as $source) { + $formatted = Formatter::formatLinks($this->parser->parse($source)); + $reformatted = Formatter::formatLinks($this->parser->parse($formatted)); + $this->assertSame($formatted, $reformatted, "Round trip changed {$source}"); + } + } + + public function testEmptyReferenceIsWrittenAsADelimiterPair(): void + { + $this->assertSame('(a "" b)', Formatter::formatLinks($this->parser->parse('(a "" b)'))); + } +} diff --git a/php/tests/LinkTest.php b/php/tests/LinkTest.php index 39fc22f..e392062 100644 --- a/php/tests/LinkTest.php +++ b/php/tests/LinkTest.php @@ -65,7 +65,14 @@ public function testLinkEscapeReferenceKeepsZero(): void { $this->assertSame('0', Link::escapeReference('0')); $this->assertSame('', Link::escapeReference(null)); - $this->assertSame('', Link::escapeReference(' ')); + // A reference made of spaces is written as it is, so that it reads back + // as itself instead of disappearing from the document. + $this->assertSame("' '", Link::escapeReference(' ')); + } + + public function testLinkEscapeReferenceWritesTheEmptyReferenceAsADelimiterPair(): void + { + $this->assertSame('""', Link::escapeReference('')); } public function testLinkSimplify(): void diff --git a/python/links_notation/link.py b/python/links_notation/link.py index 0bf1e36..b287286 100644 --- a/python/links_notation/link.py +++ b/python/links_notation/link.py @@ -89,9 +89,14 @@ def escape_reference(reference: Optional[str]) -> str: Returns: Escaped reference with quotes if needed """ - if not reference or not reference.strip(): + if reference is None: return "" + # The empty reference is written as a bare delimiter pair, so that it + # reads back as itself instead of disappearing from the document. + if reference == "": + return '""' + # Check if single quotes are needed needs_single_quotes = any(c in reference for c in [":", "(", ")", " ", "\t", "\n", "\r", '"']) diff --git a/python/links_notation/parser.py b/python/links_notation/parser.py index 2f42851..65449ff 100644 --- a/python/links_notation/parser.py +++ b/python/links_notation/parser.py @@ -14,6 +14,85 @@ class ParseError(Exception): """Exception raised when parsing fails.""" +QUOTE_CHARS = ('"', "'", "`") + + +def _is_substantive_body(content: str) -> bool: + """ + Report whether a body written between an even run of delimiters is + substantive: it holds at least one visible character and does not straddle + a parenthesis. An even run can always be read as delimiter pairs enclosing + nothing, so the n-quote reading is only taken when it carries something the + pairs cannot. + """ + depth = 0 + has_visible = False + + for char in content: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth < 0: + return False + if not char.isspace(): + has_visible = True + + return has_visible and depth == 0 + + +def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]: + """ + Parse the delimited reference that starts at ``start``. + + Any number N of quotes opens and closes the string, 2*N quotes are an + escaped quote sequence. A run of an even number of delimiters that does not + open a reference with a substantive body is the empty reference: the + shortest reading, a bare delimiter pair enclosing nothing, wins over a + longer n-quote delimiter. + + Returns ``(value, end_position)`` where ``end_position`` is the position + right after the closing quotes, or ``None`` when ``text`` does not start a + delimited reference. + """ + if start >= len(text): + return None + + quote_char = text[start] + if quote_char not in QUOTE_CHARS: + return None + + quote_count = 0 + pos = start + while pos < len(text) and text[pos] == quote_char: + quote_count += 1 + pos += 1 + + is_even_run = quote_count % 2 == 0 + empty_reference = ("", start + quote_count) if is_even_run else None + + open_close = quote_char * quote_count + escape_seq = quote_char * (quote_count * 2) + content = [] + + while pos < len(text): + if text.startswith(escape_seq, pos): + content.append(open_close) + pos += len(escape_seq) + continue + if text.startswith(open_close, pos): + after_close = pos + quote_count + if after_close >= len(text) or text[after_close] != quote_char: + value = "".join(content) + if is_even_run and not _is_substantive_body(value): + return empty_reference + return (value, after_close) + content.append(text[pos]) + pos += 1 + + return empty_reference + + class Parser: """ Parser for Lino notation. @@ -87,37 +166,11 @@ def _skip_quoted_string(self, text: str, start: int) -> int: """ Skip over the quoted string starting at start. - Any number N of quotes opens and closes the string, 2*N quotes are an - escaped quote sequence. Returns the position right after the closing - quotes, or -1 when text does not start a terminated quoted string. + Returns the position right after the closing quotes, or -1 when text + does not start a terminated quoted string. """ - if start >= len(text): - return -1 - - quote_char = text[start] - if quote_char not in ('"', "'", "`"): - return -1 - - quote_count = 0 - pos = start - while pos < len(text) and text[pos] == quote_char: - quote_count += 1 - pos += 1 - - open_close = quote_char * quote_count - escape_seq = quote_char * (quote_count * 2) - - while pos < len(text): - if text.startswith(escape_seq, pos): - pos += len(escape_seq) - continue - if text.startswith(open_close, pos): - after_close = pos + quote_count - if after_close >= len(text) or text[after_close] != quote_char: - return after_close - pos += 1 - - return -1 + parsed = _parse_quoted_string_at(text, start) + return -1 if parsed is None else parsed[1] def _split_lines_respecting_quotes(self, text: str) -> List[str]: """ @@ -381,39 +434,12 @@ def _extract_next_value(self, text: str, start: int) -> tuple: if start >= len(text): return (start, "") - # Check if this starts with a multi-quote string (supports any N quotes) - for quote_char in ['"', "'", "`"]: - if text[start:].startswith(quote_char): - # Count opening quotes dynamically - quote_count = 0 - pos = start - while pos < len(text) and text[pos] == quote_char: - quote_count += 1 - pos += 1 - - if quote_count >= 1: - # Parse this multi-quote string - remaining = text[start:] - open_close = quote_char * quote_count - escape_seq = quote_char * (quote_count * 2) - - inner_pos = len(open_close) - while inner_pos < len(remaining): - # Check for escape sequence (2*N quotes) - if remaining[inner_pos:].startswith(escape_seq): - inner_pos += len(escape_seq) - continue - # Check for closing quotes - if remaining[inner_pos:].startswith(open_close): - after_close_pos = inner_pos + len(open_close) - # Make sure this is exactly N quotes (not more) - if after_close_pos >= len(remaining) or remaining[after_close_pos] != quote_char: - # Found the end - return (start + after_close_pos, remaining[:after_close_pos]) - inner_pos += 1 - - # No closing found, treat as regular text - break + # Check if this starts with a delimited reference (any N quotes, or a + # bare delimiter pair standing for the empty reference) + quoted = _parse_quoted_string_at(text, start) + if quoted is not None: + _, end = quoted + return (end, text[start:end]) # Check if this starts with a parenthesized expression if text[start] == "(": @@ -456,66 +482,14 @@ def _extract_reference(self, text: str) -> str: """Extract reference, handling quoted strings with escaping support.""" text = text.strip() - # Try multi-quote strings (supports any N quotes) - for quote_char in ['"', "'", "`"]: - if text.startswith(quote_char): - # Count opening quotes dynamically - quote_count = 0 - while quote_count < len(text) and text[quote_count] == quote_char: - quote_count += 1 - - if quote_count >= 1 and len(text) > quote_count: - # Try to parse this multi-quote string - result = self._parse_multi_quote_string(text, quote_char, quote_count) - if result is not None: - return result + # Try delimited references (any N quotes, or a bare delimiter pair) + quoted = _parse_quoted_string_at(text, 0) + if quoted is not None: + return quoted[0] # Unquoted return text - def _parse_multi_quote_string(self, text: str, quote_char: str, quote_count: int) -> Optional[str]: - """ - Parse a multi-quote string. - - For N quotes: opening = N quotes, closing = N quotes, escape = 2*N quotes -> N quotes - """ - open_close = quote_char * quote_count - escape_seq = quote_char * (quote_count * 2) - escape_val = quote_char * quote_count - - # Check for opening quotes - if not text.startswith(open_close): - return None - - remaining = text[len(open_close) :] - content = "" - - while remaining: - # Check for escape sequence (2*N quotes) - if remaining.startswith(escape_seq): - content += escape_val - remaining = remaining[len(escape_seq) :] - continue - - # Check for closing quotes (N quotes not followed by more quotes) - if remaining.startswith(open_close): - after_close = remaining[len(open_close) :] - # Make sure this is exactly N quotes (not more) - if not after_close or not after_close.startswith(quote_char): - # Closing found - but only if we consumed the entire text - if not after_close.strip(): - return content - else: - # There's more text after closing, may not be valid - return content - - # Take the next character - content += remaining[0] - remaining = remaining[1:] - - # No closing quotes found - return None - def _transform_result(self, raw_result: List[Dict]) -> List[Link]: """Transform raw parse result into Link objects.""" links = [] diff --git a/python/pyproject.toml b/python/pyproject.toml index 27e6e26..e06bfc4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "links-notation" -version = "0.14.0" +version = "0.15.0" description = "Python implementation of the Links Notation parser" readme = "README.md" license = {text = "Unlicense"} diff --git a/python/tests/test_empty_reference.py b/python/tests/test_empty_reference.py new file mode 100644 index 0000000..44ce61f --- /dev/null +++ b/python/tests/test_empty_reference.py @@ -0,0 +1,91 @@ +"""Conformance tests for the empty reference. + +https://github.com/link-foundation/links-notation/issues/288 + +A bare delimiter pair is the empty reference. The three delimiters ``"``, ``'`` +and ``` ` ``` behave identically, and every longer n-quote run keeps the meaning +it already had. The table below is shared with the Rust, JavaScript, Go, C#, +Java and PHP suites, so a document written by one implementation reads the same +in all of them. +""" + +from links_notation import Parser, format_links + + +def render(node): + """Render a parsed node unambiguously, so an empty reference shows as <>.""" + if not node.values: + return "<%s>" % ("" if node.id is None else node.id) + head = "" if node.id is None else "<%s>: " % node.id + return "(%s%s)" % (head, " ".join(render(value) for value in node.values)) + + +def rendered(source): + return "\n".join(render(link) for link in Parser().parse(source)) + + +def assert_parses_as(source, expected): + assert rendered(source) == expected, "Parsing %r" % source + + +class TestEmptyReference: + """The bare delimiter pair reads as the empty reference.""" + + def test_bare_delimiter_pair_is_the_empty_reference(self): + assert_parses_as('(a "" b)', "( <> )") + + def test_every_delimiter_style_yields_the_same_empty_reference(self): + assert_parses_as('(a "" b)', "( <> )") + assert_parses_as("(a '' b)", "( <> )") + assert_parses_as("(a `` b)", "( <> )") + + def test_adjacent_empty_references_stay_separate(self): + assert_parses_as('(a "" "" b)', "( <> <> )") + assert_parses_as("(a '' '' b)", "( <> <> )") + assert_parses_as("(a `` `` b)", "( <> <> )") + assert_parses_as('(a "" "" b)', "( <> <> )") + + def test_nested_empty_references_parse(self): + assert_parses_as('("" ("" 1))', "(<> (<> <1>))") + assert_parses_as("(\"\" ('' 1))", "(<> (<> <1>))") + assert_parses_as('("x" ("" 1))', "( (<> <1>))") + assert_parses_as('("" ("x" 1))', "(<> ( <1>))") + assert_parses_as('("" x ("" 1))', "(<> (<> <1>))") + assert_parses_as('("" 1 ("" 1))', "(<> <1> (<> <1>))") + + def test_empty_reference_is_valid_as_an_id(self): + assert_parses_as('("": 1)', "(<>: <1>)") + assert_parses_as('(o: ("" (o: ("" 1))))', "(: (<> (: (<> <1>))))") + + +class TestNQuoteMeaningsSurvive: + """Only the bare pair changes; every existing n-quote meaning is kept.""" + + def test_n_quote_delimited_bodies_are_unchanged(self): + assert_parses_as('(a ""x"" b)', "( )") + assert_parses_as('(x "" " "")', '( < " >)') + assert_parses_as("(x ' \" ')", '( < " >)') + + def test_n_quote_delimited_empty_is_still_empty(self): + assert_parses_as('(a """" b)', "( <> )") + + def test_a_single_space_still_reads_as_a_space(self): + assert_parses_as('(a " " b)', "( < > )") + + +class TestEmptyReferenceFormatting: + """The empty reference is written so that it reads back as itself.""" + + def test_empty_reference_is_written_as_a_delimiter_pair(self): + assert format_links(Parser().parse('(a "" b)')) == '(a "" b)' + + def test_empty_reference_survives_a_round_trip(self): + for source in [ + '(a "" b)', + '(a "" "" b)', + '("" ("" 1))', + '("": 1)', + '(o: ("" (o: ("" 1))))', + ]: + formatted = format_links(Parser().parse(source)) + assert format_links(Parser().parse(formatted)) == formatted, source diff --git a/rust/links-notation/Cargo.toml b/rust/links-notation/Cargo.toml index ba58c82..6e73db6 100644 --- a/rust/links-notation/Cargo.toml +++ b/rust/links-notation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "links-notation" -version = "0.14.0" +version = "0.15.0" edition = "2021" description = "Rust implementation of the Links Notation parser" license = "Unlicense" diff --git a/rust/links-notation/src/lib.rs b/rust/links-notation/src/lib.rs index faa1ae4..680b217 100644 --- a/rust/links-notation/src/lib.rs +++ b/rust/links-notation/src/lib.rs @@ -348,11 +348,27 @@ impl LiNo { impl fmt::Display for LiNo { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - LiNo::Ref(value) => write!(f, "{}", value.to_string()), + // The empty reference is written as a bare delimiter pair; writing it + // as nothing would drop it from the document. + LiNo::Ref(value) => { + let value = value.to_string(); + if value.is_empty() { + write!(f, "\"\"") + } else { + write!(f, "{}", value) + } + } LiNo::Link { id, values } => { let id_str = id .as_ref() - .map(|id| format!("{}: ", id.to_string())) + .map(|id| { + let id = id.to_string(); + if id.is_empty() { + "\"\": ".to_string() + } else { + format!("{}: ", id) + } + }) .unwrap_or_default(); if f.alternate() { @@ -743,8 +759,10 @@ fn group_consecutive_links(links: &[LiNo]) -> Vec> { /// Escape a reference string by adding quotes if necessary. fn escape_reference(reference: &str) -> String { - if reference.is_empty() || reference.trim().is_empty() { - return String::new(); + // The empty reference is written as a bare delimiter pair, so that it reads + // back as itself instead of disappearing from the document. + if reference.is_empty() { + return "\"\"".to_string(); } let has_single_quote = reference.contains('\''); diff --git a/rust/links-notation/src/parser.rs b/rust/links-notation/src/parser.rs index bc44560..095b5bf 100644 --- a/rust/links-notation/src/parser.rs +++ b/rust/links-notation/src/parser.rs @@ -246,8 +246,39 @@ fn parse_multi_quote_string( } } +/// A body written between an even run of delimiters is substantive when it +/// holds at least one visible character and does not straddle a parenthesis. +/// An even run can always be read as delimiter pairs enclosing nothing, so the +/// n-quote reading is only taken when it carries something the pairs cannot. +fn is_substantive_body(content: &str) -> bool { + let mut depth: isize = 0; + let mut has_visible = false; + + for c in content.chars() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth < 0 { + return false; + } + } + _ => {} + } + if !c.is_whitespace() { + has_visible = true; + } + } + + has_visible && depth == 0 +} + /// Parse a quoted string with dynamically detected quote count. -/// Counts opening quotes and uses that count for parsing. +/// +/// Counts opening quotes and uses that count for parsing. A run of an even +/// number of delimiters that does not open a reference with a substantive body +/// is the empty reference: the shortest reading, a bare delimiter pair +/// enclosing nothing, wins over a longer n-quote delimiter. fn parse_dynamic_quote_string(input: &str, quote_char: char) -> IResult<&str, String> { // Count opening quotes let quote_count = input.chars().take_while(|&c| c == quote_char).count(); @@ -259,7 +290,22 @@ fn parse_dynamic_quote_string(input: &str, quote_char: char) -> IResult<&str, St ))); } - parse_multi_quote_string(input, quote_char, quote_count) + let is_even_run = quote_count % 2 == 0; + + if let Ok((rest, content)) = parse_multi_quote_string(input, quote_char, quote_count) { + if !is_even_run || is_substantive_body(&content) { + return Ok((rest, content)); + } + } + + if is_even_run { + return Ok((&input[quote_count * quote_char.len_utf8()..], String::new())); + } + + Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))) } fn double_quoted_dynamic(input: &str) -> IResult<&str, String> { diff --git a/rust/links-notation/tests/empty_reference_tests.rs b/rust/links-notation/tests/empty_reference_tests.rs new file mode 100644 index 0000000..10fa813 --- /dev/null +++ b/rust/links-notation/tests/empty_reference_tests.rs @@ -0,0 +1,109 @@ +//! Conformance tests for the empty reference (issue #288). +//! +//! A bare delimiter pair is the empty reference. The three delimiters `"`, `'` +//! and `` ` `` behave identically, and every longer n-quote run keeps the +//! meaning it already had. The table below is shared with the JavaScript, +//! Python, C#, Go, Java and PHP suites, so a document written by one +//! implementation reads the same in all of them. + +use links_notation::{format_links, parse_lino_to_links, LiNo}; + +/// Render a parsed node unambiguously: every reference is wrapped in angle +/// brackets so an empty one is visible as `<>`. +fn render(node: &LiNo) -> String { + match node { + LiNo::Ref(id) => format!("<{}>", id), + LiNo::Link { id, values } => { + let head = id + .as_ref() + .map(|id| format!("<{}>: ", id)) + .unwrap_or_default(); + let body = values.iter().map(render).collect::>().join(" "); + format!("({}{})", head, body) + } + } +} + +fn rendered(input: &str) -> String { + let links = parse_lino_to_links(input) + .unwrap_or_else(|e| panic!("expected {:?} to parse, got {}", input, e)); + links.iter().map(render).collect::>().join("\n") +} + +fn assert_parses_as(input: &str, expected: &str) { + assert_eq!(rendered(input), expected, "input: {}", input); +} + +#[test] +fn bare_delimiter_pair_is_the_empty_reference() { + assert_parses_as(r#"(a "" b)"#, "( <> )"); +} + +#[test] +fn every_delimiter_style_yields_the_same_empty_reference() { + assert_parses_as(r#"(a "" b)"#, "( <> )"); + assert_parses_as(r#"(a '' b)"#, "( <> )"); + assert_parses_as("(a `` b)", "( <> )"); +} + +#[test] +fn adjacent_empty_references_stay_separate() { + assert_parses_as(r#"(a "" "" b)"#, "( <> <> )"); + assert_parses_as(r#"(a '' '' b)"#, "( <> <> )"); + assert_parses_as("(a `` `` b)", "( <> <> )"); + assert_parses_as(r#"(a "" "" b)"#, "( <> <> )"); +} + +#[test] +fn nested_empty_references_parse() { + assert_parses_as(r#"("" ("" 1))"#, "(<> (<> <1>))"); + assert_parses_as(r#"("" ('' 1))"#, "(<> (<> <1>))"); + assert_parses_as(r#"("x" ("" 1))"#, "( (<> <1>))"); + assert_parses_as(r#"("" ("x" 1))"#, "(<> ( <1>))"); + assert_parses_as(r#"("" x ("" 1))"#, "(<> (<> <1>))"); + assert_parses_as(r#"("" 1 ("" 1))"#, "(<> <1> (<> <1>))"); +} + +#[test] +fn empty_reference_is_valid_as_an_id() { + assert_parses_as(r#"("": 1)"#, "(<>: <1>)"); + assert_parses_as(r#"(o: ("" (o: ("" 1))))"#, "(: (<> (: (<> <1>))))"); +} + +#[test] +fn n_quote_delimited_bodies_are_unchanged() { + // A run that encloses a substantive body keeps its n-quote meaning. + assert_parses_as(r#"(a ""x"" b)"#, "( )"); + assert_parses_as(r#"(x "" " "")"#, r#"( < " >)"#); + assert_parses_as(r#"(x ' " ')"#, r#"( < " >)"#); + // An n-quote-delimited empty is still empty. + assert_parses_as(r#"(a """" b)"#, "( <> )"); +} + +#[test] +fn a_single_space_still_reads_as_a_space() { + assert_parses_as(r#"(a " " b)"#, "( < > )"); +} + +#[test] +fn empty_reference_survives_a_round_trip() { + for input in [ + r#"(a "" b)"#, + r#"(a "" "" b)"#, + r#"("" ("" 1))"#, + r#"("": 1)"#, + r#"(o: ("" (o: ("" 1))))"#, + ] { + let links = parse_lino_to_links(input).expect("parses"); + let formatted = format_links(&links); + let reparsed = parse_lino_to_links(&formatted) + .unwrap_or_else(|e| panic!("formatted {:?} did not parse: {}", formatted, e)); + assert_eq!(links, reparsed, "round trip changed {:?}", input); + } +} + +#[test] +fn empty_reference_is_written_as_a_delimiter_pair() { + let links = parse_lino_to_links(r#"(a "" b)"#).expect("parses"); + assert_eq!(format_links(&links), r#"(a "" b)"#); +} diff --git a/rust/links-notation/tests/tuple_tests.rs b/rust/links-notation/tests/tuple_tests.rs index f41d583..0b30b78 100644 --- a/rust/links-notation/tests/tuple_tests.rs +++ b/rust/links-notation/tests/tuple_tests.rs @@ -142,8 +142,9 @@ fn test_empty_string_tuple() { // Test tuple with empty strings let link: LiNo = ("", "").into(); let result = format!("{}", link); - // Empty strings should result in empty link representation - assert_eq!(result, "(: )"); + // The empty reference is written as a bare delimiter pair, so the link reads + // back as itself instead of losing its id and value. + assert_eq!(result, "(\"\": \"\")"); } #[test]