1use crate::{Error, Pos};
7use numeric::Compliance;
8
9pub struct Source {
10 pub text: String,
11 pub positions: Vec<Pos>,
13 pub options: Vec<String>,
14 pub debugging: Option<Vec<(u16, u32)>>,
17 pub free: Vec<FreeSpan>,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct FreeSpan {
25 pub file: u16,
26 pub first: u32,
27 pub last: u32,
28 pub warning: Option<Error>,
29}
30
31impl FreeSpan {
32 pub fn holds(&self, pos: Pos) -> bool {
33 self.file == pos.file && (self.first..=self.last).contains(&pos.line)
34 }
35}
36
37impl Source {
38 pub fn free_at(&self, pos: Pos) -> Option<usize> {
40 self.free.iter().position(|s| s.holds(pos))
41 }
42}
43
44pub const FREE_FORM: &str = "IWX0001-W free-form source (Micro Focus and GnuCOBOL; Enterprise COBOL reads fixed form alone)";
46
47const TEXT_START: usize = 7;
48const AREA_B: usize = 11;
49const TEXT_END: usize = 72;
50
51const COMMENT_PARAGRAPHS: &[&str] = &["AUTHOR", "INSTALLATION", "DATE-WRITTEN", "DATE-COMPILED", "SECURITY", "REMARKS"];
54
55pub fn read(input: &str) -> Result<Source, Error> {
56 read_file(input, 0)
57}
58
59pub fn read_file(input: &str, file: u16) -> Result<Source, Error> {
62 read_lines(input, file, false, false, false)
63}
64
65pub fn read_file_debugging(input: &str, file: u16) -> Result<Source, Error> {
67 read_lines(input, file, true, false, false)
68}
69
70pub fn read_under(input: &str, file: u16, debugging: bool, compliance: Compliance) -> Result<Source, Error> {
73 read_lines(input, file, debugging, compliance == Compliance::Extended, false)
74}
75
76pub fn read_copied(input: &str, file: u16, debugging: bool, compliance: Compliance, copied_free: bool) -> Result<Source, Error> {
79 let extended = compliance == Compliance::Extended;
80 read_lines(input, file, debugging, extended, extended && copied_free)
81}
82
83fn read_lines(input: &str, file: u16, debugging: bool, extended: bool, copied_free: bool) -> Result<Source, Error> {
84 let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: debugging.then(Vec::new), free: Vec::new() };
85 let mut seen_program = false;
86 let mut open_quote: Option<char> = None;
87 let mut closed_at_72: Option<char> = None;
88 let (mut identification, mut comment_entry) = (false, false);
89 let lines: Vec<Vec<char>> = input.lines().map(|raw| raw.trim_end_matches('\r').chars().map(|c| if c == '\t' { ' ' } else { c }).collect()).collect();
90 let mut free = match (extended, copied_free) {
91 (true, true) => Some((1, None)),
92 (true, false) => free_from_the_start(input, file).map(|(first, warning)| (first, Some(warning))),
93 (false, _) => None,
94 };
95 for (index, chars) in lines.iter().enumerate() {
96 let line = index as u32 + 1;
97 if extended && let Some(found) = directive(chars, Pos { file, line, col: 1 }) {
98 let (format, pos) = found?;
99 match format {
100 Format::Free if free.is_none() => free = Some((line + 1, Some(Error::warning(pos, format!("{FREE_FORM}: this directive makes the lines after it free form"))))),
101 Format::Fixed => {
102 if let Some((first, warning)) = free.take().filter(|(first, _)| *first < line) {
103 out.free.push(FreeSpan { file, first, last: line - 1, warning });
104 }
105 }
106 Format::Free => {}
107 }
108 continue;
109 }
110 if free.is_some() {
111 if !seen_program && let Some(options) = option_card(&chars.iter().collect::<String>()) {
112 out.options.extend(options);
113 continue;
114 }
115 let Some((start, debugging_line)) = free_line(chars) else { continue };
116 if debugging_line && out.debugging.is_none() {
117 continue;
118 }
119 let area = &chars[start..];
120 if area.iter().all(|c| *c == ' ') {
121 continue;
122 }
123 if open_quote.is_some() {
124 return Err(Error::at(Pos { file, line, col: 1 }, "a literal runs to the end of the line with no continuation"));
125 }
126 if debugging_line && let Some(lines) = &mut out.debugging {
127 lines.push((file, line));
128 }
129 seen_program = true;
130 comment_entry = false;
131 closed_at_72 = None;
132 if listing_control(area) {
133 continue;
134 }
135 if let Some(entering) = division_header(area).or(program_id_first(area).then_some(true)) {
136 identification = entering;
137 }
138 let header_end = if identification { comment_paragraph(area) } else { None };
139 out.text.push('\n');
140 out.positions.push(Pos { file, line, col: 0 });
141 for (i, &c) in area.iter().enumerate().take(header_end.map_or(area.len(), |period| period + 1)) {
142 if floating_comment(area, i, open_quote) {
143 break;
144 }
145 push(&mut out, c, Pos { file, line, col: (start + i) as u32 + 1 }, &mut open_quote);
146 }
147 continue;
148 }
149 let body: String = chars.iter().take(TEXT_END).collect();
150 if !seen_program && let Some(options) = option_card(&body) {
151 out.options.extend(options);
152 continue;
153 }
154 let indicator = chars.get(6).copied().unwrap_or(' ');
155 let debugging_line = matches!(indicator, 'D' | 'd');
156 if indicator == '*' || indicator == '/' || (debugging_line && out.debugging.is_none()) {
157 continue;
158 }
159 let area: Vec<char> = chars.iter().take(TEXT_END).skip(TEXT_START).copied().collect();
160 if area.iter().all(|c| *c == ' ') {
161 continue;
162 }
163 if debugging_line && let Some(lines) = &mut out.debugging {
164 lines.push((file, line));
165 }
166 seen_program = true;
167 let area_a_blank = area.iter().take(AREA_B - TEXT_START).all(|c| *c == ' ');
168 if comment_entry && area_a_blank {
169 continue;
170 }
171 comment_entry = false;
172 if indicator != '-' && open_quote.is_none() && listing_control(&area) {
173 continue;
174 }
175 let start_col = TEXT_START as u32 + 1;
176 if indicator == '-' {
177 let first = area.iter().position(|c| *c != ' ').unwrap();
178 let skip = match open_quote {
179 Some(q) if area[first] == q => first + 1,
180 Some(_) => return Err(Error::at(Pos { file, line, col: start_col + first as u32 }, "a continued literal must resume with its quote")),
181 None if closed_at_72.is_some_and(|q| area[first] == q && area.get(first + 1) == Some(&q)) => first + 1,
184 None => {
185 while out.text.ends_with(' ') {
186 out.text.pop();
187 out.positions.pop();
188 }
189 if matches!(area[first], '\'' | '"') && out.text.ends_with(['\'', '"']) {
191 out.text.push(' ');
192 out.positions.push(Pos { file, line, col: start_col + first as u32 });
193 }
194 first
195 }
196 };
197 for (i, &c) in area.iter().enumerate().skip(skip) {
198 if floating_comment(&area, i, open_quote) {
199 break;
200 }
201 push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
202 }
203 } else {
204 if open_quote.is_some() {
205 return Err(Error::at(Pos { file, line, col: 1 }, "a literal runs to the end of the line with no continuation"));
206 }
207 if let Some(entering) = division_header(&area).or((extended && program_id_first(&area)).then_some(true)) {
208 identification = entering;
209 }
210 let header_end = if identification { comment_paragraph(&area) } else { None };
211 out.text.push('\n');
212 out.positions.push(Pos { file, line, col: 0 });
213 for (i, &c) in area.iter().enumerate().take(header_end.map_or(area.len(), |period| period + 1)) {
214 if floating_comment(&area, i, open_quote) {
215 break;
216 }
217 push(&mut out, c, Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
218 }
219 comment_entry = header_end.is_some();
220 if open_quote.is_some() {
221 for i in area.len()..TEXT_END - TEXT_START {
222 push(&mut out, ' ', Pos { file, line, col: start_col + i as u32 }, &mut open_quote);
223 }
224 }
225 }
226 closed_at_72 = match (out.text.chars().next_back(), out.positions.last()) {
227 (Some(q @ ('\'' | '"')), Some(p)) if open_quote.is_none() && p.line == line && p.col == TEXT_END as u32 => Some(q),
228 _ => None,
229 };
230 }
231 if open_quote.is_some() {
232 return Err(Error::at(out.positions.last().copied().unwrap_or_default(), "an unterminated literal"));
233 }
234 if let Some((first, warning)) = free {
235 out.free.push(FreeSpan { file, first, last: u32::MAX, warning });
236 }
237 Ok(out)
238}
239
240fn free_from_the_start(input: &str, file: u16) -> Option<(u32, Error)> {
246 for (index, raw) in input.lines().enumerate() {
247 let line = index as u32 + 1;
248 let mut chars = Vec::new();
249 for c in raw.trim_end_matches('\r').chars() {
250 match c {
251 '\t' => chars.resize((chars.len() / 8 + 1) * 8, ' '),
252 c => chars.push(c),
253 }
254 }
255 if directive(&chars, Pos { file, line, col: 1 }).is_some() {
256 return None;
257 }
258 let (Some(start), Some(&c)) = (chars.iter().position(|c| *c != ' '), chars.get(TEXT_START - 1)) else { continue };
259 let card = option_card(&chars.iter().take(TEXT_END).collect::<String>()).is_some();
260 if start < TEXT_START - 1 && !chars[start].is_ascii_digit() && !matches!(c, ' ' | '*' | '/' | '-' | 'D' | 'd') && !card {
261 let why = format!("{FREE_FORM}: column 7 holds {c:?}, which no fixed-form line can, so the file is read in free form");
262 return Some((1, Error::warning(Pos { file, line, col: TEXT_START as u32 }, why)));
263 }
264 }
265 None
266}
267
268fn free_line(chars: &[char]) -> Option<(usize, bool)> {
273 match (chars.first(), chars.get(1)) {
274 (Some('*' | '/'), _) => None,
275 (Some('D' | 'd'), Some(' ')) => Some((1, true)),
276 _ => Some((0, false)),
277 }
278}
279
280enum Format {
281 Free,
282 Fixed,
283}
284
285fn directive(chars: &[char], pos: Pos) -> Option<Result<(Format, Pos), Error>> {
290 let start = chars.iter().position(|c| *c != ' ')?;
291 let text: String = chars[start..].iter().collect();
292 let words = if let Some(rest) = text.strip_prefix(">>") {
293 rest
294 } else if text.starts_with('$') && (start == 0 || start == TEXT_START - 1) {
295 &text[1..]
296 } else {
297 return None;
298 };
299 let upper = words.trim().to_ascii_uppercase();
300 let words: Vec<&str> = upper.split_whitespace().filter(|w| !matches!(*w, "FORMAT" | "IS")).collect();
301 let format = |value: &str| match value.trim_matches(|c| matches!(c, '"' | '\'' | '(' | ')')) {
302 "FREE" => Some(Format::Free),
303 "FIXED" => Some(Format::Fixed),
304 _ => None,
305 };
306 let found = match words.as_slice() {
307 ["SOURCE", value] => format(value),
308 ["SET", setting] => setting.strip_prefix("SOURCEFORMAT").and_then(format),
309 _ => None,
310 };
311 let pos = Pos { col: start as u32 + 1, ..pos };
312 let shown = text.trim_end();
313 Some(found.map(|f| (f, pos)).ok_or_else(|| Error::at(pos, format!("{shown}: the source-format directives >>SOURCE and $SET SOURCEFORMAT, giving FREE or FIXED, are the only compiler directives ironwork reads"))))
314}
315
316fn listing_control(area: &[char]) -> bool {
320 let mut quote = None;
321 let end = (0..area.len())
322 .find(|&i| {
323 let comment = floating_comment(area, i, quote);
324 match (quote, area[i]) {
325 (None, c @ ('\'' | '"')) => quote = Some(c),
326 (Some(q), c) if c == q => quote = None,
327 _ => {}
328 }
329 comment
330 })
331 .unwrap_or(area.len());
332 let line: String = area[..end].iter().collect();
333 let line = line.trim();
334 let line = line.strip_suffix('.').unwrap_or(line).trim_end();
335 if ["EJECT", "SKIP1", "SKIP2", "SKIP3"].iter().any(|w| line.eq_ignore_ascii_case(w)) {
336 return true;
337 }
338 let Some(literal) = line.get(..6).filter(|t| t.eq_ignore_ascii_case("TITLE ")).map(|_| line[6..].trim_start()) else { return false };
339 let literal = literal.strip_prefix(['N', 'n', 'G', 'g']).filter(|l| l.starts_with(['\'', '"'])).unwrap_or(literal);
340 let Some(quote) = literal.chars().next().filter(|c| matches!(c, '\'' | '"')) else { return false };
341 let inner = literal.get(1..literal.len() - 1).unwrap_or_default();
342 literal.len() >= 2 && literal.ends_with(quote) && !inner.replace(&format!("{quote}{quote}"), "").contains(quote)
343}
344
345fn floating_comment(area: &[char], i: usize, open_quote: Option<char>) -> bool {
347 open_quote.is_none() && area[i] == '*' && area.get(i + 1) == Some(&'>')
348}
349
350fn word_from(area: &[char], from: usize) -> Option<(String, usize)> {
352 let start = from + area.get(from..)?.iter().position(|c| *c != ' ')?;
353 let len = area[start..].iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '-' || **c == '_').count();
354 (len > 0).then(|| (area[start..start + len].iter().collect::<String>().to_ascii_uppercase(), start + len))
355}
356
357fn program_id_first(area: &[char]) -> bool {
360 word_from(area, 0).is_some_and(|(word, _)| word == "PROGRAM-ID")
361}
362
363fn division_header(area: &[char]) -> Option<bool> {
365 let (first, end) = word_from(area, 0)?;
366 let (second, _) = word_from(area, end)?;
367 (second == "DIVISION").then(|| first == "IDENTIFICATION" || first == "ID")
368}
369
370fn comment_paragraph(area: &[char]) -> Option<usize> {
373 let (word, end) = word_from(area, 0)?;
374 let period = end + area[end..].iter().position(|c| *c != ' ')?;
375 (area[period] == '.' && COMMENT_PARAGRAPHS.contains(&word.as_str())).then_some(period)
376}
377
378fn push(out: &mut Source, c: char, pos: Pos, open_quote: &mut Option<char>) {
379 match *open_quote {
380 Some(q) if c == q => *open_quote = None,
381 None if c == '\'' || c == '"' => *open_quote = Some(c),
382 _ => {}
383 }
384 out.text.push(c);
385 out.positions.push(pos);
386}
387
388fn option_card(line: &str) -> Option<Vec<String>> {
390 let sequence: String = line.chars().take(6).collect();
391 let line = if sequence.len() == 6 && sequence.chars().all(|c| c.is_ascii_digit() || c == ' ') { &line[6..] } else { line };
392 let trimmed = line.trim_start();
393 let keyword = trimmed.split(' ').next().unwrap_or("");
394 let rest = (keyword.eq_ignore_ascii_case("CBL") || keyword.eq_ignore_ascii_case("PROCESS")).then(|| &trimmed[keyword.len()..])?;
395 let (mut options, mut current, mut depth) = (Vec::new(), String::new(), 0i32);
396 for c in rest.chars() {
397 match c {
398 '(' => depth += 1,
399 ')' => depth -= 1,
400 _ => {}
401 }
402 if depth == 0 && (c == ',' || c == ' ') {
403 if !current.is_empty() {
404 options.push(std::mem::take(&mut current));
405 }
406 } else {
407 current.push(c);
408 }
409 }
410 if !current.is_empty() {
411 options.push(current);
412 }
413 Some(options)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn option_cards_ahead_of_the_program_are_collected() {
422 let s = read(" CBL TRUNC(OPT),NUMPROC(PFD) ARITH(EXTEND)\n PROCESS SSRANGE\n IDENTIFICATION DIVISION.\n").unwrap();
423 assert_eq!(s.options, ["TRUNC(OPT)", "NUMPROC(PFD)", "ARITH(EXTEND)", "SSRANGE"]);
424 assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
425 }
426
427 #[test]
428 fn a_suboption_list_stays_whole() {
429 assert_eq!(option_card("CBL FLAG(I,W),X").unwrap(), ["FLAG(I,W)", "X"]);
430 }
431
432 #[test]
433 fn sequence_numbers_comments_and_columns_past_72_are_dropped() {
434 let s = read("000100 IDENTIFICATION DIVISION. SEQ00001\n000200*a comment\n000300/page\n").unwrap();
435 assert_eq!(s.text.trim(), "IDENTIFICATION DIVISION.");
436 }
437
438 #[test]
439 fn listing_statements_alone_on_their_line_are_left_out() {
440 let text = [
441 " 01 A PIC X.\n",
442 " EJECT\n",
443 " SKIP2.\n",
444 " TITLE 'RATES, ''A'' TO Z'.\n",
445 " title n\"NATIONAL\"\n",
446 " 01 B PIC X.\n",
447 " EJECT X\n",
448 ]
449 .concat();
450 let s = read(&text).unwrap();
451 assert_eq!(s.text.split_whitespace().collect::<Vec<_>>(), ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "EJECT", "X"]);
452 }
453
454 #[test]
455 fn a_continued_literal_keeps_its_trailing_spaces() {
456 let first = format!(" 01 A PIC X(70) VALUE 'ABC{}", " ".repeat(72 - 34));
457 let text = format!("{first}\n - 'DEF'.\n");
458 let s = read(&text).unwrap();
459 let literal = &s.text[s.text.find('\'').unwrap()..];
460 assert_eq!(literal.trim_end(), format!("'ABC{}DEF'.", " ".repeat(40)));
461 }
462
463 #[test]
464 fn an_option_card_may_be_lowercase() {
465 assert_eq!(read(" cbl dll,thread\n process ssrange\n").unwrap().options, ["dll", "thread", "ssrange"]);
466 }
467
468 #[test]
469 fn an_option_card_may_carry_a_sequence_number() {
470 assert_eq!(read("000010 CBL ARITH(EXTEND)\n").unwrap().options, ["ARITH(EXTEND)"]);
471 }
472
473 #[test]
474 fn a_continued_word_joins_without_a_space() {
475 let s = read(" MOVE ABC\n - DEF TO X.\n").unwrap();
476 assert!(s.text.contains("MOVE ABCDEF TO X."));
477 }
478
479 #[test]
480 fn a_floating_comment_ends_the_line_but_not_inside_a_literal() {
481 let s = read(" MOVE 1 TO X *> set X\n DISPLAY '*> kept'\n").unwrap();
482 assert!(s.text.contains("MOVE 1 TO X") && !s.text.contains("set X"));
483 assert!(s.text.contains("'*> kept'"));
484 }
485
486 #[test]
487 fn a_comment_entry_is_left_out_of_the_text() {
488 let s = read(concat!(
489 " IDENTIFICATION DIVISION.\n",
490 " PROGRAM-ID. CE1.\n",
491 " AUTHOR. James O'Grady & Sons @ ACME.\n",
492 " security.\n",
493 " THIS PROGRAM CHECKS THE COMPILER\"S ABILITY.\n",
494 "\n",
495 " * a comment line\n",
496 " - A HYPHEN IN COLUMN 7.\n",
497 " COPY NOTHERE.\n",
498 " DATE-COMPILED.\n",
499 " ENVIRONMENT DIVISION.\n",
500 ))
501 .unwrap();
502 let words: Vec<&str> = s.text.split_whitespace().collect();
503 assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "CE1.", "AUTHOR.", "security.", "DATE-COMPILED.", "ENVIRONMENT", "DIVISION."]);
504 }
505
506 #[test]
507 fn comment_entries_belong_to_the_identification_division_alone() {
508 let s = read(concat!(
509 " IDENTIFICATION DIVISION.\n",
510 " PROGRAM-ID. P.\n",
511 " REMARKS. KEPT OUT.\n",
512 " PROCEDURE DIVISION.\n",
513 " REMARKS.\n",
514 " DISPLAY 'IT''S'.\n",
515 " IDENTIFICATION DIVISION.\n",
516 " PROGRAM-ID. INNER.\n",
517 " AUTHOR. O'GRADY.\n",
518 ))
519 .unwrap();
520 assert!(!s.text.contains("KEPT OUT") && !s.text.contains("GRADY"), "{}", s.text);
521 assert!(s.text.contains("DISPLAY 'IT''S'."), "{}", s.text);
522 }
523
524 #[test]
525 fn a_quote_in_column_72_doubled_on_the_continuation_is_one_quote() {
526 let head = " MOVE \"";
527 let first = format!("{head}{}\"", "A".repeat(TEXT_END - head.len() - 1));
528 let s = read(&format!("{first}\n - \"\"B\" TO X.\n")).unwrap();
529 assert!(s.text.contains(&format!("\"{}\"\"B\" TO X.", "A".repeat(TEXT_END - head.len() - 1))), "{}", s.text);
530 }
531
532 #[test]
533 fn a_quote_after_a_closed_literal_starts_another() {
534 let s = read(" 88 V VALUE 'ABC'\n - 'DEF'.\n").unwrap();
535 assert!(s.text.contains("'ABC' 'DEF'."), "{}", s.text);
536 let head = " 88 V VALUE '";
537 let first = format!("{head}{}'", "A".repeat(TEXT_END - head.len() - 1));
538 let s = read(&format!("{first}\n - 'B'.\n")).unwrap();
539 assert!(s.text.ends_with("A' 'B'."), "{}", s.text);
540 }
541
542 #[test]
543 fn debugging_lines_are_comments_unless_read_as_text() {
544 let text = " MOVE 1 TO X\n D DISPLAY X\n d DISPLAY Y\n D\n";
545 let plain = read(text).unwrap();
546 assert!(!plain.text.contains("DISPLAY") && plain.debugging.is_none());
547 let debugging = read_file_debugging(text, 3).unwrap();
548 assert!(debugging.text.contains("DISPLAY X") && debugging.text.contains("DISPLAY Y"));
549 assert_eq!(debugging.debugging, Some(vec![(3, 2), (3, 3)]));
550 }
551
552 #[test]
553 fn positions_point_at_the_source() {
554 let s = read(" IDENTIFICATION DIVISION.\n").unwrap();
555 let i = s.text.find('D').unwrap();
556 assert_eq!(s.positions[i], Pos { file: 0, line: 1, col: 9 });
557 }
558
559 fn extended(text: &str) -> Result<Source, Error> {
560 read_under(text, 0, false, Compliance::Extended)
561 }
562
563 #[test]
564 fn a_file_that_cannot_be_fixed_form_is_read_in_free_form_under_extended_alone() {
565 let text = "*\nIDENTIFICATION DIVISION.\n* a comment line\n*> another\nPROGRAM-ID. F.\n DISPLAY 'A *> B' *> gone\n";
566 let s = extended(text).unwrap();
567 let words: Vec<&str> = s.text.split_whitespace().collect();
568 assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "F.", "DISPLAY", "'A", "*>", "B'"]);
569 assert_eq!(s.free.len(), 1);
570 let warning = s.free[0].warning.clone().unwrap();
571 assert_eq!((s.free[0].first, s.free[0].last, warning.pos), (1, u32::MAX, Pos { file: 0, line: 2, col: 7 }));
572 assert!(warning.message.starts_with("IWX0001-W free-form source") && warning.message.contains("column 7 holds 'F'"));
573 assert_eq!(s.positions[s.text.find('I').unwrap()], Pos { file: 0, line: 2, col: 1 });
574 let strict = read(text).unwrap();
575 assert!(strict.free.is_empty() && strict.text.contains("ICATION DIVISION."), "{}", strict.text);
576 }
577
578 #[test]
579 fn a_free_form_line_runs_past_column_72_and_has_no_continuation() {
580 let long = format!("01 A PIC X(80) VALUE '{}'.", "Z".repeat(70));
581 let s = extended(&format!("IDENTIFICATION DIVISION.\n{long}\n")).unwrap();
582 assert!(s.text.contains(&long));
583 let Err(open) = extended("IDENTIFICATION DIVISION.\n DISPLAY 'AB\n- 'C'.\n") else { panic!("an open literal is refused") };
584 assert_eq!((open.message.as_str(), open.pos.line), ("a literal runs to the end of the line with no continuation", 3));
585 }
586
587 #[test]
588 fn a_d_and_a_space_in_column_1_is_a_debugging_line_and_a_comment_entry_ends_with_its_line() {
589 let text = "IDENTIFICATION DIVISION.\nPROGRAM-ID. D.\nAUTHOR. A & B.\nD DISPLAY 'DEBUG'\nDISPLAY 'KEPT'\n";
590 assert!(!extended(text).unwrap().text.contains("DEBUG"));
591 let debugging = read_under(text, 0, true, Compliance::Extended).unwrap();
592 assert!(debugging.text.contains("DISPLAY 'DEBUG'") && debugging.text.contains("DISPLAY 'KEPT'") && !debugging.text.contains("A & B"));
593 assert_eq!(debugging.debugging, Some(vec![(0, 4)]));
594 }
595
596 #[test]
597 fn a_source_format_directive_switches_the_form_from_the_next_line() {
598 let text = " $SET SOURCEFORMAT\"FREE\"\nIDENTIFICATION DIVISION.\n>>SOURCE FORMAT IS FIXED\n000100 PROGRAM-ID. P.\n >>source free\nDATA DIVISION.\n";
599 let s = extended(text).unwrap();
600 let words: Vec<&str> = s.text.split_whitespace().collect();
601 assert_eq!(words, ["IDENTIFICATION", "DIVISION.", "PROGRAM-ID.", "P.", "DATA", "DIVISION."]);
602 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();
603 assert_eq!(spans, [(2, 2, Some(Pos { file: 0, line: 1, col: 7 })), (6, u32::MAX, Some(Pos { file: 0, line: 5, col: 3 }))]);
604 let Err(other) = extended(" >>IF X DEFINED\n") else { panic!("another directive is refused") };
605 assert!(other.message.starts_with(">>IF X DEFINED: the source-format directives"), "{}", other.message);
606 assert_eq!(other.pos.col, 8);
607 }
608
609 #[test]
610 fn a_fixed_form_file_stays_fixed_under_extended() {
611 let text = concat!(
612 "000100 IDENTIFICATION DIVISION. SEQ00001\n",
613 "007000C LABEL RECORDS SQ1054.2\n",
614 "\t RECORD IS VARYING\n",
615 " CBL APOST\n",
616 "ABC123 MOVE A TO B.\n",
617 );
618 let s = extended(text).unwrap();
619 assert!(s.free.is_empty());
620 assert_eq!(s.text, read(text).unwrap().text);
621 }
622
623 #[test]
624 fn a_member_copied_from_a_free_form_line_starts_free_with_no_warning_of_its_own() {
625 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";
626 let copied = read_copied(member, 2, false, Compliance::Extended, true).unwrap();
627 assert!(copied.text.contains("02 B PIC X(80)") && copied.text.trim_end().ends_with("whole as it reads it'."), "{}", copied.text);
628 assert_eq!((copied.free.len(), copied.free[0].first, copied.free[0].warning.clone()), (1, 1, None));
629 assert!(read_copied(member, 2, false, Compliance::Extended, false).is_err());
630 assert!(read_copied(member, 2, false, Compliance::Strict, true).is_err());
631 }
632
633 #[test]
634 fn listing_statements_alone_on_a_line_are_left_out() {
635 let s = read(concat!(
636 " 01 A PIC X.\n",
637 " EJECT\n",
638 " SKIP1\n",
639 " skip2.\n",
640 " SKIP3 *> blank lines\n",
641 " TITLE 'PAYROLL: PART 2'.\n",
642 " TITLE 'A *> B' *> the title holds *>\n",
643 " TITLE \"X\"\n",
644 " 01 B PIC X.\n",
645 " MOVE TITLE TO EJECT.\n",
646 " EJECT X.\n",
647 ))
648 .unwrap();
649 let words: Vec<&str> = s.text.split_whitespace().collect();
650 assert_eq!(words, ["01", "A", "PIC", "X.", "01", "B", "PIC", "X.", "MOVE", "TITLE", "TO", "EJECT.", "EJECT", "X."]);
651 }
652}