Skip to main content

ironwork_syntax/
extended.rs

1//! What `--compliance extended` does to the tokens before the parser reads them: each constant
2//! entry comes out and every later use of its name stands for its value, literals joined by `&`
3//! become one literal, BINARY-SHORT, BINARY-LONG and BINARY-DOUBLE become COMP-5 PICTUREs, and
4//! RETURNING OMITTED leaves a program's PROCEDURE DIVISION header (docs/compliance.md).
5
6use crate::lexer::{Tok, Token};
7use crate::messages::{IWX0002, IWX0004, IWX0005, IWX0006, IWX0009};
8use crate::{Error, Pos};
9use std::collections::HashMap;
10
11pub const CONSTANT: &str = "constant entry (Micro Focus and GnuCOBOL; Enterprise COBOL has no level 78 and no CONSTANT clause)";
12pub const CONCATENATION: &str = "literal concatenation with & (Micro Focus and GnuCOBOL; Enterprise COBOL has none)";
13pub const BINARY_USAGE: &str = "the COBOL 2002 binary usage (Micro Focus and GnuCOBOL; not Enterprise COBOL's)";
14pub const NO_IDENTIFICATION_HEADER: &str = "PROGRAM-ID with no IDENTIFICATION DIVISION header before it (COBOL 2002, Micro Focus and GnuCOBOL; Enterprise COBOL requires the header)";
15pub const ASSIGN_ITEM: &str = "ASSIGN to a data item (Micro Focus and GnuCOBOL; Enterprise COBOL's assignment-name is never a data item)";
16pub const RETURNING_OMITTED: &str = "PROCEDURE DIVISION RETURNING OMITTED (GnuCOBOL; Enterprise COBOL's RETURNING names an 01 or 77 item of the LINKAGE SECTION)";
17
18/// BINARY-SHORT, BINARY-LONG and BINARY-DOUBLE, and the COMP-5 PICTURE each is: two, four and eight
19/// bytes of native binary.
20const BINARY_USAGES: &[(&str, &str)] = &[("BINARY-SHORT", "9(4)"), ("BINARY-LONG", "9(9)"), ("BINARY-DOUBLE", "9(18)")];
21
22const FIGURATIVES: &[&str] = &["ZERO", "ZEROS", "ZEROES", "SPACE", "SPACES", "HIGH-VALUE", "HIGH-VALUES", "LOW-VALUE", "LOW-VALUES", "QUOTE", "QUOTES", "NULL", "NULLS"];
23
24/// The tokens with constant entries taken out, their names replaced by their values, `&`
25/// concatenations joined, the binary usages rewritten and RETURNING OMITTED taken out of a
26/// program's PROCEDURE DIVISION header. `cards` are the CBL and PROCESS options, whose code page
27/// reads a hexadecimal literal joined to an alphanumeric one.
28pub fn rewrite(tokens: Vec<Token>, cards: &[String]) -> Result<Vec<Token>, Error> {
29    let mut options = numeric::Options::default();
30    for card in cards {
31        options.apply(card).ok();
32    }
33    let mut r = Rewrite { tokens, at: 0, out: Vec::new(), constants: HashMap::new(), pending: Vec::new(), options };
34    // `program`: the last ID paragraph was a PROGRAM-ID, not a function's, class's or method's.
35    let (mut data, mut program, mut header) = (false, false, false);
36    while r.at < r.tokens.len() {
37        let division = r.tokens.get(r.at + 1).is_some_and(|t| matches!(&t.tok, Tok::Word(w) if w == "DIVISION"));
38        if let Tok::Word(w) = &r.tokens[r.at].tok
39            && matches!(w.as_str(), "PROGRAM-ID" | "FUNCTION-ID" | "METHOD-ID" | "CLASS-ID" | "INTERFACE-ID")
40        {
41            program = w == "PROGRAM-ID";
42        }
43        match &r.tokens[r.at].tok {
44            Tok::Word(w) if division => {
45                data = w == "DATA";
46                header = program && w == "PROCEDURE";
47            }
48            Tok::Period => header = false,
49            Tok::Word(w) if header && w == "RETURNING" && r.word_at(1) == Some("OMITTED") && r.tokens.get(r.at + 2).is_some_and(|t| t.tok == Tok::Period) => {
50                r.returning_omitted();
51                continue;
52            }
53            Tok::Number(n) if data && r.out.last().is_none_or(|t| t.tok == Tok::Period) && matches!(r.tokens.get(r.at + 1).map(|t| &t.tok), Some(Tok::Word(_))) && (n == "78" || r.word_at(2) == Some("CONSTANT") && matches!(n.as_str(), "01" | "1")) => {
54                r.constant()?;
55                continue;
56            }
57            Tok::Word(w) if data && (w == "BINARY-CHAR" || BINARY_USAGES.iter().any(|(u, _)| u == w)) => {
58                r.binary_usage()?;
59                continue;
60            }
61            Tok::Word(w) if w == "PROGRAM-ID" && !ends_with_header(&r.out) => r.identification_header(),
62            _ => {}
63        }
64        let value = r.value()?;
65        r.push(value);
66    }
67    if let Some(last) = r.out.last_mut() {
68        last.messages.append(&mut r.pending);
69    }
70    Ok(r.out)
71}
72
73struct Rewrite {
74    tokens: Vec<Token>,
75    at: usize,
76    out: Vec<Token>,
77    constants: HashMap<String, Tok>,
78    /// Messages of tokens taken out, for the next token kept.
79    pending: Vec<Error>,
80    /// The cards' options, whose code page reads a hexadecimal literal joined to an alphanumeric one.
81    options: numeric::Options,
82}
83
84impl Rewrite {
85    fn push(&mut self, mut token: Token) {
86        token.messages.splice(0..0, self.pending.drain(..));
87        self.out.push(token);
88    }
89
90    /// Token `k`, a constant's name replaced by its value and a PICTURE's `(name)` by the number.
91    fn substituted(&self, k: usize) -> Token {
92        let mut token = self.tokens[k].clone();
93        match &token.tok {
94            Tok::Word(w) => {
95                if let Some(value) = self.constants.get(w) {
96                    token.tok = value.clone();
97                    token.spelled = None;
98                }
99            }
100            Tok::Pic(p) => {
101                if let Some(p) = picture(p, &self.constants) {
102                    token.tok = Tok::Pic(p);
103                }
104            }
105            _ => {}
106        }
107        token
108    }
109
110    /// The token at `at`, substituted, and the literals any `&` joins to it.
111    fn value(&mut self) -> Result<Token, Error> {
112        let mut left = self.substituted(self.at);
113        self.at += 1;
114        while self.tokens.get(self.at).is_some_and(|t| t.tok == Tok::Ampersand) {
115            let amp = self.tokens[self.at].clone();
116            if self.at + 1 >= self.tokens.len() {
117                return Err(crate::messages::IWS0013.at(amp.pos, "& with no literal after it"));
118            }
119            let right = self.substituted(self.at + 1);
120            self.at += 2;
121            left = join(left, amp, right, |bytes| self.options.code_page().decode(bytes))?;
122        }
123        Ok(left)
124    }
125
126    fn word_at(&self, ahead: usize) -> Option<&str> {
127        match self.tokens.get(self.at + ahead).map(|t| &t.tok) {
128            Some(Tok::Word(w)) => Some(w),
129            _ => None,
130        }
131    }
132
133    /// A constant entry from its level number, `78 name [IS] [GLOBAL] VALUE [IS] value.` or
134    /// `01 name CONSTANT [IS] [GLOBAL] [AS] value.`, where the value is a literal, a figurative
135    /// constant, a constant already defined, or literals joined by `&`. The entry is taken out of
136    /// the tokens.
137    fn constant(&mut self) -> Result<(), Error> {
138        let level = self.tokens[self.at].clone();
139        let named = self.tokens[self.at + 1].clone();
140        let Tok::Word(name) = named.tok else { unreachable!("the caller saw a word") };
141        let seventy_eight = level.tok == Tok::Number("78".into());
142        self.at += if seventy_eight { 2 } else { 3 };
143        self.pending.extend(level.messages);
144        self.pending.extend(named.messages);
145        let refused = |at: Pos, why: &str| crate::messages::IWS0014.at(at, format!("constant {name}: {why}"));
146        match (self.word_at(0), self.word_at(1)) {
147            (Some("IS"), Some("GLOBAL")) => self.at += 2,
148            (Some("GLOBAL"), _) => self.at += 1,
149            _ => {}
150        }
151        let here = self.tokens.get(self.at).map_or(named.pos, |t| t.pos);
152        if seventy_eight {
153            if self.word_at(0) != Some("VALUE") {
154                return Err(refused(here, "a level-78 entry is VALUE and its value, then a period"));
155            }
156            self.at += 1;
157            if self.word_at(0) == Some("IS") {
158                self.at += 1;
159            }
160        } else if self.word_at(0) == Some("AS") {
161            self.at += 1;
162        }
163        if self.at >= self.tokens.len() {
164            return Err(refused(here, "VALUE with no value"));
165        }
166        let value = self.value()?;
167        let literal = match &value.tok {
168            Tok::Alnum(_) | Tok::Hex(_) | Tok::National(_) | Tok::Number(_) => true,
169            Tok::Word(w) => FIGURATIVES.contains(&w.as_str()),
170            _ => false,
171        };
172        let ended = self.tokens.get(self.at).is_some_and(|t| t.tok == Tok::Period);
173        if !literal || !ended {
174            let at = if literal { self.tokens.get(self.at).map_or(value.pos, |t| t.pos) } else { value.pos };
175            return Err(refused(at, "the value is a literal, a figurative constant, a constant defined before, or literals joined by &; ironwork computes no expression there"));
176        }
177        self.pending.extend(self.tokens[self.at].messages.iter().cloned());
178        self.at += 1;
179        self.pending.push(IWX0002.at(level.pos, format!("{CONSTANT}: {name} stands for its value wherever it is used after this entry")));
180        self.pending.extend(value.messages);
181        self.constants.insert(name, value.tok);
182        Ok(())
183    }
184
185    /// `IDENTIFICATION DIVISION.` before a PROGRAM-ID that has none, which it means.
186    fn identification_header(&mut self) {
187        let at = self.tokens[self.at].clone();
188        let made = |tok: Tok, messages: Vec<Error>| Token { tok, pos: at.pos, area_a: at.area_a, spelled: None, after_comma: false, messages };
189        let warning = IWX0006.at(at.pos, format!("{NO_IDENTIFICATION_HEADER}: the program reads as though IDENTIFICATION DIVISION. came before it"));
190        self.push(made(Tok::Word("IDENTIFICATION".into()), vec![warning]));
191        self.push(made(Tok::Word("DIVISION".into()), Vec::new()));
192        self.push(made(Tok::Period, Vec::new()));
193    }
194
195    /// `RETURNING OMITTED` ending a program's PROCEDURE DIVISION header, taken out: GnuCOBOL's
196    /// program that returns no item is one with no RETURNING phrase.
197    fn returning_omitted(&mut self) {
198        let returning = self.tokens[self.at].clone();
199        self.pending.push(IWX0009.at(returning.pos, format!("{RETURNING_OMITTED}: the program is read with no RETURNING phrase, and returns its RETURN-CODE to its caller as any program does")));
200        self.pending.extend(returning.messages);
201        self.pending.extend(self.tokens[self.at + 1].messages.iter().cloned());
202        self.at += 2;
203    }
204
205    /// `[USAGE [IS]] BINARY-SHORT|BINARY-LONG|BINARY-DOUBLE [SIGNED|UNSIGNED]` in a data entry, as
206    /// `PIC S9(n) COMP-5`, or `PIC 9(n) COMP-5` when UNSIGNED: SIGNED is the default.
207    fn binary_usage(&mut self) -> Result<(), Error> {
208        let token = self.tokens[self.at].clone();
209        let Tok::Word(usage) = &token.tok else { unreachable!("the caller saw a word") };
210        let Some(&(_, digits)) = BINARY_USAGES.iter().find(|(u, _)| u == usage) else {
211            return Err(crate::messages::IWS0015.at(token.pos, "BINARY-CHAR is a one-byte binary item, and ironwork's binary items are two, four or eight bytes, as Enterprise COBOL's are"));
212        };
213        let mut messages = Vec::new();
214        if self.out.last().is_some_and(|t| t.tok == Tok::Word("IS".into())) && self.out.len() >= 2 && self.out[self.out.len() - 2].tok == Tok::Word("USAGE".into()) {
215            messages.extend(self.out.pop().map(|t| t.messages).unwrap_or_default());
216        }
217        if self.out.last().is_some_and(|t| t.tok == Tok::Word("USAGE".into())) {
218            messages.extend(self.out.pop().map(|t| t.messages).unwrap_or_default());
219        }
220        self.at += 1;
221        let signed = match self.tokens.get(self.at).map(|t| &t.tok) {
222            Some(Tok::Word(w)) if w == "SIGNED" || w == "UNSIGNED" => {
223                self.at += 1;
224                w == "SIGNED"
225            }
226            _ => true,
227        };
228        let picture = format!("{}{digits}", if signed { "S" } else { "" });
229        let shown = format!("{BINARY_USAGE}: {usage}{} is read as PIC {picture} COMP-5", if signed { "" } else { " UNSIGNED" });
230        messages.push(IWX0005.at(token.pos, shown));
231        messages.extend(token.messages.iter().cloned());
232        let made = |tok: Tok, messages: Vec<Error>| Token { tok, pos: token.pos, area_a: false, spelled: None, after_comma: false, messages };
233        self.push(made(Tok::Word("PIC".into()), messages));
234        self.push(made(Tok::Pic(picture), Vec::new()));
235        self.push(made(Tok::Word("COMP-5".into()), Vec::new()));
236        Ok(())
237    }
238}
239
240/// Whether the tokens end with `IDENTIFICATION DIVISION.` or `ID DIVISION.`.
241fn ends_with_header(tokens: &[Token]) -> bool {
242    matches!(tokens, [.., a, b, c] if matches!(&a.tok, Tok::Word(w) if w == "IDENTIFICATION" || w == "ID") && b.tok == Tok::Word("DIVISION".into()) && c.tok == Tok::Period)
243}
244
245/// `(name)` in a PICTURE, where `name` is a constant whose value is an unsigned integer, with the
246/// integer in its place.
247fn picture(text: &str, constants: &HashMap<String, Tok>) -> Option<String> {
248    let (mut out, mut rest, mut changed) = (String::new(), text, false);
249    while let Some(open) = rest.find('(') {
250        let Some(close) = rest[open..].find(')').map(|c| open + c) else { break };
251        match constants.get(&rest[open + 1..close]) {
252            Some(Tok::Number(n)) if n.bytes().all(|b| b.is_ascii_digit()) => {
253                out.push_str(&rest[..=open]);
254                out.push_str(n);
255                out.push(')');
256                changed = true;
257            }
258            _ => out.push_str(&rest[..=close]),
259        }
260        rest = &rest[close + 1..];
261    }
262    out.push_str(rest);
263    changed.then_some(out)
264}
265
266/// `left & right` as one literal, at `left`'s place. Alphanumeric and hexadecimal literals join
267/// into an alphanumeric one, a hexadecimal one's bytes read in the program's code page, two
268/// hexadecimal literals into a hexadecimal one, and national literals into a national one.
269fn join(left: Token, amp: Token, right: Token, decode: impl Fn(&[u8]) -> String) -> Result<Token, Error> {
270    let tok = match (&left.tok, &right.tok) {
271        (Tok::Alnum(a), Tok::Alnum(b)) => Tok::Alnum(format!("{a}{b}")),
272        (Tok::Hex(a), Tok::Hex(b)) => Tok::Hex([a.as_slice(), b].concat()),
273        (Tok::Alnum(a), Tok::Hex(b)) => Tok::Alnum(format!("{a}{}", decode(b))),
274        (Tok::Hex(a), Tok::Alnum(b)) => Tok::Alnum(format!("{}{b}", decode(a))),
275        (Tok::National(a), Tok::National(b)) => Tok::National(format!("{a}{b}")),
276        _ => return Err(crate::messages::IWS0016.at(amp.pos, "& joins two alphanumeric or hexadecimal literals, or two national literals, either of which may be a level-78 constant standing for one")),
277    };
278    let mut messages = left.messages;
279    messages.push(IWX0004.at(amp.pos, format!("{CONCATENATION}: the literals on either side are one literal")));
280    messages.extend(amp.messages);
281    messages.extend(right.messages);
282    Ok(Token { tok, pos: left.pos, area_a: left.area_a, spelled: None, after_comma: left.after_comma, messages })
283}
284
285#[cfg(test)]
286mod tests {
287    use crate::ast::{Literal, Usage};
288    use crate::copy::Libraries;
289    use numeric::Compliance;
290
291    fn source(data: &str, procedure: &str) -> String {
292        format!("       IDENTIFICATION DIVISION.\n       PROGRAM-ID. T.\n       DATA DIVISION.\n       WORKING-STORAGE SECTION.\n{data}       PROCEDURE DIVISION.\n{procedure}           GOBACK.\n")
293    }
294
295    fn extended(text: &str) -> Result<crate::ast::Program, crate::Error> {
296        crate::parse_with(text, &Libraries::default().with_compliance(Compliance::Extended))
297    }
298
299    #[test]
300    fn a_constant_stands_for_its_value_in_pictures_values_occurs_and_statements() {
301        let data = concat!(
302            "       78  MAX-LEN VALUE 3.\n",
303            "       78  GREETING IS GLOBAL VALUE IS 'AB' & X'C1'.\n",
304            "       01  QUOTED CONSTANT AS 'Q'.\n",
305            "       01  NOTHING CONSTANT GLOBAL SPACE.\n",
306            "       01  X PIC X(MAX-LEN) VALUE GREETING.\n",
307            "       01  G.\n",
308            "           05 T OCCURS MAX-LEN TIMES PIC X VALUE NOTHING.\n",
309        );
310        let p = extended(&source(data, "           MOVE QUOTED TO X.\n")).unwrap();
311        let names: Vec<&str> = p.working_storage.iter().filter_map(|e| e.name.as_deref()).collect();
312        assert_eq!(names, ["X", "G", "T"]);
313        let x = &p.working_storage[0];
314        assert_eq!((x.picture.as_deref(), x.value.clone()), (Some("X(3)"), Some(Literal::Alnum("ABA".into()))));
315        assert_eq!(p.working_storage[2].occurs, Some(3));
316        let warnings: Vec<(u32, String)> = p.messages.iter().map(|m| (m.pos.line, m.message.clone())).collect();
317        assert_eq!(warnings.len(), 5, "{warnings:?}");
318        assert!(warnings[..2].iter().all(|(_, m)| m.starts_with(super::CONSTANT)) && warnings[0].0 == 5 && warnings[0].1.ends_with("MAX-LEN stands for its value wherever it is used after this entry"));
319        assert!(warnings[2].1.starts_with(super::CONCATENATION) && warnings[2].0 == 6);
320        assert!(warnings[3..].iter().all(|(_, m)| m.starts_with(super::CONSTANT)));
321        assert!(format!("{:?}", p.paragraphs[0].statements[0]).contains("Alnum(\"Q\")"));
322    }
323
324    #[test]
325    fn a_constant_whose_value_is_an_expression_or_missing_is_refused_by_name() {
326        let refused = |data: &str| extended(&source(data, "")).unwrap_err().message;
327        assert_eq!(refused("       78  N VALUE 1 + 2.\n"), "constant N: the value is a literal, a figurative constant, a constant defined before, or literals joined by &; ironwork computes no expression there");
328        assert_eq!(refused("       78  N PIC 9 VALUE 1.\n"), "constant N: a level-78 entry is VALUE and its value, then a period");
329        assert!(refused("       01  N PIC X VALUE 'A' & B.\n").starts_with("& joins two alphanumeric or hexadecimal literals"));
330        assert!(refused("       01  N PIC X VALUE 'A' & N'B'.\n").starts_with("& joins two alphanumeric or hexadecimal literals"));
331    }
332
333    #[test]
334    fn the_binary_usages_are_comp_5_pictures_and_binary_char_is_refused() {
335        let data = "       01  A USAGE IS BINARY-LONG.\n       01  B BINARY-SHORT UNSIGNED VALUE 7.\n       01  C BINARY-DOUBLE SIGNED.\n";
336        let p = extended(&source(data, "")).unwrap();
337        let read: Vec<(Option<&str>, Option<Usage>)> = p.working_storage.iter().map(|e| (e.picture.as_deref(), e.usage)).collect();
338        assert_eq!(read, [(Some("S9(9)"), Some(Usage::NativeBinary)), (Some("9(4)"), Some(Usage::NativeBinary)), (Some("S9(18)"), Some(Usage::NativeBinary))]);
339        assert_eq!(p.working_storage[1].value, Some(Literal::Number("7".into())));
340        let shown: Vec<String> = p.messages.iter().map(|m| m.message.clone()).collect();
341        assert_eq!(shown[1], format!("{}: BINARY-SHORT UNSIGNED is read as PIC 9(4) COMP-5", super::BINARY_USAGE));
342        let refused = extended(&source("       01  D BINARY-CHAR.\n", "")).unwrap_err();
343        assert!(refused.message.starts_with("BINARY-CHAR is a one-byte binary item") && refused.pos.line == 5);
344    }
345
346    #[test]
347    fn returning_omitted_leaves_a_programs_header_and_nothing_else() {
348        let header = "       PROCEDURE DIVISION USING A RETURNING OMITTED.\n";
349        let text = format!("       IDENTIFICATION DIVISION.\n       PROGRAM-ID. T.\n       DATA DIVISION.\n       LINKAGE SECTION.\n       01 A PIC X.\n{header}           CALL 'S' RETURNING OMITTED.\n           GOBACK.\n");
350        let p = extended(&text).unwrap();
351        assert_eq!((p.using.len(), p.returning.as_deref()), (1, None));
352        let shown: Vec<(u32, u32, &str)> = p.messages.iter().map(|m| (m.pos.line, m.pos.col, m.message.as_str())).collect();
353        let warning = format!("{}: the program is read with no RETURNING phrase, and returns its RETURN-CODE to its caller as any program does", super::RETURNING_OMITTED);
354        assert_eq!(shown, [(6, header.find("RETURNING").unwrap() as u32 + 1, warning.as_str())]);
355        assert!(format!("{:?}", p.paragraphs[0].statements[0]).contains("\"OMITTED\""), "a CALL's RETURNING OMITTED is not the header's");
356        assert_eq!(crate::parse(&text).unwrap().returning.as_deref(), Some("OMITTED"));
357        let function = "       IDENTIFICATION DIVISION.\n       FUNCTION-ID. F.\n       PROCEDURE DIVISION RETURNING OMITTED.\n           GOBACK.\n       END FUNCTION F.\n";
358        let f = extended(function).unwrap();
359        assert_eq!((f.returning.as_deref(), f.messages.len()), (Some("OMITTED"), 0), "a function returns an item");
360    }
361
362    #[test]
363    fn strict_reads_none_of_it() {
364        let strict = |data: &str| crate::parse(&source(data, ""));
365        assert_eq!(strict("       01  N PIC X(2) VALUE 'A' & 'B'.\n").unwrap_err().message, "literal concatenation with & is not Enterprise COBOL's");
366        assert_eq!(strict("       78  N VALUE 1.\n").unwrap().working_storage[0].level, 78);
367        assert!(strict("       01  A BINARY-LONG.\n").unwrap_err().message.contains("BINARY-LONG is not a data description clause"));
368    }
369}