1use crate::bms;
6use crate::source::{self, Source};
7use crate::system;
8use crate::{Error, Pos};
9use std::path::{Path, PathBuf};
10
11#[derive(Clone, Debug, Default)]
15pub struct Libraries {
16 dirs: Vec<PathBuf>,
17 program: Option<PathBuf>,
18 compliance: numeric::Compliance,
19}
20
21const COPYBOOKS: &[&str] = &[".cpy", ".CPY", ".copy", ".COPY"];
22const PROGRAM_SOURCES: &[&str] = &[".cbl", ".CBL", ".cob", ".COB"];
23const BARE: &[&str] = &[""];
24const MAX_DEPTH: usize = 32;
25
26impl Libraries {
27 pub fn new(dirs: Vec<PathBuf>) -> Self {
28 Self { dirs, program: None, compliance: numeric::Compliance::Strict }
29 }
30
31 pub fn with_program(&self, program: &Path) -> Self {
33 Self { program: Some(program.to_path_buf()), ..self.clone() }
34 }
35
36 pub fn with_compliance(&self, compliance: numeric::Compliance) -> Self {
38 Self { compliance, ..self.clone() }
39 }
40
41 pub fn compliance(&self) -> numeric::Compliance {
42 self.compliance
43 }
44
45 fn find(&self, name: &str, library: Option<&str>, literal: bool) -> Option<PathBuf> {
48 let rounds = if literal { [BARE, COPYBOOKS, PROGRAM_SOURCES] } else { [COPYBOOKS, PROGRAM_SOURCES, BARE] };
49 self.find_in_rounds(name, library, &rounds)
50 }
51
52 pub(crate) fn find_bms(&self, name: &str, library: Option<&str>) -> Option<PathBuf> {
53 self.find_in_rounds(name, library, &[&[".bms", ".BMS"]])
54 }
55
56 fn find_in_rounds(&self, name: &str, library: Option<&str>, rounds: &[&[&str]]) -> Option<PathBuf> {
57 let mut places: Vec<PathBuf> = Vec::new();
58 for d in &self.dirs {
59 if let Some(lib) = library {
60 places.extend([lib.to_owned(), lib.to_ascii_lowercase()].iter().map(|l| d.join(l)));
61 }
62 places.push(d.clone());
63 }
64 let mut names = vec![name.to_owned()];
65 for variant in [name.to_ascii_uppercase(), name.to_ascii_lowercase()] {
66 if !names.contains(&variant) {
67 names.push(variant);
68 }
69 }
70 let (places, names) = (&places, &names);
71 rounds
72 .iter()
73 .flat_map(|extensions| places.iter().flat_map(move |p| extensions.iter().flat_map(move |e| names.iter().map(move |n| p.join(format!("{n}{e}"))))))
74 .find(|p| p.is_file() && !self.program.as_deref().is_some_and(|program| same_file(program, p)))
75 }
76}
77
78fn same_file(a: &Path, b: &Path) -> bool {
80 #[cfg(unix)]
81 {
82 use std::os::unix::fs::MetadataExt;
83 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
84 return x.dev() == y.dev() && x.ino() == y.ino();
85 }
86 }
87 matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(x), Ok(y)) if x == y)
88}
89
90pub fn decode(bytes: &[u8]) -> String {
92 match std::str::from_utf8(bytes) {
93 Ok(s) => s.to_owned(),
94 Err(_) => bytes.iter().map(|&b| b as char).collect(),
95 }
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
99struct Word {
100 start: usize,
101 end: usize,
102 text: String,
103}
104
105fn text_words(chars: &[char]) -> Vec<Word> {
106 let mut words = Vec::new();
107 let mut i = 0;
108 let separator_at = |i: usize| chars.get(i + 1).is_none_or(|c| c.is_whitespace());
109 while i < chars.len() {
110 let c = chars[i];
111 if c.is_whitespace() || ((c == ',' || c == ';') && separator_at(i)) {
112 i += 1;
113 continue;
114 }
115 let start = i;
116 if c == '\'' || c == '"' {
117 i += 1;
118 while i < chars.len() {
119 if chars[i] == c && chars.get(i + 1) == Some(&c) {
120 i += 2;
121 } else if chars[i] == c {
122 i += 1;
123 break;
124 } else {
125 i += 1;
126 }
127 }
128 } else if c == '=' && chars.get(i + 1) == Some(&'=') {
129 i += 2;
130 } else if matches!(c, '(' | ')' | ':') || (c == '.' && separator_at(i)) {
131 i += 1;
132 } else {
133 while i < chars.len() {
134 let d = chars[i];
135 let ends = d.is_whitespace()
136 || matches!(d, '(' | ')' | ':' | '\'' | '"')
137 || ((d == '.' || d == ',' || d == ';') && separator_at(i))
138 || (d == '=' && chars.get(i + 1) == Some(&'='));
139 if ends {
140 break;
141 }
142 i += 1;
143 }
144 }
145 words.push(Word { start, end: i, text: chars[start..i].iter().collect() });
146 }
147 words
148}
149
150fn same(a: &str, b: &str) -> bool {
151 if a.starts_with(['\'', '"']) { a == b } else { a.eq_ignore_ascii_case(b) }
152}
153
154#[derive(Clone, Debug)]
155enum Mode {
156 Whole,
157 Leading,
158 Trailing,
159}
160
161#[derive(Clone, Debug)]
162struct Replacing {
163 mode: Mode,
164 pattern: Vec<String>,
165 replacement: String,
166}
167
168struct Statement {
169 name: String,
170 literal: bool,
172 library: Option<String>,
173 replacing: Vec<Replacing>,
174 next: usize,
176}
177
178fn copy_statement(words: &[Word], chars: &[char], at: usize, pos: Pos) -> Result<Statement, Error> {
179 let err = |m: &str| crate::messages::IWS0003.at(pos, format!("COPY: {m}"));
180 let text = |i: usize| words.get(i).map(|w| w.text.as_str());
181 let mut i = at + 1;
182 let quoted = |s: &str| s.starts_with(['\'', '"']);
183 let unquote = |s: &str| s.trim_matches(|c| c == '\'' || c == '"').to_owned();
184 let word = |s: &str| {
186 if s.ends_with('.') && !quoted(s) {
187 return Err(crate::messages::IWS0004.at(pos, format!("COPY {s}: the name ends in a period; the period that ends a COPY statement is the one followed by a space")));
188 }
189 Ok(unquote(s))
190 };
191 let first = text(i).ok_or_else(|| err("a member name"))?;
192 let (name, literal) = (word(first)?, quoted(first));
193 i += 1;
194 let mut library = None;
195 if text(i).is_some_and(|w| w.eq_ignore_ascii_case("OF") || w.eq_ignore_ascii_case("IN")) {
196 library = Some(word(text(i + 1).ok_or_else(|| err("a library name"))?)?);
197 i += 2;
198 }
199 if text(i).is_some_and(|w| w.eq_ignore_ascii_case("SUPPRESS")) {
200 i += 1;
201 }
202 let mut replacing = Vec::new();
203 if text(i).is_some_and(|w| w.eq_ignore_ascii_case("REPLACING")) {
204 (replacing, i) = operands(words, chars, i + 1, "COPY", pos)?;
205 }
206 if text(i) != Some(".") {
207 return Err(err("a period to end the statement"));
208 }
209 Ok(Statement { name, literal, library, replacing, next: i + 1 })
210}
211
212fn operands(words: &[Word], chars: &[char], at: usize, verb: &str, pos: Pos) -> Result<(Vec<Replacing>, usize), Error> {
216 let err = |m: &str| crate::messages::IWS0005.at(pos, format!("{verb}: {m}"));
217 let text = |i: usize| words.get(i).map(|w| w.text.as_str());
218 let replace = verb == "REPLACE";
219 let pseudo_text = |i: usize| !replace || text(i) == Some("==");
220 let (first, second) = if replace { ("pseudo-text between == delimiters to replace", "pseudo-text between == delimiters after BY") } else { ("an operand to replace", "an operand after BY") };
221 let (mut replacing, mut i) = (Vec::new(), at);
222 while text(i).is_some_and(|w| w != ".") {
223 let mode = match text(i) {
224 Some(w) if w.eq_ignore_ascii_case("LEADING") => Mode::Leading,
225 Some(w) if w.eq_ignore_ascii_case("TRAILING") => Mode::Trailing,
226 _ => Mode::Whole,
227 };
228 if !matches!(mode, Mode::Whole) {
229 i += 1;
230 }
231 let (pattern, _, after) = operand(words, chars, i).filter(|_| pseudo_text(i)).ok_or_else(|| err(first))?;
232 if !text(after).is_some_and(|w| w.eq_ignore_ascii_case("BY")) {
233 return Err(err("BY"));
234 }
235 let (_, replacement, after) = operand(words, chars, after + 1).filter(|_| pseudo_text(after + 1)).ok_or_else(|| err(second))?;
236 if pattern.is_empty() || (!matches!(mode, Mode::Whole) && pattern.len() != 1) {
237 return Err(err("LEADING and TRAILING take one word; an empty pattern matches nothing"));
238 }
239 replacing.push(Replacing { mode, pattern, replacement });
240 i = after;
241 }
242 Ok((replacing, i))
243}
244
245fn operand(words: &[Word], chars: &[char], at: usize) -> Option<(Vec<String>, String, usize)> {
250 let pseudo_text = words.get(at)?.text == "==";
251 let (from, to) = if pseudo_text { (at + 1, words[at + 1..].iter().position(|w| w.text == "==")? + at + 1) } else { (at, operand_end(words, at)) };
252 let text = if from < to { chars[words[from].start..words[to - 1].end].iter().collect() } else { String::new() };
253 Some((words[from..to].iter().map(|w| w.text.clone()).collect(), text, to + usize::from(pseudo_text)))
254}
255
256fn operand_end(words: &[Word], at: usize) -> usize {
260 let is = |i: usize, w: &str| words.get(i).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
261 if words[at].text.starts_with(['\'', '"']) {
262 return at + 1;
263 }
264 if is(at, "ALL") {
265 return (at + 2).min(words.len());
266 }
267 let mut i = at + 1 + usize::from(is(at, "FUNCTION") && at + 1 < words.len());
268 while (is(i, "IN") || is(i, "OF")) && i + 1 < words.len() {
269 i += 2;
270 }
271 while is(i, "(") {
272 let mut depth = 0;
273 while let Some(w) = words.get(i) {
274 depth += i32::from(w.text == "(") - i32::from(w.text == ")");
275 i += 1;
276 if depth == 0 {
277 break;
278 }
279 }
280 }
281 i
282}
283
284fn copy_span(out: &mut Source, chars: &[char], positions: &[Pos], range: std::ops::Range<usize>) {
286 out.text.extend(&chars[range.clone()]);
287 out.positions.extend_from_slice(&positions[range]);
288}
289
290fn apply(src: &Source, replacing: &[Replacing]) -> Source {
291 if replacing.is_empty() {
292 return Source { text: src.text.clone(), positions: src.positions.clone(), options: Vec::new(), debugging: None, free: Vec::new() };
293 }
294 let chars: Vec<char> = src.text.chars().collect();
295 let words = text_words(&chars);
296 let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: None, free: Vec::new() };
297 let emit = |out: &mut Source, text: &str, pos: Pos| {
298 for c in text.chars() {
299 out.text.push(c);
300 out.positions.push(pos);
301 }
302 };
303 let (mut cursor, mut i) = (0usize, 0usize);
304 while i < words.len() {
305 copy_span(&mut out, &chars, &src.positions, cursor..words[i].start);
306 let pos = src.positions[words[i].start];
307 let hit = replacing.iter().find_map(|r| match r.mode {
308 Mode::Whole => (words.len() - i >= r.pattern.len() && r.pattern.iter().zip(&words[i..]).all(|(p, w)| same(p, &w.text)))
309 .then(|| (r.replacement.clone(), r.pattern.len())),
310 Mode::Leading => {
311 let w = &words[i].text;
312 (w.len() > r.pattern[0].len() || w.eq_ignore_ascii_case(&r.pattern[0]))
313 .then_some(())
314 .filter(|_| w.to_ascii_uppercase().starts_with(&r.pattern[0].to_ascii_uppercase()))
315 .map(|_| (format!("{}{}", r.replacement, &w[r.pattern[0].len()..]), 1))
316 }
317 Mode::Trailing => {
318 let w = &words[i].text;
319 w.to_ascii_uppercase()
320 .ends_with(&r.pattern[0].to_ascii_uppercase())
321 .then(|| (format!("{}{}", &w[..w.len() - r.pattern[0].len()], r.replacement), 1))
322 }
323 });
324 match hit {
325 Some((text, consumed)) => {
326 emit(&mut out, &text, pos);
327 cursor = words[i + consumed - 1].end;
328 i += consumed;
329 }
330 None => {
331 copy_span(&mut out, &chars, &src.positions, words[i].start..words[i].end);
332 cursor = words[i].end;
333 i += 1;
334 }
335 }
336 }
337 copy_span(&mut out, &chars, &src.positions, cursor..chars.len());
338 out
339}
340
341pub fn expand(source: Source, libraries: &Libraries, files: &mut Vec<String>) -> Result<Source, Error> {
344 let mut stack = Vec::new();
345 expand_nested(source, libraries, files, &mut stack)
346}
347
348fn sql_include(words: &[Word], at: usize) -> Option<(String, bool, usize)> {
352 let is = |k: usize, w: &str| words.get(at + k).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
353 if !(is(0, "EXEC") && is(1, "SQL") && is(2, "INCLUDE") && is(4, "END-EXEC")) {
354 return None;
355 }
356 let word = &words[at + 3].text;
357 let name = word.trim_matches(|c| c == '\'' || c == '"').to_owned();
358 Some((name, word.starts_with(['\'', '"']), at + 5 + usize::from(is(5, "."))))
359}
360
361fn expand_nested(source: Source, libraries: &Libraries, files: &mut Vec<String>, stack: &mut Vec<String>) -> Result<Source, Error> {
362 let chars: Vec<char> = source.text.chars().collect();
363 let words = text_words(&chars);
364 if !words.iter().any(|w| w.text.eq_ignore_ascii_case("COPY") || w.text.eq_ignore_ascii_case("INCLUDE")) {
365 return Ok(source);
366 }
367 let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone(), free: source.free.clone() };
368 let read = |text: &str, file: u16| source::read_under(text, file, source.debugging.is_some(), libraries.compliance());
369 let (mut cursor, mut i) = (0usize, 0usize);
370 while i < words.len() {
371 let pos = source.positions[words[i].start];
372 let (name, literal, library, replacing, next, sql) = if words[i].text.eq_ignore_ascii_case("COPY") {
373 let st = copy_statement(&words, &chars, i, pos)?;
374 (st.name, st.literal, st.library, st.replacing, st.next, false)
375 } else if let Some((name, literal, next)) = sql_include(&words, i) {
376 (name, literal, None, Vec::new(), next, true)
377 } else {
378 i += 1;
379 continue;
380 };
381 copy_span(&mut out, &chars, &source.positions, cursor..words[i].start);
382 let own = sql && matches!(name.to_ascii_uppercase().as_str(), "SQLCA" | "SQLDA");
383 let path = if own { None } else { libraries.find(&name, library.as_deref(), literal) };
384 let verb = if sql { "EXEC SQL INCLUDE" } else { "COPY" };
385 let mapset = if own || path.is_some() { None } else { bms::load(libraries, &name, library.as_deref()) };
386 let (key, member) = match (path, mapset) {
387 (Some(path), _) => {
388 let copied_free = source.free_at(pos).is_some();
389 let copied = |text: &str, file: u16| source::read_copied(text, file, source.debugging.is_some(), libraries.compliance(), copied_free);
390 (path.display().to_string(), read_member(&path, pos, files, &copied)?)
391 }
392 (None, Some((path, mapset))) => {
393 let mapset = mapset.map_err(|e| crate::messages::IWS0006.at(pos, format!("{verb} {name}: {}", e.place(&path.display().to_string()))))?;
394 let file = u16::try_from(files.len()).map_err(|_| crate::messages::IWS0007.at(pos, "more than 65535 copy members"))?;
395 files.push(path.display().to_string());
396 (path.display().to_string(), read(&bms::symbolic_map(&mapset), file)?)
397 }
398 (None, None) => {
399 let text = system::member(&name).ok_or_else(|| crate::messages::IWS0002.at(pos, format!("{verb} {name}: no such member in the copy libraries")))?;
400 let key = format!("(system member {})", name.to_ascii_uppercase());
401 let file = u16::try_from(files.len()).map_err(|_| crate::messages::IWS0007.at(pos, "more than 65535 copy members"))?;
402 files.push(key.clone());
403 (key, read(&text, file)?)
404 }
405 };
406 if stack.contains(&key) || stack.len() >= MAX_DEPTH {
407 return Err(crate::messages::IWS0008.at(pos, format!("{verb} {name}: copies itself, or nests deeper than {MAX_DEPTH}")));
408 }
409 stack.push(key);
410 let mut member = expand_nested(member, libraries, files, stack)?;
411 stack.pop();
412 if let (Some(lines), Some(copied)) = (&mut out.debugging, member.debugging.take()) {
413 if lines.contains(&(pos.file, pos.line)) {
415 lines.extend(member.positions.iter().map(|p| (p.file, p.line)));
416 }
417 lines.extend(copied);
418 }
419 out.free.extend(member.free.iter().cloned());
420 let replaced = apply(&member, &replacing);
421 out.text.push_str(&replaced.text);
422 out.positions.extend(replaced.positions);
423 out.text.push('\n');
424 out.positions.push(pos);
425 cursor = words.get(next - 1).map_or(chars.len(), |w| w.end);
426 i = next;
427 }
428 copy_span(&mut out, &chars, &source.positions, cursor..chars.len());
429 Ok(out)
430}
431
432pub fn replace(source: Source) -> Result<Source, Error> {
437 let chars: Vec<char> = source.text.chars().collect();
438 let words = text_words(&chars);
439 let starts = |i: usize| words[i].text.eq_ignore_ascii_case("REPLACE");
440 if !(0..words.len()).any(starts) {
441 return Ok(source);
442 }
443 let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone(), free: source.free.clone() };
444 let segment = |out: &mut Source, range: std::ops::Range<usize>, active: &[Replacing]| {
445 let text = Source { text: chars[range.clone()].iter().collect(), positions: source.positions[range].to_vec(), options: Vec::new(), debugging: None, free: Vec::new() };
446 let replaced = apply(&text, active);
447 out.text.push_str(&replaced.text);
448 out.positions.extend(replaced.positions);
449 };
450 let (mut active, mut cursor, mut i) = (Vec::new(), 0usize, 0usize);
451 while i < words.len() {
452 if !starts(i) {
453 i += 1;
454 continue;
455 }
456 let pos = source.positions[words[i].start];
457 let next = match words.get(i + 1).map(|w| w.text.to_ascii_uppercase()).as_deref() {
458 Some("OFF") if words.get(i + 2).is_some_and(|w| w.text == ".") => (Vec::new(), i + 3),
459 Some("OFF") => return Err(crate::messages::IWS0009.at(pos, "REPLACE OFF: a period to end the statement")),
460 Some("ALSO" | "LAST") => return Err(crate::messages::IWS0010.at(pos, "REPLACE ALSO and REPLACE LAST OFF are the 2014 COBOL standard's; Enterprise COBOL has REPLACE pseudo-text BY pseudo-text and REPLACE OFF")),
461 Some("==" | "LEADING" | "TRAILING") => {
462 let (replacing, period) = operands(&words, &chars, i + 1, "REPLACE", pos)?;
463 if words.get(period).is_none_or(|w| w.text != ".") {
464 return Err(crate::messages::IWS0011.at(pos, "REPLACE: a period to end the statement"));
465 }
466 (replacing, period + 1)
467 }
468 _ => {
469 i += 1;
470 continue;
471 }
472 };
473 segment(&mut out, cursor..words[i].start, &active);
474 (active, i) = next;
475 cursor = words[i - 1].end;
476 }
477 segment(&mut out, cursor..chars.len(), &active);
478 Ok(out)
479}
480
481fn read_member(path: &Path, pos: Pos, files: &mut Vec<String>, read: &dyn Fn(&str, u16) -> Result<Source, Error>) -> Result<Source, Error> {
482 let bytes = std::fs::read(path).map_err(|e| crate::messages::IWS0012.at(pos, format!("COPY {}: {e}", path.display())))?;
483 let file = u16::try_from(files.len()).map_err(|_| crate::messages::IWS0007.at(pos, "more than 65535 copy members"))?;
484 files.push(path.display().to_string());
485 read(&decode(&bytes), file)
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn words(s: &str) -> Vec<String> {
493 text_words(&s.chars().collect::<Vec<_>>()).into_iter().map(|w| w.text).collect()
494 }
495
496 #[test]
497 fn text_words_follow_the_separators() {
498 assert_eq!(words("01 :TAG:-REC PIC X(3)."), ["01", ":", "TAG", ":", "-REC", "PIC", "X", "(", "3", ")", "."]);
499 assert_eq!(words("MOVE 'A B' TO X, Y."), ["MOVE", "'A B'", "TO", "X", "Y", "."]);
500 assert_eq!(words("==A== BY ==B==."), ["==", "A", "==", "BY", "==", "B", "==", "."]);
501 assert_eq!(words("GENAUW.CLAIM 0.1"), ["GENAUW.CLAIM", "0.1"]);
502 }
503
504 fn dir_with(files: &[(&str, &str)]) -> PathBuf {
505 let dir = std::env::temp_dir().join(format!("ironwork-copy-{}-{}", std::process::id(), files[0].0));
506 std::fs::create_dir_all(&dir).unwrap();
507 for (name, text) in files {
508 std::fs::write(dir.join(name), text).unwrap();
509 }
510 dir
511 }
512
513 fn expanded(main: &str, dir: &Path) -> Result<String, Error> {
514 let src = source::read(main)?;
515 let mut files = vec![String::new()];
516 expand(src, &Libraries::new(vec![dir.to_path_buf()]), &mut files).map(|s| s.text)
517 }
518
519 #[test]
520 fn copy_replacing_whole_words_and_tags_inside_words() {
521 let dir = dir_with(&[("RECS.cpy", " 01 :TAG:-REC.\n 05 :TAG:-ID PIC 9(4) VALUE OLD.\n")]);
522 let text = expanded(" COPY RECS REPLACING ==:TAG:== BY ==CUST== OLD BY 42.\n", &dir).unwrap();
523 assert!(text.contains("01 CUST-REC."), "{text}");
524 assert!(text.contains("05 CUST-ID PIC 9(4) VALUE 42."), "{text}");
525 assert!(!text.contains("COPY"));
526 }
527
528 #[test]
529 fn leading_and_trailing_replace_part_of_a_word() {
530 let dir = dir_with(&[("PART.cpy", " 01 WS-A PIC X.\n 01 B-WS PIC X.\n")]);
531 let text = expanded(" COPY PART REPLACING LEADING ==WS== BY ==LK==\n TRAILING ==WS== BY ==XX==.\n", &dir).unwrap();
532 assert!(text.contains("LK-A") && text.contains("B-XX"), "{text}");
533 }
534
535 #[test]
536 fn identifier_operands_take_their_qualifiers_subscripts_and_reference_modification() {
537 let dir = dir_with(&[("IDOPS.cpy", " MOVE OLD-1 TO OLD-2.\n ADD 1 TO A IN B (1).\n DISPLAY FUNCTION UPPER-CASE (X).\n")]);
538 let text = expanded(
539 concat!(
540 " COPY IDOPS REPLACING OLD-1 BY NEW-Q OF NEW-R\n",
541 " IN NEW-S\n",
542 " OLD-2 BY Z (2, 1, 1) (1:3)\n",
543 " A IN B (1) BY C\n",
544 " FUNCTION UPPER-CASE (X) BY Y.\n",
545 ),
546 &dir,
547 )
548 .unwrap();
549 let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
550 assert_eq!(words, "MOVE NEW-Q OF NEW-R IN NEW-S TO Z (2, 1, 1) (1:3). ADD 1 TO C. DISPLAY Y.");
551 }
552
553 #[test]
554 fn pseudo_text_is_copied_as_written_and_a_member_may_end_the_entry_that_copies_it() {
555 let dir = dir_with(&[("K101.cpy", " .\n 02 TST-FLD-1 PICTURE 9(5).\n 02 FILLER PICTURE X(115).\n")]);
556 let text = expanded(" 01 TST-TEST COPY K101 REPLACING TST-FLD-1 BY TF-1.\n", &dir).unwrap();
557 assert!(text.contains("01 TST-TEST") && text.contains("02 TF-1 PICTURE 9(5)."), "{text}");
558 let text = expanded(
559 concat!(
560 " 01 TEXT-TEST-1 COPY K101\n",
561 " REPLACING ==02 TST-FLD-1 PICTURE 9(5). 02 FILLER\n",
562 " PICTURE X(115)==\n",
563 " BY ==02 FILLER PICTURE X(115). 02 TXT-FLD-1\n",
564 " PIC 9(5)==.\n",
565 ),
566 &dir,
567 )
568 .unwrap();
569 let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
570 assert_eq!(words, "01 TEXT-TEST-1 . 02 FILLER PICTURE X(115). 02 TXT-FLD-1 PIC 9(5).");
571 }
572
573 #[test]
574 fn nested_copies_and_a_missing_member() {
575 let dir = dir_with(&[("OUTER.cpy", " COPY INNER.\n"), ("inner.cpy", " 01 X PIC X.\n")]);
576 assert!(expanded(" COPY OUTER.\n", &dir).unwrap().contains("01 X PIC X."));
577 let err = expanded(" COPY NOPE.\n", &dir).unwrap_err();
578 assert!(err.message.contains("NOPE"));
579 }
580
581 #[test]
582 fn system_members_answer_when_no_library_does() {
583 let dir = dir_with(&[("OTHER.cpy", " 01 O PIC X.\n")]);
584 let text = expanded(" COPY DFHAID.\n EXEC SQL INCLUDE SQLCA END-EXEC.\n", &dir).unwrap();
585 assert!(text.contains("DFHENTER") && text.contains("SQLCODE"), "{text}");
586 assert!(!text.contains("END-EXEC"));
587 let dir = dir_with(&[("DFHAID.cpy", " 01 VENDORED PIC X.\n")]);
588 assert!(expanded(" COPY DFHAID.\n", &dir).unwrap().contains("VENDORED"));
589 }
590
591 #[test]
592 fn a_member_that_copies_itself_is_refused() {
593 let dir = dir_with(&[("LOOP.cpy", " COPY LOOP.\n")]);
594 assert!(expanded(" COPY LOOP.\n", &dir).unwrap_err().message.contains("copies itself"));
595 }
596
597 fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
599 let root = std::env::temp_dir().join(format!("ironwork-copy-{}-{tag}", std::process::id()));
600 let _ = std::fs::remove_dir_all(&root);
601 for (name, text) in files {
602 let path = root.join(name);
603 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
604 std::fs::write(path, text).unwrap();
605 }
606 root
607 }
608
609 fn expanded_with(main: &str, libraries: &Libraries) -> Result<String, Error> {
610 let mut files = vec![String::new()];
611 expand(source::read(main)?, libraries, &mut files).map(|s| s.text)
612 }
613
614 #[test]
615 fn a_copybook_in_any_library_is_found_before_a_program_source() {
616 let root = tree(
617 "rounds",
618 &[
619 ("src/INQACC.cbl", " 01 PROGRAM-SOURCE PIC X.\n"),
620 ("src/ONLY.cbl", " 01 ONLY-SOURCE PIC X.\n"),
621 ("src/BARE", " 01 BARE-FILE PIC X.\n"),
622 ("cpy/INQACC.cpy", " 01 COPYBOOK PIC X.\n"),
623 ("cpy/BARE.copy", " 01 COPY-FILE PIC X.\n"),
624 ],
625 );
626 let libraries = Libraries::new(vec![root.join("src"), root.join("cpy")]);
627 assert!(expanded_with(" COPY INQACC.\n", &libraries).unwrap().contains("COPYBOOK"));
628 assert!(expanded_with(" COPY inqacc.\n", &libraries).unwrap().contains("COPYBOOK"));
629 assert!(expanded_with(" COPY BARE.\n", &libraries).unwrap().contains("COPY-FILE"));
630 assert!(expanded_with(" COPY ONLY.\n", &libraries).unwrap().contains("ONLY-SOURCE"));
631 }
632
633 #[test]
634 fn a_name_alone_is_tried_after_its_extensions_and_a_literal_first() {
635 let root = tree("bare", &[("lib/MEMBER", " 01 BARE-FILE PIC X.\n"), ("lib/MEMBER.cpy", " 01 COPYBOOK PIC X.\n"), ("lib/ALONE", " 01 ALONE PIC X.\n")]);
636 let libraries = Libraries::new(vec![root.join("lib")]);
637 assert!(expanded_with(" COPY MEMBER.\n", &libraries).unwrap().contains("COPYBOOK"));
638 assert!(expanded_with(" COPY \"MEMBER\".\n", &libraries).unwrap().contains("BARE-FILE"));
639 assert!(expanded_with(" COPY ALONE.\n", &libraries).unwrap().contains("01 ALONE"));
640 }
641
642 #[test]
643 fn the_program_being_compiled_is_never_its_own_member() {
644 let program = " IDENTIFICATION DIVISION.\n PROGRAM-ID. PGMC.\n DATA DIVISION.\n WORKING-STORAGE SECTION.\n COPY PGMC.\n";
645 let root = tree("self", &[("src/PGMC.cbl", program), ("src/OUTER.cpy", " COPY PGMC.\n"), ("lib/PGMC.cbl", " 01 PGMC-X PIC X.\n")]);
646 let main = root.join("src/../src/PGMC.cbl");
647 let libraries = Libraries::new(vec![root.join("src"), root.join("lib")]).with_program(&main);
648 assert!(expanded_with(program, &libraries).unwrap().contains("PGMC-X"));
649 assert!(expanded_with(" COPY OUTER.\n", &libraries).unwrap().contains("PGMC-X"));
650 let alone = Libraries::new(vec![root.join("src")]).with_program(&main);
651 assert!(expanded_with(program, &alone).unwrap_err().message.contains("no such member"));
652 assert!(expanded_with(program, &Libraries::new(vec![root.join("src")])).unwrap_err().message.contains("copies itself"));
653 }
654
655 #[test]
656 fn a_doubled_period_is_refused_by_name() {
657 let root = tree("period", &[("lib/COBCPARMS.cpy", " 01 PARMS PIC X.\n")]);
658 let libraries = Libraries::new(vec![root.join("lib")]);
659 let err = expanded_with(" COPY COBCPARMS..\n", &libraries).unwrap_err();
660 assert!(err.message.contains("COPY COBCPARMS.: the name ends in a period"), "{}", err.message);
661 let err = expanded_with(" COPY COBCPARMS OF LIB..\n", &libraries).unwrap_err();
662 assert!(err.message.contains("COPY LIB.: "), "{}", err.message);
663 assert!(expanded_with(" COPY COBCPARMS.\n", &libraries).unwrap().contains("PARMS"));
664 }
665
666 fn replaced(text: &str) -> Result<String, Error> {
667 replace(source::read(text)?).map(|s| s.text)
668 }
669
670 #[test]
671 fn replace_acts_from_its_period_to_the_next_replace() {
672 let text = replaced(concat!(
673 " 01 A PICTURE X.\n",
674 " REPLACE ==PICTURE== BY ==PIC==.\n",
675 " 01 B PICTURE X.\n",
676 " 01 C PICTURE X VALUE 'PICTURE'.\n",
677 " REPLACE OFF.\n",
678 " 01 D PICTURE X.\n",
679 ))
680 .unwrap();
681 let words: Vec<&str> = text.split_whitespace().collect();
682 assert_eq!(words, ["01", "A", "PICTURE", "X.", "01", "B", "PIC", "X.", "01", "C", "PIC", "X", "VALUE", "'PICTURE'.", "01", "D", "PICTURE", "X."]);
683 }
684
685 #[test]
686 fn replace_takes_the_language_references_example() {
687 let text = replaced(concat!(
688 " REPLACE ==\"(Hello, World!)\"== BY ==\"(Hello, Mom!)\"==.\n",
689 " 01 WS-STRING1 PIC X(30) VALUE \"(Hello, World!)\".\n",
690 " DISPLAY \"Modified: \" XX-WS-:TAG:1\n",
691 " REPLACE LEADING ==XX-== BY ====\n",
692 " ==:TAG:== BY ==STRING==\n",
693 " TRAILING ==1== BY ==2==.\n",
694 " DISPLAY \"Modified: \" XX-WS-:TAG:1\n",
695 ))
696 .unwrap();
697 assert!(text.contains("VALUE \"(Hello, Mom!)\"."), "{text}");
698 assert!(text.contains("DISPLAY \"Modified: \" XX-WS-:TAG:1\n") && text.ends_with("DISPLAY \"Modified: \" WS-STRING2"), "{text}");
699 assert!(!text.contains("REPLACE"), "{text}");
700 }
701
702 #[test]
703 fn a_later_replace_supersedes_and_several_words_match_as_one() {
704 let text = replaced(concat!(
705 " REPLACE ==AO== BY ==TO== == = == BY ==EQUAL==.\n",
706 " MOVE \"*\" AO X.\n",
707 " REPLACE ==MOVE \"*\" TO X.\n",
708 " IF X = \"*\"== BY ==DISPLAY X==.\n",
709 " MOVE \"*\" TO X.\n",
710 " IF X = \"*\" DISPLAY Y.\n",
711 ))
712 .unwrap();
713 assert!(text.contains("MOVE \"*\" TO X."), "{text}");
714 assert!(text.contains("DISPLAY X DISPLAY Y."), "{text}");
715 }
716
717 #[test]
718 fn replace_takes_only_pseudo_text() {
719 let text = replaced(" MOVE REPLACE TO X.\n DISPLAY Y.\n").unwrap();
720 assert!(text.contains("MOVE REPLACE TO X."));
721 assert!(replaced(" REPLACE A BY B.\n").unwrap().contains("REPLACE A BY B."));
722 let err = replaced(" REPLACE ==A== BY B.\n").unwrap_err();
723 assert_eq!(err.message, "REPLACE: pseudo-text between == delimiters after BY");
724 let err = replaced(" REPLACE ALSO ==A== BY ==B==.\n").unwrap_err();
725 assert!(err.message.starts_with("REPLACE ALSO and REPLACE LAST OFF are the 2014"), "{}", err.message);
726 assert_eq!(replaced(" REPLACE ==A== BY ==B==\n").unwrap_err().message, "REPLACE: a period to end the statement");
727 }
728
729 #[test]
730 fn replace_acts_on_copied_text() {
731 let dir = dir_with(&[("REPMEM.cpy", " 01 :TAG:-ID PIC 9.\n")]);
732 let src = source::read(" REPLACE ==:TAG:== BY ==CUST==.\n COPY REPMEM.\n").unwrap();
733 let mut files = vec![String::new()];
734 let text = expand(src, &Libraries::new(vec![dir]), &mut files).and_then(replace).unwrap().text;
735 assert!(text.contains("01 CUST-ID PIC 9."), "{text}");
736 }
737
738 #[test]
739 fn positions_in_a_member_name_its_file() {
740 let dir = dir_with(&[("POS.cpy", " 01 Y PIC X.\n")]);
741 let src = source::read(" COPY POS.\n").unwrap();
742 let mut files = vec![String::new()];
743 let out = expand(src, &Libraries::new(vec![dir]), &mut files).unwrap();
744 let at = out.text.find('Y').unwrap();
745 assert_eq!(out.positions[at].file, 1);
746 assert!(files[1].ends_with("POS.cpy"));
747 }
748}