Skip to content

Commit 5ecc9f5

Browse files
authored
Merge pull request #18 from link-foundation/issue-17-ace5a094a724
Add indented Links Notation format support (#17)
2 parents 6aeadee + 1412114 commit 5ecc9f5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2179
-123
lines changed

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ All implementations share the same design philosophy and provide feature parity.
3535
- **JSON/Lino Conversion**: Convert between JSON and Links Notation (JavaScript)
3636
- **Reference Escaping**: Properly escape strings for Links Notation format (JavaScript)
3737
- **Fuzzy Matching**: String similarity utilities for finding matches (JavaScript)
38+
- **Indented Format**: Human-readable indented Links Notation format for display and debugging
3839

3940
## Quick Start
4041

@@ -256,6 +257,69 @@ var data = new Dictionary<string, object?>
256257
var decoded = Codec.Decode(Codec.Encode(data));
257258
```
258259

260+
### Indented Links Notation Format
261+
262+
The indented format provides a human-readable representation for displaying objects:
263+
264+
**JavaScript:**
265+
```javascript
266+
import { formatIndented, parseIndented } from 'lino-objects-codec';
267+
268+
// Format an object with an identifier
269+
const formatted = formatIndented({
270+
id: '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019',
271+
obj: { uuid: '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019', status: 'executed', command: 'echo test', exitCode: '0' }
272+
});
273+
console.log(formatted);
274+
// Output:
275+
// 6dcf4c1b-ff3f-482c-95ab-711ea7d1b019
276+
// uuid "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019"
277+
// status "executed"
278+
// command "echo test"
279+
// exitCode "0"
280+
281+
// Parse it back
282+
const { id, obj } = parseIndented({ text: formatted });
283+
```
284+
285+
**Python:**
286+
```python
287+
from link_notation_objects_codec import format_indented, parse_indented
288+
289+
# Format an object with an identifier
290+
formatted = format_indented(
291+
'6dcf4c1b-ff3f-482c-95ab-711ea7d1b019',
292+
{'uuid': '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019', 'status': 'executed'}
293+
)
294+
295+
# Parse it back
296+
id, obj = parse_indented(formatted)
297+
```
298+
299+
**Rust:**
300+
```rust
301+
use lino_objects_codec::format::{format_indented_ordered, parse_indented};
302+
303+
// Format an object with an identifier
304+
let pairs = [("status", "executed"), ("exitCode", "0")];
305+
let formatted = format_indented_ordered("my-uuid", &pairs, " ").unwrap();
306+
307+
// Parse it back
308+
let (id, obj) = parse_indented(&formatted).unwrap();
309+
```
310+
311+
**C#:**
312+
```csharp
313+
using Lino.Objects.Codec;
314+
315+
// Format an object with an identifier
316+
var obj = new Dictionary<string, string?> { { "status", "executed" }, { "exitCode", "0" } };
317+
var formatted = Format.FormatIndented("my-uuid", obj);
318+
319+
// Parse it back
320+
var (id, parsedObj) = Format.ParseIndented(formatted);
321+
```
322+
259323
## How It Works
260324

261325
The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. Each object is encoded as a Link with type information:

csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<PackageId>Lino.Objects.Codec</PackageId>
8-
<Version>0.1.0</Version>
8+
<Version>0.2.0</Version>
99
<Authors>Link Foundation</Authors>
1010
<Description>A library to encode/decode objects to/from links notation</Description>
1111
<PackageLicenseExpression>Unlicense</PackageLicenseExpression>

csharp/src/Lino.Objects.Codec/ObjectCodec.cs

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,308 @@ public static class Codec
609609
/// <returns>Reconstructed C# object</returns>
610610
public static object? Decode(string notation) => new ObjectCodec().Decode(notation);
611611
}
612+
613+
/// <summary>
614+
/// Formatting utilities for indented Links Notation format.
615+
/// </summary>
616+
public static class Format
617+
{
618+
/// <summary>
619+
/// Escape a reference for Links Notation.
620+
/// References need escaping when they contain spaces, quotes, parentheses, colons, or newlines.
621+
/// </summary>
622+
/// <param name="value">The value to escape</param>
623+
/// <returns>The escaped reference string</returns>
624+
public static string EscapeReference(string value)
625+
{
626+
// Check if escaping is needed
627+
bool needsEscaping = value.Any(c => char.IsWhiteSpace(c) || c == '(' || c == ')' || c == '\'' || c == '"' || c == ':')
628+
|| value.Contains('\n');
629+
630+
if (!needsEscaping)
631+
{
632+
return value;
633+
}
634+
635+
bool hasSingle = value.Contains('\'');
636+
bool hasDouble = value.Contains('"');
637+
638+
// If contains single quotes but not double quotes, use double quotes
639+
if (hasSingle && !hasDouble)
640+
{
641+
return $"\"{value}\"";
642+
}
643+
644+
// If contains double quotes but not single quotes, use single quotes
645+
if (hasDouble && !hasSingle)
646+
{
647+
return $"'{value}'";
648+
}
649+
650+
// If contains both quotes, count which one appears more
651+
if (hasSingle && hasDouble)
652+
{
653+
int singleCount = value.Count(c => c == '\'');
654+
int doubleCount = value.Count(c => c == '"');
655+
656+
if (doubleCount < singleCount)
657+
{
658+
// Use double quotes, escape internal double quotes by doubling
659+
var escaped = value.Replace("\"", "\"\"");
660+
return $"\"{escaped}\"";
661+
}
662+
else
663+
{
664+
// Use single quotes, escape internal single quotes by doubling
665+
var escaped = value.Replace("'", "''");
666+
return $"'{escaped}'";
667+
}
668+
}
669+
670+
// Just spaces or other special characters, use single quotes by default
671+
return $"'{value}'";
672+
}
673+
674+
/// <summary>
675+
/// Unescape a reference from Links Notation format.
676+
/// Reverses the escaping done by EscapeReference.
677+
/// </summary>
678+
/// <param name="str">The escaped reference string</param>
679+
/// <returns>The unescaped string</returns>
680+
public static string UnescapeReference(string str)
681+
{
682+
if (str is null) return str!;
683+
684+
// Unescape doubled quotes
685+
return str.Replace("\"\"", "\"").Replace("''", "'");
686+
}
687+
688+
// Shared parser instance for ParseIndented
689+
private static readonly Parser SharedParser = new();
690+
691+
/// <summary>
692+
/// Format a value for display in indented Links Notation.
693+
/// Uses quoting strategy compatible with the links-notation parser:
694+
/// - If value contains double quotes, wrap in single quotes
695+
/// - Otherwise, wrap in double quotes
696+
/// </summary>
697+
private static string FormatIndentedValue(string? value)
698+
{
699+
if (value is null)
700+
{
701+
return "\"null\"";
702+
}
703+
704+
bool hasSingle = value.Contains('\'');
705+
bool hasDouble = value.Contains('"');
706+
707+
// If contains double quotes but no single quotes, use single quotes
708+
if (hasDouble && !hasSingle)
709+
{
710+
return $"'{value}'";
711+
}
712+
713+
// If contains single quotes but no double quotes, use double quotes
714+
if (hasSingle && !hasDouble)
715+
{
716+
return $"\"{value}\"";
717+
}
718+
719+
// If contains both, use single quotes and escape internal single quotes
720+
if (hasSingle && hasDouble)
721+
{
722+
var escaped = value.Replace("'", "''");
723+
return $"'{escaped}'";
724+
}
725+
726+
// Default: use double quotes
727+
return $"\"{value}\"";
728+
}
729+
730+
/// <summary>
731+
/// Format an object in indented Links Notation format.
732+
///
733+
/// This format is designed for human readability, displaying objects as:
734+
/// <code>
735+
/// &lt;identifier&gt;
736+
/// &lt;key&gt; "&lt;value&gt;"
737+
/// &lt;key&gt; "&lt;value&gt;"
738+
/// ...
739+
/// </code>
740+
/// </summary>
741+
/// <param name="id">The object identifier (displayed on first line)</param>
742+
/// <param name="obj">The dictionary with key-value pairs to format</param>
743+
/// <param name="indent">The indentation string (default: 2 spaces)</param>
744+
/// <returns>Formatted indented Links Notation string</returns>
745+
/// <exception cref="ArgumentException">If id is null or empty</exception>
746+
/// <example>
747+
/// <code>
748+
/// var obj = new Dictionary&lt;string, string&gt;
749+
/// {
750+
/// { "status", "executed" },
751+
/// { "exitCode", "0" }
752+
/// };
753+
/// var result = Format.FormatIndented("my-uuid", obj);
754+
/// </code>
755+
/// </example>
756+
public static string FormatIndented(string id, IDictionary<string, string?> obj, string indent = " ")
757+
{
758+
if (string.IsNullOrEmpty(id))
759+
{
760+
throw new ArgumentException("id is required for FormatIndented", nameof(id));
761+
}
762+
763+
if (obj is null)
764+
{
765+
throw new ArgumentNullException(nameof(obj), "obj must be a dictionary for FormatIndented");
766+
}
767+
768+
var lines = new List<string> { id };
769+
770+
foreach (var kvp in obj)
771+
{
772+
var escapedKey = EscapeReference(kvp.Key);
773+
var formattedValue = FormatIndentedValue(kvp.Value);
774+
lines.Add($"{indent}{escapedKey} {formattedValue}");
775+
}
776+
777+
return string.Join("\n", lines);
778+
}
779+
780+
/// <summary>
781+
/// Format an object in indented Links Notation format, maintaining key order.
782+
/// This is similar to FormatIndented but takes an array of tuples to preserve
783+
/// the order of keys.
784+
/// </summary>
785+
/// <param name="id">The object identifier (displayed on first line)</param>
786+
/// <param name="pairs">The key-value pairs in order</param>
787+
/// <param name="indent">The indentation string (default: 2 spaces)</param>
788+
/// <returns>Formatted indented Links Notation string</returns>
789+
public static string FormatIndentedOrdered(string id, (string Key, string? Value)[] pairs, string indent = " ")
790+
{
791+
if (string.IsNullOrEmpty(id))
792+
{
793+
throw new ArgumentException("id is required for FormatIndentedOrdered", nameof(id));
794+
}
795+
796+
var lines = new List<string> { id };
797+
798+
foreach (var (key, value) in pairs)
799+
{
800+
var escapedKey = EscapeReference(key);
801+
var formattedValue = FormatIndentedValue(value);
802+
lines.Add($"{indent}{escapedKey} {formattedValue}");
803+
}
804+
805+
return string.Join("\n", lines);
806+
}
807+
808+
/// <summary>
809+
/// Parse an indented Links Notation string back to an object.
810+
///
811+
/// This function uses the links-notation parser for proper parsing,
812+
/// supporting the standard Links Notation indented syntax.
813+
///
814+
/// Parses strings like:
815+
/// <code>
816+
/// &lt;identifier&gt;
817+
/// &lt;key&gt; "&lt;value&gt;"
818+
/// &lt;key&gt; "&lt;value&gt;"
819+
/// ...
820+
/// </code>
821+
///
822+
/// The format with colon after identifier is also supported (standard lino):
823+
/// <code>
824+
/// &lt;identifier&gt;:
825+
/// &lt;key&gt; "&lt;value&gt;"
826+
/// </code>
827+
/// </summary>
828+
/// <param name="text">The indented Links Notation string to parse</param>
829+
/// <returns>A tuple of (id, dictionary of key-value pairs)</returns>
830+
/// <exception cref="ArgumentException">If text is null or empty</exception>
831+
/// <example>
832+
/// <code>
833+
/// var text = "my-uuid\n status \"executed\"\n exitCode \"0\"";
834+
/// var (id, obj) = Format.ParseIndented(text);
835+
/// // id = "my-uuid"
836+
/// // obj["status"] = "executed"
837+
/// </code>
838+
/// </example>
839+
public static (string Id, Dictionary<string, string?> Obj) ParseIndented(string text)
840+
{
841+
if (string.IsNullOrEmpty(text))
842+
{
843+
throw new ArgumentException("text is required for ParseIndented", nameof(text));
844+
}
845+
846+
var lines = text.Split('\n');
847+
if (lines.Length == 0)
848+
{
849+
throw new ArgumentException("text must have at least one line (the identifier)", nameof(text));
850+
}
851+
852+
// Filter out empty lines to preserve indentation structure for the parser
853+
// Empty lines would break the indentation context in links-notation
854+
var nonEmptyLines = lines.Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
855+
856+
if (nonEmptyLines.Length == 0)
857+
{
858+
throw new ArgumentException("text must have at least one non-empty line (the identifier)", nameof(text));
859+
}
860+
861+
// Convert to standard lino format by adding colon after first line if not present
862+
// This allows the links-notation parser to properly parse the indented structure
863+
var firstLine = nonEmptyLines[0].Trim();
864+
string linoText;
865+
if (firstLine.EndsWith(':'))
866+
{
867+
linoText = string.Join("\n", nonEmptyLines);
868+
}
869+
else
870+
{
871+
linoText = $"{firstLine}:\n{string.Join("\n", nonEmptyLines.Skip(1))}";
872+
}
873+
874+
// Use links-notation parser
875+
var parsed = SharedParser.Parse(linoText);
876+
877+
if (parsed is null || parsed.Count == 0)
878+
{
879+
throw new ArgumentException("Failed to parse indented Links Notation", nameof(text));
880+
}
881+
882+
// Extract id and key-value pairs from parsed result
883+
var mainLink = parsed[0];
884+
var resultId = mainLink.Id ?? "";
885+
var obj = new Dictionary<string, string?>();
886+
887+
// Process the values list - each entry is a doublet (key value)
888+
if (mainLink.Values is not null)
889+
{
890+
foreach (var child in mainLink.Values)
891+
{
892+
if (child.Values is not null && child.Values.Count == 2)
893+
{
894+
var keyRef = child.Values[0];
895+
var valueRef = child.Values[1];
896+
897+
// Get key string
898+
var key = keyRef.Id ?? "";
899+
900+
// Get value string, handling null
901+
var valueStr = valueRef.Id;
902+
if (valueStr == "null")
903+
{
904+
obj[key] = null;
905+
}
906+
else
907+
{
908+
obj[key] = valueStr;
909+
}
910+
}
911+
}
912+
}
913+
914+
return (resultId, obj);
915+
}
916+
}

0 commit comments

Comments
 (0)