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. Under `--compliance extended` a file may be read in
4//! free form instead (docs/compliance.md).
5
6use crate::{Error, Pos};
7use numeric::Compliance;
8
9pub struct Source {
10    pub text: String,
11    /// For each char of `text`, where it came from.
12    pub positions: Vec<Pos>,
13    pub options: Vec<String>,
14    /// None when debugging lines (D in column 7) were read as comments; else the file and line of
15    /// each one read as program text.
16    pub debugging: Option<Vec<(u16, u32)>>,
17    /// The lines read in free form.
18    pub free: Vec<FreeSpan>,
19    /// The warnings of the compiler directives read, which the first token carries.
20    pub notes: Vec<Error>,
21}
22
23/// Lines `first` to `last` of file `file`, read in free form, and the warning that says so; a
24/// COPY member read in free form as the line that copies it was has none of its own.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct FreeSpan {
27    pub file: u16,
28    pub first: u32,
29    pub last: u32,
30    pub warning: Option<Error>,
31}
32
33impl FreeSpan {
34    pub fn holds(&self, pos: Pos) -> bool {
35        self.file == pos.file && (self.first..=self.last).contains(&pos.line)
36    }
37}
38
39impl Source {
40    /// The free-form span position `pos` is in, by its index in [`Source::free`].
41    pub fn free_at(&self, pos: Pos) -> Option<usize> {
42        self.free.iter().position(|s| s.holds(pos))
43    }
44}
45
46/// The stable identifier and text of the warning for free-form source.
47pub const FREE_FORM: &str = "free-form source (Micro Focus and GnuCOBOL; Enterprise COBOL reads fixed form alone)";
48
49const TEXT_START: usize = 7;
50const AREA_B: usize = 11;
51const TEXT_END: usize = 72;
52
53/// The IDENTIFICATION DIVISION paragraphs whose entry is a comment-entry (LR SC27-8713-03 p. 117),
54/// and REMARKS, which Enterprise COBOL does not have (numeric::assumptions::COMMENT_ENTRY_REMARKS).
55const COMMENT_PARAGRAPHS: &[&str] = &["AUTHOR", "INSTALLATION", "DATE-WRITTEN", "DATE-COMPILED", "SECURITY", "REMARKS"];
56
57pub fn read(input: &str) -> Result<Source, Error> {
58    read_file(input, 0)
59}
60
61/// Reads one file's text; `file` indexes its name in the program's file table. A comment-entry is
62/// left out of the text, so neither COPY nor the lexer sees it (LR pp. 117, 700).
63pub fn read_file(input: &str, file: u16) -> Result<Source, Error> {
64    read_lines(input, file, false, false, false)
65}
66
67/// Reads one file's text with its debugging lines as program text.
68pub fn read_file_debugging(input: &str, file: u16) -> Result<Source, Error> {
69    read_lines(input, file, true, false, false)
70}
71
72/// Reads one file's text under `compliance`, with its debugging lines as program text when
73/// `debugging` is set.
74pub fn read_under(input: &str, file: u16, debugging: bool, compliance: Compliance) -> Result<Source, Error> {
75    read_with_margin(input, file, debugging, compliance == Compliance::Extended, false)
76}
77
78/// Reads a COPY member's text as [`read_under`] does, starting in free form when the line that
79/// copies it is free form, as GnuCOBOL and Micro Focus carry the source format into a member.
80pub fn read_copied(input: &str, file: u16, debugging: bool, compliance: Compliance, copied_free: bool) -> Result<Source, Error> {
81    let extended = compliance == Compliance::Extended;
82    read_with_margin(input, file, debugging, extended, extended && copied_free)
83}
84
85/// Reads the file; under `--compliance extended`, a fixed-form file that a literal running past
86/// column 72 stops, on a line with no continuation after it, is read again in free form, as `cobc
87/// -free` reads it, with IWX0001-W. No file that reads in fixed form holds such a line.
88fn read_with_margin(input: &str, file: u16, debugging: bool, extended: bool, copied_free: bool) -> Result<Source, Error> {
89    let fixed = read_lines(input, file, debugging, extended, copied_free);
90    let Err(e) = &fixed else { return fixed };
91    let long = |l: &str| l.trim_end_matches('\r').chars().count() > TEXT_END;
92    let past_margin = (e.id == Some("IWS0092") && e.pos.line > 1 && input.lines().nth(e.pos.line as usize - 2).is_some_and(long)) || (e.id == Some("IWS0024") && input.lines().any(long));
93    if !extended || copied_free || !past_margin {
94        return fixed;
95    }
96    let mut free = read_lines(input, file, debugging, extended, true)?;
97    let first = input.lines().position(long).unwrap_or(0) as u32 + 1;
98    let why = format!("{FREE_FORM}: a literal runs past column 72, where fixed form ends, so the file is read in free form, as cobc -free reads it");
99    if let Some(span) = free.free.first_mut() {
100        span.warning = Some(crate::messages::IWX0001.at(Pos { file, line: first, col: TEXT_END as u32 + 1 }, why));
101    }
102    Ok(free)
103}
104
105fn read_lines(input: &str, file: u16, debugging: bool, extended: bool, copied_free: bool) -> Result<Source, Error> {
106    let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: debugging.then(Vec::new), free: Vec::new(), notes: Vec::new() };
107    let mut conditions = crate::directives::Conditions::default();
108    let mut seen_program = false;
109    let mut open_quote: Option<char> = None;
110    let mut closed_at_72: Option<char> = None;
111    let (mut identification, mut comment_entry) = (false, false);
112    let lines: Vec<Vec<char>> = input.lines().map(|raw| raw.trim_end_matches('\r').chars().map(|c| if c == '\t' { ' ' } else { c }).collect()).collect();
113    let mut free = match (extended, copied_free) {
114        (true, true) => Some((1, None)),
115        (true, false) => free_from_the_start(input, file).map(|(first, warning)| (first, Some(warning))),
116        (false, _) => None,
117    };
118    for (index, chars) in lines.iter().enumerate() {
119        let line = index as u32 + 1;
120        let debugging_text;
121        let mut chars = chars.as_slice();
122        if let Some(found) = crate::directives::read(chars, Pos { file, line, col: 1 }) {
123            use crate::directives::Directive;
124            match found? {
125                // Enterprise COBOL 6.3 has DEFINE and IF; the rest are read under extended alone.
126                d @ (Directive::Define { .. } | Directive::If(_) | Directive::Else | Directive::EndIf) if !extended => {
127                    let col = chars.iter().position(|c| *c != ' ').unwrap_or(0) as u32 + 1;
128                    conditions.apply(d, Pos { file, line, col }, &mut Vec::new())?;
129                    continue;
130                }
131                _ if !extended => {}
132                crate::directives::Directive::Ignored(note) => {
133                    if conditions.active() {
134                        out.notes.push(note);
135                    }
136                    continue;
137                }
138                crate::directives::Directive::Debugging(text, note) => {
139                    if !conditions.active() {
140                        continue;
141                    }
142                    out.notes.push(note);
143                    // The text as a debugging line of the form the line is read in.
144                    let indicator: &[char] = if free.is_some() { &['D', ' '] } else { &[' ', ' ', ' ', ' ', ' ', ' ', 'D', ' '] };
145                    debugging_text = [indicator, text.as_slice()].concat();
146                    chars = &debugging_text;
147                }
148                d => {
149                    let col = chars.iter().position(|c| *c != ' ').unwrap_or(0) as u32 + 1;
150                    conditions.apply(d, Pos { file, line, col }, &mut out.notes)?;
151                    continue;
152                }
153            }
154        }
155        if !conditions.active() {
156            continue;
157        }
158        if extended && let Some(found) = directive(chars, Pos { file, line, col: 1 }) {
159            let (format, pos) = found?;
160            match format {
161                Format::Free if free.is_none() => free = Some((line + 1, Some(crate::messages::IWX0001.at(pos, format!("{FREE_FORM}: this directive makes the lines after it free form"))))),
162                Format::Fixed => {
163                    if let Some((first, warning)) = free.take().filter(|(first, _)| *first < line) {
164                        out.free.push(FreeSpan { file, first, last: line - 1, warning });
165                    }
166                }
167                Format::Free => {}
168            }
169            continue;
170        }
171        if free.is_some() {
172            if !seen_program && let Some(options) = option_card(&chars.iter().collect::<String>()) {
173                out.options.extend(options);
174                continue;
175            }
176            let Some((start, debugging_line)) = free_line(chars) else { continue };
177            if debugging_line && out.debugging.is_none() {
178                continue;
179            }
180            let area = &chars[start..];
181            if area.iter().all(|c| *c == ' ') {
182                continue;
183            }
184            if open_quote.is_some() {
185                return Err(crate::messages::IWS0092.at(Pos { file, line, col: 1 }, "a literal runs to the end of the line with no continuation"));
186            }
187            if debugging_line && let Some(lines) = &mut out.debugging {
188                lines.push((file, line));
189            }
190            seen_program = true;
191            comment_entry = false;
192            closed_at_72 = None;
193            if listing_control(area) {
194                continue;
195            }
196            if let Some(entering) = division_header(area).or(program_id_first(area).then_some(true)) {
197                identification = entering;
198            }
199            let header_end = if identification { comment_paragraph(area) } else { None };
200            out.text.push('\n');
201            out.positions.push(Pos { file, line, col: 0 });
202            for (i, &c) in area.iter().enumerate().take(header_end.map_or(area.len(), |period| period + 1)) {
203                if floating_comment(area, i, open_quote) {
204                    break;
205                }
206                push(&mut out, c, Pos { file, line, col: (start + i) as u32 + 1 }, &mut open_quote);
207            }
208            continue;
209        }
210        let body: String = chars.iter().take(TEXT_END).collect();
211        if !seen_program && let Some(options) = option_card(&body) {
212            out.options.extend(options);
213            continue;
214        }
215        let indicator = chars.get(6).copied().unwrap_or(' ');
216        let debugging_line = matches!(indicator, 'D' | 'd');
217        if indicator == '*' || indicator == '/' || (debugging_line && out.debugging.is_none()) {
218            continue;
219        }
220        let area: Vec<char> = chars.iter().take(TEXT_END).skip(TEXT_START).copied().collect();
221        if area.iter().all(|c| *c == ' ') {
222            continue;
223        }
224        if debugging_line && let Some(lines) = &mut out.debugging {
225            lines.push((file, line));
226        }
227        seen_program = true;
228        let area_a_blank = area.iter().take(AREA_B - TEXT_START).all(|c| *c == ' ');
229        if comment_entry && area_a_blank {
230            continue;
231        }
232        comment_entry = false;
233        if indicator != '-' && open_quote.is_none() && listing_control(&area) {
234            continue;
235        }
236        let start_col = TEXT_START as u32 + 1;
237        if indicator == '-' {
238            let first = area.iter().position(|c| *c != ' ').unwrap();
239            let skip = match open_quote {
240                Some(q) if area[first] == q => first + 1,
241                Some(_) => return Err(crate::messages::IWS0093.at(Pos { file, line, col: start_col + first as u32 }, "a continued literal must resume with its quote")),
242                // A closing quote in column 72 and the continuation's first two quotes are one doubled
243                // quote (LR p. 58).
244                None if closed_at_72.is_some_and(|q| area[first] == q && area.get(first + 1) == Some(&q)) => first + 1,
245                None => {
246                    while out.text.ends_with(' ') {
247                        out.text.pop();
248                        out.positions.pop();
249                    }
250                    // After a closed literal, a quote starts a second literal (LR p. 58).
251                    if matches!(area[first], '\'' | '"') && out.text.ends_with(['\'', '"']) {
252                        out.text.push(' ');
253                        out.positions.push(Pos { file, line, col: start_col + first as u32 });
254                    }
255                    first
256                }
257            };
258            for (i, &c) in area.iter().enumerate().skip(skip) {
259                if floating_comment(&area, i, open_quote) {
260                    break;
261                }
262                push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
263            }
264        } else {
265            if open_quote.is_some() {
266                return Err(crate::messages::IWS0092.at(Pos { file, line, col: 1 }, "a literal runs to the end of the line with no continuation"));
267            }
268            if let Some(entering) = division_header(&area).or((extended && program_id_first(&area)).then_some(true)) {
269                identification = entering;
270            }
271            let header_end = if identification { comment_paragraph(&area) } else { None };
272            out.text.push('\n');
273            out.positions.push(Pos { file, line, col: 0 });
274            for (i, &c) in area.iter().enumerate().take(header_end.map_or(area.len(), |period| period + 1)) {
275                if floating_comment(&area, i, open_quote) {
276                    break;
277                }
278                push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
279            }
280            comment_entry = header_end.is_some();
281            if open_quote.is_some() {
282                for i in area.len()..TEXT_END - TEXT_START {
283                    push(&mut out, ' ', Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
284                }
285            }
286        }
287        closed_at_72 = match (out.text.chars().next_back(), out.positions.last()) {
288            (Some(q @ ('\'' | '"')), Some(p)) if open_quote.is_none() && p.line == line && p.col == TEXT_END as u32 => Some(q),
289            _ => None,
290        };
291    }
292    if open_quote.is_some() {
293        return Err(crate::messages::IWS0024.at(out.positions.last().copied().unwrap_or_default(), "an unterminated literal"));
294    }
295    if let Some((first, warning)) = free {
296        out.free.push(FreeSpan { file, first, last: u32::MAX, warning });
297    }
298    conditions.finish()?;
299    Ok(out)
300}
301
302/// A file is free form from its first line when a line before any source-format directive cannot
303/// be fixed form: its text starts in columns 1 to 6, with a character other than a digit, and runs
304/// on through column 7 with a character other than a space or an indicator (`*`, `/`, `-`, `D`,
305/// `d`). A tab advances to the next column after a multiple of 8 here, as GnuCOBOL and Micro Focus
306/// place it. The warning is at column 7.
307fn free_from_the_start(input: &str, file: u16) -> Option<(u32, Error)> {
308    for (index, raw) in input.lines().enumerate() {
309        let line = index as u32 + 1;
310        let mut chars = Vec::new();
311        for c in raw.trim_end_matches('\r').chars() {
312            match c {
313                '\t' => chars.resize((chars.len() / 8 + 1) * 8, ' '),
314                c => chars.push(c),
315            }
316        }
317        if directive(&chars, Pos { file, line, col: 1 }).is_some() {
318            return None;
319        }
320        let (Some(start), Some(&c)) = (chars.iter().position(|c| *c != ' '), chars.get(TEXT_START - 1)) else { continue };
321        let card = option_card(&chars.iter().take(TEXT_END).collect::<String>()).is_some();
322        if start < TEXT_START - 1 && !chars[start].is_ascii_digit() && !matches!(c, ' ' | '*' | '/' | '-' | 'D' | 'd') && !card {
323            let why = format!("{FREE_FORM}: column 7 holds {c:?}, which no fixed-form line can, so the file is read in free form");
324            return Some((1, crate::messages::IWX0001.at(Pos { file, line, col: TEXT_START as u32 }, why)));
325        }
326    }
327    None
328}
329
330/// The column a free-form line's text starts at and whether it is a debugging line, or None for a
331/// comment line: `*` or `/` in column 1 makes a comment line, and `D` followed by a space a
332/// debugging line, as in Micro Focus's free format. GnuCOBOL refuses such lines unless they begin
333/// `*>`.
334fn free_line(chars: &[char]) -> Option<(usize, bool)> {
335    match (chars.first(), chars.get(1)) {
336        (Some('*' | '/'), _) => None,
337        (Some('D' | 'd'), Some(' ')) => Some((1, true)),
338        _ => Some((0, false)),
339    }
340}
341
342enum Format {
343    Free,
344    Fixed,
345}
346
347/// A compiler directive, alone on its line, and where it starts: `>>` first on the line, or `$`
348/// first in column 1 or 7, Micro Focus's directive indicator. Err for any but a source-format
349/// directive: `>>SOURCE [FORMAT] [IS] FREE|FIXED`, or `$SET` or `>>SET` with `SOURCEFORMAT"FREE"`,
350/// `SOURCEFORMAT"FIXED"` or `SOURCEFORMAT(FREE)`.
351fn directive(chars: &[char], pos: Pos) -> Option<Result<(Format, Pos), Error>> {
352    let start = chars.iter().position(|c| *c != ' ')?;
353    let text: String = chars[start..].iter().collect();
354    let words = if let Some(rest) = text.strip_prefix(">>") {
355        rest
356    } else if text.starts_with('$') && (start == 0 || start == TEXT_START - 1) {
357        &text[1..]
358    } else {
359        return None;
360    };
361    let upper = words.trim().to_ascii_uppercase();
362    let words: Vec<&str> = upper.split_whitespace().filter(|w| !matches!(*w, "FORMAT" | "IS")).collect();
363    let format = |value: &str| match value.trim_matches(|c| matches!(c, '"' | '\'' | '(' | ')')) {
364        "FREE" => Some(Format::Free),
365        "FIXED" => Some(Format::Fixed),
366        _ => None,
367    };
368    let found = match words.as_slice() {
369        ["SOURCE", value] => format(value),
370        ["SET", setting] => setting.strip_prefix("SOURCEFORMAT").and_then(format),
371        _ => None,
372    };
373    let pos = Pos { col: start as u32 + 1, ..pos };
374    let shown = text.trim_end();
375    Some(found.map(|f| (f, pos)).ok_or_else(|| crate::messages::IWS0094.at(pos, format!("{shown}: the source-format directives >>SOURCE and $SET SOURCEFORMAT, giving FREE or FIXED, are the only compiler directives ironwork reads"))))
376}
377
378/// EJECT, SKIP1, SKIP2, SKIP3 or TITLE with its literal, alone on the line and perhaps ended by a
379/// period, and perhaps by a floating comment: statements for the listing that have no effect on
380/// compilation.
381fn listing_control(area: &[char]) -> bool {
382    let mut quote = None;
383    let end = (0..area.len())
384        .find(|&i| {
385            let comment = floating_comment(area, i, quote);
386            match (quote, area[i]) {
387                (None, c @ ('\'' | '"')) => quote = Some(c),
388                (Some(q), c) if c == q => quote = None,
389                _ => {}
390            }
391            comment
392        })
393        .unwrap_or(area.len());
394    let line: String = area[..end].iter().collect();
395    let line = line.trim();
396    let line = line.strip_suffix('.').unwrap_or(line).trim_end();
397    if ["EJECT", "SKIP1", "SKIP2", "SKIP3"].iter().any(|w| line.eq_ignore_ascii_case(w)) {
398        return true;
399    }
400    let Some(literal) = line.get(..6).filter(|t| t.eq_ignore_ascii_case("TITLE ")).map(|_| line[6..].trim_start()) else { return false };
401    let literal = literal.strip_prefix(['N', 'n', 'G', 'g']).filter(|l| l.starts_with(['\'', '"'])).unwrap_or(literal);
402    let Some(quote) = literal.chars().next().filter(|c| matches!(c, '\'' | '"')) else { return false };
403    let inner = literal.get(1..literal.len() - 1).unwrap_or_default();
404    literal.len() >= 2 && literal.ends_with(quote) && !inner.replace(&format!("{quote}{quote}"), "").contains(quote)
405}
406
407/// `*>` outside a literal starts a comment that runs to the end of the line.
408fn floating_comment(area: &[char], i: usize, open_quote: Option<char>) -> bool {
409    open_quote.is_none() && area[i] == '*' && area.get(i + 1) == Some(&'>')
410}
411
412/// The word that starts at or after `from`, uppercased, and the index just past it.
413fn word_from(area: &[char], from: usize) -> Option<(String, usize)> {
414    let start = from + area.get(from..)?.iter().position(|c| *c != ' ')?;
415    let len = area[start..].iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '-' || **c == '_').count();
416    (len > 0).then(|| (area[start..start + len].iter().collect::<String>().to_ascii_uppercase(), start + len))
417}
418
419/// Whether a line starts with PROGRAM-ID, which begins the IDENTIFICATION DIVISION of a program
420/// whose header `--compliance extended` lets it leave out.
421fn program_id_first(area: &[char]) -> bool {
422    word_from(area, 0).is_some_and(|(word, _)| word == "PROGRAM-ID")
423}
424
425/// For a line that starts with a division header, whether it is the IDENTIFICATION DIVISION's.
426fn division_header(area: &[char]) -> Option<bool> {
427    let (first, end) = word_from(area, 0)?;
428    let (second, _) = word_from(area, end)?;
429    (second == "DIVISION").then(|| first == "IDENTIFICATION" || first == "ID")
430}
431
432/// Where the period is, for a line that starts with a paragraph header whose entry is a
433/// comment-entry.
434fn comment_paragraph(area: &[char]) -> Option<usize> {
435    let (word, end) = word_from(area, 0)?;
436    let period = end + area[end..].iter().position(|c| *c != ' ')?;
437    (area[period] == '.' && COMMENT_PARAGRAPHS.contains(&word.as_str())).then_some(period)
438}
439
440fn push(out: &mut Source, c: char, pos: Pos, open_quote: &mut Option<char>) {
441    match *open_quote {
442        Some(q) if c == q => *open_quote = None,
443        None if c == '\'' || c == '"' => *open_quote = Some(c),
444        _ => {}
445    }
446    out.text.push(c);
447    out.positions.push(pos);
448}
449
450/// A CBL or PROCESS card: its options, split at commas and spaces outside parentheses.
451fn option_card(line: &str) -> Option<Vec<String>> {
452    let sequence: String = line.chars().take(6).collect();
453    let line = if sequence.len() == 6 && sequence.chars().all(|c| c.is_ascii_digit() || c == ' ') { &line[6..] } else { line };
454    let trimmed = line.trim_start();
455    let keyword = trimmed.split(' ').next().unwrap_or("");
456    let rest = (keyword.eq_ignore_ascii_case("CBL") || keyword.eq_ignore_ascii_case("PROCESS")).then(|| &trimmed[keyword.len()..])?;
457    let (mut options, mut current, mut depth) = (Vec::new(), String::new(), 0i32);
458    for c in rest.chars() {
459        match c {
460            '(' => depth += 1,
461            ')' => depth -= 1,
462            _ => {}
463        }
464        if depth == 0 && (c == ',' || c == ' ') {
465            if !current.is_empty() {
466                options.push(std::mem::take(&mut current));
467            }
468        } else {
469            current.push(c);
470        }
471    }
472    if !current.is_empty() {
473        options.push(current);
474    }
475    Some(options)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn option_cards_ahead_of_the_program_are_collected() {
484        let s = read("       CBL TRUNC(OPT),NUMPROC(PFD) ARITH(EXTEND)\n       PROCESS SSRANGE\n       IDENTIFICATION DIVISION.\n").unwrap();
485        assert_eq!(s.options, ["TRUNC(OPT)", "NUMPROC(PFD)", "ARITH(EXTEND)", "SSRANGE"]);
486        assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
487    }
488
489    #[test]
490    fn a_suboption_list_stays_whole() {
491        assert_eq!(option_card("CBL FLAG(I,W),X").unwrap(), ["FLAG(I,W)", "X"]);
492    }
493
494    #[test]
495    fn sequence_numbers_comments_and_columns_past_72_are_dropped() {
496        let s = read("000100 IDENTIFICATION DIVISION.                                         SEQ00001\n000200*a comment\n000300/page\n").unwrap();
497        assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
498    }
499
500    #[test]
501    fn listing_statements_alone_on_their_line_are_left_out() {
502        let text = [
503            "       01  A PIC X.\n",
504            "           EJECT\n",
505            "       SKIP2.\n",
506            "           TITLE 'RATES, ''A'' TO Z'.\n",
507            "           title n\"NATIONAL\"\n",
508            "       01  B PIC X.\n",
509            "           EJECT X\n",
510        ]
511        .concat();
512        let s = read(&text).unwrap();
513        assert_eq!(s.text.split_whitespace().collect::<Vec<_>>(), ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "EJECT", "X"]);
514    }
515
516    #[test]
517    fn a_continued_literal_keeps_its_trailing_spaces() {
518        let first = format!("       01 A PIC X(70) VALUE 'ABC{}", " ".repeat(72 - 34));
519        let text = format!("{first}\n      -    'DEF'.\n");
520        let s = read(&text).unwrap();
521        let literal = &s.text[s.text.find('\'').unwrap()..];
522        assert_eq!(literal.trim_end(), format!("'ABC{}DEF'.", " ".repeat(40)));
523    }
524
525    #[test]
526    fn an_option_card_may_be_lowercase() {
527        assert_eq!(read(" cbl dll,thread\n process ssrange\n").unwrap().options, ["dll", "thread", "ssrange"]);
528    }
529
530    #[test]
531    fn an_option_card_may_carry_a_sequence_number() {
532        assert_eq!(read("000010 CBL ARITH(EXTEND)\n").unwrap().options, ["ARITH(EXTEND)"]);
533    }
534
535    #[test]
536    fn a_continued_word_joins_without_a_space() {
537        let s = read("           MOVE ABC\n      -    DEF TO X.\n").unwrap();
538        assert!(s.text.contains("MOVE ABCDEF TO X."));
539    }
540
541    #[test]
542    fn a_floating_comment_ends_the_line_but_not_inside_a_literal() {
543        let s = read("           MOVE 1 TO X *> set X\n           DISPLAY '*> kept'\n").unwrap();
544        assert!(s.text.contains("MOVE 1 TO X") && !s.text.contains("set X"));
545        assert!(s.text.contains("'*> kept'"));
546    }
547
548    #[test]
549    fn a_comment_entry_is_left_out_of_the_text() {
550        let s = read(concat!(
551            "       IDENTIFICATION DIVISION.\n",
552            "       PROGRAM-ID. CE1.\n",
553            "       AUTHOR. James O'Grady & Sons @ ACME.\n",
554            "       security.\n",
555            "           THIS PROGRAM CHECKS THE COMPILER\"S ABILITY.\n",
556            "\n",
557            "      * a comment line\n",
558            "      -    A HYPHEN IN COLUMN 7.\n",
559            "           COPY NOTHERE.\n",
560            "       DATE-COMPILED.\n",
561            "       ENVIRONMENT DIVISION.\n",
562        ))
563        .unwrap();
564        let words: Vec<&str> = s.text.split_whitespace().collect();
565        assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "CE1.", "AUTHOR.", "security.", "DATE-COMPILED.", "ENVIRONMENT", "DIVISION."]);
566    }
567
568    #[test]
569    fn comment_entries_belong_to_the_identification_division_alone() {
570        let s = read(concat!(
571            "       IDENTIFICATION DIVISION.\n",
572            "       PROGRAM-ID. P.\n",
573            "       REMARKS. KEPT OUT.\n",
574            "       PROCEDURE DIVISION.\n",
575            "       REMARKS.\n",
576            "           DISPLAY 'IT''S'.\n",
577            "       IDENTIFICATION DIVISION.\n",
578            "       PROGRAM-ID. INNER.\n",
579            "       AUTHOR. O'GRADY.\n",
580        ))
581        .unwrap();
582        assert!(!s.text.contains("KEPT OUT") && !s.text.contains("GRADY"), "{}", s.text);
583        assert!(s.text.contains("DISPLAY 'IT''S'."), "{}", s.text);
584    }
585
586    #[test]
587    fn a_quote_in_column_72_doubled_on_the_continuation_is_one_quote() {
588        let head = "           MOVE \"";
589        let first = format!("{head}{}\"", "A".repeat(TEXT_END - head.len() - 1));
590        let s = read(&format!("{first}\n      -    \"\"B\" TO X.\n")).unwrap();
591        assert!(s.text.contains(&format!("\"{}\"\"B\" TO X.", "A".repeat(TEXT_END - head.len() - 1))), "{}", s.text);
592    }
593
594    #[test]
595    fn a_quote_after_a_closed_literal_starts_another() {
596        let s = read("           88 V VALUE 'ABC'\n      -    'DEF'.\n").unwrap();
597        assert!(s.text.contains("'ABC' 'DEF'."), "{}", s.text);
598        let head = "           88 V VALUE '";
599        let first = format!("{head}{}'", "A".repeat(TEXT_END - head.len() - 1));
600        let s = read(&format!("{first}\n      -    'B'.\n")).unwrap();
601        assert!(s.text.ends_with("A' 'B'."), "{}", s.text);
602    }
603
604    #[test]
605    fn debugging_lines_are_comments_unless_read_as_text() {
606        let text = "           MOVE 1 TO X\n      D    DISPLAY X\n      d    DISPLAY Y\n      D\n";
607        let plain = read(text).unwrap();
608        assert!(!plain.text.contains("DISPLAY") && plain.debugging.is_none());
609        let debugging = read_file_debugging(text, 3).unwrap();
610        assert!(debugging.text.contains("DISPLAY X") && debugging.text.contains("DISPLAY Y"));
611        assert_eq!(debugging.debugging, Some(vec![(3, 2), (3, 3)]));
612    }
613
614    #[test]
615    fn positions_point_at_the_source() {
616        let s = read("       IDENTIFICATION DIVISION.\n").unwrap();
617        let i = s.text.find('D').unwrap();
618        assert_eq!(s.positions[i], Pos { file: 0, line: 1, col: 9 });
619    }
620
621    fn extended(text: &str) -> Result<Source, Error> {
622        read_under(text, 0, false, Compliance::Extended)
623    }
624
625    #[test]
626    fn a_literal_past_column_72_makes_the_file_free_form_under_extended_alone() {
627        let long = format!("       IDENTIFICATION DIVISION.\n           DISPLAY \"{}\"\n           STOP RUN.\n", "-".repeat(70));
628        assert_eq!(read(&long).err().and_then(|e| e.id), Some("IWS0092"));
629        let s = extended(&long).unwrap();
630        assert!(s.text.contains(&format!("\"{}\"", "-".repeat(70))), "{}", s.text);
631        let warning = s.free[0].warning.as_ref().unwrap();
632        assert_eq!((warning.id, warning.pos.line, warning.pos.col), (Some("IWX0001"), 2, 73));
633        let sequenced = format!("       IDENTIFICATION DIVISION.{}00000100\n", " ".repeat(40));
634        assert!(extended(&sequenced).unwrap().free.is_empty());
635    }
636
637    #[test]
638    fn a_file_that_cannot_be_fixed_form_is_read_in_free_form_under_extended_alone() {
639        let text = "*\nIDENTIFICATION DIVISION.\n* a comment line\n*> another\nPROGRAM-ID. F.\n    DISPLAY 'A *> B' *> gone\n";
640        let s = extended(text).unwrap();
641        let words: Vec<&str> = s.text.split_whitespace().collect();
642        assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "F.", "DISPLAY", "'A", "*>", "B'"]);
643        assert_eq!(s.free.len(), 1);
644        let warning = s.free[0].warning.clone().unwrap();
645        assert_eq!((s.free[0].first, s.free[0].last, warning.pos), (1, u32::MAX, Pos { file: 0, line: 2, col: 7 }));
646        assert!(warning.labelled().starts_with("warning: IWX0001-W free-form source") && warning.message.contains("column 7 holds 'F'"));
647        assert_eq!(s.positions[s.text.find('I').unwrap()], Pos { file: 0, line: 2, col: 1 });
648        let strict = read(text).unwrap();
649        assert!(strict.free.is_empty() && strict.text.contains("ICATION DIVISION."), "{}", strict.text);
650    }
651
652    #[test]
653    fn a_free_form_line_runs_past_column_72_and_has_no_continuation() {
654        let long = format!("01 A PIC X(80) VALUE '{}'.", "Z".repeat(70));
655        let s = extended(&format!("IDENTIFICATION DIVISION.\n{long}\n")).unwrap();
656        assert!(s.text.contains(&long));
657        let Err(open) = extended("IDENTIFICATION DIVISION.\n    DISPLAY 'AB\n-    'C'.\n") else { panic!("an open literal is refused") };
658        assert_eq!((open.message.as_str(), open.pos.line), ("a literal runs to the end of the line with no continuation", 3));
659    }
660
661    #[test]
662    fn a_d_and_a_space_in_column_1_is_a_debugging_line_and_a_comment_entry_ends_with_its_line() {
663        let text = "IDENTIFICATION DIVISION.\nPROGRAM-ID. D.\nAUTHOR. A & B.\nD   DISPLAY 'DEBUG'\nDISPLAY 'KEPT'\n";
664        assert!(!extended(text).unwrap().text.contains("DEBUG"));
665        let debugging = read_under(text, 0, true, Compliance::Extended).unwrap();
666        assert!(debugging.text.contains("DISPLAY 'DEBUG'") && debugging.text.contains("DISPLAY 'KEPT'") && !debugging.text.contains("A & B"));
667        assert_eq!(debugging.debugging, Some(vec![(0, 4)]));
668    }
669
670    #[test]
671    fn a_source_format_directive_switches_the_form_from_the_next_line() {
672        let text = "      $SET SOURCEFORMAT\"FREE\"\nIDENTIFICATION DIVISION.\n>>SOURCE FORMAT IS FIXED\n000100 PROGRAM-ID. P.\n  >>source free\nDATA DIVISION.\n";
673        let s = extended(text).unwrap();
674        let words: Vec<&str> = s.text.split_whitespace().collect();
675        assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "P.", "DATA", "DIVISION."]);
676        let spans: Vec<(u32, u32, Option<Pos>)> = s.free.iter().map(|f| (f.first, f.last, f.warning.as_ref().map(|w| w.pos))).collect();
677        assert_eq!(spans, [(2, 2, Some(Pos { file: 0, line: 1, col: 7 })), (6, u32::MAX, Some(Pos { file: 0, line: 5, col: 3 }))]);
678        let Err(other) = extended("       >>CALL-CONVENTION COBOL\n") else { panic!("another directive is refused") };
679        assert!(other.message.starts_with(">>CALL-CONVENTION COBOL: the source-format directives"), "{}", other.message);
680        assert_eq!(other.pos.col, 8);
681    }
682
683    #[test]
684    fn strict_reads_enterprise_cobol_s_define_and_if_without_a_message_and_not_elif() {
685        let text = "       >>DEFINE MODE AS 'TEST'\n       >>IF MODE = 'TEST'\n       KEPT.\n       >>ELSE\n       DROPPED.\n       >>END-IF\n";
686        let s = read(text).unwrap();
687        assert_eq!((s.text.split_whitespace().collect::<Vec<_>>(), s.notes.len()), (vec!["KEPT."], 0));
688        let elif = read("       >>DEFINE MODE AS 1\n       >>IF MODE DEFINED\n       >>ELIF X DEFINED\n       >>END-IF\n").unwrap();
689        assert!(elif.text.contains(">>ELIF"), "{}", elif.text);
690    }
691
692    #[test]
693    fn conditional_compilation_keeps_the_lines_its_conditions_choose() {
694        let text = concat!(
695            "       >>DEFINE MODE AS 'TEST'\n",
696            "       >>IF MODE = 'TEST'\n",
697            "       KEPT-1.\n",
698            "       >>ELSE\n",
699            "       DROPPED-1.\n",
700            "       >>END-IF\n",
701            "       >>IF GONE IS DEFINED\n",
702            "       DROPPED-2.\n",
703            "       >>ELIF MODE NOT = 'LIVE'\n",
704            "       KEPT-2.\n",
705            "       >>ELSE\n",
706            "       DROPPED-3.\n",
707            "       >>END-IF\n",
708            "       >>DEFINE MODE AS OFF\n",
709            "       >>IF MODE DEFINED\n",
710            "       DROPPED-4.\n",
711            "       >>END-IF\n",
712            "       >>TURN EC-ALL CHECKING ON\n",
713        );
714        let s = extended(text).unwrap();
715        assert_eq!(s.text.split_whitespace().collect::<Vec<_>>(), ["KEPT-1.", "KEPT-2."]);
716        let notes: Vec<(u32, Option<&str>)> = s.notes.iter().map(|n| (n.pos.line, n.id)).collect();
717        assert_eq!(notes, [(1, Some("IWX0048")), (2, Some("IWX0048")), (7, Some("IWX0048")), (14, Some("IWX0048")), (15, Some("IWX0048")), (18, Some("IWX0049"))]);
718        for (text, why) in [("       >>IF X DEFINED\n", ">>IF with no >>END-IF after it"), ("       >>END-IF\n", ">>END-IF with no >>IF before it"), ("       >>IF X ~ 1\n       >>END-IF\n", ">>IF X ~ 1: a condition")] {
719            let Err(e) = extended(text) else { panic!("{text} is refused") };
720            assert!(e.id == Some("IWC0318") && e.message.starts_with(why), "{text}: {}", e.message);
721        }
722    }
723
724    #[test]
725    fn a_fixed_form_file_stays_fixed_under_extended() {
726        let text = concat!(
727            "000100 IDENTIFICATION DIVISION.                                         SEQ00001\n",
728            "007000C    LABEL RECORDS                                                SQ1054.2\n",
729            "\t   RECORD IS VARYING\n",
730            "       CBL APOST\n",
731            "ABC123 MOVE A TO B.\n",
732        );
733        let s = extended(text).unwrap();
734        assert!(s.free.is_empty());
735        assert_eq!(s.text, read(text).unwrap().text);
736    }
737
738    #[test]
739    fn a_member_copied_from_a_free_form_line_starts_free_with_no_warning_of_its_own() {
740        let member = "    *> a member indented as free form\n    02 B PIC X(80) VALUE 'past column seventy-two, which free form keeps whole as it reads it'.\n";
741        let copied = read_copied(member, 2, false, Compliance::Extended, true).unwrap();
742        assert!(copied.text.contains("02 B PIC X(80)") && copied.text.trim_end().ends_with("whole as it reads it'."), "{}", copied.text);
743        assert_eq!((copied.free.len(), copied.free[0].first, copied.free[0].warning.clone()), (1, 1, None));
744        let fixed_copied = read_copied(member, 2, false, Compliance::Extended, false).unwrap();
745        assert_eq!(fixed_copied.free[0].warning.as_ref().and_then(|w| w.id), Some("IWX0001"));
746        assert!(read_copied(member, 2, false, Compliance::Strict, true).is_err());
747    }
748
749    #[test]
750    fn listing_statements_alone_on_a_line_are_left_out() {
751        let s = read(concat!(
752            "       01  A PIC X.\n",
753            "           EJECT\n",
754            "       SKIP1\n",
755            "           skip2.\n",
756            "           SKIP3 *> blank lines\n",
757            "           TITLE 'PAYROLL: PART 2'.\n",
758            "           TITLE 'A *> B' *> the title holds *>\n",
759            "       TITLE \"X\"\n",
760            "       01  B PIC X.\n",
761            "           MOVE TITLE TO EJECT.\n",
762            "           EJECT X.\n",
763        ))
764        .unwrap();
765        let words: Vec<&str> = s.text.split_whitespace().collect();
766        assert_eq!(words, ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "MOVE", "TITLE", "TO", "EJECT.", "EJECT", "X."]);
767    }
768}