11using System ;
2+ using System . Collections ;
23using System . Collections . Generic ;
4+ using System . Globalization ;
35using System . IO ;
46using System . Linq ;
57using System . Security . Cryptography ;
68using System . Text ;
79using System . Text . Json ;
10+ using System . Text . Json . Nodes ;
811using Datra . Attributes ;
912using Datra . Utilities ;
13+ using YamlDotNet . Core ;
14+ using YamlDotNet . RepresentationModel ;
1015using YamlDotNet . Serialization ;
1116
1217namespace Datra . Bundles
@@ -63,7 +68,6 @@ public static DatraRawBundle NormalizeToJson(DatraRawBundle source)
6368 if ( source == null ) throw new ArgumentNullException ( nameof ( source ) ) ;
6469 if ( source . Files == null ) throw new ArgumentException ( "Source bundle has no Files." , nameof ( source ) ) ;
6570
66- var yamlDeserializer = new DeserializerBuilder ( ) . Build ( ) ;
6771 var jsonOptions = new JsonSerializerOptions { WriteIndented = false } ;
6872
6973 var outFiles = new Dictionary < string , string > ( source . Files . Count , StringComparer . Ordinal ) ;
@@ -77,12 +81,19 @@ public static DatraRawBundle NormalizeToJson(DatraRawBundle source)
7781
7882 if ( fmt == DataFormat . Yaml )
7983 {
80- // YAML → opaque object graph → JSON via STJ. The runtime serializer
81- // re-parses this content with its proper [TableData] type info,
82- // so we only need a structurally faithful conversion here.
84+ // YAML → JSON via the YamlStream representation model so we can
85+ // see each scalar's ScalarStyle: plain scalars are type-inferred
86+ // per the YAML 1.2 core schema, but quoted ones (`"0"`, `'true'`)
87+ // stay as JSON strings — matching the author's intent.
8388 using var reader = new StringReader ( content ) ;
84- var graph = yamlDeserializer . Deserialize ( reader ) ;
85- var jsonContent = JsonSerializer . Serialize ( graph , jsonOptions ) ;
89+ var yamlStream = new YamlStream ( ) ;
90+ yamlStream . Load ( reader ) ;
91+ JsonNode ? node = null ;
92+ if ( yamlStream . Documents . Count > 0 )
93+ {
94+ node = ConvertYamlNodeToJsonNode ( yamlStream . Documents [ 0 ] . RootNode ) ;
95+ }
96+ var jsonContent = node ? . ToJsonString ( jsonOptions ) ?? "null" ;
8697 outFiles [ path ] = jsonContent ;
8798 overrides [ path ] = DataFormat . Json ;
8899 }
@@ -102,6 +113,92 @@ public static DatraRawBundle NormalizeToJson(DatraRawBundle source)
102113 return bundle ;
103114 }
104115
116+ /// <summary>
117+ /// Convert a <see cref="YamlNode"/> tree to a <see cref="JsonNode"/> tree.
118+ /// Scalar typing follows YAML 1.2 core schema for plain (unquoted) scalars;
119+ /// single/double-quoted scalars are always emitted as JSON strings so
120+ /// authored quoting (e.g. <c>AttachWhenEquals: "0"</c>) survives round-trip.
121+ /// </summary>
122+ private static JsonNode ? ConvertYamlNodeToJsonNode ( YamlNode yaml )
123+ {
124+ switch ( yaml )
125+ {
126+ case YamlMappingNode map :
127+ {
128+ var obj = new JsonObject ( ) ;
129+ foreach ( var entry in map . Children )
130+ {
131+ var key = ( entry . Key as YamlScalarNode ) ? . Value ?? entry . Key . ToString ( ) ;
132+ obj [ key ?? string . Empty ] = ConvertYamlNodeToJsonNode ( entry . Value ) ;
133+ }
134+ return obj ;
135+ }
136+ case YamlSequenceNode seq :
137+ {
138+ var arr = new JsonArray ( ) ;
139+ foreach ( var child in seq . Children ) arr . Add ( ConvertYamlNodeToJsonNode ( child ) ) ;
140+ return arr ;
141+ }
142+ case YamlScalarNode scalar :
143+ return ConvertYamlScalar ( scalar ) ;
144+ default :
145+ return null ;
146+ }
147+ }
148+
149+ private static JsonNode ? ConvertYamlScalar ( YamlScalarNode scalar )
150+ {
151+ var s = scalar . Value ;
152+ // Quoted scalars are always strings (preserves authored intent like `"0"`).
153+ if ( scalar . Style == ScalarStyle . SingleQuoted ||
154+ scalar . Style == ScalarStyle . DoubleQuoted ||
155+ scalar . Style == ScalarStyle . Literal ||
156+ scalar . Style == ScalarStyle . Folded )
157+ {
158+ return JsonValue . Create ( s ?? string . Empty ) ;
159+ }
160+ // Plain scalars: apply YAML 1.2 core schema inference.
161+ if ( s is null ) return null ;
162+ if ( s . Length == 0 || s == "~" || s == "null" || s == "Null" || s == "NULL" )
163+ return null ;
164+ if ( s == "true" || s == "True" || s == "TRUE" ) return JsonValue . Create ( true ) ;
165+ if ( s == "false" || s == "False" || s == "FALSE" ) return JsonValue . Create ( false ) ;
166+ if ( LooksLikeInteger ( s ) && long . TryParse ( s , NumberStyles . Integer , CultureInfo . InvariantCulture , out var i ) )
167+ return JsonValue . Create ( i ) ;
168+ if ( LooksLikeFloat ( s ) && double . TryParse ( s , NumberStyles . Float , CultureInfo . InvariantCulture , out var d ) )
169+ return JsonValue . Create ( d ) ;
170+ return JsonValue . Create ( s ) ;
171+ }
172+
173+ private static bool LooksLikeInteger ( string s )
174+ {
175+ if ( s . Length == 0 ) return false ;
176+ int start = ( s [ 0 ] == '+' || s [ 0 ] == '-' ) ? 1 : 0 ;
177+ if ( start == s . Length ) return false ;
178+ // Reject leading zeros for multi-digit integers (YAML core schema treats
179+ // "010" as a string, not an int).
180+ if ( s . Length - start > 1 && s [ start ] == '0' ) return false ;
181+ for ( int i = start ; i < s . Length ; i ++ )
182+ if ( s [ i ] < '0' || s [ i ] > '9' ) return false ;
183+ return true ;
184+ }
185+
186+ private static bool LooksLikeFloat ( string s )
187+ {
188+ if ( s . Length == 0 ) return false ;
189+ bool seenDigit = false , seenDotOrExp = false ;
190+ int i = ( s [ 0 ] == '+' || s [ 0 ] == '-' ) ? 1 : 0 ;
191+ for ( ; i < s . Length ; i ++ )
192+ {
193+ var c = s [ i ] ;
194+ if ( c >= '0' && c <= '9' ) seenDigit = true ;
195+ else if ( c == '.' || c == 'e' || c == 'E' ) seenDotOrExp = true ;
196+ else if ( c == '+' || c == '-' ) { /* exponent sign — allow */ }
197+ else return false ;
198+ }
199+ return seenDigit && seenDotOrExp ;
200+ }
201+
105202 public static string ComputeContentHash ( IReadOnlyDictionary < string , string > files )
106203 {
107204 if ( files == null ) throw new ArgumentNullException ( nameof ( files ) ) ;
0 commit comments