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}
13
14const TEXT_START: usize = 7;
15const TEXT_END: usize = 72;
16
17pub fn read(input: &str) -> Result<Source, Error> {
18    read_file(input, 0)
19}
20
21/// Reads one file's text; `file` indexes its name in the program's file table.
22pub fn read_file(input: &str, file: u16) -> Result<Source, Error> {
23    let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new() };
24    let mut seen_program = false;
25    let mut open_quote: Option<char> = None;
26    for (index, raw) in input.lines().enumerate() {
27        let line = index as u32 + 1;
28        let chars: Vec<char> = raw.trim_end_matches('\r').chars().map(|c| if c == '\t' { ' ' } else { c }).collect();
29        let body: String = chars.iter().take(TEXT_END).collect();
30        if !seen_program && let Some(options) = option_card(&body) {
31            out.options.extend(options);
32            continue;
33        }
34        let indicator = chars.get(6).copied().unwrap_or(' ');
35        if matches!(indicator, '*' | '/' | 'D' | 'd') {
36            continue;
37        }
38        let area: Vec<char> = chars.iter().take(TEXT_END).skip(TEXT_START).copied().collect();
39        if area.iter().all(|c| *c == ' ') {
40            continue;
41        }
42        seen_program = true;
43        let start_col = TEXT_START as u32 + 1;
44        if indicator == '-' {
45            let first = area.iter().position(|c| *c != ' ').unwrap();
46            let skip = match open_quote {
47                Some(q) if area[first] == q => first + 1,
48                Some(_) => return Err(Error::at(Pos { file, line, col: start_col + first as u32 }, "a continued literal must resume with its quote")),
49                None => {
50                    while out.text.ends_with(' ') {
51                        out.text.pop();
52                        out.positions.pop();
53                    }
54                    first
55                }
56            };
57            for (i, &c) in area.iter().enumerate().skip(skip) {
58                if floating_comment(&area, i, open_quote) {
59                    break;
60                }
61                push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
62            }
63        } else {
64            if open_quote.is_some() {
65                return Err(Error::at(Pos { file, line, col: 1 }, "a literal runs to the end of the line with no continuation"));
66            }
67            out.text.push('\n');
68            out.positions.push(Pos { file, line, col: 0 });
69            for (i, &c) in area.iter().enumerate() {
70                if floating_comment(&area, i, open_quote) {
71                    break;
72                }
73                push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
74            }
75            if open_quote.is_some() {
76                for i in area.len()..TEXT_END - TEXT_START {
77                    push(&mut out, ' ', Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
78                }
79            }
80        }
81    }
82    if open_quote.is_some() {
83        return Err(Error::at(out.positions.last().copied().unwrap_or_default(), "an unterminated literal"));
84    }
85    Ok(out)
86}
87
88/// `*>` outside a literal starts a comment that runs to the end of the line.
89fn floating_comment(area: &[char], i: usize, open_quote: Option<char>) -> bool {
90    open_quote.is_none() && area[i] == '*' && area.get(i + 1) == Some(&'>')
91}
92
93fn push(out: &mut Source, c: char, pos: Pos, open_quote: &mut Option<char>) {
94    match *open_quote {
95        Some(q) if c == q => *open_quote = None,
96        None if c == '\'' || c == '"' => *open_quote = Some(c),
97        _ => {}
98    }
99    out.text.push(c);
100    out.positions.push(pos);
101}
102
103/// A CBL or PROCESS card: its options, split at commas and spaces outside parentheses.
104fn option_card(line: &str) -> Option<Vec<String>> {
105    let sequence: String = line.chars().take(6).collect();
106    let line = if sequence.len() == 6 && sequence.chars().all(|c| c.is_ascii_digit() || c == ' ') { &line[6..] } else { line };
107    let trimmed = line.trim_start();
108    let rest = trimmed.strip_prefix("CBL ").or_else(|| trimmed.strip_prefix("PROCESS ")).or_else(|| (trimmed == "CBL" || trimmed == "PROCESS").then_some(""))?;
109    let (mut options, mut current, mut depth) = (Vec::new(), String::new(), 0i32);
110    for c in rest.chars() {
111        match c {
112            '(' => depth += 1,
113            ')' => depth -= 1,
114            _ => {}
115        }
116        if depth == 0 && (c == ',' || c == ' ') {
117            if !current.is_empty() {
118                options.push(std::mem::take(&mut current));
119            }
120        } else {
121            current.push(c);
122        }
123    }
124    if !current.is_empty() {
125        options.push(current);
126    }
127    Some(options)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn option_cards_ahead_of_the_program_are_collected() {
136        let s = read("       CBL TRUNC(OPT),NUMPROC(PFD) ARITH(EXTEND)\n       PROCESS SSRANGE\n       IDENTIFICATION DIVISION.\n").unwrap();
137        assert_eq!(s.options, ["TRUNC(OPT)", "NUMPROC(PFD)", "ARITH(EXTEND)", "SSRANGE"]);
138        assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
139    }
140
141    #[test]
142    fn a_suboption_list_stays_whole() {
143        assert_eq!(option_card("CBL FLAG(I,W),X").unwrap(), ["FLAG(I,W)", "X"]);
144    }
145
146    #[test]
147    fn sequence_numbers_comments_and_columns_past_72_are_dropped() {
148        let s = read("000100 IDENTIFICATION DIVISION.                                         SEQ00001\n000200*a comment\n000300/page\n").unwrap();
149        assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
150    }
151
152    #[test]
153    fn a_continued_literal_keeps_its_trailing_spaces() {
154        let first = format!("       01 A PIC X(70) VALUE 'ABC{}", " ".repeat(72 - 34));
155        let text = format!("{first}\n      -    'DEF'.\n");
156        let s = read(&text).unwrap();
157        let literal = &s.text[s.text.find('\'').unwrap()..];
158        assert_eq!(literal.trim_end(), format!("'ABC{}DEF'.", " ".repeat(40)));
159    }
160
161    #[test]
162    fn an_option_card_may_carry_a_sequence_number() {
163        assert_eq!(read("000010 CBL ARITH(EXTEND)\n").unwrap().options, ["ARITH(EXTEND)"]);
164    }
165
166    #[test]
167    fn a_continued_word_joins_without_a_space() {
168        let s = read("           MOVE ABC\n      -    DEF TO X.\n").unwrap();
169        assert!(s.text.contains("MOVE ABCDEF TO X."));
170    }
171
172    #[test]
173    fn a_floating_comment_ends_the_line_but_not_inside_a_literal() {
174        let s = read("           MOVE 1 TO X *> set X\n           DISPLAY '*> kept'\n").unwrap();
175        assert!(s.text.contains("MOVE 1 TO X") && !s.text.contains("set X"));
176        assert!(s.text.contains("'*> kept'"));
177    }
178
179    #[test]
180    fn positions_point_at_the_source() {
181        let s = read("       IDENTIFICATION DIVISION.\n").unwrap();
182        let i = s.text.find('D').unwrap();
183        assert_eq!(s.positions[i], Pos { file: 0, line: 1, col: 9 });
184    }
185}