)")
+}
+
+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)"#, "(