Skip to main content

ironwork_syntax/
source.rs

1//! Fixed-format reference format: columns 1-6 sequence, 7 indicator, 8-72 program text, 73 on
2//! ignored. The result is one logical text with continuation lines joined, and the options of any
3//! CBL or PROCESS cards ahead of the program.
4
5use crate::{Error, Pos};
6
7pub struct Source {
8    pub text: String,
9    /// For each char of `text`, where it came from.
10    pub positions: Vec<Pos>,
11    pub options: Vec<String>,
12    /// None when debugging lines (D in column 7) were read as comments; else the file and line of
13    /// each one read as program text.
14    pub debugging: Option<Vec<(u16, u32)>>,
15}
16
17const TEXT_START: usize = 7;
18const AREA_B: usize = 11;
19const TEXT_END: usize = 72;
20
21/// The IDENTIFICATION DIVISION paragraphs whose entry is a comment-entry (LR SC27-8713-03 p. 117),
22/// and REMARKS, which Enterprise COBOL does not have (numeric::assumptions::COMMENT_ENTRY_REMARKS).
23const COMMENT_PARAGRAPHS: &[&str] = &["AUTHOR", "INSTALLATION", "DATE-WRITTEN", "DATE-COMPILED", "SECURITY", "REMARKS"];
24
25pub fn read(input: &str) -> Result<Source, Error> {
26    read_file(input, 0)
27}
28
29/// Reads one file's text; `file` indexes its name in the program's file table. A comment-entry is
30/// left out of the text, so neither COPY nor the lexer sees it (LR pp. 117, 700).
31pub fn read_file(input: &str, file: u16) -> Result<Source, Error> {
32    read_lines(input, file, false)
33}
34
35/// Reads one file's text with its debugging lines as program text.
36pub fn read_file_debugging(input: &str, file: u16) -> Result<Source, Error> {
37    read_lines(input, file, true)
38}
39
40fn read_lines(input: &str, file: u16, debugging: bool) -> Result<Source, Error> {
41    let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: debugging.then(Vec::new) };
42    let mut seen_program = false;
43    let mut open_quote: Option<char> = None;
44    let mut closed_at_72: Option<char> = None;
45    let (mut identification, mut comment_entry) = (false, false);
46    for (index, raw) in input.lines().enumerate() {
47        let line = index as u32 + 1;
48        let chars: Vec<char> = raw.trim_end_matches('\r').chars().map(|c| if c == '\t' { ' ' } else { c }).collect();
49        let body: String = chars.iter().take(TEXT_END).collect();
50        if !seen_program && let Some(options) = option_card(&body) {
51            out.options.extend(options);
52            continue;
53        }
54        let indicator = chars.get(6).copied().unwrap_or(' ');
55        let debugging_line = matches!(indicator, 'D' | 'd');
56        if indicator == '*' || indicator == '/' || (debugging_line && out.debugging.is_none()) {
57            continue;
58        }
59        let area: Vec<char> = chars.iter().take(TEXT_END).skip(TEXT_START).copied().collect();
60        if area.iter().all(|c| *c == ' ') {
61            continue;
62        }
63        if debugging_line && let Some(lines) = &mut out.debugging {
64            lines.push((file, line));
65        }
66        seen_program = true;
67        let area_a_blank = area.iter().take(AREA_B - TEXT_START).all(|c| *c == ' ');
68        if comment_entry && area_a_blank {
69            continue;
70        }
71        comment_entry = false;
72        if indicator != '-' && open_quote.is_none() && listing_control(&area) {
73            continue;
74        }
75        let start_col = TEXT_START as u32 + 1;
76        if indicator == '-' {
77            let first = area.iter().position(|c| *c != ' ').unwrap();
78            let skip = match open_quote {
79                Some(q) if area[first] == q => first + 1,
80                Some(_) => return Err(Error::at(Pos { file, line, col: start_col + first as u32 }, "a continued literal must resume with its quote")),
81                // A closing quote in column 72 and the continuation's first two quotes are one doubled
82                // quote (LR p. 58).
83                None if closed_at_72.is_some_and(|q| area[first] == q && area.get(first + 1) == Some(&q)) => first + 1,
84                None => {
85                    while out.text.ends_with(' ') {
86                        out.text.pop();
87                        out.positions.pop();
88                    }
89                    // After a closed literal, a quote starts a second literal (LR p. 58).
90                    if matches!(area[first], '\'' | '"') && out.text.ends_with(['\'', '"']) {
91                        out.text.push(' ');
92                        out.positions.push(Pos { file, line, col: start_col + first as u32 });
93                    }
94                    first
95                }
96            };
97            for (i, &c) in area.iter().enumerate().skip(skip) {
98                if floating_comment(&area, i, open_quote) {
99                    break;
100                }
101                push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
102            }
103        } else {
104            if open_quote.is_some() {
105                return Err(Error::at(Pos { file, line, col: 1 }, "a literal runs to the end of the line with no continuation"));
106            }
107            if let Some(entering) = division_header(&area) {
108                identification = entering;
109            }
110            let header_end = if identification { comment_paragraph(&area) } else { None };
111            out.text.push('\n');
112            out.positions.push(Pos { file, line, col: 0 });
113            for (i, &c) in area.iter().enumerate().take(header_end.map_or(area.len(), |period| period + 1)) {
114                if floating_comment(&area, i, open_quote) {
115                    break;
116                }
117                push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
118            }
119            comment_entry = header_end.is_some();
120            if open_quote.is_some() {
121                for i in area.len()..TEXT_END - TEXT_START {
122                    push(&mut out, ' ', Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
123                }
124            }
125        }
126        closed_at_72 = match (out.text.chars().next_back(), out.positions.last()) {
127            (Some(q @ ('\'' | '"')), Some(p)) if open_quote.is_none() && p.line == line && p.col == TEXT_END as u32 => Some(q),
128            _ => None,
129        };
130    }
131    if open_quote.is_some() {
132        return Err(Error::at(out.positions.last().copied().unwrap_or_default(), "an unterminated literal"));
133    }
134    Ok(out)
135}
136
137/// EJECT, SKIP1, SKIP2, SKIP3 or TITLE with its literal, alone on the line and perhaps ended by a
138/// period, and perhaps by a floating comment: statements for the listing that have no effect on
139/// compilation.
140fn listing_control(area: &[char]) -> bool {
141    let mut quote = None;
142    let end = (0..area.len())
143        .find(|&i| {
144            let comment = floating_comment(area, i, quote);
145            match (quote, area[i]) {
146                (None, c @ ('\'' | '"')) => quote = Some(c),
147                (Some(q), c) if c == q => quote = None,
148                _ => {}
149            }
150            comment
151        })
152        .unwrap_or(area.len());
153    let line: String = area[..end].iter().collect();
154    let line = line.trim();
155    let line = line.strip_suffix('.').unwrap_or(line).trim_end();
156    if ["EJECT", "SKIP1", "SKIP2", "SKIP3"].iter().any(|w| line.eq_ignore_ascii_case(w)) {
157        return true;
158    }
159    let Some(literal) = line.get(..6).filter(|t| t.eq_ignore_ascii_case("TITLE ")).map(|_| line[6..].trim_start()) else { return false };
160    let literal = literal.strip_prefix(['N', 'n', 'G', 'g']).filter(|l| l.starts_with(['\'', '"'])).unwrap_or(literal);
161    let Some(quote) = literal.chars().next().filter(|c| matches!(c, '\'' | '"')) else { return false };
162    let inner = literal.get(1..literal.len() - 1).unwrap_or_default();
163    literal.len() >= 2 && literal.ends_with(quote) && !inner.replace(&format!("{quote}{quote}"), "").contains(quote)
164}
165
166/// `*>` outside a literal starts a comment that runs to the end of the line.
167fn floating_comment(area: &[char], i: usize, open_quote: Option<char>) -> bool {
168    open_quote.is_none() && area[i] == '*' && area.get(i + 1) == Some(&'>')
169}
170
171/// The word that starts at or after `from`, uppercased, and the index just past it.
172fn word_from(area: &[char], from: usize) -> Option<(String, usize)> {
173    let start = from + area.get(from..)?.iter().position(|c| *c != ' ')?;
174    let len = area[start..].iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '-' || **c == '_').count();
175    (len > 0).then(|| (area[start..start + len].iter().collect::<String>().to_ascii_uppercase(), start + len))
176}
177
178/// For a line that starts with a division header, whether it is the IDENTIFICATION DIVISION's.
179fn division_header(area: &[char]) -> Option<bool> {
180    let (first, end) = word_from(area, 0)?;
181    let (second, _) = word_from(area, end)?;
182    (second == "DIVISION").then(|| first == "IDENTIFICATION" || first == "ID")
183}
184
185/// Where the period is, for a line that starts with a paragraph header whose entry is a
186/// comment-entry.
187fn comment_paragraph(area: &[char]) -> Option<usize> {
188    let (word, end) = word_from(area, 0)?;
189    let period = end + area[end..].iter().position(|c| *c != ' ')?;
190    (area[period] == '.' && COMMENT_PARAGRAPHS.contains(&word.as_str())).then_some(period)
191}
192
193fn push(out: &mut Source, c: char, pos: Pos, open_quote: &mut Option<char>) {
194    match *open_quote {
195        Some(q) if c == q => *open_quote = None,
196        None if c == '\'' || c == '"' => *open_quote = Some(c),
197        _ => {}
198    }
199    out.text.push(c);
200    out.positions.push(pos);
201}
202
203/// A CBL or PROCESS card: its options, split at commas and spaces outside parentheses.
204fn option_card(line: &str) -> Option<Vec<String>> {
205    let sequence: String = line.chars().take(6).collect();
206    let line = if sequence.len() == 6 && sequence.chars().all(|c| c.is_ascii_digit() || c == ' ') { &line[6..] } else { line };
207    let trimmed = line.trim_start();
208    let keyword = trimmed.split(' ').next().unwrap_or("");
209    let rest = (keyword.eq_ignore_ascii_case("CBL") || keyword.eq_ignore_ascii_case("PROCESS")).then(|| &trimmed[keyword.len()..])?;
210    let (mut options, mut current, mut depth) = (Vec::new(), String::new(), 0i32);
211    for c in rest.chars() {
212        match c {
213            '(' => depth += 1,
214            ')' => depth -= 1,
215            _ => {}
216        }
217        if depth == 0 && (c == ',' || c == ' ') {
218            if !current.is_empty() {
219                options.push(std::mem::take(&mut current));
220            }
221        } else {
222            current.push(c);
223        }
224    }
225    if !current.is_empty() {
226        options.push(current);
227    }
228    Some(options)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn option_cards_ahead_of_the_program_are_collected() {
237        let s = read("       CBL TRUNC(OPT),NUMPROC(PFD) ARITH(EXTEND)\n       PROCESS SSRANGE\n       IDENTIFICATION DIVISION.\n").unwrap();
238        assert_eq!(s.options, ["TRUNC(OPT)", "NUMPROC(PFD)", "ARITH(EXTEND)", "SSRANGE"]);
239        assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
240    }
241
242    #[test]
243    fn a_suboption_list_stays_whole() {
244        assert_eq!(option_card("CBL FLAG(I,W),X").unwrap(), ["FLAG(I,W)", "X"]);
245    }
246
247    #[test]
248    fn sequence_numbers_comments_and_columns_past_72_are_dropped() {
249        let s = read("000100 IDENTIFICATION DIVISION.                                         SEQ00001\n000200*a comment\n000300/page\n").unwrap();
250        assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
251    }
252
253    #[test]
254    fn listing_statements_alone_on_their_line_are_left_out() {
255        let text = [
256            "       01  A PIC X.\n",
257            "           EJECT\n",
258            "       SKIP2.\n",
259            "           TITLE 'RATES, ''A'' TO Z'.\n",
260            "           title n\"NATIONAL\"\n",
261            "       01  B PIC X.\n",
262            "           EJECT X\n",
263        ]
264        .concat();
265        let s = read(&text).unwrap();
266        assert_eq!(s.text.split_whitespace().collect::<Vec<_>>(), ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "EJECT", "X"]);
267    }
268
269    #[test]
270    fn a_continued_literal_keeps_its_trailing_spaces() {
271        let first = format!("       01 A PIC X(70) VALUE 'ABC{}", " ".repeat(72 - 34));
272        let text = format!("{first}\n      -    'DEF'.\n");
273        let s = read(&text).unwrap();
274        let literal = &s.text[s.text.find('\'').unwrap()..];
275        assert_eq!(literal.trim_end(), format!("'ABC{}DEF'.", " ".repeat(40)));
276    }
277
278    #[test]
279    fn an_option_card_may_be_lowercase() {
280        assert_eq!(read(" cbl dll,thread\n process ssrange\n").unwrap().options, ["dll", "thread", "ssrange"]);
281    }
282
283    #[test]
284    fn an_option_card_may_carry_a_sequence_number() {
285        assert_eq!(read("000010 CBL ARITH(EXTEND)\n").unwrap().options, ["ARITH(EXTEND)"]);
286    }
287
288    #[test]
289    fn a_continued_word_joins_without_a_space() {
290        let s = read("           MOVE ABC\n      -    DEF TO X.\n").unwrap();
291        assert!(s.text.contains("MOVE ABCDEF TO X."));
292    }
293
294    #[test]
295    fn a_floating_comment_ends_the_line_but_not_inside_a_literal() {
296        let s = read("           MOVE 1 TO X *> set X\n           DISPLAY '*> kept'\n").unwrap();
297        assert!(s.text.contains("MOVE 1 TO X") && !s.text.contains("set X"));
298        assert!(s.text.contains("'*> kept'"));
299    }
300
301    #[test]
302    fn a_comment_entry_is_left_out_of_the_text() {
303        let s = read(concat!(
304            "       IDENTIFICATION DIVISION.\n",
305            "       PROGRAM-ID. CE1.\n",
306            "       AUTHOR. James O'Grady & Sons @ ACME.\n",
307            "       security.\n",
308            "           THIS PROGRAM CHECKS THE COMPILER\"S ABILITY.\n",
309            "\n",
310            "      * a comment line\n",
311            "      -    A HYPHEN IN COLUMN 7.\n",
312            "           COPY NOTHERE.\n",
313            "       DATE-COMPILED.\n",
314            "       ENVIRONMENT DIVISION.\n",
315        ))
316        .unwrap();
317        let words: Vec<&str> = s.text.split_whitespace().collect();
318        assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "CE1.", "AUTHOR.", "security.", "DATE-COMPILED.", "ENVIRONMENT", "DIVISION."]);
319    }
320
321    #[test]
322    fn comment_entries_belong_to_the_identification_division_alone() {
323        let s = read(concat!(
324            "       IDENTIFICATION DIVISION.\n",
325            "       PROGRAM-ID. P.\n",
326            "       REMARKS. KEPT OUT.\n",
327            "       PROCEDURE DIVISION.\n",
328            "       REMARKS.\n",
329            "           DISPLAY 'IT''S'.\n",
330            "       IDENTIFICATION DIVISION.\n",
331            "       PROGRAM-ID. INNER.\n",
332            "       AUTHOR. O'GRADY.\n",
333        ))
334        .unwrap();
335        assert!(!s.text.contains("KEPT OUT") && !s.text.contains("GRADY"), "{}", s.text);
336        assert!(s.text.contains("DISPLAY 'IT''S'."), "{}", s.text);
337    }
338
339    #[test]
340    fn a_quote_in_column_72_doubled_on_the_continuation_is_one_quote() {
341        let head = "           MOVE \"";
342        let first = format!("{head}{}\"", "A".repeat(TEXT_END - head.len() - 1));
343        let s = read(&format!("{first}\n      -    \"\"B\" TO X.\n")).unwrap();
344        assert!(s.text.contains(&format!("\"{}\"\"B\" TO X.", "A".repeat(TEXT_END - head.len() - 1))), "{}", s.text);
345    }
346
347    #[test]
348    fn a_quote_after_a_closed_literal_starts_another() {
349        let s = read("           88 V VALUE 'ABC'\n      -    'DEF'.\n").unwrap();
350        assert!(s.text.contains("'ABC' 'DEF'."), "{}", s.text);
351        let head = "           88 V VALUE '";
352        let first = format!("{head}{}'", "A".repeat(TEXT_END - head.len() - 1));
353        let s = read(&format!("{first}\n      -    'B'.\n")).unwrap();
354        assert!(s.text.ends_with("A' 'B'."), "{}", s.text);
355    }
356
357    #[test]
358    fn debugging_lines_are_comments_unless_read_as_text() {
359        let text = "           MOVE 1 TO X\n      D    DISPLAY X\n      d    DISPLAY Y\n      D\n";
360        let plain = read(text).unwrap();
361        assert!(!plain.text.contains("DISPLAY") && plain.debugging.is_none());
362        let debugging = read_file_debugging(text, 3).unwrap();
363        assert!(debugging.text.contains("DISPLAY X") && debugging.text.contains("DISPLAY Y"));
364        assert_eq!(debugging.debugging, Some(vec![(3, 2), (3, 3)]));
365    }
366
367    #[test]
368    fn positions_point_at_the_source() {
369        let s = read("       IDENTIFICATION DIVISION.\n").unwrap();
370        let i = s.text.find('D').unwrap();
371        assert_eq!(s.positions[i], Pos { file: 0, line: 1, col: 9 });
372    }
373
374    #[test]
375    fn listing_statements_alone_on_a_line_are_left_out() {
376        let s = read(concat!(
377            "       01  A PIC X.\n",
378            "           EJECT\n",
379            "       SKIP1\n",
380            "           skip2.\n",
381            "           SKIP3 *> blank lines\n",
382            "           TITLE 'PAYROLL: PART 2'.\n",
383            "           TITLE 'A *> B' *> the title holds *>\n",
384            "       TITLE \"X\"\n",
385            "       01  B PIC X.\n",
386            "           MOVE TITLE TO EJECT.\n",
387            "           EJECT X.\n",
388        ))
389        .unwrap();
390        let words: Vec<&str> = s.text.split_whitespace().collect();
391        assert_eq!(words, ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "MOVE", "TITLE", "TO", "EJECT.", "EJECT", "X."]);
392    }
393}