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. The compliance level is
13/// how the program and its members are read.
14#[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    /// These libraries, for compiling the program in `program`.
32    pub fn with_program(&self, program: &Path) -> Self {
33        Self { program: Some(program.to_path_buf()), ..self.clone() }
34    }
35
36    /// These libraries, read under `compliance`.
37    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    /// A round of extensions searches every library before the next round starts, so a copybook in
46    /// any library is found before a program source (assumptions C85 to C87).
47    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
78/// Whether two paths name one file, however each is spelled.
79fn 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
90/// Reads a source file's bytes: UTF-8 when valid, otherwise one character per byte (Latin-1).
91pub 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    /// Whether the name is a literal, which IBM takes as a file name as written.
171    literal: bool,
172    library: Option<String>,
173    replacing: Vec<Replacing>,
174    /// Index of the word after the terminating period.
175    next: usize,
176    /// The warning for a name read without the periods after it, under `--compliance extended`.
177    note: Option<Error>,
178}
179
180fn copy_statement(words: &[Word], chars: &[char], at: usize, pos: Pos, extended: bool) -> Result<Statement, Error> {
181    let err = |m: &str| crate::messages::IWS0003.at(pos, format!("COPY: {m}"));
182    let text = |i: usize| words.get(i).map(|w| w.text.as_str());
183    let mut i = at + 1;
184    let quoted = |s: &str| s.starts_with(['\'', '"']);
185    let unquote = |s: &str| s.trim_matches(|c| c == '\'' || c == '"').to_owned();
186    // In `COPY X..` the separator period is the second, so the name is `X.` (assumption C88).
187    let word = |s: &str| {
188        if s.ends_with('.') && !quoted(s) {
189            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")));
190        }
191        Ok(unquote(s))
192    };
193    let first = text(i).ok_or_else(|| err("a member name"))?;
194    let mut note = None;
195    let first = match first.trim_end_matches('.') {
196        bare if extended && bare.len() < first.len() && !bare.is_empty() && !quoted(first) && text(i + 1).is_none_or(|w| !w.eq_ignore_ascii_case("OF") && !w.eq_ignore_ascii_case("IN")) => {
197            note = Some(crate::messages::IWX0054.at(pos, format!("COPY {first}. (GnuCOBOL and Micro Focus; Enterprise COBOL reads the name as {first}): the member is {bare}, and the periods after it end the statement")));
198            bare
199        }
200        _ => first,
201    };
202    let (name, literal) = (word(first)?, quoted(first));
203    i += 1;
204    let mut library = None;
205    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("OF") || w.eq_ignore_ascii_case("IN")) {
206        library = Some(word(text(i + 1).ok_or_else(|| err("a library name"))?)?);
207        i += 2;
208    }
209    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("SUPPRESS")) {
210        i += 1;
211    }
212    let mut replacing = Vec::new();
213    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("REPLACING")) {
214        (replacing, i) = operands(words, chars, i + 1, "COPY", pos)?;
215    }
216    if text(i) != Some(".") {
217        return Err(err("a period to end the statement"));
218    }
219    Ok(Statement { name, literal, library, replacing, next: i + 1, note })
220}
221
222/// The operand pairs of COPY REPLACING or of REPLACE, from word `at` to the period that ends the
223/// statement, and that period's index. REPLACE takes pseudo-text alone (Language Reference
224/// SC27-8713-03, p. 708).
225fn operands(words: &[Word], chars: &[char], at: usize, verb: &str, pos: Pos) -> Result<(Vec<Replacing>, usize), Error> {
226    let err = |m: &str| crate::messages::IWS0005.at(pos, format!("{verb}: {m}"));
227    let text = |i: usize| words.get(i).map(|w| w.text.as_str());
228    let replace = verb == "REPLACE";
229    let pseudo_text = |i: usize| !replace || text(i) == Some("==");
230    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") };
231    let (mut replacing, mut i) = (Vec::new(), at);
232    while text(i).is_some_and(|w| w != ".") {
233        let mode = match text(i) {
234            Some(w) if w.eq_ignore_ascii_case("LEADING") => Mode::Leading,
235            Some(w) if w.eq_ignore_ascii_case("TRAILING") => Mode::Trailing,
236            _ => Mode::Whole,
237        };
238        if !matches!(mode, Mode::Whole) {
239            i += 1;
240        }
241        let (pattern, _, after) = operand(words, chars, i).filter(|_| pseudo_text(i)).ok_or_else(|| err(first))?;
242        if !text(after).is_some_and(|w| w.eq_ignore_ascii_case("BY")) {
243            return Err(err("BY"));
244        }
245        let (_, replacement, after) = operand(words, chars, after + 1).filter(|_| pseudo_text(after + 1)).ok_or_else(|| err(second))?;
246        if pattern.is_empty() || (!matches!(mode, Mode::Whole) && pattern.len() != 1) {
247            return Err(err("LEADING and TRAILING take one word; an empty pattern matches nothing"));
248        }
249        replacing.push(Replacing { mode, pattern, replacement });
250        i = after;
251    }
252    Ok((replacing, i))
253}
254
255/// A REPLACING operand at word `at`: pseudo-text `==...==`, or an identifier, literal, word or
256/// function-identifier, which matches as pseudo-text holding it (Language Reference SC27-8713-03,
257/// p. 690). Returns its text-words, its text as written, which is what it copies in, and the
258/// index of the word after it.
259fn operand(words: &[Word], chars: &[char], at: usize) -> Option<(Vec<String>, String, usize)> {
260    let pseudo_text = words.get(at)?.text == "==";
261    let (from, to) = if pseudo_text { (at + 1, words[at + 1..].iter().position(|w| w.text == "==")? + at + 1) } else { (at, operand_end(words, at)) };
262    let text = if from < to { chars[words[from].start..words[to - 1].end].iter().collect() } else { String::new() };
263    Some((words[from..to].iter().map(|w| w.text.clone()).collect(), text, to + usize::from(pseudo_text)))
264}
265
266/// The index of the word after the operand that starts at word `at`: a literal is one word, ALL
267/// takes the literal after it, FUNCTION the function's name, and a name its IN or OF qualifiers,
268/// then its subscripts and reference modification in parentheses.
269fn operand_end(words: &[Word], at: usize) -> usize {
270    let is = |i: usize, w: &str| words.get(i).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
271    if words[at].text.starts_with(['\'', '"']) {
272        return at + 1;
273    }
274    if is(at, "ALL") {
275        return (at + 2).min(words.len());
276    }
277    let mut i = at + 1 + usize::from(is(at, "FUNCTION") && at + 1 < words.len());
278    while (is(i, "IN") || is(i, "OF")) && i + 1 < words.len() {
279        i += 2;
280    }
281    while is(i, "(") {
282        let mut depth = 0;
283        while let Some(w) = words.get(i) {
284            depth += i32::from(w.text == "(") - i32::from(w.text == ")");
285            i += 1;
286            if depth == 0 {
287                break;
288            }
289        }
290    }
291    i
292}
293
294/// Appends `chars[range]` to `out` with the positions they came from.
295fn copy_span(out: &mut Source, chars: &[char], positions: &[Pos], range: std::ops::Range<usize>) {
296    out.text.extend(&chars[range.clone()]);
297    out.positions.extend_from_slice(&positions[range]);
298}
299
300fn apply(src: &Source, replacing: &[Replacing]) -> Source {
301    if replacing.is_empty() {
302        return Source { text: src.text.clone(), positions: src.positions.clone(), options: Vec::new(), debugging: None, free: Vec::new(), notes: Vec::new() };
303    }
304    let chars: Vec<char> = src.text.chars().collect();
305    let words = text_words(&chars);
306    let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: None, free: Vec::new(), notes: Vec::new() };
307    let emit = |out: &mut Source, text: &str, pos: Pos| {
308        for c in text.chars() {
309            out.text.push(c);
310            out.positions.push(pos);
311        }
312    };
313    let (mut cursor, mut i) = (0usize, 0usize);
314    while i < words.len() {
315        copy_span(&mut out, &chars, &src.positions, cursor..words[i].start);
316        let pos = src.positions[words[i].start];
317        let hit = replacing.iter().find_map(|r| match r.mode {
318            Mode::Whole => (words.len() - i >= r.pattern.len() && r.pattern.iter().zip(&words[i..]).all(|(p, w)| same(p, &w.text)))
319                .then(|| (r.replacement.clone(), r.pattern.len())),
320            Mode::Leading => {
321                let w = &words[i].text;
322                (w.len() > r.pattern[0].len() || w.eq_ignore_ascii_case(&r.pattern[0]))
323                    .then_some(())
324                    .filter(|_| w.to_ascii_uppercase().starts_with(&r.pattern[0].to_ascii_uppercase()))
325                    .map(|_| (format!("{}{}", r.replacement, &w[r.pattern[0].len()..]), 1))
326            }
327            Mode::Trailing => {
328                let w = &words[i].text;
329                w.to_ascii_uppercase()
330                    .ends_with(&r.pattern[0].to_ascii_uppercase())
331                    .then(|| (format!("{}{}", &w[..w.len() - r.pattern[0].len()], r.replacement), 1))
332            }
333        });
334        match hit {
335            Some((text, consumed)) => {
336                emit(&mut out, &text, pos);
337                cursor = words[i + consumed - 1].end;
338                i += consumed;
339            }
340            None => {
341                copy_span(&mut out, &chars, &src.positions, words[i].start..words[i].end);
342                cursor = words[i].end;
343                i += 1;
344            }
345        }
346    }
347    copy_span(&mut out, &chars, &src.positions, cursor..chars.len());
348    out
349}
350
351/// Replaces every COPY statement in `source` with its member's text, recursively. `files` names
352/// each source file; a position's `file` indexes it.
353pub fn expand(source: Source, libraries: &Libraries, files: &mut Vec<String>) -> Result<Source, Error> {
354    let mut stack = Vec::new();
355    expand_nested(source, libraries, files, &mut stack)
356}
357
358/// `EXEC SQL INCLUDE name END-EXEC`, which the Db2 precompiler treats as a COPY of its member (and
359/// of its own SQLCA or SQLDA): the member's name, whether it is quoted, and the word after END-EXEC
360/// and any period that ends it.
361fn sql_include(words: &[Word], at: usize) -> Option<(String, bool, usize)> {
362    let is = |k: usize, w: &str| words.get(at + k).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
363    if !(is(0, "EXEC") && is(1, "SQL") && is(2, "INCLUDE") && is(4, "END-EXEC")) {
364        return None;
365    }
366    let word = &words[at + 3].text;
367    let name = word.trim_matches(|c| c == '\'' || c == '"').to_owned();
368    Some((name, word.starts_with(['\'', '"']), at + 5 + usize::from(is(5, "."))))
369}
370
371fn expand_nested(source: Source, libraries: &Libraries, files: &mut Vec<String>, stack: &mut Vec<String>) -> Result<Source, Error> {
372    let chars: Vec<char> = source.text.chars().collect();
373    let words = text_words(&chars);
374    if !words.iter().any(|w| w.text.eq_ignore_ascii_case("COPY") || w.text.eq_ignore_ascii_case("INCLUDE")) {
375        return Ok(source);
376    }
377    let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone(), free: source.free.clone(), notes: source.notes.clone() };
378    let read = |text: &str, file: u16| source::read_under(text, file, source.debugging.is_some(), libraries.compliance());
379    let (mut cursor, mut i) = (0usize, 0usize);
380    while i < words.len() {
381        let pos = source.positions[words[i].start];
382        let (name, literal, library, replacing, next, sql) = if words[i].text.eq_ignore_ascii_case("COPY") {
383            let st = copy_statement(&words, &chars, i, pos, libraries.compliance() == numeric::Compliance::Extended)?;
384            out.notes.extend(st.note);
385            (st.name, st.literal, st.library, st.replacing, st.next, false)
386        } else if let Some((name, literal, next)) = sql_include(&words, i) {
387            (name, literal, None, Vec::new(), next, true)
388        } else {
389            i += 1;
390            continue;
391        };
392        copy_span(&mut out, &chars, &source.positions, cursor..words[i].start);
393        let own = sql && matches!(name.to_ascii_uppercase().as_str(), "SQLCA" | "SQLDA");
394        let path = if own { None } else { libraries.find(&name, library.as_deref(), literal) };
395        let verb = if sql { "EXEC SQL INCLUDE" } else { "COPY" };
396        let mapset = if own || path.is_some() { None } else { bms::load(libraries, &name, library.as_deref()) };
397        let (key, member) = match (path, mapset) {
398            (Some(path), _) => {
399                let copied_free = source.free_at(pos).is_some();
400                let copied = |text: &str, file: u16| source::read_copied(text, file, source.debugging.is_some(), libraries.compliance(), copied_free);
401                (path.display().to_string(), read_member(&path, pos, files, &copied)?)
402            }
403            (None, Some((path, mapset))) => {
404                let mapset = mapset.map_err(|e| crate::messages::IWS0006.at(pos, format!("{verb} {name}: {}", e.place(&path.display().to_string()))))?;
405                let file = u16::try_from(files.len()).map_err(|_| crate::messages::IWS0007.at(pos, "more than 65535 copy members"))?;
406                files.push(path.display().to_string());
407                (path.display().to_string(), read(&bms::symbolic_map(&mapset), file)?)
408            }
409            (None, None) => {
410                let text = system::member(&name).ok_or_else(|| crate::messages::IWS0002.at(pos, format!("{verb} {name}: no such member in the copy libraries")))?;
411                let key = format!("(system member {})", name.to_ascii_uppercase());
412                let file = u16::try_from(files.len()).map_err(|_| crate::messages::IWS0007.at(pos, "more than 65535 copy members"))?;
413                files.push(key.clone());
414                (key, read(&text, file)?)
415            }
416        };
417        if stack.contains(&key) || stack.len() >= MAX_DEPTH {
418            return Err(crate::messages::IWS0008.at(pos, format!("{verb} {name}: copies itself, or nests deeper than {MAX_DEPTH}")));
419        }
420        stack.push(key);
421        let mut member = expand_nested(member, libraries, files, stack)?;
422        stack.pop();
423        if let (Some(lines), Some(copied)) = (&mut out.debugging, member.debugging.take()) {
424            // A COPY on a debugging line makes all of its member's text debugging lines.
425            if lines.contains(&(pos.file, pos.line)) {
426                lines.extend(member.positions.iter().map(|p| (p.file, p.line)));
427            }
428            lines.extend(copied);
429        }
430        out.free.extend(member.free.iter().cloned());
431        out.notes.extend(member.notes.iter().cloned());
432        let replaced = apply(&member, &replacing);
433        out.text.push_str(&replaced.text);
434        out.positions.extend(replaced.positions);
435        out.text.push('\n');
436        out.positions.push(pos);
437        cursor = words.get(next - 1).map_or(chars.len(), |w| w.end);
438        i = next;
439    }
440    copy_span(&mut out, &chars, &source.positions, cursor..chars.len());
441    Ok(out)
442}
443
444/// Applies the REPLACE statements in `source`, which COPY has expanded (Language Reference
445/// SC27-8713-03, pp. 708-712): each one's operands act on the text from its period to the next
446/// REPLACE statement or the end of the source, with COPY REPLACING's matching, and the statements
447/// themselves are left out (assumption C160).
448pub fn replace(source: Source) -> Result<Source, Error> {
449    let chars: Vec<char> = source.text.chars().collect();
450    let words = text_words(&chars);
451    let starts = |i: usize| words[i].text.eq_ignore_ascii_case("REPLACE");
452    if !(0..words.len()).any(starts) {
453        return Ok(source);
454    }
455    let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone(), free: source.free.clone(), notes: source.notes.clone() };
456    let segment = |out: &mut Source, range: std::ops::Range<usize>, active: &[Replacing]| {
457        let text = Source { text: chars[range.clone()].iter().collect(), positions: source.positions[range].to_vec(), options: Vec::new(), debugging: None, free: Vec::new(), notes: Vec::new() };
458        let replaced = apply(&text, active);
459        out.text.push_str(&replaced.text);
460        out.positions.extend(replaced.positions);
461    };
462    let (mut active, mut cursor, mut i) = (Vec::new(), 0usize, 0usize);
463    while i < words.len() {
464        if !starts(i) {
465            i += 1;
466            continue;
467        }
468        let pos = source.positions[words[i].start];
469        let next = match words.get(i + 1).map(|w| w.text.to_ascii_uppercase()).as_deref() {
470            Some("OFF") if words.get(i + 2).is_some_and(|w| w.text == ".") => (Vec::new(), i + 3),
471            Some("OFF") => return Err(crate::messages::IWS0009.at(pos, "REPLACE OFF: a period to end the statement")),
472            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")),
473            Some("==" | "LEADING" | "TRAILING") => {
474                let (replacing, period) = operands(&words, &chars, i + 1, "REPLACE", pos)?;
475                if words.get(period).is_none_or(|w| w.text != ".") {
476                    return Err(crate::messages::IWS0011.at(pos, "REPLACE: a period to end the statement"));
477                }
478                (replacing, period + 1)
479            }
480            _ => {
481                i += 1;
482                continue;
483            }
484        };
485        segment(&mut out, cursor..words[i].start, &active);
486        (active, i) = next;
487        cursor = words[i - 1].end;
488    }
489    segment(&mut out, cursor..chars.len(), &active);
490    Ok(out)
491}
492
493fn read_member(path: &Path, pos: Pos, files: &mut Vec<String>, read: &dyn Fn(&str, u16) -> Result<Source, Error>) -> Result<Source, Error> {
494    let bytes = std::fs::read(path).map_err(|e| crate::messages::IWS0012.at(pos, format!("COPY {}: {e}", path.display())))?;
495    let file = u16::try_from(files.len()).map_err(|_| crate::messages::IWS0007.at(pos, "more than 65535 copy members"))?;
496    files.push(path.display().to_string());
497    read(&decode(&bytes), file)
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn words(s: &str) -> Vec<String> {
505        text_words(&s.chars().collect::<Vec<_>>()).into_iter().map(|w| w.text).collect()
506    }
507
508    #[test]
509    fn text_words_follow_the_separators() {
510        assert_eq!(words("01 :TAG:-REC PIC X(3)."), ["01", ":", "TAG", ":", "-REC", "PIC", "X", "(", "3", ")", "."]);
511        assert_eq!(words("MOVE 'A B' TO X, Y."), ["MOVE", "'A B'", "TO", "X", "Y", "."]);
512        assert_eq!(words("==A== BY ==B==."), ["==", "A", "==", "BY", "==", "B", "==", "."]);
513        assert_eq!(words("GENAUW.CLAIM 0.1"), ["GENAUW.CLAIM", "0.1"]);
514    }
515
516    fn dir_with(files: &[(&str, &str)]) -> PathBuf {
517        let dir = std::env::temp_dir().join(format!("ironwork-copy-{}-{}", std::process::id(), files[0].0));
518        std::fs::create_dir_all(&dir).unwrap();
519        for (name, text) in files {
520            std::fs::write(dir.join(name), text).unwrap();
521        }
522        dir
523    }
524
525    fn expanded(main: &str, dir: &Path) -> Result<String, Error> {
526        let src = source::read(main)?;
527        let mut files = vec![String::new()];
528        expand(src, &Libraries::new(vec![dir.to_path_buf()]), &mut files).map(|s| s.text)
529    }
530
531    #[test]
532    fn copy_replacing_whole_words_and_tags_inside_words() {
533        let dir = dir_with(&[("RECS.cpy", "       01  :TAG:-REC.\n           05 :TAG:-ID PIC 9(4) VALUE OLD.\n")]);
534        let text = expanded("           COPY RECS REPLACING ==:TAG:== BY ==CUST== OLD BY 42.\n", &dir).unwrap();
535        assert!(text.contains("01  CUST-REC."), "{text}");
536        assert!(text.contains("05 CUST-ID PIC 9(4) VALUE 42."), "{text}");
537        assert!(!text.contains("COPY"));
538    }
539
540    #[test]
541    fn leading_and_trailing_replace_part_of_a_word() {
542        let dir = dir_with(&[("PART.cpy", "       01  WS-A PIC X.\n       01  B-WS PIC X.\n")]);
543        let text = expanded("           COPY PART REPLACING LEADING ==WS== BY ==LK==\n               TRAILING ==WS== BY ==XX==.\n", &dir).unwrap();
544        assert!(text.contains("LK-A") && text.contains("B-XX"), "{text}");
545    }
546
547    #[test]
548    fn identifier_operands_take_their_qualifiers_subscripts_and_reference_modification() {
549        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")]);
550        let text = expanded(
551            concat!(
552                "           COPY IDOPS REPLACING OLD-1 BY NEW-Q OF NEW-R\n",
553                "                                    IN NEW-S\n",
554                "                OLD-2 BY Z (2, 1, 1) (1:3)\n",
555                "                A IN B (1) BY C\n",
556                "                FUNCTION UPPER-CASE (X) BY Y.\n",
557            ),
558            &dir,
559        )
560        .unwrap();
561        let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
562        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.");
563    }
564
565    #[test]
566    fn pseudo_text_is_copied_as_written_and_a_member_may_end_the_entry_that_copies_it() {
567        let dir = dir_with(&[("K101.cpy", "             .\n           02 TST-FLD-1 PICTURE 9(5).\n           02 FILLER    PICTURE X(115).\n")]);
568        let text = expanded("       01  TST-TEST COPY K101 REPLACING TST-FLD-1 BY TF-1.\n", &dir).unwrap();
569        assert!(text.contains("01  TST-TEST") && text.contains("02 TF-1 PICTURE 9(5)."), "{text}");
570        let text = expanded(
571            concat!(
572                "       01  TEXT-TEST-1 COPY K101\n",
573                "           REPLACING ==02 TST-FLD-1  PICTURE 9(5). 02 FILLER\n",
574                "                       PICTURE X(115)==\n",
575                "           BY        ==02 FILLER PICTURE X(115).  02 TXT-FLD-1\n",
576                "                       PIC 9(5)==.\n",
577            ),
578            &dir,
579        )
580        .unwrap();
581        let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
582        assert_eq!(words, "01 TEXT-TEST-1 . 02 FILLER PICTURE X(115). 02 TXT-FLD-1 PIC 9(5).");
583    }
584
585    #[test]
586    fn nested_copies_and_a_missing_member() {
587        let dir = dir_with(&[("OUTER.cpy", "       COPY INNER.\n"), ("inner.cpy", "       01  X PIC X.\n")]);
588        assert!(expanded("       COPY OUTER.\n", &dir).unwrap().contains("01  X PIC X."));
589        let err = expanded("       COPY NOPE.\n", &dir).unwrap_err();
590        assert!(err.message.contains("NOPE"));
591    }
592
593    #[test]
594    fn system_members_answer_when_no_library_does() {
595        let dir = dir_with(&[("OTHER.cpy", "       01  O PIC X.\n")]);
596        let text = expanded("       COPY DFHAID.\n           EXEC SQL INCLUDE SQLCA END-EXEC.\n", &dir).unwrap();
597        assert!(text.contains("DFHENTER") && text.contains("SQLCODE"), "{text}");
598        assert!(!text.contains("END-EXEC"));
599        let dir = dir_with(&[("DFHAID.cpy", "       01  VENDORED PIC X.\n")]);
600        assert!(expanded("       COPY DFHAID.\n", &dir).unwrap().contains("VENDORED"));
601    }
602
603    #[test]
604    fn a_member_that_copies_itself_is_refused() {
605        let dir = dir_with(&[("LOOP.cpy", "       COPY LOOP.\n")]);
606        assert!(expanded("       COPY LOOP.\n", &dir).unwrap_err().message.contains("copies itself"));
607    }
608
609    /// A fresh directory holding `files`, each at its path under it.
610    fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
611        let root = std::env::temp_dir().join(format!("ironwork-copy-{}-{tag}", std::process::id()));
612        let _ = std::fs::remove_dir_all(&root);
613        for (name, text) in files {
614            let path = root.join(name);
615            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
616            std::fs::write(path, text).unwrap();
617        }
618        root
619    }
620
621    fn expanded_with(main: &str, libraries: &Libraries) -> Result<String, Error> {
622        let mut files = vec![String::new()];
623        expand(source::read(main)?, libraries, &mut files).map(|s| s.text)
624    }
625
626    #[test]
627    fn a_copybook_in_any_library_is_found_before_a_program_source() {
628        let root = tree(
629            "rounds",
630            &[
631                ("src/INQACC.cbl", "       01  PROGRAM-SOURCE PIC X.\n"),
632                ("src/ONLY.cbl", "       01  ONLY-SOURCE PIC X.\n"),
633                ("src/BARE", "       01  BARE-FILE PIC X.\n"),
634                ("cpy/INQACC.cpy", "       01  COPYBOOK PIC X.\n"),
635                ("cpy/BARE.copy", "       01  COPY-FILE PIC X.\n"),
636            ],
637        );
638        let libraries = Libraries::new(vec![root.join("src"), root.join("cpy")]);
639        assert!(expanded_with("       COPY INQACC.\n", &libraries).unwrap().contains("COPYBOOK"));
640        assert!(expanded_with("       COPY inqacc.\n", &libraries).unwrap().contains("COPYBOOK"));
641        assert!(expanded_with("       COPY BARE.\n", &libraries).unwrap().contains("COPY-FILE"));
642        assert!(expanded_with("       COPY ONLY.\n", &libraries).unwrap().contains("ONLY-SOURCE"));
643    }
644
645    #[test]
646    fn a_name_alone_is_tried_after_its_extensions_and_a_literal_first() {
647        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")]);
648        let libraries = Libraries::new(vec![root.join("lib")]);
649        assert!(expanded_with("       COPY MEMBER.\n", &libraries).unwrap().contains("COPYBOOK"));
650        assert!(expanded_with("       COPY \"MEMBER\".\n", &libraries).unwrap().contains("BARE-FILE"));
651        assert!(expanded_with("       COPY ALONE.\n", &libraries).unwrap().contains("01  ALONE"));
652    }
653
654    #[test]
655    fn the_program_being_compiled_is_never_its_own_member() {
656        let program = "       IDENTIFICATION DIVISION.\n       PROGRAM-ID. PGMC.\n       DATA DIVISION.\n       WORKING-STORAGE SECTION.\n       COPY PGMC.\n";
657        let root = tree("self", &[("src/PGMC.cbl", program), ("src/OUTER.cpy", "       COPY PGMC.\n"), ("lib/PGMC.cbl", "       01  PGMC-X PIC X.\n")]);
658        let main = root.join("src/../src/PGMC.cbl");
659        let libraries = Libraries::new(vec![root.join("src"), root.join("lib")]).with_program(&main);
660        assert!(expanded_with(program, &libraries).unwrap().contains("PGMC-X"));
661        assert!(expanded_with("       COPY OUTER.\n", &libraries).unwrap().contains("PGMC-X"));
662        let alone = Libraries::new(vec![root.join("src")]).with_program(&main);
663        assert!(expanded_with(program, &alone).unwrap_err().message.contains("no such member"));
664        assert!(expanded_with(program, &Libraries::new(vec![root.join("src")])).unwrap_err().message.contains("copies itself"));
665    }
666
667    #[test]
668    fn a_doubled_period_is_refused_by_name() {
669        let root = tree("period", &[("lib/COBCPARMS.cpy", "       01  PARMS PIC X.\n")]);
670        let libraries = Libraries::new(vec![root.join("lib")]);
671        let err = expanded_with("       COPY COBCPARMS..\n", &libraries).unwrap_err();
672        assert!(err.message.contains("COPY COBCPARMS.: the name ends in a period"), "{}", err.message);
673        let err = expanded_with("       COPY COBCPARMS OF LIB..\n", &libraries).unwrap_err();
674        assert!(err.message.contains("COPY LIB.: "), "{}", err.message);
675        assert!(expanded_with("       COPY COBCPARMS.\n", &libraries).unwrap().contains("PARMS"));
676    }
677
678    fn replaced(text: &str) -> Result<String, Error> {
679        replace(source::read(text)?).map(|s| s.text)
680    }
681
682    #[test]
683    fn replace_acts_from_its_period_to_the_next_replace() {
684        let text = replaced(concat!(
685            "       01  A PICTURE X.\n",
686            "       REPLACE ==PICTURE== BY ==PIC==.\n",
687            "       01  B PICTURE X.\n",
688            "       01  C PICTURE X VALUE 'PICTURE'.\n",
689            "       REPLACE OFF.\n",
690            "       01  D PICTURE X.\n",
691        ))
692        .unwrap();
693        let words: Vec<&str> = text.split_whitespace().collect();
694        assert_eq!(words, ["01", "A", "PICTURE", "X.", "01", "B", "PIC", "X.", "01", "C", "PIC", "X", "VALUE", "'PICTURE'.", "01", "D", "PICTURE", "X."]);
695    }
696
697    #[test]
698    fn replace_takes_the_language_references_example() {
699        let text = replaced(concat!(
700            "           REPLACE ==\"(Hello, World!)\"== BY ==\"(Hello, Mom!)\"==.\n",
701            "       01 WS-STRING1 PIC X(30) VALUE \"(Hello, World!)\".\n",
702            "           DISPLAY \"Modified: \" XX-WS-:TAG:1\n",
703            "           REPLACE LEADING ==XX-==  BY ====\n",
704            "                           ==:TAG:==  BY ==STRING==\n",
705            "                   TRAILING ==1== BY ==2==.\n",
706            "           DISPLAY \"Modified: \" XX-WS-:TAG:1\n",
707        ))
708        .unwrap();
709        assert!(text.contains("VALUE \"(Hello, Mom!)\"."), "{text}");
710        assert!(text.contains("DISPLAY \"Modified: \" XX-WS-:TAG:1\n") && text.ends_with("DISPLAY \"Modified: \" WS-STRING2"), "{text}");
711        assert!(!text.contains("REPLACE"), "{text}");
712    }
713
714    #[test]
715    fn a_later_replace_supersedes_and_several_words_match_as_one() {
716        let text = replaced(concat!(
717            "           REPLACE ==AO== BY ==TO== == = == BY ==EQUAL==.\n",
718            "           MOVE \"*\" AO X.\n",
719            "           REPLACE ==MOVE \"*\" TO X.\n",
720            "                      IF X = \"*\"== BY ==DISPLAY X==.\n",
721            "           MOVE \"*\" TO X.\n",
722            "           IF X = \"*\" DISPLAY Y.\n",
723        ))
724        .unwrap();
725        assert!(text.contains("MOVE \"*\" TO X."), "{text}");
726        assert!(text.contains("DISPLAY X DISPLAY Y."), "{text}");
727    }
728
729    #[test]
730    fn replace_takes_only_pseudo_text() {
731        let text = replaced("           MOVE REPLACE TO X.\n           DISPLAY Y.\n").unwrap();
732        assert!(text.contains("MOVE REPLACE TO X."));
733        assert!(replaced("       REPLACE A BY B.\n").unwrap().contains("REPLACE A BY B."));
734        let err = replaced("       REPLACE ==A== BY B.\n").unwrap_err();
735        assert_eq!(err.message, "REPLACE: pseudo-text between == delimiters after BY");
736        let err = replaced("       REPLACE ALSO ==A== BY ==B==.\n").unwrap_err();
737        assert!(err.message.starts_with("REPLACE ALSO and REPLACE LAST OFF are the 2014"), "{}", err.message);
738        assert_eq!(replaced("       REPLACE ==A== BY ==B==\n").unwrap_err().message, "REPLACE: a period to end the statement");
739    }
740
741    #[test]
742    fn replace_acts_on_copied_text() {
743        let dir = dir_with(&[("REPMEM.cpy", "       01  :TAG:-ID PIC 9.\n")]);
744        let src = source::read("       REPLACE ==:TAG:== BY ==CUST==.\n       COPY REPMEM.\n").unwrap();
745        let mut files = vec![String::new()];
746        let text = expand(src, &Libraries::new(vec![dir]), &mut files).and_then(replace).unwrap().text;
747        assert!(text.contains("01  CUST-ID PIC 9."), "{text}");
748    }
749
750    #[test]
751    fn positions_in_a_member_name_its_file() {
752        let dir = dir_with(&[("POS.cpy", "       01  Y PIC X.\n")]);
753        let src = source::read("       COPY POS.\n").unwrap();
754        let mut files = vec![String::new()];
755        let out = expand(src, &Libraries::new(vec![dir]), &mut files).unwrap();
756        let at = out.text.find('Y').unwrap();
757        assert_eq!(out.positions[at].file, 1);
758        assert!(files[1].ends_with("POS.cpy"));
759    }
760
761    #[test]
762    fn under_extended_a_name_followed_by_two_periods_names_the_member_without_them() {
763        let dir = std::env::temp_dir().join(format!("iw-copy-dots-{}", std::process::id()));
764        std::fs::create_dir_all(&dir).unwrap();
765        std::fs::write(dir.join("CSTMT.cpy"), "           DISPLAY 'FROM COPY'.\n").unwrap();
766        let main = "       PROCEDURE DIVISION.\n           COPY CSTMT..\n";
767        let libraries = Libraries::new(vec![dir.clone()]).with_compliance(numeric::Compliance::Extended);
768        let source = crate::source::read_under(main, 0, false, numeric::Compliance::Extended).unwrap();
769        let expanded = expand(source, &libraries, &mut Vec::new()).unwrap();
770        assert!(expanded.text.contains("DISPLAY 'FROM COPY'"), "{}", expanded.text);
771        assert_eq!(expanded.notes.iter().map(|n| (n.pos.line, n.id)).collect::<Vec<_>>(), [(2, Some("IWX0054"))]);
772        let strict = expand(crate::source::read_under(main, 0, false, numeric::Compliance::Strict).unwrap(), &Libraries::new(vec![dir.clone()]), &mut Vec::new());
773        assert_eq!(strict.err().map(|e| e.id), Some(Some("IWS0004")));
774        std::fs::remove_dir_all(&dir).ok();
775    }
776}