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)]
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 pub fn with_program(&self, program: &Path) -> Self {
31 Self { dirs: self.dirs.clone(), program: Some(program.to_path_buf()) }
32 }
33
34 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
67fn 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
79pub 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 literal: bool,
161 library: Option<String>,
162 replacing: Vec<Replacing>,
163 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 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 i += 1;
194 while text(i).is_some_and(|w| w != ".") {
195 let mode = match text(i) {
196 Some(w) if w.eq_ignore_ascii_case("LEADING") => Mode::Leading,
197 Some(w) if w.eq_ignore_ascii_case("TRAILING") => Mode::Trailing,
198 _ => Mode::Whole,
199 };
200 if !matches!(mode, Mode::Whole) {
201 i += 1;
202 }
203 let (pattern, after) = operand(words, i).ok_or_else(|| err("an operand to replace"))?;
204 if !text(after).is_some_and(|w| w.eq_ignore_ascii_case("BY")) {
205 return Err(err("BY"));
206 }
207 let (replacement, after) = operand(words, after + 1).ok_or_else(|| err("an operand after BY"))?;
208 if pattern.is_empty() || (!matches!(mode, Mode::Whole) && pattern.len() != 1) {
209 return Err(err("LEADING and TRAILING take one word; an empty pattern matches nothing"));
210 }
211 replacing.push(Replacing { mode, pattern, replacement: replacement.join(" ") });
212 i = after;
213 }
214 }
215 if text(i) != Some(".") {
216 return Err(err("a period to end the statement"));
217 }
218 Ok(Statement { name, literal, library, replacing, next: i + 1 })
219}
220
221fn operand(words: &[Word], at: usize) -> Option<(Vec<String>, usize)> {
223 let first = words.get(at)?;
224 if first.text != "==" {
225 return Some((vec![first.text.clone()], at + 1));
226 }
227 let close = words[at + 1..].iter().position(|w| w.text == "==")? + at + 1;
228 Some((words[at + 1..close].iter().map(|w| w.text.clone()).collect(), close + 1))
229}
230
231fn copy_span(out: &mut Source, chars: &[char], positions: &[Pos], range: std::ops::Range<usize>) {
233 out.text.extend(&chars[range.clone()]);
234 out.positions.extend_from_slice(&positions[range]);
235}
236
237fn apply(src: &Source, replacing: &[Replacing]) -> Source {
238 if replacing.is_empty() {
239 return Source { text: src.text.clone(), positions: src.positions.clone(), options: Vec::new(), debugging: None };
240 }
241 let chars: Vec<char> = src.text.chars().collect();
242 let words = text_words(&chars);
243 let mut out = Source { text: String::new(), positions: Vec::new(), options: Vec::new(), debugging: None };
244 let emit = |out: &mut Source, text: &str, pos: Pos| {
245 for c in text.chars() {
246 out.text.push(c);
247 out.positions.push(pos);
248 }
249 };
250 let (mut cursor, mut i) = (0usize, 0usize);
251 while i < words.len() {
252 copy_span(&mut out, &chars, &src.positions, cursor..words[i].start);
253 let pos = src.positions[words[i].start];
254 let hit = replacing.iter().find_map(|r| match r.mode {
255 Mode::Whole => (words.len() - i >= r.pattern.len() && r.pattern.iter().zip(&words[i..]).all(|(p, w)| same(p, &w.text)))
256 .then(|| (r.replacement.clone(), r.pattern.len())),
257 Mode::Leading => {
258 let w = &words[i].text;
259 (w.len() > r.pattern[0].len() || w.eq_ignore_ascii_case(&r.pattern[0]))
260 .then_some(())
261 .filter(|_| w.to_ascii_uppercase().starts_with(&r.pattern[0].to_ascii_uppercase()))
262 .map(|_| (format!("{}{}", r.replacement, &w[r.pattern[0].len()..]), 1))
263 }
264 Mode::Trailing => {
265 let w = &words[i].text;
266 w.to_ascii_uppercase()
267 .ends_with(&r.pattern[0].to_ascii_uppercase())
268 .then(|| (format!("{}{}", &w[..w.len() - r.pattern[0].len()], r.replacement), 1))
269 }
270 });
271 match hit {
272 Some((text, consumed)) => {
273 emit(&mut out, &text, pos);
274 cursor = words[i + consumed - 1].end;
275 i += consumed;
276 }
277 None => {
278 copy_span(&mut out, &chars, &src.positions, words[i].start..words[i].end);
279 cursor = words[i].end;
280 i += 1;
281 }
282 }
283 }
284 copy_span(&mut out, &chars, &src.positions, cursor..chars.len());
285 out
286}
287
288pub fn expand(source: Source, libraries: &Libraries, files: &mut Vec<String>) -> Result<Source, Error> {
291 let mut stack = Vec::new();
292 expand_nested(source, libraries, files, &mut stack)
293}
294
295fn sql_include(words: &[Word], at: usize) -> Option<(String, bool, usize)> {
299 let is = |k: usize, w: &str| words.get(at + k).is_some_and(|x| x.text.eq_ignore_ascii_case(w));
300 if !(is(0, "EXEC") && is(1, "SQL") && is(2, "INCLUDE") && is(4, "END-EXEC")) {
301 return None;
302 }
303 let word = &words[at + 3].text;
304 let name = word.trim_matches(|c| c == '\'' || c == '"').to_owned();
305 Some((name, word.starts_with(['\'', '"']), at + 5 + usize::from(is(5, "."))))
306}
307
308fn expand_nested(source: Source, libraries: &Libraries, files: &mut Vec<String>, stack: &mut Vec<String>) -> Result<Source, Error> {
309 let chars: Vec<char> = source.text.chars().collect();
310 let words = text_words(&chars);
311 if !words.iter().any(|w| w.text.eq_ignore_ascii_case("COPY") || w.text.eq_ignore_ascii_case("INCLUDE")) {
312 return Ok(source);
313 }
314 let mut out = Source { text: String::new(), positions: Vec::new(), options: source.options.clone(), debugging: source.debugging.clone() };
315 let read = |text: &str, file: u16| if source.debugging.is_some() { source::read_file_debugging(text, file) } else { source::read_file(text, file) };
316 let (mut cursor, mut i) = (0usize, 0usize);
317 while i < words.len() {
318 let pos = source.positions[words[i].start];
319 let (name, literal, library, replacing, next, sql) = if words[i].text.eq_ignore_ascii_case("COPY") {
320 let st = copy_statement(&words, i, pos)?;
321 (st.name, st.literal, st.library, st.replacing, st.next, false)
322 } else if let Some((name, literal, next)) = sql_include(&words, i) {
323 (name, literal, None, Vec::new(), next, true)
324 } else {
325 i += 1;
326 continue;
327 };
328 copy_span(&mut out, &chars, &source.positions, cursor..words[i].start);
329 let own = sql && matches!(name.to_ascii_uppercase().as_str(), "SQLCA" | "SQLDA");
330 let path = if own { None } else { libraries.find(&name, library.as_deref(), literal) };
331 let verb = if sql { "EXEC SQL INCLUDE" } else { "COPY" };
332 let mapset = if own || path.is_some() { None } else { bms::load(libraries, &name, library.as_deref()) };
333 let (key, member) = match (path, mapset) {
334 (Some(path), _) => (path.display().to_string(), read_member(&path, pos, files, &read)?),
335 (None, Some((path, mapset))) => {
336 let mapset = mapset.map_err(|e| Error::at(pos, format!("{verb} {name}: {}", e.place(&path.display().to_string()))))?;
337 let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
338 files.push(path.display().to_string());
339 (path.display().to_string(), read(&bms::symbolic_map(&mapset), file)?)
340 }
341 (None, None) => {
342 let text = system::member(&name).ok_or_else(|| Error::at(pos, format!("{verb} {name}: no such member in the copy libraries")))?;
343 let key = format!("(system member {})", name.to_ascii_uppercase());
344 let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
345 files.push(key.clone());
346 (key, read(&text, file)?)
347 }
348 };
349 if stack.contains(&key) || stack.len() >= MAX_DEPTH {
350 return Err(Error::at(pos, format!("{verb} {name}: copies itself, or nests deeper than {MAX_DEPTH}")));
351 }
352 stack.push(key);
353 let mut member = expand_nested(member, libraries, files, stack)?;
354 stack.pop();
355 if let (Some(lines), Some(copied)) = (&mut out.debugging, member.debugging.take()) {
356 if lines.contains(&(pos.file, pos.line)) {
358 lines.extend(member.positions.iter().map(|p| (p.file, p.line)));
359 }
360 lines.extend(copied);
361 }
362 let replaced = apply(&member, &replacing);
363 out.text.push_str(&replaced.text);
364 out.positions.extend(replaced.positions);
365 out.text.push('\n');
366 out.positions.push(pos);
367 cursor = words.get(next - 1).map_or(chars.len(), |w| w.end);
368 i = next;
369 }
370 copy_span(&mut out, &chars, &source.positions, cursor..chars.len());
371 Ok(out)
372}
373
374fn read_member(path: &Path, pos: Pos, files: &mut Vec<String>, read: &dyn Fn(&str, u16) -> Result<Source, Error>) -> Result<Source, Error> {
375 let bytes = std::fs::read(path).map_err(|e| Error::at(pos, format!("COPY {}: {e}", path.display())))?;
376 let file = u16::try_from(files.len()).map_err(|_| Error::at(pos, "more than 65535 copy members"))?;
377 files.push(path.display().to_string());
378 read(&decode(&bytes), file)
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn words(s: &str) -> Vec<String> {
386 text_words(&s.chars().collect::<Vec<_>>()).into_iter().map(|w| w.text).collect()
387 }
388
389 #[test]
390 fn text_words_follow_the_separators() {
391 assert_eq!(words("01 :TAG:-REC PIC X(3)."), ["01", ":", "TAG", ":", "-REC", "PIC", "X", "(", "3", ")", "."]);
392 assert_eq!(words("MOVE 'A B' TO X, Y."), ["MOVE", "'A B'", "TO", "X", "Y", "."]);
393 assert_eq!(words("==A== BY ==B==."), ["==", "A", "==", "BY", "==", "B", "==", "."]);
394 assert_eq!(words("GENAUW.CLAIM 0.1"), ["GENAUW.CLAIM", "0.1"]);
395 }
396
397 fn dir_with(files: &[(&str, &str)]) -> PathBuf {
398 let dir = std::env::temp_dir().join(format!("ironwork-copy-{}-{}", std::process::id(), files[0].0));
399 std::fs::create_dir_all(&dir).unwrap();
400 for (name, text) in files {
401 std::fs::write(dir.join(name), text).unwrap();
402 }
403 dir
404 }
405
406 fn expanded(main: &str, dir: &Path) -> Result<String, Error> {
407 let src = source::read(main)?;
408 let mut files = vec![String::new()];
409 expand(src, &Libraries::new(vec![dir.to_path_buf()]), &mut files).map(|s| s.text)
410 }
411
412 #[test]
413 fn copy_replacing_whole_words_and_tags_inside_words() {
414 let dir = dir_with(&[("RECS.cpy", " 01 :TAG:-REC.\n 05 :TAG:-ID PIC 9(4) VALUE OLD.\n")]);
415 let text = expanded(" COPY RECS REPLACING ==:TAG:== BY ==CUST== OLD BY 42.\n", &dir).unwrap();
416 assert!(text.contains("01 CUST-REC."), "{text}");
417 assert!(text.contains("05 CUST-ID PIC 9(4) VALUE 42."), "{text}");
418 assert!(!text.contains("COPY"));
419 }
420
421 #[test]
422 fn leading_and_trailing_replace_part_of_a_word() {
423 let dir = dir_with(&[("PART.cpy", " 01 WS-A PIC X.\n 01 B-WS PIC X.\n")]);
424 let text = expanded(" COPY PART REPLACING LEADING ==WS== BY ==LK==\n TRAILING ==WS== BY ==XX==.\n", &dir).unwrap();
425 assert!(text.contains("LK-A") && text.contains("B-XX"), "{text}");
426 }
427
428 #[test]
429 fn nested_copies_and_a_missing_member() {
430 let dir = dir_with(&[("OUTER.cpy", " COPY INNER.\n"), ("inner.cpy", " 01 X PIC X.\n")]);
431 assert!(expanded(" COPY OUTER.\n", &dir).unwrap().contains("01 X PIC X."));
432 let err = expanded(" COPY NOPE.\n", &dir).unwrap_err();
433 assert!(err.message.contains("NOPE"));
434 }
435
436 #[test]
437 fn system_members_answer_when_no_library_does() {
438 let dir = dir_with(&[("OTHER.cpy", " 01 O PIC X.\n")]);
439 let text = expanded(" COPY DFHAID.\n EXEC SQL INCLUDE SQLCA END-EXEC.\n", &dir).unwrap();
440 assert!(text.contains("DFHENTER") && text.contains("SQLCODE"), "{text}");
441 assert!(!text.contains("END-EXEC"));
442 let dir = dir_with(&[("DFHAID.cpy", " 01 VENDORED PIC X.\n")]);
443 assert!(expanded(" COPY DFHAID.\n", &dir).unwrap().contains("VENDORED"));
444 }
445
446 #[test]
447 fn a_member_that_copies_itself_is_refused() {
448 let dir = dir_with(&[("LOOP.cpy", " COPY LOOP.\n")]);
449 assert!(expanded(" COPY LOOP.\n", &dir).unwrap_err().message.contains("copies itself"));
450 }
451
452 fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
454 let root = std::env::temp_dir().join(format!("ironwork-copy-{}-{tag}", std::process::id()));
455 let _ = std::fs::remove_dir_all(&root);
456 for (name, text) in files {
457 let path = root.join(name);
458 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
459 std::fs::write(path, text).unwrap();
460 }
461 root
462 }
463
464 fn expanded_with(main: &str, libraries: &Libraries) -> Result<String, Error> {
465 let mut files = vec![String::new()];
466 expand(source::read(main)?, libraries, &mut files).map(|s| s.text)
467 }
468
469 #[test]
470 fn a_copybook_in_any_library_is_found_before_a_program_source() {
471 let root = tree(
472 "rounds",
473 &[
474 ("src/INQACC.cbl", " 01 PROGRAM-SOURCE PIC X.\n"),
475 ("src/ONLY.cbl", " 01 ONLY-SOURCE PIC X.\n"),
476 ("src/BARE", " 01 BARE-FILE PIC X.\n"),
477 ("cpy/INQACC.cpy", " 01 COPYBOOK PIC X.\n"),
478 ("cpy/BARE.copy", " 01 COPY-FILE PIC X.\n"),
479 ],
480 );
481 let libraries = Libraries::new(vec![root.join("src"), root.join("cpy")]);
482 assert!(expanded_with(" COPY INQACC.\n", &libraries).unwrap().contains("COPYBOOK"));
483 assert!(expanded_with(" COPY inqacc.\n", &libraries).unwrap().contains("COPYBOOK"));
484 assert!(expanded_with(" COPY BARE.\n", &libraries).unwrap().contains("COPY-FILE"));
485 assert!(expanded_with(" COPY ONLY.\n", &libraries).unwrap().contains("ONLY-SOURCE"));
486 }
487
488 #[test]
489 fn a_name_alone_is_tried_after_its_extensions_and_a_literal_first() {
490 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")]);
491 let libraries = Libraries::new(vec![root.join("lib")]);
492 assert!(expanded_with(" COPY MEMBER.\n", &libraries).unwrap().contains("COPYBOOK"));
493 assert!(expanded_with(" COPY \"MEMBER\".\n", &libraries).unwrap().contains("BARE-FILE"));
494 assert!(expanded_with(" COPY ALONE.\n", &libraries).unwrap().contains("01 ALONE"));
495 }
496
497 #[test]
498 fn the_program_being_compiled_is_never_its_own_member() {
499 let program = " IDENTIFICATION DIVISION.\n PROGRAM-ID. PGMC.\n DATA DIVISION.\n WORKING-STORAGE SECTION.\n COPY PGMC.\n";
500 let root = tree("self", &[("src/PGMC.cbl", program), ("src/OUTER.cpy", " COPY PGMC.\n"), ("lib/PGMC.cbl", " 01 PGMC-X PIC X.\n")]);
501 let main = root.join("src/../src/PGMC.cbl");
502 let libraries = Libraries::new(vec![root.join("src"), root.join("lib")]).with_program(&main);
503 assert!(expanded_with(program, &libraries).unwrap().contains("PGMC-X"));
504 assert!(expanded_with(" COPY OUTER.\n", &libraries).unwrap().contains("PGMC-X"));
505 let alone = Libraries::new(vec![root.join("src")]).with_program(&main);
506 assert!(expanded_with(program, &alone).unwrap_err().message.contains("no such member"));
507 assert!(expanded_with(program, &Libraries::new(vec![root.join("src")])).unwrap_err().message.contains("copies itself"));
508 }
509
510 #[test]
511 fn a_doubled_period_is_refused_by_name() {
512 let root = tree("period", &[("lib/COBCPARMS.cpy", " 01 PARMS PIC X.\n")]);
513 let libraries = Libraries::new(vec![root.join("lib")]);
514 let err = expanded_with(" COPY COBCPARMS..\n", &libraries).unwrap_err();
515 assert!(err.message.contains("COPY COBCPARMS.: the name ends in a period"), "{}", err.message);
516 let err = expanded_with(" COPY COBCPARMS OF LIB..\n", &libraries).unwrap_err();
517 assert!(err.message.contains("COPY LIB.: "), "{}", err.message);
518 assert!(expanded_with(" COPY COBCPARMS.\n", &libraries).unwrap().contains("PARMS"));
519 }
520
521 #[test]
522 fn positions_in_a_member_name_its_file() {
523 let dir = dir_with(&[("POS.cpy", " 01 Y PIC X.\n")]);
524 let src = source::read(" COPY POS.\n").unwrap();
525 let mut files = vec![String::new()];
526 let out = expand(src, &Libraries::new(vec![dir]), &mut files).unwrap();
527 let at = out.text.find('Y').unwrap();
528 assert_eq!(out.positions[at].file, 1);
529 assert!(files[1].ends_with("POS.cpy"));
530 }
531}