Skip to main content

styx_format/
writer.rs

1//! Low-level Styx output writer.
2//!
3//! Provides a structured way to build Styx output with proper formatting,
4//! independent of any serialization framework.
5
6use crate::options::{ForceStyle, FormatOptions};
7use crate::scalar::{can_be_bare, count_escapes, count_newlines, escape_quoted};
8
9/// Context for tracking serialization state.
10#[derive(Debug, Clone)]
11pub enum Context {
12    /// Inside a struct/object - tracks if we've written any fields
13    Struct {
14        first: bool,
15        is_root: bool,
16        force_multiline: bool,
17        /// True if this struct started on the same line as its key (inline start)
18        inline_start: bool,
19        /// Positions of comma separators written in this struct (for fixing mixed separators)
20        comma_positions: Vec<usize>,
21        /// Position right after the opening `{` (for inserting newline when going multiline)
22        open_brace_pos: Option<usize>,
23    },
24    /// Inside a sequence - tracks if we've written any items
25    Seq {
26        first: bool,
27        /// True if this sequence started on the same line as its key (inline start)
28        inline_start: bool,
29    },
30}
31
32/// Low-level Styx output writer.
33///
34/// This writer handles the formatting logic for Styx output, including:
35/// - Indentation and newlines
36/// - Scalar quoting decisions
37/// - Inline vs multi-line formatting
38pub struct StyxWriter {
39    out: Vec<u8>,
40    stack: Vec<Context>,
41    options: FormatOptions,
42    /// If true, skip the next before_value() call (used after writing a tag)
43    skip_next_before_value: bool,
44    /// If true, force the next scalar to be quoted (used after writing a tag,
45    /// since bare scalars cannot be tagged)
46    force_quote_next_scalar: bool,
47}
48
49impl StyxWriter {
50    /// Create a new writer with default options.
51    pub fn new() -> Self {
52        Self::with_options(FormatOptions::default())
53    }
54
55    /// Create a new writer with the given options.
56    pub fn with_options(options: FormatOptions) -> Self {
57        Self {
58            out: Vec::new(),
59            stack: Vec::new(),
60            skip_next_before_value: false,
61            force_quote_next_scalar: false,
62            options,
63        }
64    }
65
66    /// Consume the writer and return the output bytes.
67    pub fn finish(self) -> Vec<u8> {
68        self.out
69    }
70
71    /// Consume the writer and return the output bytes, ensuring a trailing newline.
72    /// This matches CST formatter behavior for standalone documents.
73    pub fn finish_document(mut self) -> Vec<u8> {
74        if !self.out.is_empty() && !self.out.ends_with(b"\n") {
75            self.out.push(b'\n');
76        }
77        self.out
78    }
79
80    /// Consume the writer and return the output as a String.
81    ///
82    /// # Panics
83    /// Panics if the output is not valid UTF-8 (should never happen with Styx).
84    pub fn finish_string(self) -> String {
85        String::from_utf8(self.out).expect("Styx output should always be valid UTF-8")
86    }
87
88    /// Current nesting depth.
89    pub fn depth(&self) -> usize {
90        self.stack.len()
91    }
92
93    /// Effective indentation depth (accounts for root struct and inline containers).
94    fn indent_depth(&self) -> usize {
95        let mut depth = 0;
96        for ctx in &self.stack {
97            match ctx {
98                Context::Struct { is_root: true, .. } => {
99                    // Root struct doesn't add indentation
100                }
101                Context::Struct {
102                    inline_start: true,
103                    force_multiline: false,
104                    ..
105                } => {
106                    // Inline struct that stays inline doesn't add indentation
107                }
108                Context::Seq {
109                    inline_start: true, ..
110                } => {
111                    // Inline-started sequence doesn't add indentation
112                }
113                _ => {
114                    depth += 1;
115                }
116            }
117        }
118        depth
119    }
120
121    /// Calculate available width at current depth.
122    pub fn available_width(&self) -> usize {
123        let used = self.depth() * self.options.indent.len();
124        self.options.max_width.saturating_sub(used)
125    }
126
127    /// Check if we should use inline formatting at current depth.
128    pub fn should_inline(&self) -> bool {
129        if self.options.force_style == ForceStyle::Inline {
130            return true;
131        } else if self.options.force_style == ForceStyle::Multiline {
132            return false;
133        }
134        // Root level always uses newlines
135        if self.depth() == 0 {
136            return false;
137        }
138        // Check if we're inside a root struct
139        if let Some(Context::Struct { is_root: true, .. }) = self.stack.first()
140            && self.depth() == 1
141        {
142            return false;
143        }
144        // Check if current struct is forced multiline
145        if let Some(Context::Struct {
146            force_multiline: true,
147            ..
148        }) = self.stack.last()
149        {
150            return false;
151        }
152        // If available width is too small, force multiline
153        self.available_width() >= self.options.min_inline_width
154    }
155
156    /// Write indentation for the current depth.
157    pub fn write_indent(&mut self) {
158        for _ in 0..self.indent_depth() {
159            self.out.extend_from_slice(self.options.indent.as_bytes());
160        }
161    }
162
163    /// Write a newline and indentation.
164    pub fn write_newline_indent(&mut self) {
165        self.out.push(b'\n');
166        self.write_indent();
167    }
168
169    /// Write raw bytes to the output.
170    pub fn write_raw(&mut self, bytes: &[u8]) {
171        self.out.extend_from_slice(bytes);
172    }
173
174    /// Write a raw string to the output.
175    pub fn write_str(&mut self, s: &str) {
176        self.out.extend_from_slice(s.as_bytes());
177    }
178
179    /// Write a single byte.
180    pub fn write_byte(&mut self, b: u8) {
181        self.out.push(b);
182    }
183
184    /// Begin a struct/object.
185    ///
186    /// If `is_root` is true, no braces are written (implicit root object).
187    pub fn begin_struct(&mut self, is_root: bool) {
188        self.begin_struct_with_options(is_root, false);
189    }
190
191    /// Begin a struct/object with explicit multiline control.
192    ///
193    /// If `is_root` is true, no braces are written (implicit root object).
194    /// If `force_multiline` is true, the struct will never be inlined.
195    pub fn begin_struct_with_options(&mut self, is_root: bool, force_multiline: bool) {
196        self.before_value();
197
198        // A struct starts inline if it's appearing as a value on the same line as its key
199        // (i.e., not the root and the opening brace is on the same line)
200        let inline_start = !is_root;
201
202        if is_root {
203            self.stack.push(Context::Struct {
204                first: true,
205                is_root: true,
206                force_multiline,
207                inline_start: false,
208                comma_positions: Vec::new(),
209                open_brace_pos: None,
210            });
211        } else {
212            self.out.push(b'{');
213            let open_pos = self.out.len(); // Position right after '{'
214            self.stack.push(Context::Struct {
215                first: true,
216                is_root: false,
217                force_multiline,
218                inline_start,
219                comma_positions: Vec::new(),
220                open_brace_pos: Some(open_pos),
221            });
222        }
223    }
224
225    /// Begin a struct directly after a tag (no space before the brace).
226    pub fn begin_struct_after_tag(&mut self, force_multiline: bool) {
227        // Don't call before_value() - we want no space after the tag
228        // Clear the force_quote flag since the tag's payload is a struct, not a scalar
229        self.force_quote_next_scalar = false;
230        self.out.push(b'{');
231        let open_pos = self.out.len(); // Position right after '{'
232        self.stack.push(Context::Struct {
233            first: true,
234            is_root: false,
235            force_multiline,
236            inline_start: true,
237            comma_positions: Vec::new(),
238            open_brace_pos: Some(open_pos),
239        });
240    }
241
242    /// Write a field key without quoting (raw).
243    ///
244    /// Use this for keys that should be written exactly as-is, like `@` for unit keys.
245    pub fn field_key_raw(&mut self, key: &str) -> Result<(), &'static str> {
246        // Extract state first to avoid borrow conflicts
247        let (is_struct, is_first, is_root, inline_start, force_multiline) = match self.stack.last()
248        {
249            Some(Context::Struct {
250                first,
251                is_root,
252                inline_start,
253                force_multiline,
254                ..
255            }) => (true, *first, *is_root, *inline_start, *force_multiline),
256            _ => (false, true, false, false, false),
257        };
258
259        if !is_struct {
260            return Err("field_key_raw called outside of struct");
261        }
262
263        // Struct should be inline if it started inline AND isn't forced multiline
264        let struct_is_inline = inline_start && !force_multiline;
265        let should_inline = struct_is_inline || self.should_inline();
266
267        if !is_first {
268            if should_inline && !is_root {
269                // Record comma position for potential later fixing
270                let comma_pos = self.out.len();
271                self.out.extend_from_slice(b", ");
272                if let Some(Context::Struct {
273                    comma_positions, ..
274                }) = self.stack.last_mut()
275                {
276                    comma_positions.push(comma_pos);
277                }
278            } else {
279                // At root level, add blank line between records
280                if is_root {
281                    self.out.push(b'\n');
282                }
283                self.write_newline_indent();
284            }
285        } else {
286            // First field - only add newline if we need multiline and didn't start inline
287            if !is_root && !should_inline {
288                self.write_newline_indent();
289            }
290        }
291
292        // Update the first flag
293        if let Some(Context::Struct { first, .. }) = self.stack.last_mut() {
294            *first = false;
295        }
296
297        // Write key as-is (no quoting)
298        self.out.extend_from_slice(key.as_bytes());
299        self.out.push(b' ');
300        Ok(())
301    }
302
303    /// Write a field key.
304    ///
305    /// Returns an error message if called outside of a struct context.
306    pub fn field_key(&mut self, key: &str) -> Result<(), &'static str> {
307        // Extract state first to avoid borrow conflicts
308        let (is_struct, is_first, is_root, inline_start, force_multiline) = match self.stack.last()
309        {
310            Some(Context::Struct {
311                first,
312                is_root,
313                inline_start,
314                force_multiline,
315                ..
316            }) => (true, *first, *is_root, *inline_start, *force_multiline),
317            _ => (false, true, false, false, false),
318        };
319
320        if !is_struct {
321            return Err("field_key called outside of struct");
322        }
323
324        // Struct should be inline if it started inline AND isn't forced multiline
325        let struct_is_inline = inline_start && !force_multiline;
326        let should_inline = struct_is_inline || self.should_inline();
327
328        if !is_first {
329            if should_inline && !is_root {
330                // Record comma position for potential later fixing
331                let comma_pos = self.out.len();
332                self.out.extend_from_slice(b", ");
333                if let Some(Context::Struct {
334                    comma_positions, ..
335                }) = self.stack.last_mut()
336                {
337                    comma_positions.push(comma_pos);
338                }
339            } else {
340                // At root level, add blank line between records
341                if is_root {
342                    self.out.push(b'\n');
343                }
344                self.write_newline_indent();
345            }
346        } else {
347            // First field - only add newline if we need multiline and didn't start inline
348            if !is_root && !should_inline {
349                self.write_newline_indent();
350            }
351        }
352
353        // Update the first flag
354        if let Some(Context::Struct { first, .. }) = self.stack.last_mut() {
355            *first = false;
356        }
357
358        // Write the key - keys are typically bare identifiers
359        if can_be_bare(key) {
360            self.out.extend_from_slice(key.as_bytes());
361        } else {
362            self.write_quoted_string(key);
363        }
364        self.out.push(b' ');
365        Ok(())
366    }
367
368    /// End a struct/object.
369    ///
370    /// Returns an error message if called without a matching begin_struct.
371    pub fn end_struct(&mut self) -> Result<(), &'static str> {
372        // Check should_inline before popping (need stack state)
373        let should_inline = self.should_inline();
374
375        match self.stack.pop() {
376            Some(Context::Struct {
377                first,
378                is_root,
379                force_multiline,
380                inline_start,
381                ..
382            }) => {
383                if is_root {
384                    // Root struct: add trailing newline if we wrote anything
385                    if !first {
386                        self.out.push(b'\n');
387                    }
388                } else {
389                    // Add newline before closing brace only if:
390                    // - We wrote at least one field, AND
391                    // - Either forced multiline OR (not inline start AND not should_inline)
392                    let needs_newline =
393                        !first && (force_multiline || (!inline_start && !should_inline));
394                    if needs_newline {
395                        // Newline before closing brace
396                        self.out.push(b'\n');
397                        // Indent at the PARENT level (we already popped)
398                        self.write_indent();
399                    }
400                    self.out.push(b'}');
401                }
402                Ok(())
403            }
404            _ => Err("end_struct called without matching begin_struct"),
405        }
406    }
407
408    /// Begin a sequence.
409    pub fn begin_seq(&mut self) {
410        self.before_value();
411        self.out.push(b'(');
412        // Sequences always start inline (on the same line as their key)
413        self.stack.push(Context::Seq {
414            first: true,
415            inline_start: true,
416        });
417    }
418
419    /// End a sequence.
420    ///
421    /// Returns an error message if called without a matching begin_seq.
422    pub fn end_seq(&mut self) -> Result<(), &'static str> {
423        // Check should_inline before popping (need stack state)
424        let should_inline = self.should_inline();
425
426        match self.stack.pop() {
427            Some(Context::Seq {
428                first,
429                inline_start,
430            }) => {
431                // If the sequence started inline, it should end inline
432                // Only add newline if it didn't start inline AND has items AND shouldn't inline
433                if !first && !inline_start && !should_inline {
434                    self.write_newline_indent();
435                }
436                self.out.push(b')');
437                Ok(())
438            }
439            _ => Err("end_seq called without matching begin_seq"),
440        }
441    }
442
443    /// Write a null/unit value.
444    pub fn write_null(&mut self) {
445        self.before_value();
446        self.out.push(b'@');
447    }
448
449    /// Write a boolean value.
450    pub fn write_bool(&mut self, v: bool) {
451        self.before_value();
452        if v {
453            self.out.extend_from_slice(b"true");
454        } else {
455            self.out.extend_from_slice(b"false");
456        }
457    }
458
459    /// Write an i64 value.
460    pub fn write_i64(&mut self, v: i64) {
461        self.before_value();
462        self.out.extend_from_slice(v.to_string().as_bytes());
463    }
464
465    /// Write a u64 value.
466    pub fn write_u64(&mut self, v: u64) {
467        self.before_value();
468        self.out.extend_from_slice(v.to_string().as_bytes());
469    }
470
471    /// Write an i128 value.
472    pub fn write_i128(&mut self, v: i128) {
473        self.before_value();
474        self.out.extend_from_slice(v.to_string().as_bytes());
475    }
476
477    /// Write a u128 value.
478    pub fn write_u128(&mut self, v: u128) {
479        self.before_value();
480        self.out.extend_from_slice(v.to_string().as_bytes());
481    }
482
483    /// Write an f64 value.
484    pub fn write_f64(&mut self, v: f64) {
485        self.before_value();
486        self.out.extend_from_slice(v.to_string().as_bytes());
487    }
488
489    /// Write a string value with appropriate quoting.
490    pub fn write_string(&mut self, s: &str) {
491        self.before_value();
492        self.write_scalar_string(s);
493    }
494
495    /// Write a char value.
496    pub fn write_char(&mut self, c: char) {
497        self.before_value();
498        let mut buf = [0u8; 4];
499        let s = c.encode_utf8(&mut buf);
500        self.write_scalar_string(s);
501    }
502
503    /// Write bytes as hex-encoded string.
504    pub fn write_bytes(&mut self, bytes: &[u8]) {
505        self.before_value();
506        self.out.push(b'"');
507        for byte in bytes.iter() {
508            let hex = |d: u8| {
509                if d < 10 { b'0' + d } else { b'a' + (d - 10) }
510            };
511            self.out.push(hex(byte >> 4));
512            self.out.push(hex(byte & 0xf));
513        }
514        self.out.push(b'"');
515    }
516
517    /// Write a variant tag (e.g., `@some`).
518    pub fn write_variant_tag(&mut self, name: &str) {
519        self.before_value();
520        self.out.push(b'@');
521        self.out.extend_from_slice(name.as_bytes());
522        // The payload should follow without spacing, and must be quoted
523        // (bare scalars cannot be tagged)
524        self.skip_next_before_value = true;
525        self.force_quote_next_scalar = true;
526    }
527
528    /// Write a scalar value with appropriate quoting.
529    /// Alias for write_string, for when you have a pre-existing scalar.
530    pub fn write_scalar(&mut self, s: &str) {
531        self.write_string(s);
532    }
533
534    /// Write a tag (e.g., `@string`). Same as write_variant_tag.
535    pub fn write_tag(&mut self, name: &str) {
536        self.write_variant_tag(name);
537    }
538
539    /// Clear the skip_next_before_value flag.
540    /// Call this when a tag's payload is skipped (e.g., None for a unit variant).
541    pub fn clear_skip_before_value(&mut self) {
542        self.skip_next_before_value = false;
543    }
544
545    /// Begin a sequence directly after a tag (no space before the paren).
546    pub fn begin_seq_after_tag(&mut self) {
547        // Clear the force_quote flag since the tag's payload is a sequence, not a scalar
548        self.force_quote_next_scalar = false;
549        self.out.push(b'(');
550        // Sequences after tags always start inline
551        self.stack.push(Context::Seq {
552            first: true,
553            inline_start: true,
554        });
555    }
556
557    /// Write a doc comment followed by a field key.
558    /// Multiple lines are supported (each line gets `/// ` prefix).
559    pub fn write_doc_comment_and_key(&mut self, doc: &str, key: &str) {
560        // Check if first field and root
561        let (is_first, is_root) = match self.stack.last() {
562            Some(Context::Struct { first, is_root, .. }) => (*first, *is_root),
563            _ => (true, false),
564        };
565
566        // Mark that we're no longer on first field, and force multiline since we have doc comments
567        if let Some(Context::Struct {
568            first,
569            force_multiline,
570            ..
571        }) = self.stack.last_mut()
572        {
573            *first = false;
574            *force_multiline = true;
575        }
576
577        // Fix any commas we wrote before this doc comment (they need to become newlines)
578        self.fix_comma_separators();
579
580        // Propagate multiline to all parent structs that started inline
581        self.propagate_multiline_to_parents();
582
583        // For non-first fields at root level, add a blank line before doc comment
584        // This matches the CST formatter behavior for top-level documented entries
585        if !is_first && is_root {
586            // Add blank line (extra newline) before doc comment
587            self.out.push(b'\n');
588        }
589
590        // For non-first fields, or non-root structs, add newline before doc
591        let need_leading_newline = !is_first || !is_root;
592
593        for (i, line) in doc.lines().enumerate() {
594            if i > 0 || need_leading_newline {
595                self.write_newline_indent();
596            }
597            self.out.extend_from_slice(b"/// ");
598            self.out.extend_from_slice(line.as_bytes());
599        }
600
601        // Newline before the key (but no indent for root first field)
602        if is_first && is_root {
603            self.out.push(b'\n');
604        } else {
605            self.write_newline_indent();
606        }
607
608        // Write the key
609        if can_be_bare(key) {
610            self.out.extend_from_slice(key.as_bytes());
611        } else {
612            self.write_quoted_string(key);
613        }
614        self.out.push(b' ');
615    }
616
617    /// Write a doc comment followed by a raw field key (no quoting).
618    /// Use this for keys like `@` that should be written literally.
619    pub fn write_doc_comment_and_key_raw(&mut self, doc: &str, key: &str) {
620        // Check if first field and root
621        let (is_first, is_root) = match self.stack.last() {
622            Some(Context::Struct { first, is_root, .. }) => (*first, *is_root),
623            _ => (true, false),
624        };
625
626        // Mark that we're no longer on first field, and force multiline since we have doc comments
627        if let Some(Context::Struct {
628            first,
629            force_multiline,
630            ..
631        }) = self.stack.last_mut()
632        {
633            *first = false;
634            *force_multiline = true;
635        }
636
637        // Fix any commas we wrote before this doc comment (they need to become newlines)
638        self.fix_comma_separators();
639
640        // Propagate multiline to all parent structs that started inline
641        self.propagate_multiline_to_parents();
642
643        // For non-first fields at root level, add a blank line before doc comment
644        // This matches the CST formatter behavior for top-level documented entries
645        if !is_first && is_root {
646            // Add blank line (extra newline) before doc comment
647            self.out.push(b'\n');
648        }
649
650        // For non-first fields, or non-root structs, add newline before doc
651        let need_leading_newline = !is_first || !is_root;
652
653        for (i, line) in doc.lines().enumerate() {
654            if i > 0 || need_leading_newline {
655                self.write_newline_indent();
656            }
657            self.out.extend_from_slice(b"/// ");
658            self.out.extend_from_slice(line.as_bytes());
659        }
660
661        // Newline before the key (but no indent for root first field)
662        if is_first && is_root {
663            self.out.push(b'\n');
664        } else {
665            self.write_newline_indent();
666        }
667
668        // Write the key as-is (no quoting)
669        self.out.extend_from_slice(key.as_bytes());
670        self.out.push(b' ');
671    }
672
673    // ─────────────────────────────────────────────────────────────────────────
674    // Internal helpers
675    // ─────────────────────────────────────────────────────────────────────────
676
677    /// Propagate multiline formatting up to all parent structs that started inline.
678    /// This is called when doc comments force the current struct to be multiline.
679    fn propagate_multiline_to_parents(&mut self) {
680        // First, collect all comma positions from parent structs that will go multiline
681        // We need to fix these before inserting newlines
682        let mut all_comma_positions: Vec<usize> = Vec::new();
683        let mut structs_to_fix: Vec<usize> = Vec::new(); // indices in stack
684
685        for (idx, ctx) in self.stack.iter().enumerate() {
686            if let Context::Struct {
687                inline_start,
688                force_multiline,
689                is_root,
690                comma_positions,
691                ..
692            } = ctx
693                && !*is_root
694                && *inline_start
695                && !*force_multiline
696            {
697                structs_to_fix.push(idx);
698                all_comma_positions.extend(comma_positions.iter().copied());
699            }
700        }
701
702        // Fix all comma separators first (before we insert newlines which would shift positions)
703        if !all_comma_positions.is_empty() {
704            // Sort and process from end to start
705            all_comma_positions.sort_unstable();
706            for &comma_pos in all_comma_positions.iter().rev() {
707                // Replace ", " with newline + appropriate indent
708                if comma_pos + 2 <= self.out.len()
709                    && self.out[comma_pos] == b','
710                    && self.out[comma_pos + 1] == b' '
711                {
712                    // Calculate indent for this position
713                    let indent = self.options.indent.repeat(self.indent_depth());
714                    let newline_indent = format!("\n{}", indent);
715                    self.out.drain(comma_pos..comma_pos + 2);
716                    let bytes = newline_indent.as_bytes();
717                    for (i, &b) in bytes.iter().enumerate() {
718                        self.out.insert(comma_pos + i, b);
719                    }
720                }
721            }
722        }
723
724        // Clear comma positions from structs we're fixing
725        for &idx in &structs_to_fix {
726            if let Some(Context::Struct {
727                comma_positions, ..
728            }) = self.stack.get_mut(idx)
729            {
730                comma_positions.clear();
731            }
732        }
733
734        // Now collect positions and effective depths of structs that need newline after brace
735        let mut fixes: Vec<(usize, usize)> = Vec::new(); // (open_brace_pos, effective_depth)
736
737        let mut effective_depth = 0;
738        for ctx in self.stack.iter_mut() {
739            if let Context::Struct {
740                inline_start,
741                force_multiline,
742                is_root,
743                open_brace_pos: Some(pos),
744                ..
745            } = ctx
746            {
747                // Root doesn't add depth
748                if !*is_root {
749                    // If this struct started inline but hasn't been fixed yet
750                    if *inline_start && !*force_multiline {
751                        *force_multiline = true;
752                        // Content inside this struct should be at effective_depth + 1
753                        fixes.push((*pos, effective_depth + 1));
754                    }
755                    // After forcing multiline, this struct now contributes to depth
756                    effective_depth += 1;
757                }
758            }
759        }
760
761        // Apply fixes from end to start (so positions stay valid)
762        for (pos, indent_depth) in fixes.into_iter().rev() {
763            // Insert newline + indent after the opening brace
764            let indent = self.options.indent.repeat(indent_depth);
765            let insert = format!("\n{}", indent);
766            for (i, b) in insert.bytes().enumerate() {
767                self.out.insert(pos + i, b);
768            }
769
770            // Update all positions in the stack that come after this insertion
771            for ctx in self.stack.iter_mut() {
772                if let Context::Struct {
773                    open_brace_pos: Some(p),
774                    comma_positions,
775                    ..
776                } = ctx
777                {
778                    if *p > pos {
779                        *p += insert.len();
780                    }
781                    for cp in comma_positions.iter_mut() {
782                        if *cp >= pos {
783                            *cp += insert.len();
784                        }
785                    }
786                }
787            }
788        }
789    }
790
791    /// Fix any comma separators in the current struct by replacing them with newlines.
792    /// Call this when switching from inline to multiline formatting mid-struct.
793    fn fix_comma_separators(&mut self) {
794        // Extract comma positions from current struct context
795        let comma_positions = match self.stack.last_mut() {
796            Some(Context::Struct {
797                comma_positions, ..
798            }) => std::mem::take(comma_positions),
799            _ => return,
800        };
801
802        if comma_positions.is_empty() {
803            return;
804        }
805
806        // Calculate the indentation string for this struct
807        let indent = self.options.indent.repeat(self.indent_depth());
808        let newline_indent = format!("\n{}", indent);
809
810        // Process comma positions from end to start (so earlier positions stay valid)
811        for &comma_pos in comma_positions.iter().rev() {
812            // Replace ", " (2 bytes) with "\n" + indent
813            // First, verify this position has ", " (sanity check)
814            if comma_pos + 2 <= self.out.len()
815                && self.out[comma_pos] == b','
816                && self.out[comma_pos + 1] == b' '
817            {
818                // Remove ", " and insert newline+indent
819                self.out.drain(comma_pos..comma_pos + 2);
820                let bytes = newline_indent.as_bytes();
821                for (i, &b) in bytes.iter().enumerate() {
822                    self.out.insert(comma_pos + i, b);
823                }
824            }
825        }
826    }
827
828    /// Handle separator before a value in a container.
829    pub fn before_value(&mut self) {
830        // If we just wrote a tag, skip spacing for the payload
831        if self.skip_next_before_value {
832            self.skip_next_before_value = false;
833            // Still need to update the first flag
834            if let Some(Context::Seq { first, .. }) = self.stack.last_mut() {
835                *first = false;
836            }
837            return;
838        }
839
840        // Extract state first to avoid borrow conflicts
841        let (is_seq, is_first, inline_start) = match self.stack.last() {
842            Some(Context::Seq {
843                first,
844                inline_start,
845            }) => (true, *first, *inline_start),
846            _ => (false, true, false),
847        };
848
849        if is_seq && !is_first {
850            // Sequence stays inline if it started inline
851            if inline_start || self.should_inline() {
852                self.out.push(b' ');
853            } else {
854                self.write_newline_indent();
855            }
856        }
857
858        // Update the first flag
859        if let Some(Context::Seq { first, .. }) = self.stack.last_mut() {
860            *first = false;
861        }
862    }
863
864    /// Write a scalar value with appropriate quoting.
865    fn write_scalar_string(&mut self, s: &str) {
866        // Check if we need to force quoting (e.g., after a tag)
867        let force_quote = self.force_quote_next_scalar;
868        self.force_quote_next_scalar = false;
869
870        // Rule 1: Prefer bare scalars when valid (but not after a tag)
871        if !force_quote && can_be_bare(s) {
872            self.out.extend_from_slice(s.as_bytes());
873            return;
874        }
875
876        let newline_count = count_newlines(s);
877        let escape_count = count_escapes(s);
878
879        // Rule 3: Use heredocs for multi-line text
880        if newline_count >= self.options.heredoc_line_threshold {
881            self.write_heredoc(s);
882            return;
883        }
884
885        // Rule 2: Use raw strings for complex escaping (> 3 escapes)
886        if escape_count > 3 && !s.contains("\"#") {
887            self.write_raw_string(s);
888            return;
889        }
890
891        // Default: quoted string
892        self.write_quoted_string(s);
893    }
894
895    /// Write a quoted string with proper escaping.
896    fn write_quoted_string(&mut self, s: &str) {
897        self.out.push(b'"');
898        let escaped = escape_quoted(s);
899        self.out.extend_from_slice(escaped.as_bytes());
900        self.out.push(b'"');
901    }
902
903    /// Write a raw string (r#"..."#).
904    fn write_raw_string(&mut self, s: &str) {
905        // Find the minimum number of # needed
906        let mut hashes = 0;
907        let mut check = String::from("\"");
908        while s.contains(&check) {
909            hashes += 1;
910            check = format!("\"{}#", "#".repeat(hashes - 1));
911        }
912
913        self.out.push(b'r');
914        for _ in 0..hashes {
915            self.out.push(b'#');
916        }
917        self.out.push(b'"');
918        self.out.extend_from_slice(s.as_bytes());
919        self.out.push(b'"');
920        for _ in 0..hashes {
921            self.out.push(b'#');
922        }
923    }
924
925    /// Write a heredoc string.
926    fn write_heredoc(&mut self, s: &str) {
927        // Find a delimiter that doesn't appear in the string
928        let delimiters = ["TEXT", "END", "HEREDOC", "DOC", "STR", "CONTENT"];
929        let delimiter = delimiters
930            .iter()
931            .find(|d| !s.contains(*d))
932            .unwrap_or(&"TEXT");
933
934        self.out.extend_from_slice(b"<<");
935        self.out.extend_from_slice(delimiter.as_bytes());
936        self.out.push(b'\n');
937        self.out.extend_from_slice(s.as_bytes());
938        if !s.ends_with('\n') {
939            self.out.push(b'\n');
940        }
941        self.out.extend_from_slice(delimiter.as_bytes());
942    }
943}
944
945impl Default for StyxWriter {
946    fn default() -> Self {
947        Self::new()
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn test_simple_struct() {
957        let mut w = StyxWriter::new();
958        w.begin_struct(true);
959        w.field_key("name").unwrap();
960        w.write_string("hello");
961        w.field_key("value").unwrap();
962        w.write_i64(42);
963        w.end_struct().unwrap();
964
965        let result = w.finish_string();
966        assert!(result.contains("name hello"));
967        assert!(result.contains("value 42"));
968    }
969
970    #[test]
971    fn test_nested_inline() {
972        let mut w = StyxWriter::with_options(FormatOptions::default());
973        w.begin_struct(true);
974        w.field_key("point").unwrap();
975        w.begin_struct(false);
976        w.field_key("x").unwrap();
977        w.write_i64(10);
978        w.field_key("y").unwrap();
979        w.write_i64(20);
980        w.end_struct().unwrap();
981        w.end_struct().unwrap();
982
983        let result = w.finish_string();
984        // Nested struct should be inline
985        assert!(result.contains("{x 10, y 20}"));
986    }
987
988    #[test]
989    fn test_sequence() {
990        let mut w = StyxWriter::new();
991        w.begin_struct(true);
992        w.field_key("items").unwrap();
993        w.begin_seq();
994        w.write_i64(1);
995        w.write_i64(2);
996        w.write_i64(3);
997        w.end_seq().unwrap();
998        w.end_struct().unwrap();
999
1000        let result = w.finish_string();
1001        assert!(result.contains("items (1 2 3)"));
1002    }
1003
1004    #[test]
1005    fn test_quoted_string() {
1006        let mut w = StyxWriter::new();
1007        w.begin_struct(true);
1008        w.field_key("message").unwrap();
1009        w.write_string("hello world");
1010        w.end_struct().unwrap();
1011
1012        let result = w.finish_string();
1013        assert!(result.contains("message \"hello world\""));
1014    }
1015
1016    #[test]
1017    fn test_force_inline() {
1018        let mut w = StyxWriter::with_options(FormatOptions::default().inline());
1019        w.begin_struct(false);
1020        w.field_key("a").unwrap();
1021        w.write_i64(1);
1022        w.field_key("b").unwrap();
1023        w.write_i64(2);
1024        w.end_struct().unwrap();
1025
1026        let result = w.finish_string();
1027        assert_eq!(result, "{a 1, b 2}");
1028    }
1029
1030    #[test]
1031    fn test_doc_comment_fixes_commas() {
1032        // When a doc comment is added mid-struct, any previously written
1033        // commas should be replaced with newlines to avoid mixed separators.
1034        let mut w = StyxWriter::with_options(FormatOptions::default().inline());
1035        w.begin_struct(false);
1036        w.field_key("a").unwrap();
1037        w.write_i64(1);
1038        w.field_key("b").unwrap();
1039        w.write_i64(2);
1040        // This doc comment should trigger comma -> newline conversion
1041        w.write_doc_comment_and_key("A documented field", "c");
1042        w.write_i64(3);
1043        w.end_struct().unwrap();
1044
1045        let result = w.finish_string();
1046        // Should NOT contain ", " since commas were replaced with newlines
1047        assert!(
1048            !result.contains(", "),
1049            "Result should not contain commas after doc comment: {}",
1050            result
1051        );
1052        // Should contain newline-separated entries
1053        assert!(
1054            result.contains("a 1\n"),
1055            "Expected newline after a: {}",
1056            result
1057        );
1058    }
1059}