1use crate::{Error, Pos};
6
7pub struct Source {
8 pub text: String,
9 pub positions: Vec<Pos>,
11 pub options: Vec<String>,
12 pub debugging: Option<Vec<(u16, u32)>>,
15}
16
17const TEXT_START: usize = 7;
18const AREA_B: usize = 11;
19const TEXT_END: usize = 72;
20
21const 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
29pub fn read_file(input: &str, file: u16) -> Result<Source, Error> {
32 read_lines(input, file, false)
33}
34
35pub 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 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 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
137fn listing_control(area: &[char]) -> bool {
140 let line: String = area.iter().collect();
141 let line = line.trim();
142 let line = line.strip_suffix('.').unwrap_or(line).trim_end();
143 if ["EJECT", "SKIP1", "SKIP2", "SKIP3"].iter().any(|w| line.eq_ignore_ascii_case(w)) {
144 return true;
145 }
146 let Some(literal) = line.get(..6).filter(|t| t.eq_ignore_ascii_case("TITLE ")).map(|_| line[6..].trim_start()) else { return false };
147 let literal = literal.strip_prefix(['N', 'n', 'G', 'g']).filter(|l| l.starts_with(['\'', '"'])).unwrap_or(literal);
148 let Some(quote) = literal.chars().next().filter(|c| matches!(c, '\'' | '"')) else { return false };
149 let inner = literal.get(1..literal.len() - 1).unwrap_or_default();
150 literal.len() >= 2 && literal.ends_with(quote) && !inner.replace(&format!("{quote}{quote}"), "").contains(quote)
151}
152
153fn floating_comment(area: &[char], i: usize, open_quote: Option<char>) -> bool {
155 open_quote.is_none() && area[i] == '*' && area.get(i + 1) == Some(&'>')
156}
157
158fn word_from(area: &[char], from: usize) -> Option<(String, usize)> {
160 let start = from + area.get(from..)?.iter().position(|c| *c != ' ')?;
161 let len = area[start..].iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '-' || **c == '_').count();
162 (len > 0).then(|| (area[start..start + len].iter().collect::<String>().to_ascii_uppercase(), start + len))
163}
164
165fn division_header(area: &[char]) -> Option<bool> {
167 let (first, end) = word_from(area, 0)?;
168 let (second, _) = word_from(area, end)?;
169 (second == "DIVISION").then(|| first == "IDENTIFICATION" || first == "ID")
170}
171
172fn comment_paragraph(area: &[char]) -> Option<usize> {
175 let (word, end) = word_from(area, 0)?;
176 let period = end + area[end..].iter().position(|c| *c != ' ')?;
177 (area[period] == '.' && COMMENT_PARAGRAPHS.contains(&word.as_str())).then_some(period)
178}
179
180fn push(out: &mut Source, c: char, pos: Pos, open_quote: &mut Option<char>) {
181 match *open_quote {
182 Some(q) if c == q => *open_quote = None,
183 None if c == '\'' || c == '"' => *open_quote = Some(c),
184 _ => {}
185 }
186 out.text.push(c);
187 out.positions.push(pos);
188}
189
190fn option_card(line: &str) -> Option<Vec<String>> {
192 let sequence: String = line.chars().take(6).collect();
193 let line = if sequence.len() == 6 && sequence.chars().all(|c| c.is_ascii_digit() || c == ' ') { &line[6..] } else { line };
194 let trimmed = line.trim_start();
195 let keyword = trimmed.split(' ').next().unwrap_or("");
196 let rest = (keyword.eq_ignore_ascii_case("CBL") || keyword.eq_ignore_ascii_case("PROCESS")).then(|| &trimmed[keyword.len()..])?;
197 let (mut options, mut current, mut depth) = (Vec::new(), String::new(), 0i32);
198 for c in rest.chars() {
199 match c {
200 '(' => depth += 1,
201 ')' => depth -= 1,
202 _ => {}
203 }
204 if depth == 0 && (c == ',' || c == ' ') {
205 if !current.is_empty() {
206 options.push(std::mem::take(&mut current));
207 }
208 } else {
209 current.push(c);
210 }
211 }
212 if !current.is_empty() {
213 options.push(current);
214 }
215 Some(options)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn option_cards_ahead_of_the_program_are_collected() {
224 let s = read(" CBL TRUNC(OPT),NUMPROC(PFD) ARITH(EXTEND)\n PROCESS SSRANGE\n IDENTIFICATION DIVISION.\n").unwrap();
225 assert_eq!(s.options, ["TRUNC(OPT)", "NUMPROC(PFD)", "ARITH(EXTEND)", "SSRANGE"]);
226 assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
227 }
228
229 #[test]
230 fn a_suboption_list_stays_whole() {
231 assert_eq!(option_card("CBL FLAG(I,W),X").unwrap(), ["FLAG(I,W)", "X"]);
232 }
233
234 #[test]
235 fn sequence_numbers_comments_and_columns_past_72_are_dropped() {
236 let s = read("000100 IDENTIFICATION DIVISION. SEQ00001\n000200*a comment\n000300/page\n").unwrap();
237 assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
238 }
239
240 #[test]
241 fn listing_statements_alone_on_their_line_are_left_out() {
242 let text = [
243 " 01 A PIC X.\n",
244 " EJECT\n",
245 " SKIP2.\n",
246 " TITLE 'RATES, ''A'' TO Z'.\n",
247 " title n\"NATIONAL\"\n",
248 " 01 B PIC X.\n",
249 " EJECT X\n",
250 ]
251 .concat();
252 let s = read(&text).unwrap();
253 assert_eq!(s.text.split_whitespace().collect::<Vec<_>>(), ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "EJECT", "X"]);
254 }
255
256 #[test]
257 fn a_continued_literal_keeps_its_trailing_spaces() {
258 let first = format!(" 01 A PIC X(70) VALUE 'ABC{}", " ".repeat(72 - 34));
259 let text = format!("{first}\n - 'DEF'.\n");
260 let s = read(&text).unwrap();
261 let literal = &s.text[s.text.find('\'').unwrap()..];
262 assert_eq!(literal.trim_end(), format!("'ABC{}DEF'.", " ".repeat(40)));
263 }
264
265 #[test]
266 fn an_option_card_may_be_lowercase() {
267 assert_eq!(read(" cbl dll,thread\n process ssrange\n").unwrap().options, ["dll", "thread", "ssrange"]);
268 }
269
270 #[test]
271 fn an_option_card_may_carry_a_sequence_number() {
272 assert_eq!(read("000010 CBL ARITH(EXTEND)\n").unwrap().options, ["ARITH(EXTEND)"]);
273 }
274
275 #[test]
276 fn a_continued_word_joins_without_a_space() {
277 let s = read(" MOVE ABC\n - DEF TO X.\n").unwrap();
278 assert!(s.text.contains("MOVE ABCDEF TO X."));
279 }
280
281 #[test]
282 fn a_floating_comment_ends_the_line_but_not_inside_a_literal() {
283 let s = read(" MOVE 1 TO X *> set X\n DISPLAY '*> kept'\n").unwrap();
284 assert!(s.text.contains("MOVE 1 TO X") && !s.text.contains("set X"));
285 assert!(s.text.contains("'*> kept'"));
286 }
287
288 #[test]
289 fn a_comment_entry_is_left_out_of_the_text() {
290 let s = read(concat!(
291 " IDENTIFICATION DIVISION.\n",
292 " PROGRAM-ID. CE1.\n",
293 " AUTHOR. James O'Grady & Sons @ ACME.\n",
294 " security.\n",
295 " THIS PROGRAM CHECKS THE COMPILER\"S ABILITY.\n",
296 "\n",
297 " * a comment line\n",
298 " - A HYPHEN IN COLUMN 7.\n",
299 " COPY NOTHERE.\n",
300 " DATE-COMPILED.\n",
301 " ENVIRONMENT DIVISION.\n",
302 ))
303 .unwrap();
304 let words: Vec<&str> = s.text.split_whitespace().collect();
305 assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "CE1.", "AUTHOR.", "security.", "DATE-COMPILED.", "ENVIRONMENT", "DIVISION."]);
306 }
307
308 #[test]
309 fn comment_entries_belong_to_the_identification_division_alone() {
310 let s = read(concat!(
311 " IDENTIFICATION DIVISION.\n",
312 " PROGRAM-ID. P.\n",
313 " REMARKS. KEPT OUT.\n",
314 " PROCEDURE DIVISION.\n",
315 " REMARKS.\n",
316 " DISPLAY 'IT''S'.\n",
317 " IDENTIFICATION DIVISION.\n",
318 " PROGRAM-ID. INNER.\n",
319 " AUTHOR. O'GRADY.\n",
320 ))
321 .unwrap();
322 assert!(!s.text.contains("KEPT OUT") && !s.text.contains("GRADY"), "{}", s.text);
323 assert!(s.text.contains("DISPLAY 'IT''S'."), "{}", s.text);
324 }
325
326 #[test]
327 fn a_quote_in_column_72_doubled_on_the_continuation_is_one_quote() {
328 let head = " MOVE \"";
329 let first = format!("{head}{}\"", "A".repeat(TEXT_END - head.len() - 1));
330 let s = read(&format!("{first}\n - \"\"B\" TO X.\n")).unwrap();
331 assert!(s.text.contains(&format!("\"{}\"\"B\" TO X.", "A".repeat(TEXT_END - head.len() - 1))), "{}", s.text);
332 }
333
334 #[test]
335 fn a_quote_after_a_closed_literal_starts_another() {
336 let s = read(" 88 V VALUE 'ABC'\n - 'DEF'.\n").unwrap();
337 assert!(s.text.contains("'ABC' 'DEF'."), "{}", s.text);
338 let head = " 88 V VALUE '";
339 let first = format!("{head}{}'", "A".repeat(TEXT_END - head.len() - 1));
340 let s = read(&format!("{first}\n - 'B'.\n")).unwrap();
341 assert!(s.text.ends_with("A' 'B'."), "{}", s.text);
342 }
343
344 #[test]
345 fn debugging_lines_are_comments_unless_read_as_text() {
346 let text = " MOVE 1 TO X\n D DISPLAY X\n d DISPLAY Y\n D\n";
347 let plain = read(text).unwrap();
348 assert!(!plain.text.contains("DISPLAY") && plain.debugging.is_none());
349 let debugging = read_file_debugging(text, 3).unwrap();
350 assert!(debugging.text.contains("DISPLAY X") && debugging.text.contains("DISPLAY Y"));
351 assert_eq!(debugging.debugging, Some(vec![(3, 2), (3, 3)]));
352 }
353
354 #[test]
355 fn positions_point_at_the_source() {
356 let s = read(" IDENTIFICATION DIVISION.\n").unwrap();
357 let i = s.text.find('D').unwrap();
358 assert_eq!(s.positions[i], Pos { file: 0, line: 1, col: 9 });
359 }
360}