Skip to main content

ironwork_syntax/
copy.rs

1//! COPY and COPY REPLACING, applied to the logical text before it is lexed. Matching is by
2//! text-words, as the standard defines them, and replacement edits the text itself, so a
3//! pseudo-text such as `==:TAG:==` can replace part of a word like `:TAG:-RECORD`.
4
5use crate::bms;
6use crate::source::{self, Source};
7use crate::system;
8use crate::{Error, Pos};
9use std::path::{Path, PathBuf};
10
11/// Directories searched for COPY members, in order. A `COPY X OF LIB` looks in `<dir>/LIB` first.
12/// The file being compiled, when named, is never one of its own members.
13#[derive(Clone, Debug, Default)]
14pub struct Libraries {
15    dirs: Vec<PathBuf>,
16    program: Option<PathBuf>,
17}
18
19const COPYBOOKS: &[&str] = &[".cpy", ".CPY", ".copy", ".COPY"];
20const PROGRAM_SOURCES: &[&str] = &[".cbl", ".CBL", ".cob", ".COB"];
21const BARE: &[&str] = &[""];
22const MAX_DEPTH: usize = 32;
23
24impl Libraries {
25    pub fn new(dirs: Vec<PathBuf>) -> Self {
26        Self { dirs, program: None }
27    }
28
29    /// These libraries, for compiling the program in `program`.
30    pub fn with_program(&self, program: &Path) -> Self {
31        Self { dirs: self.dirs.clone(), program: Some(program.to_path_buf()) }
32    }
33
34    /// A round of extensions searches every library before the next round starts, so a copybook in
35    /// any library is found before a program source (assumptions C85 to C87).
36    fn find(&self, name: &str, library: Option<&str>, literal: bool) -> Option<PathBuf> {
37        let rounds = if literal { [BARE, COPYBOOKS, PROGRAM_SOURCES] } else { [COPYBOOKS, PROGRAM_SOURCES, BARE] };
38        self.find_in_rounds(name, library, &rounds)
39    }
40
41    pub(crate) fn find_bms(&self, name: &str, library: Option<&str>) -> Option<PathBuf> {
42        self.find_in_rounds(name, library, &[&[".bms", ".BMS"]])
43    }
44
45    fn find_in_rounds(&self, name: &str, library: Option<&str>, rounds: &[&[&str]]) -> Option<PathBuf> {
46        let mut places: Vec<PathBuf> = Vec::new();
47        for d in &self.dirs {
48            if let Some(lib) = library {
49                places.extend([lib.to_owned(), lib.to_ascii_lowercase()].iter().map(|l| d.join(l)));
50            }
51            places.push(d.clone());
52        }
53        let mut names = vec![name.to_owned()];
54        for variant in [name.to_ascii_uppercase(), name.to_ascii_lowercase()] {
55            if !names.contains(&variant) {
56                names.push(variant);
57            }
58        }
59        let (places, names) = (&places, &names);
60        rounds
61            .iter()
62            .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}"))))))
63            .find(|p| p.is_file() && !self.program.as_deref().is_some_and(|program| same_file(program, p)))
64    }
65}
66
67/// Whether two paths name one file, however each is spelled.
68fn same_file(a: &Path, b: &Path) -> bool {
69    #[cfg(unix)]
70    {
71        use std::os::unix::fs::MetadataExt;
72        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
73            return x.dev() == y.dev() && x.ino() == y.ino();
74        }
75    }
76    matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(x), Ok(y)) if x == y)
77}
78
79/// Reads a source file's bytes: UTF-8 when valid, otherwise one character per byte (Latin-1).
80pub fn decode(bytes: &[u8]) -> String {
81    match std::str::from_utf8(bytes) {
82        Ok(s) => s.to_owned(),
83        Err(_) => bytes.iter().map(|&b| b as char).collect(),
84    }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
88struct Word {
89    start: usize,
90    end: usize,
91    text: String,
92}
93
94fn text_words(chars: &[char]) -> Vec<Word> {
95    let mut words = Vec::new();
96    let mut i = 0;
97    let separator_at = |i: usize| chars.get(i + 1).is_none_or(|c| c.is_whitespace());
98    while i < chars.len() {
99        let c = chars[i];
100        if c.is_whitespace() || ((c == ',' || c == ';') && separator_at(i)) {
101            i += 1;
102            continue;
103        }
104        let start = i;
105        if c == '\'' || c == '"' {
106            i += 1;
107            while i < chars.len() {
108                if chars[i] == c && chars.get(i + 1) == Some(&c) {
109                    i += 2;
110                } else if chars[i] == c {
111                    i += 1;
112                    break;
113                } else {
114                    i += 1;
115                }
116            }
117        } else if c == '=' && chars.get(i + 1) == Some(&'=') {
118            i += 2;
119        } else if matches!(c, '(' | ')' | ':') || (c == '.' && separator_at(i)) {
120            i += 1;
121        } else {
122            while i < chars.len() {
123                let d = chars[i];
124                let ends = d.is_whitespace()
125                    || matches!(d, '(' | ')' | ':' | '\'' | '"')
126                    || ((d == '.' || d == ',' || d == ';') && separator_at(i))
127                    || (d == '=' && chars.get(i + 1) == Some(&'='));
128                if ends {
129                    break;
130                }
131                i += 1;
132            }
133        }
134        words.push(Word { start, end: i, text: chars[start..i].iter().collect() });
135    }
136    words
137}
138
139fn same(a: &str, b: &str) -> bool {
140    if a.starts_with(['\'', '"']) { a == b } else { a.eq_ignore_ascii_case(b) }
141}
142
143#[derive(Clone, Debug)]
144enum Mode {
145    Whole,
146    Leading,
147    Trailing,
148}
149
150#[derive(Clone, Debug)]
151struct Replacing {
152    mode: Mode,
153    pattern: Vec<String>,
154    replacement: String,
155}
156
157struct Statement {
158    name: String,
159    /// Whether the name is a literal, which IBM takes as a file name as written.
160    literal: bool,
161    library: Option<String>,
162    replacing: Vec<Replacing>,
163    /// Index of the word after the terminating period.
164    next: usize,
165}
166
167fn copy_statement(words: &[Word], at: usize, pos: Pos) -> Result<Statement, Error> {
168    let err = |m: &str| Error::at(pos, format!("COPY: {m}"));
169    let text = |i: usize| words.get(i).map(|w| w.text.as_str());
170    let mut i = at + 1;
171    let quoted = |s: &str| s.starts_with(['\'', '"']);
172    let unquote = |s: &str| s.trim_matches(|c| c == '\'' || c == '"').to_owned();
173    // In `COPY X..` the separator period is the second, so the name is `X.` (assumption C88).
174    let word = |s: &str| {
175        if s.ends_with('.') && !quoted(s) {
176            return Err(Error::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")));
177        }
178        Ok(unquote(s))
179    };
180    let first = text(i).ok_or_else(|| err("a member name"))?;
181    let (name, literal) = (word(first)?, quoted(first));
182    i += 1;
183    let mut library = None;
184    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("OF") || w.eq_ignore_ascii_case("IN")) {
185        library = Some(word(text(i + 1).ok_or_else(|| err("a library name"))?)?);
186        i += 2;
187    }
188    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("SUPPRESS")) {
189        i += 1;
190    }
191    let mut replacing = Vec::new();
192    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("REPLACING")) {
193        (replacing, i) = operands(words, i + 1, "COPY", pos)?;
194    }
195    if text(i) != Some(".") {
196        return Err(err("a period to end the statement"));
197    }
198    Ok(Statement { name, literal, library, replacing, next: i + 1 })
199}
200
201/// The operand pairs of COPY REPLACING or of REPLACE, from word `at` to the period that ends the
202/// statement, and that period's index. REPLACE takes pseudo-text alone (Language Reference
203/// SC27-8713-03, p. 708).
204fn operands(words: &[Word], at: usize, verb: &str, pos: Pos) -> Result<(Vec<Replacing>, usize), Error> {
205    let err = |m: &str| Error::at(pos, format!("{verb}: {m}"));
206    let text = |i: usize| words.get(i).map(|w| w.text.as_str());
207    let replace = verb == "REPLACE";
208    let pseudo_text = |i: usize| !replace || text(i) == Some("==");
209    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") };
210    let (mut replacing, mut i) = (Vec::new(), at);
211    while text(i).is_some_and(|w| w != ".") {
212        let mode = match text(i) {
213            Some(w) if w.eq_ignore_ascii_case("LEADING") => Mode::Leading,
214            Some(w) if w.eq_ignore_ascii_case("TRAILING") => Mode::Trailing,
215            _ => Mode::Whole,
216        };
217        if !matches!(mode, Mode::Whole) {
218            i += 1;
219        }
220        let (pattern, after) = operand(words, i).filter(|_| pseudo_text(i)).ok_or_else(|| err(first))?;
221        if !text(after).is_some_and(|w| w.eq_ignore_ascii_case("BY")) {
222            return Err(err("BY"));
223        }
224        let (replacement, after) = operand(words, after + 1).filter(|_| pseudo_text(after + 1)).ok_or_else(|| err(second))?;
225        if pattern.is_empty() || (!matches!(mode, Mode::Whole) && pattern.len() != 1) {
226            return Err(err("LEADING and TRAILING take one word; an empty pattern matches nothing"));
227        }
228        replacing.push(Replacing { mode, pattern, replacement: replacement.join(" ") });
229        i = after;
230    }
231    Ok((replacing, i))
232}
233
234/// A REPLACING operand: pseudo-text `==...==` as its text-words, or one text-word.
235fn operand(words: &[Word], at: usize) -> Option<(Vec<String>, usize)> {
236    let first = words.get(at)?;
237    if first.text != "==" {
238        return Some((vec![first.text.clone()], at + 1));
239    }
240    let close = words[at + 1..].iter().position(|w| w.text == "==")? + at + 1;
241    Some((words[at + 1..close].iter().map(|w| w.text.clone()).collect(), close + 1))
242}
243
244/// Appends `chars[range]` to `out` with the positions they came from.
245fn copy_span(out: &mut Source, chars: &[char], positions: &[Pos], range: std::ops::Range<usize>) {
246    out.text.extend(&chars[range.clone()]);
247    out.positions.extend_from_slice(&positions[range]);
248}
249
250fn apply(src: &Source, replacing: &[Replacing]) -> Source {
251    if replacing.is_empty() {
252        return Source { text: src.text.clone(), positions: src.positions.clone(), options: Vec::new(), debugging: None };
253    }
254    let chars: Vec<char> = src.text.chars().collect();
255    let words = text_words(&chars);
256    let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: None };
257    let emit = |out: &mut Source, text: &str, pos: Pos| {
258        for c in text.chars() {
259            out.text.push(c);
260            out.positions.push(pos);
261        }
262    };
263    let (mut cursor, mut i) = (0usize, 0usize);
264    while i < words.len() {
265        copy_span(&mut out, &chars, &src.positions, cursor..words[i].start);
266        let pos = src.positions[words[i].start];
267        let hit = replacing.iter().find_map(|r| match r.mode {
268            Mode::Whole => (words.len() - i >= r.pattern.len() && r.pattern.iter().zip(&words[i..]).all(|(p, w)| same(p, &w.text)))
269                .then(|| (r.replacement.clone(), r.pattern.len())),
270            Mode::Leading => {
271                let w = &words[i].text;
272                (w.len() > r.pattern[0].len() || w.eq_ignore_ascii_case(&r.pattern[0]))
273                    .then_some(())
274                    .filter(|_| w.to_ascii_uppercase().starts_with(&r.pattern[0].to_ascii_uppercase()))
275                    .map(|_| (format!("{}{}", r.replacement, &w[r.pattern[0].len()..]), 1))
276            }
277            Mode::Trailing => {
278                let w = &words[i].text;
279                w.to_ascii_uppercase()
280                    .ends_with(&r.pattern[0].to_ascii_uppercase())
281                    .then(|| (format!("{}{}", &w[..w.len() - r.pattern[0].len()], r.replacement), 1))
282            }
283        });
284        match hit {
285            Some((text, consumed)) => {
286                emit(&mut out, &text, pos);
287                cursor = words[i + consumed - 1].end;
288                i += consumed;
289            }
290            None => {
291                copy_span(&mut out, &chars, &src.positions, words[i].start..words[i].end);
292                cursor = words[i].end;
293                i += 1;
294            }
295        }
296    }
297    copy_span(&mut out, &chars, &src.positions, cursor..chars.len());
298    out
299}
300
301/// Replaces every COPY statement in `source` with its member's text, recursively. `files` names
302/// each source file; a position's `file` indexes it.
303pub fn expand(source: Source, libraries: &Libraries, files: &mut Vec<String>) -> Result<Source, Error> {
304    let mut stack = Vec::new();
305    expand_nested(source, libraries, files, &mut stack)
306}
307
308/// `EXEC SQL INCLUDE name END-EXEC`, which the Db2 precompiler treats as a COPY of its member (and
309/// of its own SQLCA or SQLDA): the member's name, whether it is quoted, and the word after END-EXEC
310/// and any period that ends it.
311fn sql_include(words: &[Word], at: usize) -> Option<(String, bool, usize)> {
312    let is = |k: usize, w: &str| words.get(at + k).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
313    if !(is(0, "EXEC") && is(1, "SQL") && is(2, "INCLUDE") && is(4, "END-EXEC")) {
314        return None;
315    }
316    let word = &words[at + 3].text;
317    let name = word.trim_matches(|c| c == '\'' || c == '"').to_owned();
318    Some((name, word.starts_with(['\'', '"']), at + 5 + usize::from(is(5, "."))))
319}
320
321fn expand_nested(source: Source, libraries: &Libraries, files: &mut Vec<String>, stack: &mut Vec<String>) -> Result<Source, Error> {
322    let chars: Vec<char> = source.text.chars().collect();
323    let words = text_words(&chars);
324    if !words.iter().any(|w| w.text.eq_ignore_ascii_case("COPY") || w.text.eq_ignore_ascii_case("INCLUDE")) {
325        return Ok(source);
326    }
327    let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone() };
328    let read = |text: &str, file: u16| if source.debugging.is_some() { source::read_file_debugging(text, file) } else { source::read_file(text, file) };
329    let (mut cursor, mut i) = (0usize, 0usize);
330    while i < words.len() {
331        let pos = source.positions[words[i].start];
332        let (name, literal, library, replacing, next, sql) = if words[i].text.eq_ignore_ascii_case("COPY") {
333            let st = copy_statement(&words, i, pos)?;
334            (st.name, st.literal, st.library, st.replacing, st.next, false)
335        } else if let Some((name, literal, next)) = sql_include(&words, i) {
336            (name, literal, None, Vec::new(), next, true)
337        } else {
338            i += 1;
339            continue;
340        };
341        copy_span(&mut out, &chars, &source.positions, cursor..words[i].start);
342        let own = sql && matches!(name.to_ascii_uppercase().as_str(), "SQLCA" | "SQLDA");
343        let path = if own { None } else { libraries.find(&name, library.as_deref(), literal) };
344        let verb = if sql { "EXEC SQL INCLUDE" } else { "COPY" };
345        let mapset = if own || path.is_some() { None } else { bms::load(libraries, &name, library.as_deref()) };
346        let (key, member) = match (path, mapset) {
347            (Some(path), _) => (path.display().to_string(), read_member(&path, pos, files, &read)?),
348            (None, Some((path, mapset))) => {
349                let mapset = mapset.map_err(|e| Error::at(pos, format!("{verb} {name}: {}", e.place(&path.display().to_string()))))?;
350                let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
351                files.push(path.display().to_string());
352                (path.display().to_string(), read(&bms::symbolic_map(&mapset), file)?)
353            }
354            (None, None) => {
355                let text = system::member(&name).ok_or_else(|| Error::at(pos, format!("{verb} {name}: no such member in the copy libraries")))?;
356                let key = format!("(system member {})", name.to_ascii_uppercase());
357                let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
358                files.push(key.clone());
359                (key, read(&text, file)?)
360            }
361        };
362        if stack.contains(&key) || stack.len() >= MAX_DEPTH {
363            return Err(Error::at(pos, format!("{verb} {name}: copies itself, or nests deeper than {MAX_DEPTH}")));
364        }
365        stack.push(key);
366        let mut member = expand_nested(member, libraries, files, stack)?;
367        stack.pop();
368        if let (Some(lines), Some(copied)) = (&mut out.debugging, member.debugging.take()) {
369            // A COPY on a debugging line makes all of its member's text debugging lines.
370            if lines.contains(&(pos.file, pos.line)) {
371                lines.extend(member.positions.iter().map(|p| (p.file, p.line)));
372            }
373            lines.extend(copied);
374        }
375        let replaced = apply(&member, &replacing);
376        out.text.push_str(&replaced.text);
377        out.positions.extend(replaced.positions);
378        out.text.push('\n');
379        out.positions.push(pos);
380        cursor = words.get(next - 1).map_or(chars.len(), |w| w.end);
381        i = next;
382    }
383    copy_span(&mut out, &chars, &source.positions, cursor..chars.len());
384    Ok(out)
385}
386
387/// Applies the REPLACE statements in `source`, which COPY has expanded (Language Reference
388/// SC27-8713-03, pp. 708-712): each one's operands act on the text from its period to the next
389/// REPLACE statement or the end of the source, with COPY REPLACING's matching, and the statements
390/// themselves are left out (assumption C160).
391pub fn replace(source: Source) -> Result<Source, Error> {
392    let chars: Vec<char> = source.text.chars().collect();
393    let words = text_words(&chars);
394    let starts = |i: usize| words[i].text.eq_ignore_ascii_case("REPLACE");
395    if !(0..words.len()).any(starts) {
396        return Ok(source);
397    }
398    let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone() };
399    let segment = |out: &mut Source, range: std::ops::Range<usize>, active: &[Replacing]| {
400        let text = Source { text: chars[range.clone()].iter().collect(), positions: source.positions[range].to_vec(), options: Vec::new(), debugging: None };
401        let replaced = apply(&text, active);
402        out.text.push_str(&replaced.text);
403        out.positions.extend(replaced.positions);
404    };
405    let (mut active, mut cursor, mut i) = (Vec::new(), 0usize, 0usize);
406    while i < words.len() {
407        if !starts(i) {
408            i += 1;
409            continue;
410        }
411        let pos = source.positions[words[i].start];
412        let next = match words.get(i + 1).map(|w| w.text.to_ascii_uppercase()).as_deref() {
413            Some("OFF") if words.get(i + 2).is_some_and(|w| w.text == ".") => (Vec::new(), i + 3),
414            Some("OFF") => return Err(Error::at(pos, "REPLACE OFF: a period to end the statement")),
415            Some("ALSO" | "LAST") => return Err(Error::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")),
416            Some("==" | "LEADING" | "TRAILING") => {
417                let (replacing, period) = operands(&words, i + 1, "REPLACE", pos)?;
418                if words.get(period).is_none_or(|w| w.text != ".") {
419                    return Err(Error::at(pos, "REPLACE: a period to end the statement"));
420                }
421                (replacing, period + 1)
422            }
423            _ => {
424                i += 1;
425                continue;
426            }
427        };
428        segment(&mut out, cursor..words[i].start, &active);
429        (active, i) = next;
430        cursor = words[i - 1].end;
431    }
432    segment(&mut out, cursor..chars.len(), &active);
433    Ok(out)
434}
435
436fn read_member(path: &Path, pos: Pos, files: &mut Vec<String>, read: &dyn Fn(&str, u16) -> Result<Source, Error>) -> Result<Source, Error> {
437    let bytes = std::fs::read(path).map_err(|e| Error::at(pos, format!("COPY {}: {e}", path.display())))?;
438    let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
439    files.push(path.display().to_string());
440    read(&decode(&bytes), file)
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn words(s: &str) -> Vec<String> {
448        text_words(&s.chars().collect::<Vec<_>>()).into_iter().map(|w| w.text).collect()
449    }
450
451    #[test]
452    fn text_words_follow_the_separators() {
453        assert_eq!(words("01 :TAG:-REC PIC X(3)."), ["01", ":", "TAG", ":", "-REC", "PIC", "X", "(", "3", ")", "."]);
454        assert_eq!(words("MOVE 'A B' TO X, Y."), ["MOVE", "'A B'", "TO", "X", "Y", "."]);
455        assert_eq!(words("==A== BY ==B==."), ["==", "A", "==", "BY", "==", "B", "==", "."]);
456        assert_eq!(words("GENAUW.CLAIM 0.1"), ["GENAUW.CLAIM", "0.1"]);
457    }
458
459    fn dir_with(files: &[(&str, &str)]) -> PathBuf {
460        let dir = std::env::temp_dir().join(format!("ironwork-copy-{}-{}", std::process::id(), files[0].0));
461        std::fs::create_dir_all(&dir).unwrap();
462        for (name, text) in files {
463            std::fs::write(dir.join(name), text).unwrap();
464        }
465        dir
466    }
467
468    fn expanded(main: &str, dir: &Path) -> Result<String, Error> {
469        let src = source::read(main)?;
470        let mut files = vec![String::new()];
471        expand(src, &Libraries::new(vec![dir.to_path_buf()]), &mut files).map(|s| s.text)
472    }
473
474    #[test]
475    fn copy_replacing_whole_words_and_tags_inside_words() {
476        let dir = dir_with(&[("RECS.cpy", "       01  :TAG:-REC.\n           05 :TAG:-ID PIC 9(4) VALUE OLD.\n")]);
477        let text = expanded("           COPY RECS REPLACING ==:TAG:== BY ==CUST== OLD BY 42.\n", &dir).unwrap();
478        assert!(text.contains("01  CUST-REC."), "{text}");
479        assert!(text.contains("05 CUST-ID PIC 9(4) VALUE 42."), "{text}");
480        assert!(!text.contains("COPY"));
481    }
482
483    #[test]
484    fn leading_and_trailing_replace_part_of_a_word() {
485        let dir = dir_with(&[("PART.cpy", "       01  WS-A PIC X.\n       01  B-WS PIC X.\n")]);
486        let text = expanded("           COPY PART REPLACING LEADING ==WS== BY ==LK==\n               TRAILING ==WS== BY ==XX==.\n", &dir).unwrap();
487        assert!(text.contains("LK-A") && text.contains("B-XX"), "{text}");
488    }
489
490    #[test]
491    fn nested_copies_and_a_missing_member() {
492        let dir = dir_with(&[("OUTER.cpy", "       COPY INNER.\n"), ("inner.cpy", "       01  X PIC X.\n")]);
493        assert!(expanded("       COPY OUTER.\n", &dir).unwrap().contains("01  X PIC X."));
494        let err = expanded("       COPY NOPE.\n", &dir).unwrap_err();
495        assert!(err.message.contains("NOPE"));
496    }
497
498    #[test]
499    fn system_members_answer_when_no_library_does() {
500        let dir = dir_with(&[("OTHER.cpy", "       01  O PIC X.\n")]);
501        let text = expanded("       COPY DFHAID.\n           EXEC SQL INCLUDE SQLCA END-EXEC.\n", &dir).unwrap();
502        assert!(text.contains("DFHENTER") && text.contains("SQLCODE"), "{text}");
503        assert!(!text.contains("END-EXEC"));
504        let dir = dir_with(&[("DFHAID.cpy", "       01  VENDORED PIC X.\n")]);
505        assert!(expanded("       COPY DFHAID.\n", &dir).unwrap().contains("VENDORED"));
506    }
507
508    #[test]
509    fn a_member_that_copies_itself_is_refused() {
510        let dir = dir_with(&[("LOOP.cpy", "       COPY LOOP.\n")]);
511        assert!(expanded("       COPY LOOP.\n", &dir).unwrap_err().message.contains("copies itself"));
512    }
513
514    /// A fresh directory holding `files`, each at its path under it.
515    fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
516        let root = std::env::temp_dir().join(format!("ironwork-copy-{}-{tag}", std::process::id()));
517        let _ = std::fs::remove_dir_all(&root);
518        for (name, text) in files {
519            let path = root.join(name);
520            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
521            std::fs::write(path, text).unwrap();
522        }
523        root
524    }
525
526    fn expanded_with(main: &str, libraries: &Libraries) -> Result<String, Error> {
527        let mut files = vec![String::new()];
528        expand(source::read(main)?, libraries, &mut files).map(|s| s.text)
529    }
530
531    #[test]
532    fn a_copybook_in_any_library_is_found_before_a_program_source() {
533        let root = tree(
534            "rounds",
535            &[
536                ("src/INQACC.cbl", "       01  PROGRAM-SOURCE PIC X.\n"),
537                ("src/ONLY.cbl", "       01  ONLY-SOURCE PIC X.\n"),
538                ("src/BARE", "       01  BARE-FILE PIC X.\n"),
539                ("cpy/INQACC.cpy", "       01  COPYBOOK PIC X.\n"),
540                ("cpy/BARE.copy", "       01  COPY-FILE PIC X.\n"),
541            ],
542        );
543        let libraries = Libraries::new(vec![root.join("src"), root.join("cpy")]);
544        assert!(expanded_with("       COPY INQACC.\n", &libraries).unwrap().contains("COPYBOOK"));
545        assert!(expanded_with("       COPY inqacc.\n", &libraries).unwrap().contains("COPYBOOK"));
546        assert!(expanded_with("       COPY BARE.\n", &libraries).unwrap().contains("COPY-FILE"));
547        assert!(expanded_with("       COPY ONLY.\n", &libraries).unwrap().contains("ONLY-SOURCE"));
548    }
549
550    #[test]
551    fn a_name_alone_is_tried_after_its_extensions_and_a_literal_first() {
552        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")]);
553        let libraries = Libraries::new(vec![root.join("lib")]);
554        assert!(expanded_with("       COPY MEMBER.\n", &libraries).unwrap().contains("COPYBOOK"));
555        assert!(expanded_with("       COPY \"MEMBER\".\n", &libraries).unwrap().contains("BARE-FILE"));
556        assert!(expanded_with("       COPY ALONE.\n", &libraries).unwrap().contains("01  ALONE"));
557    }
558
559    #[test]
560    fn the_program_being_compiled_is_never_its_own_member() {
561        let program = "       IDENTIFICATION DIVISION.\n       PROGRAM-ID. PGMC.\n       DATA DIVISION.\n       WORKING-STORAGE SECTION.\n       COPY PGMC.\n";
562        let root = tree("self", &[("src/PGMC.cbl", program), ("src/OUTER.cpy", "       COPY PGMC.\n"), ("lib/PGMC.cbl", "       01  PGMC-X PIC X.\n")]);
563        let main = root.join("src/../src/PGMC.cbl");
564        let libraries = Libraries::new(vec![root.join("src"), root.join("lib")]).with_program(&main);
565        assert!(expanded_with(program, &libraries).unwrap().contains("PGMC-X"));
566        assert!(expanded_with("       COPY OUTER.\n", &libraries).unwrap().contains("PGMC-X"));
567        let alone = Libraries::new(vec![root.join("src")]).with_program(&main);
568        assert!(expanded_with(program, &alone).unwrap_err().message.contains("no such member"));
569        assert!(expanded_with(program, &Libraries::new(vec![root.join("src")])).unwrap_err().message.contains("copies itself"));
570    }
571
572    #[test]
573    fn a_doubled_period_is_refused_by_name() {
574        let root = tree("period", &[("lib/COBCPARMS.cpy", "       01  PARMS PIC X.\n")]);
575        let libraries = Libraries::new(vec![root.join("lib")]);
576        let err = expanded_with("       COPY COBCPARMS..\n", &libraries).unwrap_err();
577        assert!(err.message.contains("COPY COBCPARMS.: the name ends in a period"), "{}", err.message);
578        let err = expanded_with("       COPY COBCPARMS OF LIB..\n", &libraries).unwrap_err();
579        assert!(err.message.contains("COPY LIB.: "), "{}", err.message);
580        assert!(expanded_with("       COPY COBCPARMS.\n", &libraries).unwrap().contains("PARMS"));
581    }
582
583    fn replaced(text: &str) -> Result<String, Error> {
584        replace(source::read(text)?).map(|s| s.text)
585    }
586
587    #[test]
588    fn replace_acts_from_its_period_to_the_next_replace() {
589        let text = replaced(concat!(
590            "       01  A PICTURE X.\n",
591            "       REPLACE ==PICTURE== BY ==PIC==.\n",
592            "       01  B PICTURE X.\n",
593            "       01  C PICTURE X VALUE 'PICTURE'.\n",
594            "       REPLACE OFF.\n",
595            "       01  D PICTURE X.\n",
596        ))
597        .unwrap();
598        let words: Vec<&str> = text.split_whitespace().collect();
599        assert_eq!(words, ["01", "A", "PICTURE", "X.", "01", "B", "PIC", "X.", "01", "C", "PIC", "X", "VALUE", "'PICTURE'.", "01", "D", "PICTURE", "X."]);
600    }
601
602    #[test]
603    fn replace_takes_the_language_references_example() {
604        let text = replaced(concat!(
605            "           REPLACE ==\"(Hello, World!)\"== BY ==\"(Hello, Mom!)\"==.\n",
606            "       01 WS-STRING1 PIC X(30) VALUE \"(Hello, World!)\".\n",
607            "           DISPLAY \"Modified: \" XX-WS-:TAG:1\n",
608            "           REPLACE LEADING ==XX-==  BY ====\n",
609            "                           ==:TAG:==  BY ==STRING==\n",
610            "                   TRAILING ==1== BY ==2==.\n",
611            "           DISPLAY \"Modified: \" XX-WS-:TAG:1\n",
612        ))
613        .unwrap();
614        assert!(text.contains("VALUE \"(Hello, Mom!)\"."), "{text}");
615        assert!(text.contains("DISPLAY \"Modified: \" XX-WS-:TAG:1\n") && text.ends_with("DISPLAY \"Modified: \" WS-STRING2"), "{text}");
616        assert!(!text.contains("REPLACE"), "{text}");
617    }
618
619    #[test]
620    fn a_later_replace_supersedes_and_several_words_match_as_one() {
621        let text = replaced(concat!(
622            "           REPLACE ==AO== BY ==TO== == = == BY ==EQUAL==.\n",
623            "           MOVE \"*\" AO X.\n",
624            "           REPLACE ==MOVE \"*\" TO X.\n",
625            "                      IF X = \"*\"== BY ==DISPLAY X==.\n",
626            "           MOVE \"*\" TO X.\n",
627            "           IF X = \"*\" DISPLAY Y.\n",
628        ))
629        .unwrap();
630        assert!(text.contains("MOVE \"*\" TO X."), "{text}");
631        assert!(text.contains("DISPLAY X DISPLAY Y."), "{text}");
632    }
633
634    #[test]
635    fn replace_takes_only_pseudo_text() {
636        let text = replaced("           MOVE REPLACE TO X.\n           DISPLAY Y.\n").unwrap();
637        assert!(text.contains("MOVE REPLACE TO X."));
638        assert!(replaced("       REPLACE A BY B.\n").unwrap().contains("REPLACE A BY B."));
639        let err = replaced("       REPLACE ==A== BY B.\n").unwrap_err();
640        assert_eq!(err.message, "REPLACE: pseudo-text between == delimiters after BY");
641        let err = replaced("       REPLACE ALSO ==A== BY ==B==.\n").unwrap_err();
642        assert!(err.message.starts_with("REPLACE ALSO and REPLACE LAST OFF are the 2014"), "{}", err.message);
643        assert_eq!(replaced("       REPLACE ==A== BY ==B==\n").unwrap_err().message, "REPLACE: a period to end the statement");
644    }
645
646    #[test]
647    fn replace_acts_on_copied_text() {
648        let dir = dir_with(&[("REPMEM.cpy", "       01  :TAG:-ID PIC 9.\n")]);
649        let src = source::read("       REPLACE ==:TAG:== BY ==CUST==.\n       COPY REPMEM.\n").unwrap();
650        let mut files = vec![String::new()];
651        let text = expand(src, &Libraries::new(vec![dir]), &mut files).and_then(replace).unwrap().text;
652        assert!(text.contains("01  CUST-ID PIC 9."), "{text}");
653    }
654
655    #[test]
656    fn positions_in_a_member_name_its_file() {
657        let dir = dir_with(&[("POS.cpy", "       01  Y PIC X.\n")]);
658        let src = source::read("       COPY POS.\n").unwrap();
659        let mut files = vec![String::new()];
660        let out = expand(src, &Libraries::new(vec![dir]), &mut files).unwrap();
661        let at = out.text.find('Y').unwrap();
662        assert_eq!(out.positions[at].file, 1);
663        assert!(files[1].ends_with("POS.cpy"));
664    }
665}