Skip to main content

ironwork_compile/
picture.rs

1pub use rt::picture::Sym;
2use syntax::ast::{CurrencySign, Environment};
3use syntax::messages::{Message, Refused};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum Category {
7    Alphanumeric,
8    Numeric,
9    National,
10    /// G, with B: DBCS character positions, which USAGE DISPLAY-1 holds.
11    Dbcs,
12    NumericEdited,
13    AlphanumericEdited,
14}
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct Picture {
18    pub category: Category,
19    /// Character positions: bytes for alphanumeric, digits for numeric, characters for national
20    /// and DBCS.
21    pub size: u32,
22    pub digits: u32,
23    pub scale: u32,
24    pub signed: bool,
25    pub edit: Option<Vec<Sym>>,
26    /// Scaling positions P to the right of the digits: the value is the digits times ten to this
27    /// power. Ps to the left of the digits raise `scale` past `digits` instead.
28    pub scaling: u32,
29    /// The currency sign value its currency symbol stands for.
30    pub currency: Option<String>,
31}
32
33/// What SPECIAL-NAMES changes in a PICTURE: whether the comma is the decimal point, and which
34/// characters are currency symbols, for which values.
35#[derive(Clone, Copy, Debug, Default)]
36pub struct Notation<'a> {
37    pub decimal_comma: bool,
38    pub currency: &'a [CurrencySign],
39}
40
41impl<'a> Notation<'a> {
42    pub fn of(environment: &'a Environment) -> Self {
43        Self { decimal_comma: environment.decimal_point_comma, currency: &environment.currency }
44    }
45
46    /// The value `symbol` stands for: with no CURRENCY SIGN clause or CURRENCY option, $ for $
47    /// (Language Reference SC27-8713-03, p. 212).
48    pub fn currency_value(&self, symbol: char) -> Option<&'a str> {
49        match self.currency {
50            [] => (symbol == '$').then_some("$"),
51            signs => signs.iter().find(|c| c.symbol == symbol).map(|c| c.value.as_str()),
52        }
53    }
54}
55
56/// An edited PICTURE is written out position by position, so its length is bounded tighter.
57const MAX_EDITED: u64 = 4096;
58
59/// Enterprise COBOL's limit on an elementary item's character positions.
60pub const MAX_POSITIONS: u64 = 134_217_727;
61
62pub fn analyse(text: &str) -> Result<Picture, Refused> {
63    analyse_with(text, Notation::default())
64}
65
66/// A PICTURE under `notation`. Under DECIMAL-POINT IS COMMA the comma is the decimal point and the
67/// period an insertion character (Language Reference SC27-8713-03, p. 208); a currency symbol is
68/// read as $, and the value it stands for kept.
69pub fn analyse_with(text: &str, notation: Notation) -> Result<Picture, Refused> {
70    let (runs, currency) = runs(text, notation)?;
71    let has = |symbol: char| runs.iter().any(|&(c, _)| c == symbol);
72    if has('G') || has('N') && has('B') {
73        return dbcs(text, &runs);
74    }
75    if runs.iter().any(|&(c, _)| matches!(c, 'Z' | '*' | '+' | '-' | '.' | ',' | 'B' | '0' | '/' | '$' | 'C' | 'R' | 'D')) {
76        return edited(text, &runs, notation.decimal_comma, currency);
77    }
78    let (mut digits, mut scale, mut signed, mut after_point) = (0u64, 0u64, false, false);
79    let (mut alnum, mut national) = (0u64, 0u64);
80    let (mut left, mut right) = (0u64, 0u64);
81    let misplaced = || Err((syntax::messages::IWC0260, format!("PICTURE {text}: P must be one string of scaling positions at the left or right end of the digits")));
82    for (i, &(c, n)) in runs.iter().enumerate() {
83        match c {
84            '9' if right > 0 => return misplaced(),
85            '9' => {
86                digits += n;
87                if after_point {
88                    scale += n;
89                }
90            }
91            'S' if i == 0 && n == 1 => signed = true,
92            'V' if left > 0 => return misplaced(),
93            'V' if !after_point && n == 1 => after_point = true,
94            'X' | 'A' => alnum += n,
95            'N' => national += n,
96            'P' if digits == 0 && runs.get(i + 1).is_some_and(|&(c, _)| matches!(c, 'P' | '9')) => left += n,
97            'P' if digits > 0 && left == 0 && !after_point => right += n,
98            'P' => return misplaced(),
99            _ => return Err((syntax::messages::IWC0261, format!("PICTURE {text}: {c:?} is not a PICTURE symbol"))),
100        }
101    }
102    if alnum + digits > MAX_POSITIONS || national > MAX_POSITIONS {
103        return Err((syntax::messages::IWC0262, format!("PICTURE {text}: more than {MAX_POSITIONS} character positions")));
104    }
105    let positions = digits + left + right;
106    if left > 0 {
107        scale = left + digits;
108    }
109    let (digits, scale, alnum, national) = (digits as u32, scale as u32, alnum as u32, national as u32);
110    let scaled = left + right > 0;
111    match (digits > 0, alnum > 0, national > 0) {
112        (true, false, false) if positions <= 31 => Ok(Picture { category: Category::Numeric, size: digits, digits, scale, signed, edit: None, scaling: right as u32, currency: None }),
113        (true, false, false) => Err((syntax::messages::IWC0263, format!("PICTURE {text}: more than 31 digits"))),
114        (_, true, false) if !signed && !after_point && !scaled => Ok(Picture { category: Category::Alphanumeric, size: alnum + digits, digits: 0, scale: 0, signed, edit: None, scaling: 0, currency: None }),
115        (false, false, true) if !signed && !after_point && !scaled => Ok(Picture { category: Category::National, size: national, digits: 0, scale: 0, signed, edit: None, scaling: 0, currency: None }),
116        _ => Err((syntax::messages::IWC0264, format!("PICTURE {text}: mixes symbols of different categories"))),
117    }
118}
119
120/// A PICTURE of G, or of N with B: DBCS character positions, each B one that holds a DBCS space
121/// (Language Reference SC27-8713-03, pp. 214, 219). N with B is national-edited unless the item is
122/// DISPLAY-1, which the item's USAGE decides.
123fn dbcs(text: &str, runs: &[(char, u64)]) -> Result<Picture, Refused> {
124    let symbol = if runs.iter().any(|&(c, _)| c == 'G') { 'G' } else { 'N' };
125    if let Some(&(c, _)) = runs.iter().find(|&&(c, _)| c != symbol && c != 'B') {
126        return Err((syntax::messages::IWC0265, format!("PICTURE {text}: {c:?} cannot be in a PICTURE of {symbol}, which takes {symbol} and B only")));
127    }
128    let size: u64 = runs.iter().map(|&(_, n)| n).sum();
129    let inserts = runs.iter().any(|&(c, _)| c == 'B');
130    if size > MAX_POSITIONS || inserts && size > MAX_EDITED {
131        return Err((syntax::messages::IWC0266, format!("PICTURE {text}: more character positions than a DBCS item holds")));
132    }
133    let edit = inserts.then(|| runs.iter().flat_map(|&(c, n)| std::iter::repeat_n(if c == 'B' { Sym::Insert(' ') } else { Sym::Char }, n as usize)).collect());
134    let category = if symbol == 'G' { Category::Dbcs } else { Category::National };
135    Ok(Picture { category, size: size as u32, digits: 0, scale: 0, signed: false, edit, scaling: 0, currency: None })
136}
137
138/// A numeric PICTURE under BLANK WHEN ZERO, which makes the item numeric-edited (Language
139/// Reference SC27-8713-03, p. 195).
140pub fn blank_when_zero(p: &Picture) -> Result<Picture, Refused> {
141    if p.signed {
142        return Err((syntax::messages::IWC0267, "BLANK WHEN ZERO cannot be given for a PICTURE with S".into()));
143    }
144    let int = p.digits.saturating_sub(p.scale) as usize;
145    let mut syms = vec![Sym::Nine; int];
146    if p.scale > 0 {
147        syms.push(Sym::Implied);
148    }
149    syms.extend(std::iter::repeat_n(Sym::Nine, p.digits as usize - int));
150    Ok(Picture { category: Category::NumericEdited, edit: Some(syms), ..p.clone() })
151}
152
153fn edited(text: &str, runs: &[(char, u64)], decimal_comma: bool, currency: Option<&str>) -> Result<Picture, Refused> {
154    let total: u64 = runs.iter().map(|&(_, n)| n).sum();
155    if total > MAX_EDITED {
156        return Err((syntax::messages::IWC0268, format!("PICTURE {text}: an edited PICTURE longer than {MAX_EDITED} positions")));
157    }
158    let chars: Vec<char> = runs.iter().flat_map(|&(c, n)| std::iter::repeat_n(c, n as usize)).collect();
159    let bad = |message: Message, why: &str| Err((message, format!("PICTURE {text}: {why}")));
160    if chars.iter().any(|c| matches!(c, 'S' | 'N')) {
161        return bad(syntax::messages::IWC0269, "S and N are not allowed in an edited PICTURE");
162    }
163    if chars.iter().any(|c| matches!(c, 'X' | 'A')) {
164        let mut syms = Vec::new();
165        for &c in &chars {
166            syms.push(match c {
167                'X' | 'A' | '9' => Sym::Char,
168                'B' => Sym::Insert(' '),
169                '0' | '/' => Sym::Insert(c),
170                _ => return bad(syntax::messages::IWC0270, "an alphanumeric-edited PICTURE takes only X, A, 9, B, 0 and /"),
171            });
172        }
173        let size = syms.len() as u32;
174        return Ok(Picture { category: Category::AlphanumericEdited, size, digits: 0, scale: 0, signed: false, edit: Some(syms), scaling: 0, currency: None });
175    }
176    let (point, comma) = if decimal_comma { (',', '.') } else { ('.', ',') };
177    let floating: Vec<char> = ['+', '-', '$'].into_iter().filter(|f| chars.iter().filter(|c| *c == f).count() >= 2).collect();
178    if floating.len() > 1 {
179        return bad(syntax::messages::IWC0271, "two floating insertion strings");
180    }
181    let float = floating.first().copied();
182    let (mut syms, mut led, mut i) = (Vec::new(), false, 0);
183    let misplaced = "P must be one string of scaling positions at the left or right end of the digits";
184    let mut scaling: Option<(usize, u32, usize)> = None;
185    while i < chars.len() {
186        let c = chars[i];
187        let next = chars.get(i + 1).copied();
188        if c == 'P' {
189            match &mut scaling {
190                Some((at, n, _)) if *at + *n as usize == i => *n += 1,
191                Some(_) => return bad(syntax::messages::IWC0260, misplaced),
192                None => scaling = Some((i, 1, syms.len())),
193            }
194            i += 1;
195            continue;
196        }
197        let sym = match c {
198            'C' if next == Some('R') => {
199                i += 1;
200                Sym::Cr
201            }
202            'D' if next == Some('B') => {
203                i += 1;
204                Sym::Db
205            }
206            c if Some(c) == float && !led => {
207                led = true;
208                Sym::FloatLead(c)
209            }
210            c if Some(c) == float => Sym::Float(c),
211            '+' | '-' => Sym::Sign(c),
212            '$' => Sym::Currency,
213            '9' => Sym::Nine,
214            'Z' => Sym::Z,
215            '*' => Sym::Star,
216            c if c == point => Sym::Point,
217            'V' => Sym::Implied,
218            'B' => Sym::Insert(' '),
219            '0' | '/' => Sym::Insert(c),
220            c if c == comma => Sym::Insert(c),
221            _ => return bad(syntax::messages::IWC0272, &format!("{c:?} is not a numeric-edited symbol")),
222        };
223        syms.push(sym);
224        i += 1;
225    }
226    let points = syms.iter().filter(|s| matches!(s, Sym::Point | Sym::Implied)).count();
227    if points > 1 {
228        return bad(syntax::messages::IWC0273, "more than one decimal point");
229    }
230    let digits = syms.iter().filter(|s| s.is_digit()).count() as u32;
231    let mut scale = syms.iter().skip_while(|s| !matches!(s, Sym::Point | Sym::Implied)).filter(|s| s.is_digit()).count() as u32;
232    let mut right = 0;
233    if let Some((_, n, at)) = scaling {
234        let before = syms[..at].iter().filter(|s| s.is_digit()).count() as u32;
235        match (points, before) {
236            (0, 0) => scale = n + digits,
237            (0, b) if b == digits => right = n,
238            _ => return bad(syntax::messages::IWC0260, misplaced),
239        }
240    }
241    if digits == 0 || digits + scaling.map_or(0, |(_, n, _)| n) > 31 {
242        return bad(syntax::messages::IWC0274, "a numeric-edited PICTURE needs 1 to 31 digit positions");
243    }
244    let widths: u32 = syms.iter().map(|s| s.width() as u32).sum();
245    let size = widths + currency.map_or(0, |v| v.chars().count() as u32 - 1);
246    Ok(Picture { category: Category::NumericEdited, size, digits, scale, signed: false, edit: Some(syms), scaling: right, currency: currency.map(str::to_owned) })
247}
248
249/// Runs of one PICTURE symbol and a count, and the currency sign value the PICTURE uses.
250type Runs<'a> = (Vec<(char, u64)>, Option<&'a str>);
251
252/// The PICTURE as runs of one symbol and a count, `9(4)` read as four nines without writing them
253/// out, its currency symbol as $, with the value that symbol stands for.
254fn runs<'a>(text: &str, notation: Notation<'a>) -> Result<Runs<'a>, Refused> {
255    let mut out: Vec<(char, u64)> = Vec::new();
256    let (mut chars, mut currency) = (text.chars(), None);
257    while let Some(c) = chars.next() {
258        if c == '(' {
259            let count: String = chars.by_ref().take_while(|&d| d != ')').collect();
260            let n: u64 = count.parse().ok().filter(|&n| (1..=MAX_POSITIONS).contains(&n)).ok_or_else(|| (syntax::messages::IWC0275, format!("PICTURE {text}: bad repetition ({count})")))?;
261            let last = out.last_mut().ok_or_else(|| (syntax::messages::IWC0276, format!("PICTURE {text}: a repetition with nothing to repeat")))?;
262            last.1 += n - 1;
263        } else if let Some(value) = notation.currency_value(c) {
264            match currency {
265                Some((symbol, _)) if symbol != c => return Err((syntax::messages::IWC0277, format!("PICTURE {text}: two different currency symbols"))),
266                _ => currency = Some((c, value)),
267            }
268            out.push(('$', 1));
269        } else if c == '$' {
270            return Err((syntax::messages::IWC0278, format!("PICTURE {text}: '$' is not a currency symbol of this program, whose CURRENCY SIGN clauses or CURRENCY option name others")));
271        } else {
272            out.push((c.to_ascii_uppercase(), 1));
273        }
274    }
275    Ok((out, currency.map(|(_, value)| value)))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    const COMMA: Notation = Notation { decimal_comma: true, currency: &[] };
283
284    #[test]
285    fn numeric_pictures() {
286        assert_eq!(analyse("S9(3)V99").unwrap(), Picture { category: Category::Numeric, size: 5, digits: 5, scale: 2, signed: true, edit: None, scaling: 0, currency: None });
287        assert_eq!(analyse("9(18)").unwrap().digits, 18);
288        assert_eq!(analyse("SV9").unwrap().scale, 1);
289    }
290
291    #[test]
292    fn edited_pictures() {
293        let p = analyse("$$,$$9.99CR").unwrap();
294        assert_eq!((p.category, p.size, p.digits, p.scale), (Category::NumericEdited, 11, 6, 2));
295        assert_eq!(p.edit.as_ref().unwrap()[0], Sym::FloatLead('$'));
296        let z = analyse("-ZZ,ZZ9").unwrap();
297        assert_eq!(z.edit.unwrap()[0], Sym::Sign('-'));
298        assert_eq!(analyse("XXBXX/99").unwrap().category, Category::AlphanumericEdited);
299    }
300
301    #[test]
302    fn alphanumeric_and_national() {
303        assert_eq!(analyse("X(512)").unwrap().size, 512);
304        assert_eq!(analyse("N(256)").unwrap().category, Category::National);
305    }
306
307    #[test]
308    fn unsupported_and_invalid_pictures_say_why() {
309        assert!(analyse("ZZ9.99.9").unwrap_err().1.contains("decimal point"));
310        assert!(analyse("9(32)").unwrap_err().1.contains("31 digits"));
311        assert!(analyse("XN").unwrap_err().1.contains("categories"));
312        assert!(analyse("X(999999999)").unwrap_err().1.contains("repetition"));
313        assert!(analyse("X(134217727)X").unwrap_err().1.contains("character positions"));
314        assert_eq!(analyse("XX99").unwrap(), Picture { category: Category::Alphanumeric, size: 4, digits: 0, scale: 0, signed: false, edit: None, scaling: 0, currency: None });
315    }
316
317    #[test]
318    fn scaling_positions_left_of_the_digits_raise_the_scale_and_right_of_them_scale_the_value() {
319        let shape = |pic: &str| analyse(pic).map(|p| (p.category, p.size, p.digits, p.scale, p.scaling));
320        assert_eq!(shape("SP(8)9"), Ok((Category::Numeric, 1, 1, 9, 0)));
321        assert_eq!(shape("VPP99"), Ok((Category::Numeric, 2, 2, 4, 0)));
322        assert_eq!(shape("S999PP"), Ok((Category::Numeric, 3, 3, 0, 2)));
323        assert_eq!(shape("99P(6)V"), Ok((Category::Numeric, 2, 2, 0, 6)));
324        assert_eq!(shape("ZZZPP"), Ok((Category::NumericEdited, 3, 3, 0, 2)));
325        for bad in ["P9P", "9P9", "PPV9", "9V9P", "99VPP", "Z.ZPP", "P(31)9"] {
326            assert!(analyse(bad).is_err(), "{bad}");
327        }
328        assert!(analyse("P(30)9").is_ok());
329    }
330
331    #[test]
332    fn under_decimal_point_is_comma_the_comma_is_the_point_and_the_period_an_insertion() {
333        let p = analyse_with("Z.ZZ9,99", COMMA).unwrap();
334        assert_eq!((p.category, p.size, p.digits, p.scale), (Category::NumericEdited, 8, 6, 2));
335        assert_eq!(p.edit.as_ref().unwrap()[1], Sym::Insert('.'));
336        assert_eq!(p.edit.as_ref().unwrap()[5], Sym::Point);
337        assert_eq!(analyse_with("ZZ9.99", Notation::default()).unwrap().scale, 2);
338        assert_eq!(analyse_with("ZZ9.99", COMMA).unwrap().scale, 0);
339        assert!(analyse_with("9,99,9", COMMA).unwrap_err().1.contains("decimal point"));
340    }
341
342    #[test]
343    fn a_picture_can_end_in_an_insertion_comma_or_the_decimal_point() {
344        let edit = |pic: &str, notation: Notation, value: u128| {
345            let p = analyse_with(pic, notation).unwrap();
346            let point = if notation.decimal_comma { ',' } else { '.' };
347            ((p.category, p.size, p.digits, p.scale), rt::edit::numeric(p.edit.as_ref().unwrap(), p.digits, false, value, false, point, ""))
348        };
349        assert_eq!(edit("9,9,9,", Notation::default(), 123), ((Category::NumericEdited, 6, 3, 0), "1,2,3,".into()));
350        assert_eq!(edit("999.", Notation::default(), 7), ((Category::NumericEdited, 4, 3, 0), "007.".into()));
351        assert_eq!(edit("9.9.9,", COMMA, 123), ((Category::NumericEdited, 6, 3, 0), "1.2.3,".into()));
352        assert_eq!(edit("999.", COMMA, 7), ((Category::NumericEdited, 4, 3, 0), "007.".into()));
353        assert_eq!(analyse_with("999.", COMMA).unwrap().edit.unwrap()[3], Sym::Insert('.'));
354    }
355
356    #[test]
357    fn a_currency_sign_clause_names_the_symbol_and_the_value_it_stands_for() {
358        let signs = [CurrencySign { value: "W".into(), symbol: 'W', hex: None }, CurrencySign { value: "EUR ".into(), symbol: 'e', hex: None }];
359        let notation = Notation { decimal_comma: true, currency: &signs };
360        let w = analyse_with("W9.999,99", notation).unwrap();
361        assert_eq!((w.category, w.size, w.digits, w.scale, w.currency.as_deref()), (Category::NumericEdited, 9, 6, 2, Some("W")));
362        assert_eq!(w.edit.as_ref().unwrap()[0], Sym::Currency);
363        let floating = analyse_with("eeee9,99", notation).unwrap();
364        assert_eq!((floating.size, floating.digits, floating.currency.as_deref()), (11, 6, Some("EUR ")));
365        assert_eq!(floating.edit.as_ref().unwrap()[0], Sym::FloatLead('$'));
366        assert!(analyse_with("$$9", notation).unwrap_err().1.contains("'$'"));
367        assert!(analyse_with("We9", notation).unwrap_err().1.contains("two different currency symbols"));
368        assert!(analyse_with("E9", notation).unwrap_err().1.contains("not a PICTURE symbol"));
369        assert_eq!(analyse("$9").unwrap().currency.as_deref(), Some("$"));
370    }
371
372    #[test]
373    fn blank_when_zero_makes_a_numeric_picture_numeric_edited() {
374        let p = blank_when_zero(&analyse("99V9").unwrap()).unwrap();
375        assert_eq!((p.category, p.size, p.digits, p.scale), (Category::NumericEdited, 3, 3, 1));
376        assert_eq!(p.edit.unwrap(), [Sym::Nine, Sym::Nine, Sym::Implied, Sym::Nine]);
377        assert_eq!(blank_when_zero(&analyse("VPP99").unwrap()).unwrap().edit.unwrap(), [Sym::Implied, Sym::Nine, Sym::Nine]);
378        assert!(blank_when_zero(&analyse("S99").unwrap()).unwrap_err().1.contains("with S"));
379    }
380}