@@ -85,6 +85,13 @@ pub const ParsedConfig = struct {
8585 range_max : ? f64 ,
8686 is_secret : bool ,
8787 ) ! void {
88+ // A field with an empty key is meaningless (and breaks getField lookup
89+ // and A2ML emission). Malformed input — e.g. a lone "=value" line or a
90+ // JSON key of "" — must not produce one. Silently skip it; this is the
91+ // single funnel every parser routes through, so the invariant "every
92+ // field has a non-empty key" holds by construction (asserted by the
93+ // config fuzz harness).
94+ if (key .len == 0 ) return ;
8895 try self .fields .append (.{
8996 .key = try self .allocator .dupe (u8 , key ),
9097 .value = try self .allocator .dupe (u8 , value ),
@@ -155,12 +162,18 @@ pub fn detectFormat(data: []const u8) ConfigFormat {
155162 }
156163 }
157164
165+ // YAML document marker is an unambiguous signal.
166+ if (std .mem .startsWith (u8 , trimmed , "---" )) return .YAML ;
167+
158168 // Scan lines for format indicators
159169 var has_section_header = false ;
160170 var has_export = false ;
161171 var has_dotted_key = false ;
162172 var has_lua_table = false ;
163173 var has_triple_bracket = false ;
174+ var yaml_kv_lines : usize = 0 ; // `key: value` or `key:` (mapping) lines
175+ var yaml_seq_lines : usize = 0 ; // `- item` block-sequence lines
176+ var eq_lines : usize = 0 ; // lines containing `=`
164177
165178 var line_iter = std .mem .splitScalar (u8 , data , '\n ' );
166179 while (line_iter .next ()) | line | {
@@ -172,6 +185,7 @@ pub fn detectFormat(data: []const u8) ConfigFormat {
172185 if (std .mem .startsWith (u8 , stripped , "[[" )) has_triple_bracket = true ;
173186 }
174187 if (std .mem .startsWith (u8 , stripped , "export " )) has_export = true ;
188+ if (std .mem .indexOfScalar (u8 , stripped , '=' ) != null ) eq_lines += 1 ;
175189 if (std .mem .indexOf (u8 , stripped , "." ) != null and std .mem .indexOf (u8 , stripped , "=" ) != null ) {
176190 // Check if it looks like "section.key = value" (TOML dotted keys)
177191 if (std .mem .indexOfScalar (u8 , stripped , '=' )) | eq_pos | {
@@ -183,16 +197,69 @@ pub fn detectFormat(data: []const u8) ConfigFormat {
183197 if (std .mem .indexOf (u8 , stripped , "= {" ) != null or std .mem .indexOf (u8 , stripped , "={" ) != null ) {
184198 has_lua_table = true ;
185199 }
200+ // YAML block sequence: "- " or a bare "-"
201+ if (std .mem .startsWith (u8 , stripped , "- " ) or std .mem .eql (u8 , stripped , "-" )) yaml_seq_lines += 1 ;
202+ // YAML mapping line: "key:" or "key: value" (colon followed by space or EOL),
203+ // with no '=' on the line and a plausible bareword key.
204+ if (std .mem .indexOfScalar (u8 , stripped , '=' ) == null ) {
205+ if (std .mem .indexOfScalar (u8 , stripped , ':' )) | ci | {
206+ const after = stripped [ci + 1 .. ];
207+ const key = stripped [0.. ci ];
208+ if (isYamlKey (key ) and (after .len == 0 or after [0 ] == ' ' )) yaml_kv_lines += 1 ;
209+ }
210+ }
186211 }
187212
188213 if (has_triple_bracket or (has_section_header and has_dotted_key )) return .TOML ;
189214 if (has_lua_table ) return .Lua ;
190215 if (has_section_header ) return .INI ;
191216 if (has_export ) return .ENV ;
217+ // YAML: colon-delimited mappings and/or block sequences, and crucially no
218+ // `=` lines (which would make it ENV/INI/KeyValue).
219+ if (eq_lines == 0 and (yaml_kv_lines > 0 or yaml_seq_lines > 0 )) return .YAML ;
192220
193221 return .KeyValue ;
194222}
195223
224+ /// A YAML key is a bareword of `[A-Za-z0-9._-]` (no spaces) — enough to
225+ /// distinguish `key: value` mappings from prose lines that merely contain a
226+ /// colon (e.g. a URL or a sentence).
227+ fn isYamlKey (s : []const u8 ) bool {
228+ if (s .len == 0 ) return false ;
229+ for (s ) | c | {
230+ if (! (std .ascii .isAlphanumeric (c ) or c == '.' or c == '_' or c == '-' )) return false ;
231+ }
232+ return true ;
233+ }
234+
235+ /// One level of YAML block-mapping nesting: its indentation and key.
236+ const YamlEntry = struct { indent : usize , key : []const u8 };
237+
238+ /// Strip surrounding single/double quotes from a YAML scalar.
239+ fn yamlScalar (s : []const u8 ) []const u8 {
240+ if (s .len >= 2 and
241+ ((s [0 ] == '"' and s [s .len - 1 ] == '"' ) or (s [0 ] == '\' ' and s [s .len - 1 ] == '\' ' )))
242+ {
243+ return s [1 .. s .len - 1 ];
244+ }
245+ return s ;
246+ }
247+
248+ /// Join the mapping-key path (`a`, `a.b`, …) into `buf`, returning the slice.
249+ fn buildYamlPath (buf : []u8 , entries : []const YamlEntry ) []const u8 {
250+ var len : usize = 0 ;
251+ for (entries , 0.. ) | e , i | {
252+ if (i != 0 and len < buf .len ) {
253+ buf [len ] = '.' ;
254+ len += 1 ;
255+ }
256+ const n = @min (e .key .len , buf .len - len );
257+ @memcpy (buf [len .. len + n ], e .key [0.. n ]);
258+ len += n ;
259+ }
260+ return buf [0.. len ];
261+ }
262+
196263// ═══════════════════════════════════════════════════════════════════════════════
197264// Format parsers
198265// ═══════════════════════════════════════════════════════════════════════════════
@@ -830,6 +897,91 @@ pub fn parseKeyValue(allocator: Allocator, data: []const u8) !ParsedConfig {
830897 return config ;
831898}
832899
900+ /// Parse a YAML config — a scoped subset (block mappings, block sequences of
901+ /// scalars, scalar values, comments, `---`/`...` document markers), NOT full
902+ /// YAML 1.2. Nested mappings flatten to dotted keys (`parent.child`) and
903+ /// sequence items to `parent.0`, `parent.1`, … — the same flat model
904+ /// parseTOML/parseJSON use. Anchors, aliases, flow collections, and multi-line
905+ /// scalars are out of scope.
906+ pub fn parseYAML (allocator : Allocator , data : []const u8 ) ! ParsedConfig {
907+ var config = ParsedConfig .init (allocator , .YAML , "" );
908+ errdefer config .deinit ();
909+ config .raw_text = try allocator .dupe (u8 , data );
910+
911+ var stack : [16 ]YamlEntry = undefined ;
912+ var depth : usize = 0 ;
913+ var seq_indent : ? usize = null ;
914+ var seq_index : usize = 0 ;
915+
916+ var it = std .mem .splitScalar (u8 , data , '\n ' );
917+ while (it .next ()) | raw_line | {
918+ var line = raw_line ;
919+ if (line .len > 0 and line [line .len - 1 ] == '\r ' ) line = line [0 .. line .len - 1 ];
920+
921+ var indent : usize = 0 ;
922+ while (indent < line .len and line [indent ] == ' ' ) indent += 1 ;
923+ const content = line [indent .. ];
924+ if (content .len == 0 or content [0 ] == '#' ) continue ;
925+ if (std .mem .startsWith (u8 , content , "---" ) or std .mem .startsWith (u8 , content , "..." )) continue ;
926+
927+ // Pop mapping keys no longer in scope; reset sequence tracking on dedent.
928+ while (depth > 0 and stack [depth - 1 ].indent >= indent ) depth -= 1 ;
929+ if (seq_indent ) | si | {
930+ if (indent < si ) {
931+ seq_indent = null ;
932+ seq_index = 0 ;
933+ }
934+ }
935+
936+ // Block sequence item: parent.N
937+ if (std .mem .startsWith (u8 , content , "- " ) or std .mem .eql (u8 , content , "-" )) {
938+ const item = if (content .len > 2 ) yamlScalar (std .mem .trim (u8 , content [2.. ], " \t " )) else "" ;
939+ if (seq_indent == null or indent != seq_indent .? ) {
940+ seq_indent = indent ;
941+ seq_index = 0 ;
942+ }
943+ var kb : [1024 ]u8 = undefined ;
944+ const base = buildYamlPath (& kb , stack [0.. depth ]);
945+ var full : [1152 ]u8 = undefined ;
946+ const fk = std .fmt .bufPrint (& full , "{s}{s}{d}" , .{
947+ base , if (base .len > 0 ) "." else "" , seq_index ,
948+ }) catch continue ;
949+ try config .addField (fk , item , "string" , fk , "" , null , null , false );
950+ seq_index += 1 ;
951+ continue ;
952+ }
953+
954+ // Mapping line: `key:` (parent) or `key: value` (leaf).
955+ const ci = std .mem .indexOfScalar (u8 , content , ':' ) orelse continue ;
956+ const key = std .mem .trim (u8 , content [0.. ci ], " \t " );
957+ if (! isYamlKey (key )) continue ;
958+ const after = std .mem .trim (u8 , content [ci + 1 .. ], " \t " );
959+
960+ if (after .len == 0 ) {
961+ if (depth < stack .len ) {
962+ stack [depth ] = .{ .indent = indent , .key = key };
963+ depth += 1 ;
964+ }
965+ continue ;
966+ }
967+
968+ var kb : [1024 ]u8 = undefined ;
969+ const base = buildYamlPath (& kb , stack [0.. depth ]);
970+ var full : [1152 ]u8 = undefined ;
971+ const fk = if (base .len > 0 )
972+ (std .fmt .bufPrint (& full , "{s}.{s}" , .{ base , key }) catch continue )
973+ else
974+ key ;
975+ const val = yamlScalar (after );
976+ const is_secret = std .mem .indexOf (u8 , fk , "password" ) != null or
977+ std .mem .indexOf (u8 , fk , "secret" ) != null or
978+ std .mem .indexOf (u8 , fk , "token" ) != null ;
979+ try config .addField (fk , val , "string" , fk , "" , null , null , is_secret );
980+ }
981+
982+ return config ;
983+ }
984+
833985/// Dispatch to the correct parser based on format.
834986pub fn parseAuto (allocator : Allocator , data : []const u8 ) ! ParsedConfig {
835987 const format = detectFormat (data );
@@ -841,7 +993,7 @@ pub fn parseAuto(allocator: Allocator, data: []const u8) !ParsedConfig {
841993 .TOML = > parseTOML (allocator , data ),
842994 .Lua = > parseLua (allocator , data ),
843995 .KeyValue = > parseKeyValue (allocator , data ),
844- .YAML = > parseKeyValue (allocator , data ), // basic YAML fallback
996+ .YAML = > parseYAML (allocator , data ),
845997 };
846998}
847999
0 commit comments