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    /// Append a chained tag segment (e.g. `/@inner`) after an already-written tag.
540    pub fn write_tag_chain_segment(&mut self, name: &str) {
541        self.out.extend_from_slice(b"/@");
542        self.out.extend_from_slice(name.as_bytes());
543        self.skip_next_before_value = true;
544        self.force_quote_next_scalar = true;
545    }
546
547    /// Clear tag-follow state when no attached payload will be written.
548    pub fn clear_skip_before_value(&mut self) {
549        self.skip_next_before_value = false;
550        self.force_quote_next_scalar = false;
551    }
552
553    /// Begin a sequence directly after a tag (no space before the paren).
554    pub fn begin_seq_after_tag(&mut self) {
555        // Clear the force_quote flag since the tag's payload is a sequence, not a scalar
556        self.force_quote_next_scalar = false;
557        self.out.push(b'(');
558        // Sequences after tags always start inline
559        self.stack.push(Context::Seq {
560            first: true,
561            inline_start: true,
562        });
563    }
564
565    /// Write a doc comment followed by a field key.
566    /// Multiple lines are supported (each line gets `/// ` prefix).
567    pub fn write_doc_comment_and_key(&mut self, doc: &str, key: &str) {
568        // Check if first field and root
569        let (is_first, is_root) = match self.stack.last() {
570            Some(Context::Struct { first, is_root, .. }) => (*first, *is_root),
571            _ => (true, false),
572        };
573
574        // Mark that we're no longer on first field, and force multiline since we have doc comments
575        if let Some(Context::Struct {
576            first,
577            force_multiline,
578            ..
579        }) = self.stack.last_mut()
580        {
581            *first = false;
582            *force_multiline = true;
583        }
584
585        // Fix any commas we wrote before this doc comment (they need to become newlines)
586        self.fix_comma_separators();
587
588        // Propagate multiline to all parent structs that started inline
589        self.propagate_multiline_to_parents();
590
591        // For non-first fields at root level, add a blank line before doc comment
592        // This matches the CST formatter behavior for top-level documented entries
593        if !is_first && is_root {
594            // Add blank line (extra newline) before doc comment
595            self.out.push(b'\n');
596        }
597
598        // For non-first fields, or non-root structs, add newline before doc
599        let need_leading_newline = !is_first || !is_root;
600
601        for (i, line) in doc.lines().enumerate() {
602            if i > 0 || need_leading_newline {
603                self.write_newline_indent();
604            }
605            self.out.extend_from_slice(b"/// ");
606            self.out.extend_from_slice(line.as_bytes());
607        }
608
609        // Newline before the key (but no indent for root first field)
610        if is_first && is_root {
611            self.out.push(b'\n');
612        } else {
613            self.write_newline_indent();
614        }
615
616        // Write the key
617        if can_be_bare(key) {
618            self.out.extend_from_slice(key.as_bytes());
619        } else {
620            self.write_quoted_string(key);
621        }
622        self.out.push(b' ');
623    }
624
625    /// Write a doc comment followed by a raw field key (no quoting).
626    /// Use this for keys like `@` that should be written literally.
627    pub fn write_doc_comment_and_key_raw(&mut self, doc: &str, key: &str) {
628        // Check if first field and root
629        let (is_first, is_root) = match self.stack.last() {
630            Some(Context::Struct { first, is_root, .. }) => (*first, *is_root),
631            _ => (true, false),
632        };
633
634        // Mark that we're no longer on first field, and force multiline since we have doc comments
635        if let Some(Context::Struct {
636            first,
637            force_multiline,
638            ..
639        }) = self.stack.last_mut()
640        {
641            *first = false;
642            *force_multiline = true;
643        }
644
645        // Fix any commas we wrote before this doc comment (they need to become newlines)
646        self.fix_comma_separators();
647
648        // Propagate multiline to all parent structs that started inline
649        self.propagate_multiline_to_parents();
650
651        // For non-first fields at root level, add a blank line before doc comment
652        // This matches the CST formatter behavior for top-level documented entries
653        if !is_first && is_root {
654            // Add blank line (extra newline) before doc comment
655            self.out.push(b'\n');
656        }
657
658        // For non-first fields, or non-root structs, add newline before doc
659        let need_leading_newline = !is_first || !is_root;
660
661        for (i, line) in doc.lines().enumerate() {
662            if i > 0 || need_leading_newline {
663                self.write_newline_indent();
664            }
665            self.out.extend_from_slice(b"/// ");
666            self.out.extend_from_slice(line.as_bytes());
667        }
668
669        // Newline before the key (but no indent for root first field)
670        if is_first && is_root {
671            self.out.push(b'\n');
672        } else {
673            self.write_newline_indent();
674        }
675
676        // Write the key as-is (no quoting)
677        self.out.extend_from_slice(key.as_bytes());
678        self.out.push(b' ');
679    }
680
681    // ─────────────────────────────────────────────────────────────────────────
682    // Internal helpers
683    // ─────────────────────────────────────────────────────────────────────────
684
685    /// Propagate multiline formatting up to all parent structs that started inline.
686    /// This is called when doc comments force the current struct to be multiline.
687    fn propagate_multiline_to_parents(&mut self) {
688        // First, collect all comma positions from parent structs that will go multiline
689        // We need to fix these before inserting newlines
690        let mut all_comma_positions: Vec<usize> = Vec::new();
691        let mut structs_to_fix: Vec<usize> = Vec::new(); // indices in stack
692
693        for (idx, ctx) in self.stack.iter().enumerate() {
694            if let Context::Struct {
695                inline_start,
696                force_multiline,
697                is_root,
698                comma_positions,
699                ..
700            } = ctx
701                && !*is_root
702                && *inline_start
703                && !*force_multiline
704            {
705                structs_to_fix.push(idx);
706                all_comma_positions.extend(comma_positions.iter().copied());
707            }
708        }
709
710        // Fix all comma separators first (before we insert newlines which would shift positions)
711        if !all_comma_positions.is_empty() {
712            // Sort and process from end to start
713            all_comma_positions.sort_unstable();
714            for &comma_pos in all_comma_positions.iter().rev() {
715                // Replace ", " with newline + appropriate indent
716                if comma_pos + 2 <= self.out.len()
717                    && self.out[comma_pos] == b','
718                    && self.out[comma_pos + 1] == b' '
719                {
720                    // Calculate indent for this position
721                    let indent = self.options.indent.repeat(self.indent_depth());
722                    let newline_indent = format!("\n{}", indent);
723                    self.out.drain(comma_pos..comma_pos + 2);
724                    let bytes = newline_indent.as_bytes();
725                    for (i, &b) in bytes.iter().enumerate() {
726                        self.out.insert(comma_pos + i, b);
727                    }
728                }
729            }
730        }
731
732        // Clear comma positions from structs we're fixing
733        for &idx in &structs_to_fix {
734            if let Some(Context::Struct {
735                comma_positions, ..
736            }) = self.stack.get_mut(idx)
737            {
738                comma_positions.clear();
739            }
740        }
741
742        // Now collect positions and effective depths of structs that need newline after brace
743        let mut fixes: Vec<(usize, usize)> = Vec::new(); // (open_brace_pos, effective_depth)
744
745        let mut effective_depth = 0;
746        for ctx in self.stack.iter_mut() {
747            if let Context::Struct {
748                inline_start,
749                force_multiline,
750                is_root,
751                open_brace_pos: Some(pos),
752                ..
753            } = ctx
754            {
755                // Root doesn't add depth
756                if !*is_root {
757                    // If this struct started inline but hasn't been fixed yet
758                    if *inline_start && !*force_multiline {
759                        *force_multiline = true;
760                        // Content inside this struct should be at effective_depth + 1
761                        fixes.push((*pos, effective_depth + 1));
762                    }
763                    // After forcing multiline, this struct now contributes to depth
764                    effective_depth += 1;
765                }
766            }
767        }
768
769        // Apply fixes from end to start (so positions stay valid)
770        for (pos, indent_depth) in fixes.into_iter().rev() {
771            // Insert newline + indent after the opening brace
772            let indent = self.options.indent.repeat(indent_depth);
773            let insert = format!("\n{}", indent);
774            for (i, b) in insert.bytes().enumerate() {
775                self.out.insert(pos + i, b);
776            }
777
778            // Update all positions in the stack that come after this insertion
779            for ctx in self.stack.iter_mut() {
780                if let Context::Struct {
781                    open_brace_pos: Some(p),
782                    comma_positions,
783                    ..
784                } = ctx
785                {
786                    if *p > pos {
787                        *p += insert.len();
788                    }
789                    for cp in comma_positions.iter_mut() {
790                        if *cp >= pos {
791                            *cp += insert.len();
792                        }
793                    }
794                }
795            }
796        }
797    }
798
799    /// Fix any comma separators in the current struct by replacing them with newlines.
800    /// Call this when switching from inline to multiline formatting mid-struct.
801    fn fix_comma_separators(&mut self) {
802        // Extract comma positions from current struct context
803        let comma_positions = match self.stack.last_mut() {
804            Some(Context::Struct {
805                comma_positions, ..
806            }) => std::mem::take(comma_positions),
807            _ => return,
808        };
809
810        if comma_positions.is_empty() {
811            return;
812        }
813
814        // Calculate the indentation string for this struct
815        let indent = self.options.indent.repeat(self.indent_depth());
816        let newline_indent = format!("\n{}", indent);
817
818        // Process comma positions from end to start (so earlier positions stay valid)
819        for &comma_pos in comma_positions.iter().rev() {
820            // Replace ", " (2 bytes) with "\n" + indent
821            // First, verify this position has ", " (sanity check)
822            if comma_pos + 2 <= self.out.len()
823                && self.out[comma_pos] == b','
824                && self.out[comma_pos + 1] == b' '
825            {
826                // Remove ", " and insert newline+indent
827                self.out.drain(comma_pos..comma_pos + 2);
828                let bytes = newline_indent.as_bytes();
829                for (i, &b) in bytes.iter().enumerate() {
830                    self.out.insert(comma_pos + i, b);
831                }
832            }
833        }
834    }
835
836    /// Handle separator before a value in a container.
837    pub fn before_value(&mut self) {
838        // If we just wrote a tag, skip spacing for the payload
839        if self.skip_next_before_value {
840            self.skip_next_before_value = false;
841            // Still need to update the first flag
842            if let Some(Context::Seq { first, .. }) = self.stack.last_mut() {
843                *first = false;
844            }
845            return;
846        }
847
848        // Extract state first to avoid borrow conflicts
849        let (is_seq, is_first, inline_start) = match self.stack.last() {
850            Some(Context::Seq {
851                first,
852                inline_start,
853            }) => (true, *first, *inline_start),
854            _ => (false, true, false),
855        };
856
857        if is_seq && !is_first {
858            // Sequence stays inline if it started inline
859            if inline_start || self.should_inline() {
860                self.out.push(b' ');
861            } else {
862                self.write_newline_indent();
863            }
864        }
865
866        // Update the first flag
867        if let Some(Context::Seq { first, .. }) = self.stack.last_mut() {
868            *first = false;
869        }
870    }
871
872    /// Write a scalar value with appropriate quoting.
873    fn write_scalar_string(&mut self, s: &str) {
874        // Check if we need to force quoting (e.g., after a tag)
875        let force_quote = self.force_quote_next_scalar;
876        self.force_quote_next_scalar = false;
877
878        // Rule 1: Prefer bare scalars when valid (but not after a tag)
879        if !force_quote && can_be_bare(s) {
880            self.out.extend_from_slice(s.as_bytes());
881            return;
882        }
883
884        let newline_count = count_newlines(s);
885        let escape_count = count_escapes(s);
886
887        // Rule 3: Use heredocs for multi-line text
888        if newline_count >= self.options.heredoc_line_threshold {
889            self.write_heredoc(s);
890            return;
891        }
892
893        // Rule 2: Use raw strings for complex escaping (> 3 escapes)
894        if escape_count > 3 && !s.contains("\"#") {
895            self.write_raw_string(s);
896            return;
897        }
898
899        // Default: quoted string
900        self.write_quoted_string(s);
901    }
902
903    /// Write a quoted string with proper escaping.
904    fn write_quoted_string(&mut self, s: &str) {
905        self.out.push(b'"');
906        let escaped = escape_quoted(s);
907        self.out.extend_from_slice(escaped.as_bytes());
908        self.out.push(b'"');
909    }
910
911    /// Write a raw string (r#"..."#).
912    fn write_raw_string(&mut self, s: &str) {
913        // Find the minimum number of # needed
914        let mut hashes = 0;
915        let mut check = String::from("\"");
916        while s.contains(&check) {
917            hashes += 1;
918            check = format!("\"{}#", "#".repeat(hashes - 1));
919        }
920
921        self.out.push(b'r');
922        for _ in 0..hashes {
923            self.out.push(b'#');
924        }
925        self.out.push(b'"');
926        self.out.extend_from_slice(s.as_bytes());
927        self.out.push(b'"');
928        for _ in 0..hashes {
929            self.out.push(b'#');
930        }
931    }
932
933    /// Write a heredoc string.
934    fn write_heredoc(&mut self, s: &str) {
935        // Find a delimiter that doesn't appear in the string
936        let delimiters = ["TEXT", "END", "HEREDOC", "DOC", "STR", "CONTENT"];
937        let delimiter = delimiters
938            .iter()
939            .find(|d| !s.contains(*d))
940            .unwrap_or(&"TEXT");
941
942        self.out.extend_from_slice(b"<<");
943        self.out.extend_from_slice(delimiter.as_bytes());
944        self.out.push(b'\n');
945        self.out.extend_from_slice(s.as_bytes());
946        if !s.ends_with('\n') {
947            self.out.push(b'\n');
948        }
949        self.out.extend_from_slice(delimiter.as_bytes());
950    }
951}
952
953impl Default for StyxWriter {
954    fn default() -> Self {
955        Self::new()
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    #[test]
964    fn test_simple_struct() {
965        let mut w = StyxWriter::new();
966        w.begin_struct(true);
967        w.field_key("name").unwrap();
968        w.write_string("hello");
969        w.field_key("value").unwrap();
970        w.write_i64(42);
971        w.end_struct().unwrap();
972
973        let result = w.finish_string();
974        assert!(result.contains("name hello"));
975        assert!(result.contains("value 42"));
976    }
977
978    #[test]
979    fn test_nested_inline() {
980        let mut w = StyxWriter::with_options(FormatOptions::default());
981        w.begin_struct(true);
982        w.field_key("point").unwrap();
983        w.begin_struct(false);
984        w.field_key("x").unwrap();
985        w.write_i64(10);
986        w.field_key("y").unwrap();
987        w.write_i64(20);
988        w.end_struct().unwrap();
989        w.end_struct().unwrap();
990
991        let result = w.finish_string();
992        // Nested struct should be inline
993        assert!(result.contains("{x 10, y 20}"));
994    }
995
996    #[test]
997    fn test_sequence() {
998        let mut w = StyxWriter::new();
999        w.begin_struct(true);
1000        w.field_key("items").unwrap();
1001        w.begin_seq();
1002        w.write_i64(1);
1003        w.write_i64(2);
1004        w.write_i64(3);
1005        w.end_seq().unwrap();
1006        w.end_struct().unwrap();
1007
1008        let result = w.finish_string();
1009        assert!(result.contains("items (1 2 3)"));
1010    }
1011
1012    #[test]
1013    fn test_quoted_string() {
1014        let mut w = StyxWriter::new();
1015        w.begin_struct(true);
1016        w.field_key("message").unwrap();
1017        w.write_string("hello world");
1018        w.end_struct().unwrap();
1019
1020        let result = w.finish_string();
1021        assert!(result.contains("message \"hello world\""));
1022    }
1023
1024    #[test]
1025    fn test_force_inline() {
1026        let mut w = StyxWriter::with_options(FormatOptions::default().inline());
1027        w.begin_struct(false);
1028        w.field_key("a").unwrap();
1029        w.write_i64(1);
1030        w.field_key("b").unwrap();
1031        w.write_i64(2);
1032        w.end_struct().unwrap();
1033
1034        let result = w.finish_string();
1035        assert_eq!(result, "{a 1, b 2}");
1036    }
1037
1038    #[test]
1039    fn test_doc_comment_fixes_commas() {
1040        // When a doc comment is added mid-struct, any previously written
1041        // commas should be replaced with newlines to avoid mixed separators.
1042        let mut w = StyxWriter::with_options(FormatOptions::default().inline());
1043        w.begin_struct(false);
1044        w.field_key("a").unwrap();
1045        w.write_i64(1);
1046        w.field_key("b").unwrap();
1047        w.write_i64(2);
1048        // This doc comment should trigger comma -> newline conversion
1049        w.write_doc_comment_and_key("A documented field", "c");
1050        w.write_i64(3);
1051        w.end_struct().unwrap();
1052
1053        let result = w.finish_string();
1054        // Should NOT contain ", " since commas were replaced with newlines
1055        assert!(
1056            !result.contains(", "),
1057            "Result should not contain commas after doc comment: {}",
1058            result
1059        );
1060        // Should contain newline-separated entries
1061        assert!(
1062            result.contains("a 1\n"),
1063            "Expected newline after a: {}",
1064            result
1065        );
1066    }
1067}