Skip to main content

stryke/
format.rs

1//! Perl `format` / `write` — picture lines and field padding (subset of Perl 5 `perlform`).
2
3use crate::ast::Expr;
4use crate::error::{StrykeError, StrykeResult};
5use crate::parser::parse_format_value_line;
6
7/// Parsed `format NAME = ... .` body (after registration).
8#[derive(Debug, Clone)]
9pub struct FormatTemplate {
10    /// `records` field.
11    pub records: Vec<FormatRecord>,
12}
13/// `FormatRecord` — see variants.
14#[derive(Debug, Clone)]
15pub enum FormatRecord {
16    /// Line with no `@` fields — printed as-is (newline added by `write`).
17    Literal(String),
18    /// Picture line with `@…` fields plus the following value line (comma-separated exprs).
19    Picture {
20        segments: Vec<PictureSegment>,
21        exprs: Vec<Expr>,
22    },
23}
24/// `FieldAlign` — see variants.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FieldAlign {
27    /// `Left` variant.
28    Left,
29    /// `Right` variant.
30    Right,
31    /// `Center` variant.
32    Center,
33    /// `Numeric` variant.
34    Numeric,
35    /// `Multiline` variant.
36    Multiline,
37}
38/// `FieldKind` — see variants.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum FieldKind {
41    /// `Text` variant.
42    Text,
43    /// `Numeric` variant.
44    Numeric,
45    /// `Multiline` variant.
46    Multiline,
47}
48/// `PictureSegment` — see variants.
49#[derive(Debug, Clone)]
50pub enum PictureSegment {
51    /// `Literal` variant.
52    Literal(String),
53    /// `Field` variant.
54    Field {
55        width: usize,
56        align: FieldAlign,
57        kind: FieldKind,
58    },
59}
60
61/// Build a template from raw lines between `format N =` and `.`.
62pub fn parse_format_template(lines: &[String]) -> StrykeResult<FormatTemplate> {
63    let mut records = Vec::new();
64    let mut i = 0;
65    while i < lines.len() {
66        let pic_line = &lines[i];
67        if !pic_line.contains('@') {
68            records.push(FormatRecord::Literal(pic_line.clone()));
69            i += 1;
70            continue;
71        }
72        let segments = parse_picture_segments(pic_line)?;
73        let n_fields = segments
74            .iter()
75            .filter(|s| matches!(s, PictureSegment::Field { .. }))
76            .count();
77        i += 1;
78        if i >= lines.len() {
79            return Err(StrykeError::syntax(
80                "picture line with @ fields must be followed by a value line",
81                0,
82            ));
83        }
84        let exprs = parse_format_value_line(&lines[i])?;
85        if exprs.len() != n_fields {
86            return Err(StrykeError::syntax(
87                format!(
88                    "format: {} picture field(s) but {} value expression(s)",
89                    n_fields,
90                    exprs.len()
91                ),
92                0,
93            ));
94        }
95        records.push(FormatRecord::Picture { segments, exprs });
96        i += 1;
97    }
98    Ok(FormatTemplate { records })
99}
100
101fn parse_picture_segments(pic: &str) -> StrykeResult<Vec<PictureSegment>> {
102    let mut out = Vec::new();
103    let mut lit = String::new();
104    let mut chars = pic.chars().peekable();
105    while let Some(c) = chars.next() {
106        if c == '@' {
107            if chars.peek() == Some(&'@') {
108                chars.next();
109                lit.push('@');
110                continue;
111            }
112            if !lit.is_empty() {
113                out.push(PictureSegment::Literal(std::mem::take(&mut lit)));
114            }
115            // width starts at 1 because `@` itself counts as one column
116            let mut width = 1usize;
117            let align = match chars.peek() {
118                Some('<') => {
119                    while chars.peek() == Some(&'<') {
120                        chars.next();
121                        width += 1;
122                    }
123                    FieldAlign::Left
124                }
125                Some('>') => {
126                    while chars.peek() == Some(&'>') {
127                        chars.next();
128                        width += 1;
129                    }
130                    FieldAlign::Right
131                }
132                Some('|') => {
133                    while chars.peek() == Some(&'|') {
134                        chars.next();
135                        width += 1;
136                    }
137                    FieldAlign::Center
138                }
139                Some('#') => {
140                    while chars.peek() == Some(&'#') {
141                        chars.next();
142                        width += 1;
143                    }
144                    FieldAlign::Numeric
145                }
146                Some('*') => {
147                    while chars.peek() == Some(&'*') {
148                        chars.next();
149                        width += 1;
150                    }
151                    FieldAlign::Multiline
152                }
153                _ => {
154                    width = 1;
155                    FieldAlign::Left
156                }
157            };
158            let kind = match align {
159                FieldAlign::Numeric => FieldKind::Numeric,
160                FieldAlign::Multiline => FieldKind::Multiline,
161                _ => FieldKind::Text,
162            };
163            out.push(PictureSegment::Field { width, align, kind });
164        } else {
165            lit.push(c);
166        }
167    }
168    if !lit.is_empty() {
169        out.push(PictureSegment::Literal(lit));
170    }
171    Ok(out)
172}
173
174/// Pad/truncate a value to `width` display columns (character count).
175pub fn pad_field(s: &str, width: usize, align: FieldAlign) -> String {
176    let s = if s.chars().count() > width {
177        s.chars().take(width).collect::<String>()
178    } else {
179        s.to_string()
180    };
181    let len = s.chars().count();
182    match align {
183        FieldAlign::Left => {
184            let pad = width.saturating_sub(len);
185            format!("{}{}", s, " ".repeat(pad))
186        }
187        FieldAlign::Multiline => {
188            let first = s.lines().next().unwrap_or("");
189            let fl = first.chars().count();
190            let t = if fl > width {
191                first.chars().take(width).collect::<String>()
192            } else {
193                first.to_string()
194            };
195            let pad = width.saturating_sub(t.chars().count());
196            format!("{}{}", t, " ".repeat(pad))
197        }
198        FieldAlign::Right => {
199            let pad = width.saturating_sub(len);
200            format!("{}{}", " ".repeat(pad), s)
201        }
202        FieldAlign::Center => {
203            let pad = width.saturating_sub(len);
204            let left = pad / 2;
205            let right = pad - left;
206            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
207        }
208        FieldAlign::Numeric => {
209            if let Ok(n) = s.parse::<i64>() {
210                format!("{n:>width$}", n = n, width = width)
211            } else if let Ok(f) = s.parse::<f64>() {
212                format!("{f:>width$}", f = f, width = width)
213            } else {
214                let pad = width.saturating_sub(len);
215                format!("{}{}", " ".repeat(pad), s)
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn parse_format_template_empty() {
227        let t = parse_format_template(&[]).expect("parse");
228        assert!(t.records.is_empty());
229    }
230
231    #[test]
232    fn parse_format_template_literal_only() {
233        let t = parse_format_template(&["no fields here".to_string()]).expect("parse");
234        assert_eq!(t.records.len(), 1);
235        assert!(matches!(
236            &t.records[0],
237            FormatRecord::Literal(s) if s == "no fields here"
238        ));
239    }
240
241    #[test]
242    fn parse_format_template_picture_and_value_line() {
243        let t =
244            parse_format_template(&["@<<<<".to_string(), r#"qq(ab)"#.to_string()]).expect("parse");
245        assert_eq!(t.records.len(), 1);
246        let FormatRecord::Picture { segments, exprs } = &t.records[0] else {
247            panic!("expected picture");
248        };
249        assert_eq!(exprs.len(), 1);
250        assert_eq!(segments.len(), 1);
251        assert!(matches!(
252            &segments[0],
253            PictureSegment::Field {
254                width: 5,
255                align: FieldAlign::Left,
256                kind: FieldKind::Text,
257            }
258        ));
259    }
260
261    #[test]
262    fn parse_format_template_doubled_at_is_literal_at() {
263        // Any line containing `@` is a picture line; `@@` escapes to one `@` in the picture.
264        // With zero fields, the value line must be empty.
265        let t = parse_format_template(&["@@email".to_string(), "".to_string()]).expect("parse");
266        let FormatRecord::Picture { segments, exprs } = &t.records[0] else {
267            panic!("expected picture");
268        };
269        assert!(exprs.is_empty());
270        assert!(matches!(
271            segments.as_slice(),
272            [PictureSegment::Literal(s)] if s == "@email"
273        ));
274    }
275
276    #[test]
277    fn parse_format_template_picture_requires_value_line() {
278        let err = parse_format_template(&["@<<<<".to_string()]).expect_err("missing value");
279        assert!(err.to_string().contains("value line"));
280    }
281
282    #[test]
283    fn parse_format_template_field_count_mismatch() {
284        let err = parse_format_template(&["@<<, @<<".to_string(), "1".to_string()])
285            .expect_err("mismatch");
286        assert!(err.to_string().contains("picture field"));
287    }
288
289    #[test]
290    fn parse_format_template_two_fields_two_exprs() {
291        let t = parse_format_template(&["@<< @>>".to_string(), "1, 2".to_string()]).expect("parse");
292        assert_eq!(t.records.len(), 1);
293        let FormatRecord::Picture { exprs, .. } = &t.records[0] else {
294            panic!("expected picture");
295        };
296        assert_eq!(exprs.len(), 2);
297    }
298
299    #[test]
300    fn parse_format_value_line_qq_comma_qq_is_two_exprs() {
301        let v = parse_format_value_line("qq(x), qq(y)").expect("parse");
302        assert_eq!(
303            v.len(),
304            2,
305            "comma-separated qq() should be two value expressions"
306        );
307    }
308
309    #[test]
310    fn parse_format_value_line_rejects_extra_tokens_after_expr() {
311        let err = parse_format_value_line("42 junk").expect_err("extra tokens");
312        assert!(err.to_string().contains("Extra tokens"));
313    }
314
315    #[test]
316    fn parse_picture_numeric_field() {
317        let t = parse_format_template(&["@###".to_string(), "0".to_string()]).expect("parse");
318        let FormatRecord::Picture { segments, .. } = &t.records[0] else {
319            panic!("expected picture");
320        };
321        assert!(matches!(
322            &segments[0],
323            PictureSegment::Field {
324                width: 4,
325                align: FieldAlign::Numeric,
326                kind: FieldKind::Numeric,
327            }
328        ));
329    }
330
331    #[test]
332    fn parse_picture_right_center_multiline_and_bare_at() {
333        let t = parse_format_template(&["@>> @|| @** @".to_string(), "1, 2, 3, 4".to_string()])
334            .expect("parse");
335        let FormatRecord::Picture { segments, .. } = &t.records[0] else {
336            panic!("expected picture");
337        };
338        let fields: Vec<_> = segments
339            .iter()
340            .filter_map(|s| match s {
341                PictureSegment::Field { width, align, kind } => Some((*width, *align, *kind)),
342                _ => None,
343            })
344            .collect();
345        assert_eq!(fields.len(), 4);
346        assert!(matches!(fields[0], (3, FieldAlign::Right, FieldKind::Text)));
347        assert!(matches!(
348            fields[1],
349            (3, FieldAlign::Center, FieldKind::Text)
350        ));
351        assert!(matches!(
352            fields[2],
353            (3, FieldAlign::Multiline, FieldKind::Multiline)
354        ));
355        assert!(matches!(fields[3], (1, FieldAlign::Left, FieldKind::Text)));
356    }
357
358    #[test]
359    fn parse_picture_literal_between_fields() {
360        let t = parse_format_template(&["a@<<b".to_string(), "qq(z)".to_string()]).expect("parse");
361        let FormatRecord::Picture { segments, .. } = &t.records[0] else {
362            panic!("expected picture");
363        };
364        assert!(matches!(&segments[0], PictureSegment::Literal(s) if s == "a"));
365        assert!(matches!(
366            &segments[1],
367            PictureSegment::Field {
368                width: 3,
369                align: FieldAlign::Left,
370                kind: FieldKind::Text,
371            }
372        ));
373        assert!(matches!(&segments[2], PictureSegment::Literal(s) if s == "b"));
374    }
375
376    #[test]
377    fn parse_format_template_literal_after_picture() {
378        let t =
379            parse_format_template(&["@<<".to_string(), "qq(x)".to_string(), "footer".to_string()])
380                .expect("parse");
381        assert_eq!(t.records.len(), 2);
382        assert!(matches!(&t.records[1], FormatRecord::Literal(s) if s == "footer"));
383    }
384
385    #[test]
386    fn pad_field_left_aligns_and_pads() {
387        assert_eq!(pad_field("hi", 5, FieldAlign::Left), "hi   ");
388    }
389
390    #[test]
391    fn pad_field_right_aligns() {
392        assert_eq!(pad_field("hi", 5, FieldAlign::Right), "   hi");
393    }
394
395    #[test]
396    fn pad_field_center_aligns() {
397        assert_eq!(pad_field("hi", 5, FieldAlign::Center), " hi  ");
398    }
399
400    #[test]
401    fn pad_field_numeric_right_aligns_integer() {
402        assert_eq!(pad_field("42", 5, FieldAlign::Numeric), "   42");
403    }
404
405    #[test]
406    fn pad_field_numeric_float() {
407        assert_eq!(pad_field("3.5", 6, FieldAlign::Numeric), "   3.5");
408    }
409
410    #[test]
411    fn pad_field_numeric_non_numeric_fallback_like_right() {
412        assert_eq!(pad_field("n/a", 5, FieldAlign::Numeric), "  n/a");
413    }
414
415    #[test]
416    fn pad_field_truncates_to_width() {
417        assert_eq!(pad_field("abcdef", 3, FieldAlign::Left), "abc");
418    }
419
420    #[test]
421    fn pad_field_multiline_uses_first_line() {
422        assert_eq!(
423            pad_field("first\nsecond", 6, FieldAlign::Multiline),
424            "first "
425        );
426    }
427
428    // ─── parse_picture_segments — directly exercise the picture parser ──
429    //
430    // The picture parser drives `write()` / `format` template
431    // dispatch. Until now it was only smoke-tested through
432    // `parse_format_template` round-trips (a 1-line picture per
433    // test). These tests pin the parser's behaviour for every
434    // segment shape so a regression to one align type or to the
435    // literal-escape path can't sneak past CI by leaving
436    // `parse_format_template` plumbing intact.
437
438    fn fields_only(pic: &str) -> Vec<(usize, FieldAlign)> {
439        parse_picture_segments(pic)
440            .expect("parse picture")
441            .into_iter()
442            .filter_map(|s| match s {
443                PictureSegment::Field { width, align, .. } => Some((width, align)),
444                PictureSegment::Literal(_) => None,
445            })
446            .collect()
447    }
448
449    fn segments(pic: &str) -> Vec<PictureSegment> {
450        parse_picture_segments(pic).expect("parse picture")
451    }
452
453    #[test]
454    fn picture_empty_string_is_zero_segments() {
455        assert!(segments("").is_empty());
456    }
457
458    #[test]
459    fn picture_pure_literal_emits_single_literal_segment() {
460        let segs = segments("just text");
461        assert_eq!(segs.len(), 1);
462        assert!(matches!(&segs[0], PictureSegment::Literal(s) if s == "just text"));
463    }
464
465    #[test]
466    fn picture_bare_at_with_no_modifier_is_width_one_left_field() {
467        // `@` alone (no `<`/`>`/`|`/`#`/`*`) → width 1 left-aligned text.
468        let segs = segments("@");
469        assert_eq!(segs.len(), 1);
470        let PictureSegment::Field { width, align, kind } = &segs[0] else {
471            panic!("expected Field, got {:?}", segs[0])
472        };
473        assert_eq!(*width, 1);
474        assert_eq!(*align, FieldAlign::Left);
475        assert_eq!(*kind, FieldKind::Text);
476    }
477
478    #[test]
479    fn picture_left_align_lt_chain_counts_width_correctly() {
480        // `@<<<<` = `@` + four `<` = width 5 left-aligned.
481        assert_eq!(fields_only("@<<<<"), vec![(5, FieldAlign::Left)]);
482    }
483
484    #[test]
485    fn picture_right_align_gt_chain() {
486        // `@>>` = `@` + two `>` = width 3 right-aligned.
487        assert_eq!(fields_only("@>>"), vec![(3, FieldAlign::Right)]);
488    }
489
490    #[test]
491    fn picture_center_align_pipe_chain() {
492        assert_eq!(fields_only("@|||"), vec![(4, FieldAlign::Center)]);
493    }
494
495    #[test]
496    fn picture_numeric_align_hash_chain_carries_numeric_kind() {
497        let segs = segments("@###");
498        let PictureSegment::Field { width, align, kind } = &segs[0] else {
499            panic!("expected Field")
500        };
501        assert_eq!(*width, 4);
502        assert_eq!(*align, FieldAlign::Numeric);
503        assert_eq!(*kind, FieldKind::Numeric, "numeric align → numeric kind");
504    }
505
506    #[test]
507    fn picture_multiline_align_star_chain_carries_multiline_kind() {
508        let segs = segments("@**");
509        let PictureSegment::Field { width, align, kind } = &segs[0] else {
510            panic!("expected Field")
511        };
512        assert_eq!(*width, 3);
513        assert_eq!(*align, FieldAlign::Multiline);
514        assert_eq!(*kind, FieldKind::Multiline);
515    }
516
517    #[test]
518    fn picture_doubled_at_escapes_to_literal_at() {
519        // `@@` → literal `@` (no field emitted), no value-line entry needed.
520        let segs = segments("@@");
521        assert_eq!(segs.len(), 1);
522        assert!(matches!(&segs[0], PictureSegment::Literal(s) if s == "@"));
523    }
524
525    #[test]
526    fn picture_literal_around_field_preserves_both() {
527        // `[name: @<<<<]` → 3 segments: literal "[name: ", field, literal "]".
528        let segs = segments("[name: @<<<<]");
529        assert_eq!(segs.len(), 3);
530        assert!(matches!(&segs[0], PictureSegment::Literal(s) if s == "[name: "));
531        assert!(matches!(
532            &segs[1],
533            PictureSegment::Field {
534                width: 5,
535                align: FieldAlign::Left,
536                ..
537            }
538        ));
539        assert!(matches!(&segs[2], PictureSegment::Literal(s) if s == "]"));
540    }
541
542    #[test]
543    fn picture_multiple_fields_collect_in_order() {
544        // `@<< @>>> @###` → left(3), right(4), numeric(4) — separated by
545        // single-space literals.
546        let segs = segments("@<< @>>> @###");
547        let fields: Vec<_> = segs
548            .iter()
549            .filter_map(|s| match s {
550                PictureSegment::Field { width, align, .. } => Some((*width, *align)),
551                _ => None,
552            })
553            .collect();
554        assert_eq!(
555            fields,
556            vec![
557                (3, FieldAlign::Left),
558                (4, FieldAlign::Right),
559                (4, FieldAlign::Numeric),
560            ],
561        );
562    }
563
564    #[test]
565    fn picture_at_followed_by_non_modifier_treats_as_width_one() {
566        // `@x` — `@` not followed by a known modifier byte falls into the
567        // width=1 left-align default arm; the `x` becomes part of the
568        // trailing literal.
569        let segs = segments("@x");
570        assert_eq!(segs.len(), 2);
571        assert!(matches!(
572            &segs[0],
573            PictureSegment::Field {
574                width: 1,
575                align: FieldAlign::Left,
576                ..
577            }
578        ));
579        assert!(matches!(&segs[1], PictureSegment::Literal(s) if s == "x"));
580    }
581}