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#[derive(Clone, Debug, Default)]
13pub struct Libraries {
14    dirs: Vec<PathBuf>,
15}
16
17const EXTENSIONS: &[&str] = &["", ".cpy", ".CPY", ".cbl", ".CBL", ".cob", ".COB", ".copy", ".COPY"];
18const MAX_DEPTH: usize = 32;
19
20impl Libraries {
21    pub fn new(dirs: Vec<PathBuf>) -> Self {
22        Self { dirs }
23    }
24
25    fn find(&self, name: &str, library: Option<&str>) -> Option<PathBuf> {
26        self.find_with(name, library, EXTENSIONS)
27    }
28
29    pub(crate) fn find_bms(&self, name: &str, library: Option<&str>) -> Option<PathBuf> {
30        self.find_with(name, library, &[".bms", ".BMS"])
31    }
32
33    fn find_with(&self, name: &str, library: Option<&str>, extensions: &[&str]) -> Option<PathBuf> {
34        let mut places: Vec<PathBuf> = Vec::new();
35        for d in &self.dirs {
36            if let Some(lib) = library {
37                places.extend([lib.to_owned(), lib.to_ascii_lowercase()].iter().map(|l| d.join(l)));
38            }
39            places.push(d.clone());
40        }
41        let names = [name.to_owned(), name.to_ascii_uppercase(), name.to_ascii_lowercase()];
42        places.iter().flat_map(|p| names.iter().flat_map(move |n| extensions.iter().map(move |e| p.join(format!("{n}{e}"))))).find(|p| p.is_file())
43    }
44}
45
46/// Reads a source file's bytes: UTF-8 when valid, otherwise one character per byte (Latin-1).
47pub fn decode(bytes: &[u8]) -> String {
48    match std::str::from_utf8(bytes) {
49        Ok(s) => s.to_owned(),
50        Err(_) => bytes.iter().map(|&b| b as char).collect(),
51    }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
55struct Word {
56    start: usize,
57    end: usize,
58    text: String,
59}
60
61fn text_words(chars: &[char]) -> Vec<Word> {
62    let mut words = Vec::new();
63    let mut i = 0;
64    let separator_at = |i: usize| chars.get(i + 1).is_none_or(|c| c.is_whitespace());
65    while i < chars.len() {
66        let c = chars[i];
67        if c.is_whitespace() || ((c == ',' || c == ';') && separator_at(i)) {
68            i += 1;
69            continue;
70        }
71        let start = i;
72        if c == '\'' || c == '"' {
73            i += 1;
74            while i < chars.len() {
75                if chars[i] == c && chars.get(i + 1) == Some(&c) {
76                    i += 2;
77                } else if chars[i] == c {
78                    i += 1;
79                    break;
80                } else {
81                    i += 1;
82                }
83            }
84        } else if c == '=' && chars.get(i + 1) == Some(&'=') {
85            i += 2;
86        } else if matches!(c, '(' | ')' | ':') || (c == '.' && separator_at(i)) {
87            i += 1;
88        } else {
89            while i < chars.len() {
90                let d = chars[i];
91                let ends = d.is_whitespace()
92                    || matches!(d, '(' | ')' | ':' | '\'' | '"')
93                    || ((d == '.' || d == ',' || d == ';') && separator_at(i))
94                    || (d == '=' && chars.get(i + 1) == Some(&'='));
95                if ends {
96                    break;
97                }
98                i += 1;
99            }
100        }
101        words.push(Word { start, end: i, text: chars[start..i].iter().collect() });
102    }
103    words
104}
105
106fn same(a: &str, b: &str) -> bool {
107    if a.starts_with(['\'', '"']) { a == b } else { a.eq_ignore_ascii_case(b) }
108}
109
110#[derive(Clone, Debug)]
111enum Mode {
112    Whole,
113    Leading,
114    Trailing,
115}
116
117#[derive(Clone, Debug)]
118struct Replacing {
119    mode: Mode,
120    pattern: Vec<String>,
121    replacement: String,
122}
123
124struct Statement {
125    name: String,
126    library: Option<String>,
127    replacing: Vec<Replacing>,
128    /// Index of the word after the terminating period.
129    next: usize,
130}
131
132fn copy_statement(words: &[Word], at: usize, pos: Pos) -> Result<Statement, Error> {
133    let err = |m: &str| Error::at(pos, format!("COPY: {m}"));
134    let text = |i: usize| words.get(i).map(|w| w.text.as_str());
135    let mut i = at + 1;
136    let unquote = |s: &str| s.trim_matches(|c| c == '\'' || c == '"').to_owned();
137    let name = unquote(text(i).ok_or_else(|| err("a member name"))?);
138    i += 1;
139    let mut library = None;
140    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("OF") || w.eq_ignore_ascii_case("IN")) {
141        library = Some(unquote(text(i + 1).ok_or_else(|| err("a library name"))?));
142        i += 2;
143    }
144    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("SUPPRESS")) {
145        i += 1;
146    }
147    let mut replacing = Vec::new();
148    if text(i).is_some_and(|w| w.eq_ignore_ascii_case("REPLACING")) {
149        i += 1;
150        while text(i).is_some_and(|w| w != ".") {
151            let mode = match text(i) {
152                Some(w) if w.eq_ignore_ascii_case("LEADING") => Mode::Leading,
153                Some(w) if w.eq_ignore_ascii_case("TRAILING") => Mode::Trailing,
154                _ => Mode::Whole,
155            };
156            if !matches!(mode, Mode::Whole) {
157                i += 1;
158            }
159            let (pattern, after) = operand(words, i).ok_or_else(|| err("an operand to replace"))?;
160            if !text(after).is_some_and(|w| w.eq_ignore_ascii_case("BY")) {
161                return Err(err("BY"));
162            }
163            let (replacement, after) = operand(words, after + 1).ok_or_else(|| err("an operand after BY"))?;
164            if pattern.is_empty() || (!matches!(mode, Mode::Whole) && pattern.len() != 1) {
165                return Err(err("LEADING and TRAILING take one word; an empty pattern matches nothing"));
166            }
167            replacing.push(Replacing { mode, pattern, replacement: replacement.join(" ") });
168            i = after;
169        }
170    }
171    if text(i) != Some(".") {
172        return Err(err("a period to end the statement"));
173    }
174    Ok(Statement { name, library, replacing, next: i + 1 })
175}
176
177/// A REPLACING operand: pseudo-text `==...==` as its text-words, or one text-word.
178fn operand(words: &[Word], at: usize) -> Option<(Vec<String>, usize)> {
179    let first = words.get(at)?;
180    if first.text != "==" {
181        return Some((vec![first.text.clone()], at + 1));
182    }
183    let close = words[at + 1..].iter().position(|w| w.text == "==")? + at + 1;
184    Some((words[at + 1..close].iter().map(|w| w.text.clone()).collect(), close + 1))
185}
186
187/// Appends `chars[range]` to `out` with the positions they came from.
188fn copy_span(out: &mut Source, chars: &[char], positions: &[Pos], range: std::ops::Range<usize>) {
189    out.text.extend(&chars[range.clone()]);
190    out.positions.extend_from_slice(&positions[range]);
191}
192
193fn apply(src: &Source, replacing: &[Replacing]) -> Source {
194    if replacing.is_empty() {
195        return Source { text: src.text.clone(), positions: src.positions.clone(), options: Vec::new() };
196    }
197    let chars: Vec<char> = src.text.chars().collect();
198    let words = text_words(&chars);
199    let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new() };
200    let emit = |out: &mut Source, text: &str, pos: Pos| {
201        for c in text.chars() {
202            out.text.push(c);
203            out.positions.push(pos);
204        }
205    };
206    let (mut cursor, mut i) = (0usize, 0usize);
207    while i < words.len() {
208        copy_span(&mut out, &chars, &src.positions, cursor..words[i].start);
209        let pos = src.positions[words[i].start];
210        let hit = replacing.iter().find_map(|r| match r.mode {
211            Mode::Whole => (words.len() - i >= r.pattern.len() && r.pattern.iter().zip(&words[i..]).all(|(p, w)| same(p, &w.text)))
212                .then(|| (r.replacement.clone(), r.pattern.len())),
213            Mode::Leading => {
214                let w = &words[i].text;
215                (w.len() > r.pattern[0].len() || w.eq_ignore_ascii_case(&r.pattern[0]))
216                    .then_some(())
217                    .filter(|_| w.to_ascii_uppercase().starts_with(&r.pattern[0].to_ascii_uppercase()))
218                    .map(|_| (format!("{}{}", r.replacement, &w[r.pattern[0].len()..]), 1))
219            }
220            Mode::Trailing => {
221                let w = &words[i].text;
222                w.to_ascii_uppercase()
223                    .ends_with(&r.pattern[0].to_ascii_uppercase())
224                    .then(|| (format!("{}{}", &w[..w.len() - r.pattern[0].len()], r.replacement), 1))
225            }
226        });
227        match hit {
228            Some((text, consumed)) => {
229                emit(&mut out, &text, pos);
230                cursor = words[i + consumed - 1].end;
231                i += consumed;
232            }
233            None => {
234                copy_span(&mut out, &chars, &src.positions, words[i].start..words[i].end);
235                cursor = words[i].end;
236                i += 1;
237            }
238        }
239    }
240    copy_span(&mut out, &chars, &src.positions, cursor..chars.len());
241    out
242}
243
244/// Replaces every COPY statement in `source` with its member's text, recursively. `files` names
245/// each source file; a position's `file` indexes it.
246pub fn expand(source: Source, libraries: &Libraries, files: &mut Vec<String>) -> Result<Source, Error> {
247    let mut stack = Vec::new();
248    expand_nested(source, libraries, files, &mut stack)
249}
250
251/// `EXEC SQL INCLUDE name END-EXEC`, which the Db2 precompiler treats as a COPY of its member (and
252/// of its own SQLCA or SQLDA); `next` is the word after END-EXEC and any period that ends it.
253fn sql_include(words: &[Word], at: usize) -> Option<(String, usize)> {
254    let is = |k: usize, w: &str| words.get(at + k).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
255    if !(is(0, "EXEC") && is(1, "SQL") && is(2, "INCLUDE") && is(4, "END-EXEC")) {
256        return None;
257    }
258    let name = words[at + 3].text.trim_matches(|c| c == '\'' || c == '"').to_owned();
259    Some((name, at + 5 + usize::from(is(5, "."))))
260}
261
262fn expand_nested(source: Source, libraries: &Libraries, files: &mut Vec<String>, stack: &mut Vec<String>) -> Result<Source, Error> {
263    let chars: Vec<char> = source.text.chars().collect();
264    let words = text_words(&chars);
265    if !words.iter().any(|w| w.text.eq_ignore_ascii_case("COPY") || w.text.eq_ignore_ascii_case("INCLUDE")) {
266        return Ok(source);
267    }
268    let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone() };
269    let (mut cursor, mut i) = (0usize, 0usize);
270    while i < words.len() {
271        let pos = source.positions[words[i].start];
272        let (name, library, replacing, next, sql) = if words[i].text.eq_ignore_ascii_case("COPY") {
273            let st = copy_statement(&words, i, pos)?;
274            (st.name, st.library, st.replacing, st.next, false)
275        } else if let Some((name, next)) = sql_include(&words, i) {
276            (name, None, Vec::new(), next, true)
277        } else {
278            i += 1;
279            continue;
280        };
281        copy_span(&mut out, &chars, &source.positions, cursor..words[i].start);
282        let own = sql && matches!(name.to_ascii_uppercase().as_str(), "SQLCA" | "SQLDA");
283        let path = if own { None } else { libraries.find(&name, library.as_deref()) };
284        let verb = if sql { "EXEC SQL INCLUDE" } else { "COPY" };
285        let mapset = if own || path.is_some() { None } else { bms::load(libraries, &name, library.as_deref()) };
286        let (key, member) = match (path, mapset) {
287            (Some(path), _) => (path.display().to_string(), read_member(&path, pos, files)?),
288            (None, Some((path, mapset))) => {
289                let mapset = mapset.map_err(|e| Error::at(pos, format!("{verb} {name}: {}", e.place(&path.display().to_string()))))?;
290                let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
291                files.push(path.display().to_string());
292                (path.display().to_string(), source::read_file(&bms::symbolic_map(&mapset), file)?)
293            }
294            (None, None) => {
295                let text = system::member(&name).ok_or_else(|| Error::at(pos, format!("{verb} {name}: no such member in the copy libraries")))?;
296                let key = format!("(system member {})", name.to_ascii_uppercase());
297                let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
298                files.push(key.clone());
299                (key, source::read_file(&text, file)?)
300            }
301        };
302        if stack.contains(&key) || stack.len() >= MAX_DEPTH {
303            return Err(Error::at(pos, format!("{verb} {name}: copies itself, or nests deeper than {MAX_DEPTH}")));
304        }
305        stack.push(key);
306        let member = expand_nested(member, libraries, files, stack)?;
307        stack.pop();
308        let replaced = apply(&member, &replacing);
309        out.text.push_str(&replaced.text);
310        out.positions.extend(replaced.positions);
311        out.text.push('\n');
312        out.positions.push(pos);
313        cursor = words.get(next - 1).map_or(chars.len(), |w| w.end);
314        i = next;
315    }
316    copy_span(&mut out, &chars, &source.positions, cursor..chars.len());
317    Ok(out)
318}
319
320fn read_member(path: &Path, pos: Pos, files: &mut Vec<String>) -> Result<Source, Error> {
321    let bytes = std::fs::read(path).map_err(|e| Error::at(pos, format!("COPY {}: {e}", path.display())))?;
322    let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
323    files.push(path.display().to_string());
324    source::read_file(&decode(&bytes), file)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn words(s: &str) -> Vec<String> {
332        text_words(&s.chars().collect::<Vec<_>>()).into_iter().map(|w| w.text).collect()
333    }
334
335    #[test]
336    fn text_words_follow_the_separators() {
337        assert_eq!(words("01 :TAG:-REC PIC X(3)."), ["01", ":", "TAG", ":", "-REC", "PIC", "X", "(", "3", ")", "."]);
338        assert_eq!(words("MOVE 'A B' TO X, Y."), ["MOVE", "'A B'", "TO", "X", "Y", "."]);
339        assert_eq!(words("==A== BY ==B==."), ["==", "A", "==", "BY", "==", "B", "==", "."]);
340        assert_eq!(words("GENAUW.CLAIM 0.1"), ["GENAUW.CLAIM", "0.1"]);
341    }
342
343    fn dir_with(files: &[(&str, &str)]) -> PathBuf {
344        let dir = std::env::temp_dir().join(format!("ironwork-copy-{}-{}", std::process::id(), files[0].0));
345        std::fs::create_dir_all(&dir).unwrap();
346        for (name, text) in files {
347            std::fs::write(dir.join(name), text).unwrap();
348        }
349        dir
350    }
351
352    fn expanded(main: &str, dir: &Path) -> Result<String, Error> {
353        let src = source::read(main)?;
354        let mut files = vec![String::new()];
355        expand(src, &Libraries::new(vec![dir.to_path_buf()]), &mut files).map(|s| s.text)
356    }
357
358    #[test]
359    fn copy_replacing_whole_words_and_tags_inside_words() {
360        let dir = dir_with(&[("RECS.cpy", "       01  :TAG:-REC.\n           05 :TAG:-ID PIC 9(4) VALUE OLD.\n")]);
361        let text = expanded("           COPY RECS REPLACING ==:TAG:== BY ==CUST== OLD BY 42.\n", &dir).unwrap();
362        assert!(text.contains("01  CUST-REC."), "{text}");
363        assert!(text.contains("05 CUST-ID PIC 9(4) VALUE 42."), "{text}");
364        assert!(!text.contains("COPY"));
365    }
366
367    #[test]
368    fn leading_and_trailing_replace_part_of_a_word() {
369        let dir = dir_with(&[("PART.cpy", "       01  WS-A PIC X.\n       01  B-WS PIC X.\n")]);
370        let text = expanded("           COPY PART REPLACING LEADING ==WS== BY ==LK==\n               TRAILING ==WS== BY ==XX==.\n", &dir).unwrap();
371        assert!(text.contains("LK-A") && text.contains("B-XX"), "{text}");
372    }
373
374    #[test]
375    fn nested_copies_and_a_missing_member() {
376        let dir = dir_with(&[("OUTER.cpy", "       COPY INNER.\n"), ("inner.cpy", "       01  X PIC X.\n")]);
377        assert!(expanded("       COPY OUTER.\n", &dir).unwrap().contains("01  X PIC X."));
378        let err = expanded("       COPY NOPE.\n", &dir).unwrap_err();
379        assert!(err.message.contains("NOPE"));
380    }
381
382    #[test]
383    fn system_members_answer_when_no_library_does() {
384        let dir = dir_with(&[("OTHER.cpy", "       01  O PIC X.\n")]);
385        let text = expanded("       COPY DFHAID.\n           EXEC SQL INCLUDE SQLCA END-EXEC.\n", &dir).unwrap();
386        assert!(text.contains("DFHENTER") && text.contains("SQLCODE"), "{text}");
387        assert!(!text.contains("END-EXEC"));
388        let dir = dir_with(&[("DFHAID.cpy", "       01  VENDORED PIC X.\n")]);
389        assert!(expanded("       COPY DFHAID.\n", &dir).unwrap().contains("VENDORED"));
390    }
391
392    #[test]
393    fn a_member_that_copies_itself_is_refused() {
394        let dir = dir_with(&[("LOOP.cpy", "       COPY LOOP.\n")]);
395        assert!(expanded("       COPY LOOP.\n", &dir).unwrap_err().message.contains("copies itself"));
396    }
397
398    #[test]
399    fn positions_in_a_member_name_its_file() {
400        let dir = dir_with(&[("POS.cpy", "       01  Y PIC X.\n")]);
401        let src = source::read("       COPY POS.\n").unwrap();
402        let mut files = vec![String::new()];
403        let out = expand(src, &Libraries::new(vec![dir]), &mut files).unwrap();
404        let at = out.text.find('Y').unwrap();
405        assert_eq!(out.positions[at].file, 1);
406        assert!(files[1].ends_with("POS.cpy"));
407    }
408}