1use crate::ast::Expr;
4use crate::error::{StrykeError, StrykeResult};
5use crate::parser::parse_format_value_line;
6
7#[derive(Debug, Clone)]
9pub struct FormatTemplate {
10 pub records: Vec<FormatRecord>,
12}
13#[derive(Debug, Clone)]
15pub enum FormatRecord {
16 Literal(String),
18 Picture {
20 segments: Vec<PictureSegment>,
21 exprs: Vec<Expr>,
22 },
23}
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FieldAlign {
27 Left,
29 Right,
31 Center,
33 Numeric,
35 Multiline,
37}
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum FieldKind {
41 Text,
43 Numeric,
45 Multiline,
47}
48#[derive(Debug, Clone)]
50pub enum PictureSegment {
51 Literal(String),
53 Field {
55 width: usize,
56 align: FieldAlign,
57 kind: FieldKind,
58 },
59}
60
61pub 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 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
174pub 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 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 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 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 assert_eq!(fields_only("@<<<<"), vec![(5, FieldAlign::Left)]);
482 }
483
484 #[test]
485 fn picture_right_align_gt_chain() {
486 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 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 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 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 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}