Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ public static void IndentedIdSyntaxWithQuotedIdTest()
var parser = new Parser();
var result = parser.Parse(input);
var formatted = result.Format();

Assert.Equal("('complex id': value1 value2)", formatted);
// Multi-reference support: spaces alone do NOT require quoting on output
Assert.Equal("(complex id: value1 value2)", formatted);
}

[Fact]
Expand Down
3 changes: 2 additions & 1 deletion csharp/Link.Foundation.Links.Notation.Tests/LinkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ public static void LinkEscapeReferenceSimpleTest()
[Fact]
public static void LinkEscapeReferenceWithSpecialCharactersTest()
{
Assert.Equal("'has space'", Link<string>.EscapeReference("has space"));
// Multi-reference support: spaces alone do NOT require quoting
Assert.Equal("has space", Link<string>.EscapeReference("has space"));
Assert.Equal("'has:colon'", Link<string>.EscapeReference("has:colon"));
Assert.Equal("'has(paren)'", Link<string>.EscapeReference("has(paren)"));
Assert.Equal("'has)paren'", Link<string>.EscapeReference("has)paren"));
Expand Down
128 changes: 128 additions & 0 deletions csharp/Link.Foundation.Links.Notation.Tests/MultiRefTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using Xunit;

namespace Link.Foundation.Links.Notation.Tests
{
/// <summary>
/// Multi-Reference Feature Tests (Issue #184)
/// Tests for multi-word references without quotes:
/// - (some example: some example is a link)
/// - ID as multi-word string: "some example"
/// </summary>
public static class MultiRefTests
{
[Fact]
public static void ParsesTwoWordMultiReferenceId()
{
var parser = new Parser();
var result = parser.Parse("(some example: value)");
Assert.Single(result);
// Multi-word ID should be joined with space
Assert.Equal("some example", result[0].Id);
Assert.Single(result[0].Values);
}

[Fact]
public static void ParsesThreeWordMultiReferenceId()
{
var parser = new Parser();
var result = parser.Parse("(new york city: value)");
Assert.Single(result);
Assert.Equal("new york city", result[0].Id);
}

[Fact]
public static void ParsesSingleWordIdBackwardCompatible()
{
var parser = new Parser();
var result = parser.Parse("(papa: value)");
Assert.Single(result);
Assert.Equal("papa", result[0].Id);
}

[Fact]
public static void ParsesQuotedMultiWordIdBackwardCompatible()
{
var parser = new Parser();
var result = parser.Parse("('some example': value)");
Assert.Single(result);
// Quoted ID should be preserved as-is
Assert.Equal("some example", result[0].Id);
}

[Fact]
public static void FormatMultiReferenceId()
{
var parser = new Parser();
var result = parser.Parse("(some example: value)");
var formatted = result.Format();
// Multi-reference IDs are formatted without quotes
Assert.Equal("(some example: value)", formatted);
}

[Fact]
public static void RoundTripMultiReference()
{
var parser = new Parser();
var input = "(new york city: great)";
var result = parser.Parse(input);
var formatted = result.Format();
// Round-trip preserves the multi-word ID structure
Assert.Equal("(new york city: great)", formatted);
}

[Fact]
public static void ParsesIndentedSyntaxMultiReference()
{
var parser = new Parser();
var input = "some example:\n value1\n value2";
var result = parser.Parse(input);
Assert.Single(result);
Assert.Equal("some example", result[0].Id);
Assert.Equal(2, result[0].Values?.Count);
}

[Fact]
public static void BackwardCompatibilitySingleLine()
{
var parser = new Parser();
var result = parser.Parse("papa: loves mama");
Assert.Single(result);
Assert.Equal("papa", result[0].Id);
Assert.Equal(2, result[0].Values?.Count);
}

[Fact]
public static void BackwardCompatibilityParenthesized()
{
var parser = new Parser();
var result = parser.Parse("(papa: loves mama)");
Assert.Single(result);
Assert.Equal("papa", result[0].Id);
Assert.Equal(2, result[0].Values?.Count);
}

[Fact]
public static void BackwardCompatibilityNested()
{
var parser = new Parser();
var result = parser.Parse("(outer: (inner: value))");
Assert.Single(result);
Assert.Equal("outer", result[0].Id);
Assert.Single(result[0].Values);
Assert.Equal("inner", result[0].Values?[0].Id);
}

[Fact]
public static void MultiRefWithMultipleValues()
{
var parser = new Parser();
var result = parser.Parse("(some example: one two three)");
Assert.Single(result);
Assert.Equal("some example", result[0].Id);
Assert.Equal(3, result[0].Values?.Count);
Assert.Equal("one", result[0].Values?[0].Id);
Assert.Equal("two", result[0].Values?[1].Id);
Assert.Equal("three", result[0].Values?[2].Id);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
public static void QuotedReferencesWithSpacesTest()
{
var source = @"('a a': 'b b' ""c c"")";
var target = @"('a a': 'b b' 'c c')";
// Multi-reference support: spaces alone do NOT require quoting on output
var target = @"(a a: b b c c)";
var parser = new Parser();
var links = parser.Parse(source);
var formattedLinks = links.Format();
Expand All @@ -67,7 +68,7 @@
// Simple reference creates a singlet link with null Id and one value
Assert.Null(links[0].Id);
Assert.NotNull(links[0].Values);
Assert.Single(links[0].Values);

Check warning on line 71 in csharp/Link.Foundation.Links.Notation.Tests/SingleLineParserTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'collection' in 'Link<string> Assert.Single<Link<string>>(IEnumerable<Link<string>> collection)'.
Assert.Equal("test", links[0].Values?[0].Id);
Assert.Null(links[0].Values?[0].Values);
}
Expand Down Expand Up @@ -118,7 +119,7 @@
Assert.Single(result);
Assert.Null(result[0].Id);
Assert.NotNull(result[0].Values);
Assert.Single(result[0].Values);

Check warning on line 122 in csharp/Link.Foundation.Links.Notation.Tests/SingleLineParserTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'collection' in 'Link<string> Assert.Single<Link<string>>(IEnumerable<Link<string>> collection)'.
Assert.Equal("singlet", result[0].Values?[0].Id);
Assert.Null(result[0].Values?[0].Values);
}
Expand Down Expand Up @@ -164,7 +165,8 @@
Assert.Equal("has space", links[0].Values![0].Id);
Assert.Equal("has:colon", links[0].Values![1].Id);
var formatted = links.Format();
Assert.Equal("('has space' 'has:colon')", formatted);
// Multi-reference support: spaces alone do NOT require quoting on output
Assert.Equal("(has space 'has:colon')", formatted);
}

[Fact]
Expand Down
5 changes: 4 additions & 1 deletion csharp/Link.Foundation.Links.Notation/Link.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ public Link<TLinkAddress> Simplify()

/// <summary>
/// Escapes a reference string for safe use in Links Notation format by adding quotes if necessary.
/// Multi-word references (space-separated simple words) are NOT quoted to support multi-reference syntax.
/// </summary>
/// <param name="reference">The reference string to escape.</param>
/// <returns>The escaped reference string with appropriate quoting.</returns>
Expand All @@ -142,11 +143,12 @@ public static string EscapeReference(string? reference)
{
return "";
}
// Check for special characters that require quoting
// Note: spaces alone do NOT require quoting (multi-reference support)
if (
reference.Contains(":") ||
reference.Contains("(") ||
reference.Contains(")") ||
reference.Contains(" ") ||
reference.Contains("\t") ||
reference.Contains("\n") ||
reference.Contains("\r") ||
Expand All @@ -161,6 +163,7 @@ public static string EscapeReference(string? reference)
}
else
{
// Multi-word references and simple references are returned as-is
return reference;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
if (link.Values == null || link.Values.Count == 0)
{
var escapedId = Link<TLinkAddress>.EscapeReference(link.Id?.ToString());
return options.LessParentheses && !NeedsParentheses(link.Id?.ToString()) ?

Check warning on line 33 in csharp/Link.Foundation.Links.Notation/LinkFormatExtensions.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 's' in 'bool LinkFormatExtensions.NeedsParentheses(string s)'.
escapedId :
$"({escapedId})";
}
Expand Down Expand Up @@ -89,7 +89,7 @@
// Link with ID and values
var id = Link<TLinkAddress>.EscapeReference(link.Id.ToString());
var withColon = $"{id}: {values}";
return options.LessParentheses && !NeedsParentheses(link.Id?.ToString()) ?

Check warning on line 92 in csharp/Link.Foundation.Links.Notation/LinkFormatExtensions.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 's' in 'bool LinkFormatExtensions.NeedsParentheses(string s)'.
withColon :
$"({withColon})";
}
Expand All @@ -102,7 +102,7 @@
if (link.Id == null)
{
// Values only - format each on separate line
var lines = link.Values.Select(v => options.IndentString + GetValueString(v));

Check warning on line 105 in csharp/Link.Foundation.Links.Notation/LinkFormatExtensions.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'source' in 'IEnumerable<string> Enumerable.Select<Link<TLinkAddress>, string>(IEnumerable<Link<TLinkAddress>> source, Func<Link<TLinkAddress>, string> selector)'.
return string.Join(Environment.NewLine, lines);
}

Expand All @@ -111,7 +111,7 @@
var sb = new StringBuilder();
sb.Append($"{idStr}:");

foreach (var v in link.Values)

Check warning on line 114 in csharp/Link.Foundation.Links.Notation/LinkFormatExtensions.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.
{
sb.Append(Environment.NewLine);
sb.Append(options.IndentString);
Expand All @@ -131,10 +131,11 @@

/// <summary>
/// Check if a string needs to be wrapped in parentheses.
/// Note: spaces alone don't require parentheses (multi-reference support).
/// </summary>
private static bool NeedsParentheses(string s)
{
return s != null && (s.Contains(" ") || s.Contains(":") || s.Contains("(") || s.Contains(")"));
return s != null && (s.Contains(":") || s.Contains("(") || s.Contains(")"));
}
}
}
11 changes: 8 additions & 3 deletions csharp/Link.Foundation.Links.Notation/Parser.peg
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,16 @@ multiLineValueAndWhitespace <Link<string>> = value:referenceOrLink _ { value }
multiLineValues <IList<Link<string>>> = _ list:multiLineValueAndWhitespace* { list }
singleLineValueAndWhitespace <Link<string>> = __ value:referenceOrLink { value }
singleLineValues <IList<Link<string>>> = list:singleLineValueAndWhitespace+ { list }
singleLineLink <Link<string>> = __ id:(reference) __ ":" v:singleLineValues { new Link<string>(id, v) }
multiLineLink <Link<string>> = "(" _ id:(reference) _ ":" v:multiLineValues _ ")" { new Link<string>(id, v) }
singleLineLink <Link<string>> = __ id:multiRefId __ ":" v:singleLineValues { new Link<string>(id, v) }
multiLineLink <Link<string>> = "(" _ id:multiRefId _ ":" v:multiLineValues _ ")" { new Link<string>(id, v) }
singleLineValueLink <Link<string>> = v:singleLineValues { new Link<string>(v) }
multiLineValueLink <Link<string>> = "(" v:multiLineValues _ ")" { new Link<string>(v) }
indentedIdLink <Link<string>> = id:(reference) __ ":" eol { new Link<string>(id) }
indentedIdLink <Link<string>> = id:multiRefId __ ":" eol { new Link<string>(id) }

// Multi-reference ID: space-separated words before colon (joined with space)
// For backward compatibility, single word remains as-is
multiRefId <string> = refs:multiRefIdParts { string.Join(" ", refs) }
multiRefIdParts <IList<string>> = first:reference rest:(__ !(":" / eol / ")") r:reference { r })* { new List<string> { first }.Concat(rest).ToList() }

// 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
Expand Down
61 changes: 61 additions & 0 deletions experiments/multi_reference_design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Multi-Reference Feature Design (Issue #184)

## Overview

This document outlines the design for supporting multi-references in Links Notation.

## Current Behavior

```
Input: (papa: loves mama)
Parsed: Link(id="papa", values=[Ref("loves"), Ref("mama")])
```

For multi-word references, quoting is required:
```
Input: ('some example': value)
Parsed: Link(id="some example", values=[Ref("value")])
```

## Proposed Behavior

### Multi-Reference Definition

When a colon appears after multiple space-separated words, those words form a multi-reference:

```
Input: (some example: some example is a link)
Parsed: Link(id=["some", "example"], values=[MultiRef(["some", "example"]), Ref("is"), Ref("a"), Ref("link")])
```

### Key Changes

1. **ID field becomes an array**:
- Single-word: `id = ["papa"]`
- Multi-word: `id = ["some", "example"]`

2. **Values remain an array** but can contain multi-references:
- `values = [MultiRef(["some", "example"]), Ref("is"), ...]`

3. **Context-aware parsing**:
- First pass: Identify all multi-reference definitions (IDs before colons)
- Second pass: When parsing values, check if consecutive tokens form a known multi-reference

## Implementation Strategy

### Phase 1: Data Structure Changes
- Change `id` from `string | null` to `string[] | null`
- Add helper methods for multi-reference comparison

### Phase 2: Parser Changes
- Collect multi-reference definitions during parsing
- When parsing values, check for multi-reference matches

### Phase 3: Formatter Changes
- Format multi-word IDs without quotes (when possible)
- Preserve backward compatibility with quoted strings

## Backward Compatibility

- Quoted strings (`'some example'`) still work as single-token references
- Single-word IDs work the same way: `papa` -> `id = ["papa"]`
Loading
Loading