@@ -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+ /// <identifier>
736+ /// <key> "<value>"
737+ /// <key> "<value>"
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<string, string>
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+ /// <identifier>
817+ /// <key> "<value>"
818+ /// <key> "<value>"
819+ /// ...
820+ /// </code>
821+ ///
822+ /// The format with colon after identifier is also supported (standard lino):
823+ /// <code>
824+ /// <identifier>:
825+ /// <key> "<value>"
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