lib/choir/src/passes/textual.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pass_mod = @import("pass/root.zig");
  3 const registry_mod = @import("pipeline.zig");
  4 
  5 pub const TextualPipelineError = error{
  6     EmptyElement,
  7     ExpectedComma,
  8     ExpectedPassOptionName,
  9     ExpectedPassOptionValue,
 10     MissingCloseParen,
 11     MissingCloseBrace,
 12     PassOptionsNotSupported,
 13     UnknownPassOrPipeline,
 14     UnexpectedCloseParen,
 15 };
 16 
 17 pub fn parsePassPipeline(
 18     registry: *const registry_mod.PassRegistry,
 19     text: []const u8,
 20     manager: *pass_mod.PassManager,
 21 ) anyerror!void {
 22     try parseOpPassPipeline(registry, text, &manager.root);
 23 }
 24 
 25 pub fn parseOpPassPipeline(
 26     registry: *const registry_mod.PassRegistry,
 27     text: []const u8,
 28     manager: *pass_mod.OpPassManager,
 29 ) anyerror!void {
 30     var parsed = pass_mod.OpPassManager.initWithTarget(
 31         manager.allocator,
 32         manager.target_kind,
 33         manager.target_op_name,
 34     );
 35     defer parsed.deinit();
 36 
 37     var parser = Parser{
 38         .registry = registry,
 39         .text = text,
 40     };
 41     try parser.parsePipeline(&parsed, null);
 42     try parser.expectDone();
 43     try appendParsedManager(manager, &parsed);
 44 }
 45 
 46 pub fn formatPassManagerPipelineAlloc(
 47     allocator: std.mem.Allocator,
 48     manager: *const pass_mod.PassManager,
 49 ) ![]u8 {
 50     return try formatOpPassManagerPipelineAlloc(allocator, &manager.root);
 51 }
 52 
 53 pub fn formatOpPassManagerPipelineAlloc(
 54     allocator: std.mem.Allocator,
 55     manager: *const pass_mod.OpPassManager,
 56 ) ![]u8 {
 57     var out = std.Io.Writer.Allocating.init(allocator);
 58     defer out.deinit();
 59     try writeOpPassManagerPipeline(&out.writer, manager);
 60     return try out.toOwnedSlice();
 61 }
 62 
 63 pub fn writePassManagerPipeline(
 64     writer: *std.Io.Writer,
 65     manager: *const pass_mod.PassManager,
 66 ) std.Io.Writer.Error!void {
 67     try writeOpPassManagerPipeline(writer, &manager.root);
 68 }
 69 
 70 pub fn writeOpPassManagerPipeline(
 71     writer: *std.Io.Writer,
 72     manager: *const pass_mod.OpPassManager,
 73 ) std.Io.Writer.Error!void {
 74     switch (manager.target_kind) {
 75         .root => {},
 76         .any => {
 77             try writer.writeAll("any(");
 78         },
 79         .op => {
 80             try writer.writeAll(manager.target_op_name.?);
 81             try writer.writeByte('(');
 82         },
 83     }
 84 
 85     for (manager.pipeline.items, 0..) |entry, index| {
 86         if (index != 0) try writer.writeByte(',');
 87         switch (entry) {
 88             .pass => |pass| {
 89                 try writer.writeAll(pass.name);
 90                 if (pass.textual_options) |options| {
 91                     try writer.writeByte('{');
 92                     try writer.writeAll(options);
 93                     try writer.writeByte('}');
 94                 }
 95             },
 96             .nested => |nested| try writeOpPassManagerPipeline(writer, nested),
 97         }
 98     }
 99 
100     switch (manager.target_kind) {
101         .root => {},
102         .any, .op => try writer.writeByte(')'),
103     }
104 }
105 
106 const Parser = struct {
107     registry: *const registry_mod.PassRegistry,
108     text: []const u8,
109     index: usize = 0,
110 
111     fn parsePipeline(
112         self: *Parser,
113         manager: *pass_mod.OpPassManager,
114         close: ?u8,
115     ) anyerror!void {
116         self.skipWhitespace();
117         if (close) |close_char| {
118             if (self.consume(close_char)) return;
119             if (self.atEnd()) return TextualPipelineError.MissingCloseParen;
120         } else if (self.atEnd()) {
121             return;
122         } else if (self.peek() == ')') {
123             return TextualPipelineError.UnexpectedCloseParen;
124         }
125 
126         while (true) {
127             if (self.atEnd()) {
128                 if (close != null) return TextualPipelineError.MissingCloseParen;
129                 return;
130             }
131 
132             self.skipWhitespace();
133             if (self.atEnd()) return TextualPipelineError.EmptyElement;
134             if (self.peek() == ')') return TextualPipelineError.UnexpectedCloseParen;
135 
136             const start = self.index;
137             while (!self.atEnd() and !isNameTerminator(self.peek())) {
138                 self.index += 1;
139             }
140 
141             const name = std.mem.trim(u8, self.text[start..self.index], " \t\r\n");
142             if (name.len == 0) return TextualPipelineError.EmptyElement;
143 
144             self.skipWhitespace();
145             var option_block: ?ParsedOptionBlock = null;
146             defer if (option_block) |*block| block.deinit(manager.allocator);
147             if (self.consume('{')) {
148                 option_block = try self.parseOptionBlock(manager.allocator);
149                 self.skipWhitespace();
150             }
151             if (self.consume('(')) {
152                 if (option_block != null) return TextualPipelineError.ExpectedComma;
153                 const nested = if (std.mem.eql(u8, name, "any"))
154                     try manager.nestAny()
155                 else
156                     try manager.nest(name);
157                 try self.parsePipeline(nested, ')');
158             } else if (self.registry.lookupPipeline(name)) |registration| {
159                 if (option_block) |block| {
160                     try registration.addToWithOptions(manager, .{ .assignments = block.assignments });
161                 } else {
162                     try registration.addTo(manager);
163                 }
164             } else if (self.registry.lookupPass(name)) |registration| {
165                 if (option_block) |block| {
166                     try registration.addToWithOptions(
167                         manager,
168                         block.text,
169                         .{ .assignments = block.assignments },
170                     );
171                 } else {
172                     try registration.addTo(manager);
173                 }
174             } else {
175                 return TextualPipelineError.UnknownPassOrPipeline;
176             }
177             self.skipWhitespace();
178 
179             if (self.consume(',')) {
180                 self.skipWhitespace();
181                 if (self.atEnd()) return TextualPipelineError.EmptyElement;
182                 if (close) |close_char| {
183                     if (self.peek() == close_char) return TextualPipelineError.EmptyElement;
184                 } else if (self.peek() == ')') {
185                     return TextualPipelineError.UnexpectedCloseParen;
186                 }
187                 continue;
188             }
189 
190             if (close) |close_char| {
191                 if (self.consume(close_char)) return;
192                 if (self.atEnd()) return TextualPipelineError.MissingCloseParen;
193             } else if (self.atEnd()) {
194                 return;
195             } else if (self.peek() == ')') {
196                 return TextualPipelineError.UnexpectedCloseParen;
197             }
198 
199             return TextualPipelineError.ExpectedComma;
200         }
201     }
202 
203     fn parseOptionBlock(self: *Parser, allocator: std.mem.Allocator) anyerror!ParsedOptionBlock {
204         const text_start = self.index;
205         var assignments: std.ArrayListUnmanaged(registry_mod.PassOptionAssignment) = .empty;
206         errdefer assignments.deinit(allocator);
207 
208         while (true) {
209             self.skipWhitespace();
210             if (self.atEnd()) return TextualPipelineError.MissingCloseBrace;
211             if (self.consume('}')) {
212                 return .{
213                     .text = self.text[text_start .. self.index - 1],
214                     .assignments = try assignments.toOwnedSlice(allocator),
215                 };
216             }
217 
218             const name_start = self.index;
219             while (!self.atEnd() and isOptionNameChar(self.peek())) {
220                 self.index += 1;
221             }
222             const option_name = self.text[name_start..self.index];
223             if (option_name.len == 0) return TextualPipelineError.ExpectedPassOptionName;
224 
225             self.skipWhitespace();
226             const option_value = if (self.consume('=')) value: {
227                 self.skipWhitespace();
228                 break :value try self.parseOptionValue();
229             } else "";
230             try assignments.append(allocator, .{
231                 .name = option_name,
232                 .value = option_value,
233             });
234 
235             self.skipWhitespace();
236             if (self.consume(',')) continue;
237             if (self.consume('}')) {
238                 return .{
239                     .text = self.text[text_start .. self.index - 1],
240                     .assignments = try assignments.toOwnedSlice(allocator),
241                 };
242             }
243         }
244     }
245 
246     fn parseOptionValue(self: *Parser) TextualPipelineError![]const u8 {
247         if (self.atEnd()) return TextualPipelineError.ExpectedPassOptionValue;
248         if (self.consume('"')) {
249             const start = self.index;
250             while (!self.atEnd() and self.peek() != '"') {
251                 self.index += 1;
252             }
253             if (self.atEnd()) return TextualPipelineError.ExpectedPassOptionValue;
254             const value = self.text[start..self.index];
255             self.index += 1;
256             if (value.len == 0) return TextualPipelineError.ExpectedPassOptionValue;
257             return value;
258         }
259 
260         const start = self.index;
261         while (!self.atEnd() and !isOptionValueTerminator(self.peek())) {
262             self.index += 1;
263         }
264         const value = self.text[start..self.index];
265         if (value.len == 0) return TextualPipelineError.ExpectedPassOptionValue;
266         return value;
267     }
268 
269     fn expectDone(self: *Parser) TextualPipelineError!void {
270         self.skipWhitespace();
271         if (self.atEnd()) return;
272         if (self.peek() == ')') return TextualPipelineError.UnexpectedCloseParen;
273         return TextualPipelineError.ExpectedComma;
274     }
275 
276     fn skipWhitespace(self: *Parser) void {
277         while (!self.atEnd() and std.ascii.isWhitespace(self.text[self.index])) {
278             self.index += 1;
279         }
280     }
281 
282     fn consume(self: *Parser, ch: u8) bool {
283         if (self.atEnd() or self.text[self.index] != ch) return false;
284         self.index += 1;
285         return true;
286     }
287 
288     fn peek(self: *const Parser) u8 {
289         return self.text[self.index];
290     }
291 
292     fn atEnd(self: *const Parser) bool {
293         return self.index >= self.text.len;
294     }
295 };
296 
297 fn appendParsedManager(
298     dst: *pass_mod.OpPassManager,
299     src: *pass_mod.OpPassManager,
300 ) std.mem.Allocator.Error!void {
301     try dst.pipeline.ensureUnusedCapacity(dst.allocator, src.pipeline.items.len);
302     try dst.nested_managers.ensureUnusedCapacity(dst.allocator, src.nested_managers.items.len);
303 
304     for (src.nested_managers.items) |nested| {
305         nested.parent = dst;
306     }
307     dst.pipeline.appendSliceAssumeCapacity(src.pipeline.items);
308     dst.nested_managers.appendSliceAssumeCapacity(src.nested_managers.items);
309 
310     src.pipeline.clearRetainingCapacity();
311     src.nested_managers.clearRetainingCapacity();
312 }
313 
314 fn isNameTerminator(ch: u8) bool {
315     return ch == ',' or ch == '(' or ch == ')' or ch == '{' or ch == '}' or std.ascii.isWhitespace(ch);
316 }
317 
318 fn isOptionNameChar(ch: u8) bool {
319     return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '-';
320 }
321 
322 fn isOptionValueTerminator(ch: u8) bool {
323     return ch == ',' or ch == '}' or std.ascii.isWhitespace(ch);
324 }
325 
326 const ParsedOptionBlock = struct {
327     text: []const u8,
328     assignments: []registry_mod.PassOptionAssignment,
329 
330     fn deinit(self: *ParsedOptionBlock, allocator: std.mem.Allocator) void {
331         allocator.free(self.assignments);
332     }
333 };
334 
335 fn noopPass(_: *pass_mod.PassContext) pass_mod.PassResult {
336     return .success;
337 }
338 
339 const textual_test_pass = pass_mod.Pass{
340     .name = "choir-textual-test-pass",
341     .description = "test pass for textual pipeline parsing",
342     .run_fn = noopPass,
343 };
344 
345 const textual_test_pass_registration = registry_mod.PassRegistration{
346     .name = textual_test_pass.name,
347     .description = textual_test_pass.description,
348     .pass = textual_test_pass,
349 };
350 
351 const textual_option_choices = [_]registry_mod.PassOptionChoice{
352     .{ .name = "fast" },
353     .{ .name = "slow" },
354 };
355 
356 const textual_test_option_specs = [_]registry_mod.PassOptionSpec{
357     .{
358         .name = "mode",
359         .description = "test mode",
360         .kind = .choice,
361         .choices = &textual_option_choices,
362         .default_value = "fast",
363     },
364     .{
365         .name = "limit",
366         .description = "test limit",
367         .kind = .unsigned,
368         .default_value = "0",
369     },
370     .{
371         .name = "enabled",
372         .description = "test toggle",
373         .kind = .boolean,
374         .default_value = "false",
375     },
376 };
377 
378 const textual_test_option_pass = pass_mod.Pass{
379     .name = "choir-textual-test-option-pass",
380     .description = "test pass for textual pipeline options",
381     .run_fn = noopPass,
382 };
383 
384 fn buildTextualTestOptionPass(_: std.mem.Allocator, options: registry_mod.PassOptionSet) anyerror!pass_mod.Pass {
385     _ = options.choiceValue("mode", "fast");
386     _ = try options.unsignedValue(usize, "limit", 0);
387     _ = try options.boolValue("enabled", false);
388     return textual_test_option_pass;
389 }
390 
391 const textual_test_option_pass_registration = registry_mod.PassRegistration{
392     .name = textual_test_option_pass.name,
393     .description = textual_test_option_pass.description,
394     .pass = textual_test_option_pass,
395     .options = &textual_test_option_specs,
396     .build_with_options = buildTextualTestOptionPass,
397 };
398 
399 fn buildTextualTestPipeline(manager: *pass_mod.OpPassManager) anyerror!void {
400     try manager.addPass(textual_test_pass);
401 }
402 
403 const textual_test_pipeline = registry_mod.PipelineRegistration{
404     .name = "choir-textual-test-pipeline",
405     .description = "test textual pipeline registration",
406     .build = buildTextualTestPipeline,
407 };
408 
409 const textual_pipeline_option_specs = [_]registry_mod.PassOptionSpec{
410     .{
411         .name = "limit",
412         .description = "test pipeline limit",
413         .kind = .unsigned,
414         .default_value = "0",
415     },
416     .{
417         .name = "enabled",
418         .description = "test pipeline toggle",
419         .kind = .boolean,
420         .default_value = "false",
421     },
422 };
423 
424 fn buildTextualTestPipelineWithOptions(
425     manager: *pass_mod.OpPassManager,
426     options: registry_mod.PassOptionSet,
427 ) anyerror!void {
428     _ = try options.boolValue("enabled", false);
429     const limit = options.get("limit") orelse "0";
430     const option_text = try std.fmt.allocPrint(manager.allocator, "limit={s}", .{limit});
431     defer manager.allocator.free(option_text);
432     try textual_test_option_pass_registration.addToWithOptions(
433         manager,
434         option_text,
435         options,
436     );
437 }
438 
439 const textual_test_option_pipeline = registry_mod.PipelineRegistration{
440     .name = "choir-textual-test-option-pipeline",
441     .description = "test textual pipeline options",
442     .build = buildTextualTestPipeline,
443     .options = &textual_pipeline_option_specs,
444     .build_with_options = buildTextualTestPipelineWithOptions,
445 };
446 
447 fn buildTextualRegistry(allocator: std.mem.Allocator) !registry_mod.PassRegistry {
448     var registry = registry_mod.PassRegistry.init(allocator);
449     errdefer registry.deinit();
450     try registry.registerPass(textual_test_pass_registration);
451     try registry.registerPass(textual_test_option_pass_registration);
452     try registry.registerPipeline(textual_test_pipeline);
453     try registry.registerPipeline(textual_test_option_pipeline);
454     return registry;
455 }
456 
457 test "textual pipeline parses registered passes and pipelines" {
458     var registry = try buildTextualRegistry(std.testing.allocator);
459     defer registry.deinit();
460 
461     var manager = pass_mod.PassManager.init(std.testing.allocator);
462     defer manager.deinit();
463     try parsePassPipeline(&registry, " choir-textual-test-pass , choir-textual-test-pipeline ", &manager);
464 
465     try std.testing.expectEqual(@as(usize, 2), manager.root.pipeline.items.len);
466 
467     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
468     defer std.testing.allocator.free(text);
469     try std.testing.expectEqualStrings("choir-textual-test-pass,choir-textual-test-pass", text);
470 }
471 
472 test "textual pipeline parses explicit operation nesting" {
473     var registry = try buildTextualRegistry(std.testing.allocator);
474     defer registry.deinit();
475 
476     var manager = pass_mod.PassManager.init(std.testing.allocator);
477     defer manager.deinit();
478     try parsePassPipeline(&registry, "test.op(choir-textual-test-pass)", &manager);
479 
480     try std.testing.expectEqual(@as(usize, 1), manager.root.pipeline.items.len);
481     switch (manager.root.pipeline.items[0]) {
482         .nested => |nested| {
483             try std.testing.expectEqualStrings("test.op", nested.target_op_name.?);
484             try std.testing.expectEqual(@as(usize, 1), nested.pipeline.items.len);
485         },
486         .pass => return error.TestExpectedNestedPassManager,
487     }
488 
489     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
490     defer std.testing.allocator.free(text);
491     try std.testing.expectEqualStrings("test.op(choir-textual-test-pass)", text);
492 }
493 
494 test "textual pipeline parses op-agnostic any nesting" {
495     var registry = try buildTextualRegistry(std.testing.allocator);
496     defer registry.deinit();
497 
498     var manager = pass_mod.PassManager.init(std.testing.allocator);
499     defer manager.deinit();
500     try parsePassPipeline(&registry, "any(choir-textual-test-pass)", &manager);
501 
502     try std.testing.expectEqual(@as(usize, 1), manager.root.pipeline.items.len);
503     switch (manager.root.pipeline.items[0]) {
504         .nested => |nested| {
505             try std.testing.expectEqual(pass_mod.OpPassManagerTargetKind.any, nested.target_kind);
506             try std.testing.expectEqual(@as(?[]const u8, null), nested.target_op_name);
507             try std.testing.expectEqual(@as(usize, 1), nested.pipeline.items.len);
508         },
509         .pass => return error.TestExpectedNestedPassManager,
510     }
511 
512     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
513     defer std.testing.allocator.free(text);
514     try std.testing.expectEqualStrings("any(choir-textual-test-pass)", text);
515 }
516 
517 test "textual pipeline parses pass options and formats them" {
518     var registry = try buildTextualRegistry(std.testing.allocator);
519     defer registry.deinit();
520 
521     var manager = pass_mod.PassManager.init(std.testing.allocator);
522     defer manager.deinit();
523     try parsePassPipeline(
524         &registry,
525         "choir-textual-test-option-pass{mode=slow,limit=7}",
526         &manager,
527     );
528 
529     try std.testing.expectEqual(@as(usize, 1), manager.root.pipeline.items.len);
530     switch (manager.root.pipeline.items[0]) {
531         .pass => |pass| {
532             try std.testing.expectEqualStrings(textual_test_option_pass.name, pass.name);
533             try std.testing.expectEqualStrings("mode=slow,limit=7", pass.textual_options.?);
534         },
535         .nested => return error.TestExpectedPass,
536     }
537 
538     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
539     defer std.testing.allocator.free(text);
540     try std.testing.expectEqualStrings("choir-textual-test-option-pass{mode=slow,limit=7}", text);
541 }
542 
543 test "textual pipeline parses MLIR-style boolean pass option values" {
544     var registry = try buildTextualRegistry(std.testing.allocator);
545     defer registry.deinit();
546 
547     var manager = pass_mod.PassManager.init(std.testing.allocator);
548     defer manager.deinit();
549     try parsePassPipeline(
550         &registry,
551         "choir-textual-test-option-pass{enabled,limit=7},choir-textual-test-option-pass{enabled=1},choir-textual-test-option-pass{enabled=0}",
552         &manager,
553     );
554 
555     try std.testing.expectEqual(@as(usize, 3), manager.root.pipeline.items.len);
556     switch (manager.root.pipeline.items[0]) {
557         .pass => |pass| try std.testing.expectEqualStrings("enabled,limit=7", pass.textual_options.?),
558         .nested => return error.TestExpectedPass,
559     }
560     switch (manager.root.pipeline.items[1]) {
561         .pass => |pass| try std.testing.expectEqualStrings("enabled=1", pass.textual_options.?),
562         .nested => return error.TestExpectedPass,
563     }
564     switch (manager.root.pipeline.items[2]) {
565         .pass => |pass| try std.testing.expectEqualStrings("enabled=0", pass.textual_options.?),
566         .nested => return error.TestExpectedPass,
567     }
568 }
569 
570 test "textual pipeline parses pass options inside nested managers" {
571     var registry = try buildTextualRegistry(std.testing.allocator);
572     defer registry.deinit();
573 
574     var manager = pass_mod.PassManager.init(std.testing.allocator);
575     defer manager.deinit();
576     try parsePassPipeline(
577         &registry,
578         "test.op(choir-textual-test-option-pass{mode=slow limit=11})",
579         &manager,
580     );
581 
582     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
583     defer std.testing.allocator.free(text);
584     try std.testing.expectEqualStrings("test.op(choir-textual-test-option-pass{mode=slow limit=11})", text);
585 }
586 
587 test "textual pipeline parses registered pipeline options" {
588     var registry = try buildTextualRegistry(std.testing.allocator);
589     defer registry.deinit();
590 
591     var manager = pass_mod.PassManager.init(std.testing.allocator);
592     defer manager.deinit();
593     try parsePassPipeline(
594         &registry,
595         "choir-textual-test-option-pipeline{limit=13}",
596         &manager,
597     );
598 
599     try std.testing.expectEqual(@as(usize, 1), manager.root.pipeline.items.len);
600     switch (manager.root.pipeline.items[0]) {
601         .pass => |pass| {
602             try std.testing.expectEqualStrings(textual_test_option_pass.name, pass.name);
603             try std.testing.expectEqualStrings("limit=13", pass.textual_options.?);
604         },
605         .nested => return error.TestExpectedPass,
606     }
607 
608     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
609     defer std.testing.allocator.free(text);
610     try std.testing.expectEqualStrings("choir-textual-test-option-pass{limit=13}", text);
611 }
612 
613 test "textual pipeline validates declared pass options" {
614     var registry = try buildTextualRegistry(std.testing.allocator);
615     defer registry.deinit();
616 
617     var manager = pass_mod.PassManager.init(std.testing.allocator);
618     defer manager.deinit();
619 
620     try std.testing.expectError(
621         error.UnknownPassOption,
622         parsePassPipeline(&registry, "choir-textual-test-option-pass{missing=1}", &manager),
623     );
624     try std.testing.expectError(
625         error.DuplicatePassOption,
626         parsePassPipeline(&registry, "choir-textual-test-option-pass{limit=1,limit=2}", &manager),
627     );
628     try std.testing.expectError(
629         error.InvalidPassOptionValue,
630         parsePassPipeline(&registry, "choir-textual-test-option-pass{mode=medium}", &manager),
631     );
632     try std.testing.expectError(
633         error.PassOptionsNotSupported,
634         parsePassPipeline(&registry, "choir-textual-test-pass{limit=1}", &manager),
635     );
636     try std.testing.expectError(
637         error.UnknownPassOption,
638         parsePassPipeline(&registry, "choir-textual-test-option-pipeline{missing=1}", &manager),
639     );
640     try std.testing.expectError(
641         error.InvalidPassOptionValue,
642         parsePassPipeline(&registry, "choir-textual-test-option-pipeline{limit=nope}", &manager),
643     );
644     try std.testing.expectError(
645         error.MissingPassOptionValue,
646         parsePassPipeline(&registry, "choir-textual-test-option-pipeline{limit}", &manager),
647     );
648     try std.testing.expectError(
649         error.PassOptionsNotSupported,
650         parsePassPipeline(&registry, "choir-textual-test-pipeline{limit=1}", &manager),
651     );
652 }
653 
654 test "textual pipeline accepts empty root and nested pipelines" {
655     var registry = try buildTextualRegistry(std.testing.allocator);
656     defer registry.deinit();
657 
658     var manager = pass_mod.PassManager.init(std.testing.allocator);
659     defer manager.deinit();
660     try parsePassPipeline(&registry, " \t\n ", &manager);
661     try std.testing.expectEqual(@as(usize, 0), manager.root.pipeline.items.len);
662 
663     try parsePassPipeline(&registry, "test.empty()", &manager);
664     const text = try formatPassManagerPipelineAlloc(std.testing.allocator, &manager);
665     defer std.testing.allocator.free(text);
666     try std.testing.expectEqualStrings("test.empty()", text);
667 }
668 
669 test "textual pipeline parse errors do not mutate the destination manager" {
670     var registry = try buildTextualRegistry(std.testing.allocator);
671     defer registry.deinit();
672 
673     var manager = pass_mod.PassManager.init(std.testing.allocator);
674     defer manager.deinit();
675     try parsePassPipeline(&registry, "choir-textual-test-pass", &manager);
676     try std.testing.expectEqual(@as(usize, 1), manager.root.pipeline.items.len);
677 
678     try std.testing.expectError(
679         TextualPipelineError.UnknownPassOrPipeline,
680         parsePassPipeline(&registry, "choir-textual-test-pipeline,missing-pass", &manager),
681     );
682     try std.testing.expectEqual(@as(usize, 1), manager.root.pipeline.items.len);
683 }
684 
685 test "textual pipeline reports malformed nesting" {
686     var registry = try buildTextualRegistry(std.testing.allocator);
687     defer registry.deinit();
688 
689     var manager = pass_mod.PassManager.init(std.testing.allocator);
690     defer manager.deinit();
691 
692     try std.testing.expectError(
693         TextualPipelineError.MissingCloseParen,
694         parsePassPipeline(&registry, "test.op(choir-textual-test-pass", &manager),
695     );
696     try std.testing.expectError(
697         TextualPipelineError.UnexpectedCloseParen,
698         parsePassPipeline(&registry, "choir-textual-test-pass)", &manager),
699     );
700     try std.testing.expectError(
701         TextualPipelineError.EmptyElement,
702         parsePassPipeline(&registry, "choir-textual-test-pass,", &manager),
703     );
704 }