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::{PerlError, PerlResult};
5use crate::parser::parse_format_value_line;
6
7/// Parsed `format NAME = ... .` body (after registration).
8#[derive(Debug, Clone)]
9pub struct FormatTemplate {
10    pub records: Vec<FormatRecord>,
11}
12
13#[derive(Debug, Clone)]
14pub enum FormatRecord {
15    /// Line with no `@` fields — printed as-is (newline added by `write`).
16    Literal(String),
17    /// Picture line with `@…` fields plus the following value line (comma-separated exprs).
18    Picture {
19        segments: Vec<PictureSegment>,
20        exprs: Vec<Expr>,
21    },
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FieldAlign {
26    Left,
27    Right,
28    Center,
29    Numeric,
30    Multiline,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum FieldKind {
35    Text,
36    Numeric,
37    Multiline,
38}
39
40#[derive(Debug, Clone)]
41pub enum PictureSegment {
42    Literal(String),
43    Field {
44        width: usize,
45        align: FieldAlign,
46        kind: FieldKind,
47    },
48}
49
50/// Build a template from raw lines between `format N =` and `.`.
51pub fn parse_format_template(lines: &[String]) -> PerlResult<FormatTemplate> {
52    let mut records = Vec::new();
53    let mut i = 0;
54    while i < lines.len() {
55        let pic_line = &lines[i];
56        if !pic_line.contains('@') {
57            records.push(FormatRecord::Literal(pic_line.clone()));
58            i += 1;
59            continue;
60        }
61        let segments = parse_picture_segments(pic_line)?;
62        let n_fields = segments
63            .iter()
64            .filter(|s| matches!(s, PictureSegment::Field { .. }))
65            .count();
66        i += 1;
67        if i >= lines.len() {
68            return Err(PerlError::syntax(
69                "picture line with @ fields must be followed by a value line",
70                0,
71            ));
72        }
73        let exprs = parse_format_value_line(&lines[i])?;
74        if exprs.len() != n_fields {
75            return Err(PerlError::syntax(
76                format!(
77                    "format: {} picture field(s) but {} value expression(s)",
78                    n_fields,
79                    exprs.len()
80                ),
81                0,
82            ));
83        }
84        records.push(FormatRecord::Picture { segments, exprs });
85        i += 1;
86    }
87    Ok(FormatTemplate { records })
88}
89
90fn parse_picture_segments(pic: &str) -> PerlResult<Vec<PictureSegment>> {
91    let mut out = Vec::new();
92    let mut lit = String::new();
93    let mut chars = pic.chars().peekable();
94    while let Some(c) = chars.next() {
95        if c == '@' {
96            if chars.peek() == Some(&'@') {
97                chars.next();
98                lit.push('@');
99                continue;
100            }
101            if !lit.is_empty() {
102                out.push(PictureSegment::Literal(std::mem::take(&mut lit)));
103            }
104            // width starts at 1 because `@` itself counts as one column
105            let mut width = 1usize;
106            let align = match chars.peek() {
107                Some('<') => {
108                    while chars.peek() == Some(&'<') {
109                        chars.next();
110                        width += 1;
111                    }
112                    FieldAlign::Left
113                }
114                Some('>') => {
115                    while chars.peek() == Some(&'>') {
116                        chars.next();
117                        width += 1;
118                    }
119                    FieldAlign::Right
120                }
121                Some('|') => {
122                    while chars.peek() == Some(&'|') {
123                        chars.next();
124                        width += 1;
125                    }
126                    FieldAlign::Center
127                }
128                Some('#') => {
129                    while chars.peek() == Some(&'#') {
130                        chars.next();
131                        width += 1;
132                    }
133                    FieldAlign::Numeric
134                }
135                Some('*') => {
136                    while chars.peek() == Some(&'*') {
137                        chars.next();
138                        width += 1;
139                    }
140                    FieldAlign::Multiline
141                }
142                _ => {
143                    width = 1;
144                    FieldAlign::Left
145                }
146            };
147            let kind = match align {
148                FieldAlign::Numeric => FieldKind::Numeric,
149                FieldAlign::Multiline => FieldKind::Multiline,
150                _ => FieldKind::Text,
151            };
152            out.push(PictureSegment::Field { width, align, kind });
153        } else {
154            lit.push(c);
155        }
156    }
157    if !lit.is_empty() {
158        out.push(PictureSegment::Literal(lit));
159    }
160    Ok(out)
161}
162
163/// Pad/truncate a value to `width` display columns (character count).
164pub fn pad_field(s: &str, width: usize, align: FieldAlign) -> String {
165    let s = if s.chars().count() > width {
166        s.chars().take(width).collect::<String>()
167    } else {
168        s.to_string()
169    };
170    let len = s.chars().count();
171    match align {
172        FieldAlign::Left => {
173            let pad = width.saturating_sub(len);
174            format!("{}{}", s, " ".repeat(pad))
175        }
176        FieldAlign::Multiline => {
177            let first = s.lines().next().unwrap_or("");
178            let fl = first.chars().count();
179            let t = if fl > width {
180                first.chars().take(width).collect::<String>()
181            } else {
182                first.to_string()
183            };
184            let pad = width.saturating_sub(t.chars().count());
185            format!("{}{}", t, " ".repeat(pad))
186        }
187        FieldAlign::Right => {
188            let pad = width.saturating_sub(len);
189            format!("{}{}", " ".repeat(pad), s)
190        }
191        FieldAlign::Center => {
192            let pad = width.saturating_sub(len);
193            let left = pad / 2;
194            let right = pad - left;
195            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
196        }
197        FieldAlign::Numeric => {
198            if let Ok(n) = s.parse::<i64>() {
199                format!("{n:>width$}", n = n, width = width)
200            } else if let Ok(f) = s.parse::<f64>() {
201                format!("{f:>width$}", f = f, width = width)
202            } else {
203                let pad = width.saturating_sub(len);
204                format!("{}{}", " ".repeat(pad), s)
205            }
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn parse_format_template_empty() {
216        let t = parse_format_template(&[]).expect("parse");
217        assert!(t.records.is_empty());
218    }
219
220    #[test]
221    fn parse_format_template_literal_only() {
222        let t = parse_format_template(&["no fields here".to_string()]).expect("parse");
223        assert_eq!(t.records.len(), 1);
224        assert!(matches!(
225            &t.records[0],
226            FormatRecord::Literal(s) if s == "no fields here"
227        ));
228    }
229
230    #[test]
231    fn parse_format_template_picture_and_value_line() {
232        let t =
233            parse_format_template(&["@<<<<".to_string(), r#"qq(ab)"#.to_string()]).expect("parse");
234        assert_eq!(t.records.len(), 1);
235        let FormatRecord::Picture { segments, exprs } = &t.records[0] else {
236            panic!("expected picture");
237        };
238        assert_eq!(exprs.len(), 1);
239        assert_eq!(segments.len(), 1);
240        assert!(matches!(
241            &segments[0],
242            PictureSegment::Field {
243                width: 5,
244                align: FieldAlign::Left,
245                kind: FieldKind::Text,
246            }
247        ));
248    }
249
250    #[test]
251    fn parse_format_template_doubled_at_is_literal_at() {
252        // Any line containing `@` is a picture line; `@@` escapes to one `@` in the picture.
253        // With zero fields, the value line must be empty.
254        let t = parse_format_template(&["@@email".to_string(), "".to_string()]).expect("parse");
255        let FormatRecord::Picture { segments, exprs } = &t.records[0] else {
256            panic!("expected picture");
257        };
258        assert!(exprs.is_empty());
259        assert!(matches!(
260            segments.as_slice(),
261            [PictureSegment::Literal(s)] if s == "@email"
262        ));
263    }
264
265    #[test]
266    fn parse_format_template_picture_requires_value_line() {
267        let err = parse_format_template(&["@<<<<".to_string()]).expect_err("missing value");
268        assert!(err.to_string().contains("value line"));
269    }
270
271    #[test]
272    fn parse_format_template_field_count_mismatch() {
273        let err = parse_format_template(&["@<<, @<<".to_string(), "1".to_string()])
274            .expect_err("mismatch");
275        assert!(err.to_string().contains("picture field"));
276    }
277
278    #[test]
279    fn parse_format_template_two_fields_two_exprs() {
280        let t = parse_format_template(&["@<< @>>".to_string(), "1, 2".to_string()]).expect("parse");
281        assert_eq!(t.records.len(), 1);
282        let FormatRecord::Picture { exprs, .. } = &t.records[0] else {
283            panic!("expected picture");
284        };
285        assert_eq!(exprs.len(), 2);
286    }
287
288    #[test]
289    fn parse_format_value_line_qq_comma_qq_is_two_exprs() {
290        let v = parse_format_value_line("qq(x), qq(y)").expect("parse");
291        assert_eq!(
292            v.len(),
293            2,
294            "comma-separated qq() should be two value expressions"
295        );
296    }
297
298    #[test]
299    fn parse_format_value_line_rejects_extra_tokens_after_expr() {
300        let err = parse_format_value_line("42 junk").expect_err("extra tokens");
301        assert!(err.to_string().contains("Extra tokens"));
302    }
303
304    #[test]
305    fn parse_picture_numeric_field() {
306        let t = parse_format_template(&["@###".to_string(), "0".to_string()]).expect("parse");
307        let FormatRecord::Picture { segments, .. } = &t.records[0] else {
308            panic!("expected picture");
309        };
310        assert!(matches!(
311            &segments[0],
312            PictureSegment::Field {
313                width: 4,
314                align: FieldAlign::Numeric,
315                kind: FieldKind::Numeric,
316            }
317        ));
318    }
319
320    #[test]
321    fn parse_picture_right_center_multiline_and_bare_at() {
322        let t = parse_format_template(&["@>> @|| @** @".to_string(), "1, 2, 3, 4".to_string()])
323            .expect("parse");
324        let FormatRecord::Picture { segments, .. } = &t.records[0] else {
325            panic!("expected picture");
326        };
327        let fields: Vec<_> = segments
328            .iter()
329            .filter_map(|s| match s {
330                PictureSegment::Field { width, align, kind } => Some((*width, *align, *kind)),
331                _ => None,
332            })
333            .collect();
334        assert_eq!(fields.len(), 4);
335        assert!(matches!(fields[0], (3, FieldAlign::Right, FieldKind::Text)));
336        assert!(matches!(
337            fields[1],
338            (3, FieldAlign::Center, FieldKind::Text)
339        ));
340        assert!(matches!(
341            fields[2],
342            (3, FieldAlign::Multiline, FieldKind::Multiline)
343        ));
344        assert!(matches!(fields[3], (1, FieldAlign::Left, FieldKind::Text)));
345    }
346
347    #[test]
348    fn parse_picture_literal_between_fields() {
349        let t = parse_format_template(&["a@<<b".to_string(), "qq(z)".to_string()]).expect("parse");
350        let FormatRecord::Picture { segments, .. } = &t.records[0] else {
351            panic!("expected picture");
352        };
353        assert!(matches!(&segments[0], PictureSegment::Literal(s) if s == "a"));
354        assert!(matches!(
355            &segments[1],
356            PictureSegment::Field {
357                width: 3,
358                align: FieldAlign::Left,
359                kind: FieldKind::Text,
360            }
361        ));
362        assert!(matches!(&segments[2], PictureSegment::Literal(s) if s == "b"));
363    }
364
365    #[test]
366    fn parse_format_template_literal_after_picture() {
367        let t =
368            parse_format_template(&["@<<".to_string(), "qq(x)".to_string(), "footer".to_string()])
369                .expect("parse");
370        assert_eq!(t.records.len(), 2);
371        assert!(matches!(&t.records[1], FormatRecord::Literal(s) if s == "footer"));
372    }
373
374    #[test]
375    fn pad_field_left_aligns_and_pads() {
376        assert_eq!(pad_field("hi", 5, FieldAlign::Left), "hi   ");
377    }
378
379    #[test]
380    fn pad_field_right_aligns() {
381        assert_eq!(pad_field("hi", 5, FieldAlign::Right), "   hi");
382    }
383
384    #[test]
385    fn pad_field_center_aligns() {
386        assert_eq!(pad_field("hi", 5, FieldAlign::Center), " hi  ");
387    }
388
389    #[test]
390    fn pad_field_numeric_right_aligns_integer() {
391        assert_eq!(pad_field("42", 5, FieldAlign::Numeric), "   42");
392    }
393
394    #[test]
395    fn pad_field_numeric_float() {
396        assert_eq!(pad_field("3.5", 6, FieldAlign::Numeric), "   3.5");
397    }
398
399    #[test]
400    fn pad_field_numeric_non_numeric_fallback_like_right() {
401        assert_eq!(pad_field("n/a", 5, FieldAlign::Numeric), "  n/a");
402    }
403
404    #[test]
405    fn pad_field_truncates_to_width() {
406        assert_eq!(pad_field("abcdef", 3, FieldAlign::Left), "abc");
407    }
408
409    #[test]
410    fn pad_field_multiline_uses_first_line() {
411        assert_eq!(
412            pad_field("first\nsecond", 6, FieldAlign::Multiline),
413            "first "
414        );
415    }
416}