Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitkeep
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
129 changes: 129 additions & 0 deletions csharp/Link.Foundation.Links.Notation.Tests/EmptyReferenceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Linq;
using Xunit;

namespace Link.Foundation.Links.Notation.Tests
{
/// <summary>
/// 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
/// <c>"</c>, <c>'</c> and <c>`</c> 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.
/// </summary>
public static class EmptyReferenceTests
{
/// <summary>
/// Renders a parsed node unambiguously: every reference is wrapped in
/// angle brackets so an empty one is visible as &lt;&gt;.
/// </summary>
private static string Render(Link<string> 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)", "(<a> <> <b>)");
}

[Fact]
public static void EveryDelimiterStyleYieldsTheSameEmptyReference()
{
AssertParsesAs("(a \"\" b)", "(<a> <> <b>)");
AssertParsesAs("(a '' b)", "(<a> <> <b>)");
AssertParsesAs("(a `` b)", "(<a> <> <b>)");
}

[Fact]
public static void AdjacentEmptyReferencesStaySeparate()
{
AssertParsesAs("(a \"\" \"\" b)", "(<a> <> <> <b>)");
AssertParsesAs("(a '' '' b)", "(<a> <> <> <b>)");
AssertParsesAs("(a `` `` b)", "(<a> <> <> <b>)");
AssertParsesAs("(a \"\" \"\" b)", "(<a> <> <> <b>)");
}

[Fact]
public static void NestedEmptyReferencesParse()
{
AssertParsesAs("(\"\" (\"\" 1))", "(<> (<> <1>))");
AssertParsesAs("(\"\" ('' 1))", "(<> (<> <1>))");
AssertParsesAs("(\"x\" (\"\" 1))", "(<x> (<> <1>))");
AssertParsesAs("(\"\" (\"x\" 1))", "(<> (<x> <1>))");
AssertParsesAs("(\"\" x (\"\" 1))", "(<> <x> (<> <1>))");
AssertParsesAs("(\"\" 1 (\"\" 1))", "(<> <1> (<> <1>))");
}

[Fact]
public static void EmptyReferenceIsValidAsAnId()
{
AssertParsesAs("(\"\": 1)", "(<>: <1>)");
AssertParsesAs("(o: (\"\" (o: (\"\" 1))))", "(<o>: (<> (<o>: (<> <1>))))");
}

[Fact]
public static void NQuoteDelimitedBodiesAreUnchanged()
{
// A run that encloses a substantive body keeps its n-quote meaning.
AssertParsesAs("(a \"\"x\"\" b)", "(<a> <x> <b>)");
AssertParsesAs("(x \"\" \" \"\")", "(<x> < \" >)");
AssertParsesAs("(x ' \" ')", "(<x> < \" >)");
// An n-quote-delimited empty is still empty.
AssertParsesAs("(a \"\"\"\" b)", "(<a> <> <b>)");
}

[Fact]
public static void ASingleSpaceStillReadsAsASpace()
{
AssertParsesAs("(a \" \" b)", "(<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<Link<string>>)new Parser().Parse(source)).Format();
var reparsed = ((IList<Link<string>>)new Parser().Parse(formatted)).Format();
Assert.Equal(formatted, reparsed);
}
}

[Fact]
public static void EmptyReferenceIsWrittenAsADelimiterPair()
{
var links = (IList<Link<string>>)new Parser().Parse("(a \"\" b)");
Assert.Equal("(a \"\" b)", links.Format());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<Description>Link.Foundation's Platform.Protocols.Lino Class Library</Description>
<Copyright>Konstantin Diachenko</Copyright>
<AssemblyTitle>Link.Foundation.Links.Notation</AssemblyTitle>
<VersionPrefix>0.14.0</VersionPrefix>
<VersionPrefix>0.15.0</VersionPrefix>
<Authors>Konstantin Diachenko</Authors>
<TargetFramework>net8</TargetFramework>
<PackageId>Link.Foundation.Links.Notation</PackageId>
Expand Down
8 changes: 7 additions & 1 deletion csharp/Link.Foundation.Links.Notation/Link.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,16 @@ public Link<TLinkAddress> Simplify()
/// <returns>The escaped reference string with appropriate quoting.</returns>
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("(") ||
Expand Down
Loading
Loading