Skip to main content

ironwork_syntax/
bms.rs

1//! BMS map source (the DFHMSD, DFHMDI and DFHMDF assembler macros) as a mapset model, and the
2//! COBOL symbolic map a program COPYs for it.
3
4use crate::copy::Libraries;
5use crate::{Error, Pos};
6use std::fmt::Write as _;
7use std::path::PathBuf;
8
9pub use rt::bms::{Attrb, Field, Initial, Intensity, Map, Mapset, Mode, Protection, Slot, extended_attributes, picture_size, slots};
10use rt::bms::EXTENDED;
11
12/// The mapset a COPY of `name` finds in the libraries: the one named `name` in `NAME.bms`, or the
13/// file's only mapset. None when no library holds a BMS file of that name.
14pub fn find_mapset(libraries: &Libraries, name: &str) -> Option<Result<Mapset, Error>> {
15    load(libraries, name, None).map(|(_, mapset)| mapset)
16}
17
18pub(crate) fn load(libraries: &Libraries, name: &str, library: Option<&str>) -> Option<(PathBuf, Result<Mapset, Error>)> {
19    let path = libraries.find_bms(name, library)?;
20    let mapset = std::fs::read(&path)
21        .map_err(|e| Error::at(Pos::default(), e.to_string()))
22        .map(|bytes| crate::copy::decode(&bytes))
23        .and_then(|text| parse(&text))
24        .and_then(|mut sets| {
25            match sets.iter().position(|s| s.name.eq_ignore_ascii_case(name)) {
26                Some(i) => Ok(sets.swap_remove(i)),
27                None if sets.len() == 1 => Ok(sets.remove(0)),
28                None => Err(Error::at(Pos::default(), format!("no mapset {} among the {} in the file", name.to_ascii_uppercase(), sets.len()))),
29            }
30        })
31        .map_err(|mut e| {
32            e.file = Some(path.display().to_string());
33            e
34        });
35    Some((path, mapset))
36}
37
38#[derive(Clone, Debug)]
39enum Val {
40    Word(String),
41    Quoted(String),
42    List(Vec<String>),
43}
44
45struct Statement {
46    line: u32,
47    label: Option<String>,
48    macro_name: String,
49    operands: Vec<(String, Val)>,
50}
51
52fn fail(line: u32, message: impl Into<String>) -> Error {
53    Error::at(Pos { file: 0, line, col: 1 }, message)
54}
55
56/// The statements of the source: comments dropped, continuation lines joined, columns 73-80 ignored.
57fn statements(text: &str) -> Result<Vec<Statement>, Error> {
58    let mut out = Vec::new();
59    let mut lines = text.lines().enumerate();
60    while let Some((n, raw)) = lines.next() {
61        let start = n as u32 + 1;
62        let line: Vec<char> = raw.chars().take(72).collect();
63        if line.first() == Some(&'*') || line.starts_with(&['.', '*']) || line.iter().all(|c| c.is_whitespace()) {
64            continue;
65        }
66        let mut joined = String::new();
67        let mut from = 0;
68        let mut line = line;
69        loop {
70            let continued = line.len() == 72 && line[71] != ' ';
71            let end = if continued { 71 } else { line.len() };
72            joined.extend(line.get(from..end).unwrap_or_default());
73            if !continued {
74                break;
75            }
76            let (_, next) = lines.next().ok_or_else(|| fail(start, "a continuation line is missing"))?;
77            line = next.chars().take(72).collect();
78            from = 15;
79        }
80        out.push(statement(&joined, start)?);
81    }
82    Ok(out)
83}
84
85fn statement(text: &str, line: u32) -> Result<Statement, Error> {
86    let token = |s: &str| s.split(char::is_whitespace).next().unwrap_or_default().len();
87    let (label, rest) = if text.starts_with(char::is_whitespace) {
88        (None, text.trim_start())
89    } else {
90        let n = token(text);
91        (Some(text[..n].to_ascii_uppercase()), text[n..].trim_start())
92    };
93    let n = token(rest);
94    let macro_name = rest[..n].to_ascii_uppercase();
95    let rest = rest[n..].trim_start();
96    if macro_name.is_empty() {
97        return Err(fail(line, "a label with no macro"));
98    }
99    Ok(Statement { line, label, macro_name, operands: operands(rest, line)? })
100}
101
102/// `KEY=value` operands separated by commas; the first blank outside quotes and not after a comma
103/// starts the remarks.
104fn operands(text: &str, line: u32) -> Result<Vec<(String, Val)>, Error> {
105    let chars: Vec<char> = text.chars().collect();
106    let mut i = 0;
107    let mut out = Vec::new();
108    while i < chars.len() {
109        let key_start = i;
110        while i < chars.len() && !matches!(chars[i], '=' | ',') && !chars[i].is_whitespace() {
111            i += 1;
112        }
113        let key: String = chars[key_start..i].iter().collect::<String>().to_ascii_uppercase();
114        let mut value = Val::Word(String::new());
115        if chars.get(i) == Some(&'=') {
116            i += 1;
117            value = match chars.get(i) {
118                Some('\'') => {
119                    let (s, next) = quoted(&chars, i, line)?;
120                    i = next;
121                    Val::Quoted(s)
122                }
123                Some('(') => {
124                    let (items, next) = list(&chars, i, line)?;
125                    i = next;
126                    Val::List(items)
127                }
128                _ => {
129                    let start = i;
130                    let mut in_quote = false;
131                    while i < chars.len() && (in_quote || !(chars[i] == ',' || chars[i].is_whitespace())) {
132                        in_quote ^= chars[i] == '\'';
133                        i += 1;
134                    }
135                    Val::Word(chars[start..i].iter().collect())
136                }
137            };
138        }
139        if !key.is_empty() {
140            out.push((key, value));
141        }
142        if chars.get(i) == Some(&',') {
143            i += 1;
144            while chars.get(i).is_some_and(|c| c.is_whitespace()) {
145                i += 1;
146            }
147        } else {
148            break;
149        }
150    }
151    Ok(out)
152}
153
154/// The quoted string at `at` (`''` is one quote) and the index after its closing quote.
155fn quoted(chars: &[char], at: usize, line: u32) -> Result<(String, usize), Error> {
156    let mut s = String::new();
157    let mut i = at + 1;
158    while i < chars.len() {
159        if chars[i] == '\'' {
160            if chars.get(i + 1) == Some(&'\'') {
161                s.push('\'');
162                i += 2;
163                continue;
164            }
165            return Ok((s, i + 1));
166        }
167        s.push(chars[i]);
168        i += 1;
169    }
170    Err(fail(line, "a quoted string is not closed"))
171}
172
173fn list(chars: &[char], at: usize, line: u32) -> Result<(Vec<String>, usize), Error> {
174    let mut items = Vec::new();
175    let mut current = String::new();
176    let mut i = at + 1;
177    while i < chars.len() {
178        match chars[i] {
179            '\'' => {
180                let (s, next) = quoted(chars, i, line)?;
181                current.push_str(&s);
182                i = next;
183                continue;
184            }
185            ')' => {
186                items.push(current.trim().to_owned());
187                return Ok((items.into_iter().filter(|s| !s.is_empty()).collect(), i + 1));
188            }
189            ',' => items.push(std::mem::take(&mut current).trim().to_owned()),
190            c => current.push(c),
191        }
192        i += 1;
193    }
194    Err(fail(line, "a parenthesised list is not closed"))
195}
196
197struct Operands<'a> {
198    ops: &'a [(String, Val)],
199    line: u32,
200}
201
202impl Operands<'_> {
203    fn get(&self, key: &str) -> Option<&Val> {
204        self.ops.iter().find(|(k, _)| k == key).map(|(_, v)| v)
205    }
206
207    fn words(&self, key: &str) -> Option<Vec<String>> {
208        self.get(key).map(|v| match v {
209            Val::List(items) => items.iter().map(|s| s.to_ascii_uppercase()).collect(),
210            Val::Word(w) | Val::Quoted(w) => vec![w.to_ascii_uppercase()],
211        })
212    }
213
214    fn word(&self, key: &str) -> Result<Option<String>, Error> {
215        match self.words(key) {
216            None => Ok(None),
217            Some(mut w) if w.len() == 1 => Ok(w.pop()),
218            Some(_) => Err(fail(self.line, format!("{key} takes one value"))),
219        }
220    }
221
222    fn text(&self, key: &str) -> Result<Option<String>, Error> {
223        match self.get(key) {
224            None => Ok(None),
225            Some(Val::Word(s) | Val::Quoted(s)) => Ok(Some(s.clone())),
226            Some(Val::List(_)) => Err(fail(self.line, format!("{key} takes a quoted string"))),
227        }
228    }
229
230    fn number(&self, key: &str, low: u32, high: u32) -> Result<Option<u32>, Error> {
231        let Some(w) = self.word(key)? else { return Ok(None) };
232        match w.parse::<u32>() {
233            Ok(n) if (low..=high).contains(&n) => Ok(Some(n)),
234            _ => Err(fail(self.line, format!("{key}={w} is not a number from {low} to {high}"))),
235        }
236    }
237
238    fn yes_no(&self, key: &str) -> Result<Option<bool>, Error> {
239        match self.word(key)?.as_deref() {
240            None => Ok(None),
241            Some("YES") => Ok(Some(true)),
242            Some("NO") => Ok(Some(false)),
243            Some(other) => Err(fail(self.line, format!("{key}={other}: YES or NO"))),
244        }
245    }
246
247    /// DSATTS, or what EXTATT implies; None when neither is written.
248    fn dsatts(&self) -> Result<Option<Vec<String>>, Error> {
249        if let Some(list) = self.words("DSATTS") {
250            let mut out: Vec<String> = Vec::new();
251            for a in list {
252                if !EXTENDED.iter().any(|(name, _, _)| *name == a) {
253                    return Err(fail(self.line, format!("DSATTS={a} is not an extended attribute")));
254                }
255                if !out.contains(&a) {
256                    out.push(a);
257                }
258            }
259            return Ok(Some(out));
260        }
261        Ok(match self.word("EXTATT")?.as_deref() {
262            None => None,
263            Some("YES") => Some(EXTENDED.iter().filter(|(_, _, implied)| *implied).map(|(a, _, _)| a.to_string()).collect()),
264            Some("NO" | "MAPONLY") => Some(Vec::new()),
265            Some(other) => return Err(fail(self.line, format!("EXTATT={other}: NO, MAPONLY or YES"))),
266        })
267    }
268}
269
270const MAX_NAME: usize = 30;
271
272struct OpenSet {
273    set: Mapset,
274    tioapfx: bool,
275    dsatts: Vec<String>,
276}
277
278/// Every mapset in the source, each ended by `DFHMSD TYPE=FINAL` (or by the next mapset or the end).
279pub fn parse(text: &str) -> Result<Vec<Mapset>, Error> {
280    let mut done = Vec::new();
281    let mut open: Option<OpenSet> = None;
282    for st in statements(text)? {
283        let ops = Operands { ops: &st.operands, line: st.line };
284        match st.macro_name.as_str() {
285            "PRINT" | "TITLE" | "EJECT" | "SPACE" | "END" => {}
286            "DFHMSD" => {
287                if ops.word("TYPE")?.as_deref() == Some("FINAL") {
288                    done.push(open.take().ok_or_else(|| fail(st.line, "DFHMSD TYPE=FINAL with no mapset open"))?.set);
289                    continue;
290                }
291                done.extend(open.take().map(|o| o.set));
292                open = Some(mapset_header(&st, &ops)?);
293            }
294            "DFHMDI" => {
295                let o = open.as_mut().ok_or_else(|| fail(st.line, "DFHMDI outside a DFHMSD"))?;
296                let map = map_header(&st, &ops, o)?;
297                o.set.maps.push(map);
298            }
299            "DFHMDF" => {
300                let map = open.as_mut().and_then(|o| o.set.maps.last_mut()).ok_or_else(|| fail(st.line, "DFHMDF outside a DFHMDI map"))?;
301                let field = field(&st, &ops, map)?;
302                map.fields.push(field);
303            }
304            other => return Err(fail(st.line, format!("unknown macro {other}"))),
305        }
306    }
307    done.extend(open.map(|o| o.set));
308    Ok(done)
309}
310
311fn name_of(st: &Statement, what: &str, max: usize) -> Result<String, Error> {
312    let name = st.label.clone().ok_or_else(|| fail(st.line, format!("{what} needs a name")))?;
313    if name.len() > max {
314        return Err(fail(st.line, format!("{what} name {name} is longer than {max} characters")));
315    }
316    Ok(name)
317}
318
319fn mapset_header(st: &Statement, ops: &Operands) -> Result<OpenSet, Error> {
320    let name = name_of(st, "DFHMSD", MAX_NAME)?;
321    // CardDemo's maps write &&SYSPARM, and CICS generated their copybooks from them.
322    if let Some(t) = ops.word("TYPE")?
323        && !matches!(t.as_str(), "DSECT" | "MAP" | "&SYSPARM" | "&&SYSPARM")
324    {
325        return Err(fail(st.line, format!("TYPE={t}: DSECT, MAP, FINAL or &SYSPARM")));
326    }
327    let mode = match ops.word("MODE")?.as_deref() {
328        None | Some("OUT") => Mode::Out,
329        Some("IN") => Mode::In,
330        Some("INOUT") => Mode::InOut,
331        Some(other) => return Err(fail(st.line, format!("MODE={other}: IN, OUT or INOUT"))),
332    };
333    let auto = ops.word("STORAGE")?.as_deref() == Some("AUTO");
334    let tioapfx = ops.yes_no("TIOAPFX")?.unwrap_or(auto);
335    Ok(OpenSet {
336        set: Mapset { name, mode, ctrl: ops.words("CTRL").unwrap_or_default(), maps: Vec::new() },
337        tioapfx,
338        dsatts: ops.dsatts()?.unwrap_or_default(),
339    })
340}
341
342fn map_header(st: &Statement, ops: &Operands, open: &OpenSet) -> Result<Map, Error> {
343    let name = name_of(st, "DFHMDI", 7)?;
344    let (lines, columns) = match ops.words("SIZE") {
345        None => (24, 80),
346        Some(v) => {
347            let dims: Vec<u16> = v.iter().map(|s| s.parse().ok().filter(|n| (1..=240).contains(n)).ok_or(())).collect::<Result<_, _>>().map_err(|()| fail(st.line, "SIZE=(lines,columns), each 1 to 240"))?;
348            match dims[..] {
349                [l, c] => (l, c),
350                _ => return Err(fail(st.line, "SIZE=(lines,columns), each 1 to 240")),
351            }
352        }
353    };
354    let origin = |key: &str| -> Result<u16, Error> {
355        match ops.word(key)?.as_deref() {
356            None | Some("NEXT" | "SAME") => Ok(1),
357            Some(w) => w.parse().ok().filter(|n| (1..=240).contains(n)).ok_or_else(|| fail(st.line, format!("{key}={w} is not a number from 1 to 240"))),
358        }
359    };
360    Ok(Map {
361        name,
362        lines,
363        columns,
364        line: origin("LINE")?,
365        column: origin("COLUMN")?,
366        ctrl: ops.words("CTRL").unwrap_or_else(|| open.set.ctrl.clone()),
367        tioapfx: ops.yes_no("TIOAPFX")?.unwrap_or(open.tioapfx),
368        dsatts: ops.dsatts()?.unwrap_or_else(|| open.dsatts.clone()),
369        fields: Vec::new(),
370    })
371}
372
373fn attrb(ops: &Operands) -> Result<Attrb, Error> {
374    let mut a = Attrb { protection: Protection::Askip, numeric: false, intensity: Intensity::Norm, detectable: false, cursor: false, fset: false };
375    let Some(words) = ops.words("ATTRB") else { return Ok(a) };
376    a.protection = Protection::Unprot;
377    for w in words {
378        match w.as_str() {
379            "ASKIP" => a.protection = Protection::Askip,
380            "PROT" => a.protection = Protection::Prot,
381            "UNPROT" => a.protection = Protection::Unprot,
382            "NUM" => a.numeric = true,
383            "BRT" => a.intensity = Intensity::Brt,
384            "NORM" => a.intensity = Intensity::Norm,
385            "DRK" => a.intensity = Intensity::Drk,
386            "DET" => a.detectable = true,
387            "IC" => a.cursor = true,
388            "FSET" => a.fset = true,
389            other => return Err(fail(ops.line, format!("ATTRB={other} is not an attribute"))),
390        }
391    }
392    Ok(a)
393}
394
395fn hex_bytes(s: &str, line: u32) -> Result<Vec<u8>, Error> {
396    if !s.len().is_multiple_of(2) || !s.is_ascii() {
397        return Err(fail(line, "XINIT takes an even number of hexadecimal digits"));
398    }
399    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| fail(line, "XINIT takes hexadecimal digits"))).collect()
400}
401
402fn field(st: &Statement, ops: &Operands, map: &Map) -> Result<Field, Error> {
403    let name = st.label.as_ref().map(|_| name_of(st, "DFHMDF", MAX_NAME)).transpose()?;
404    let line = st.line;
405    let attrb = attrb(ops)?;
406    let occurs = ops.number("OCCURS", 1, u16::MAX.into())?.unwrap_or(1) as u16;
407    let group = ops.word("GRPNAME")?;
408    if group.is_some() && (ops.get("OCCURS").is_some() || name.is_none()) {
409        return Err(fail(line, "GRPNAME needs a labelled field and does not go with OCCURS"));
410    }
411    if group.as_ref().is_some_and(|g| g.len() > MAX_NAME) {
412        return Err(fail(line, "GRPNAME is longer than 30 characters"));
413    }
414    let initial = match (ops.text("INITIAL")?.or(ops.text("GINIT")?), ops.text("XINIT")?) {
415        (_, Some(x)) => Some(Initial::Bytes(hex_bytes(&x, line)?)),
416        (Some(t), None) => Some(Initial::Text(t)),
417        (None, None) => None,
418    };
419    let (picin, picout) = (ops.text("PICIN")?, ops.text("PICOUT")?);
420    let from_data = match &initial {
421        Some(Initial::Text(t)) => t.chars().count() as u32,
422        Some(Initial::Bytes(b)) => b.len() as u32,
423        None => 0,
424    };
425    let length = match ops.number("LENGTH", 0, 256)? {
426        Some(0) if name.is_some() => return Err(fail(line, "LENGTH=0 is allowed only on an unlabelled field, where it delimits an input field")),
427        Some(n) => n,
428        None => picin.iter().chain(&picout).map(|p| picture_size(p)).max().unwrap_or(from_data),
429    };
430    if length > 256 || (length == 0 && name.is_some()) {
431        return Err(fail(line, "LENGTH is missing, or is not from 1 to 256"));
432    }
433    let (line_no, column) = position(ops, map)?;
434    let justify = ops.words("JUSTIFY").unwrap_or_default();
435    for w in &justify {
436        if !matches!(w.as_str(), "LEFT" | "RIGHT" | "BLANK" | "ZERO") {
437            return Err(fail(line, format!("JUSTIFY={w}: LEFT or RIGHT, BLANK or ZERO")));
438        }
439    }
440    let has = |w: &str| justify.iter().any(|j| j == w);
441    let color = ops.word("COLOR")?;
442    if let Some(c) = &color
443        && !matches!(c.as_str(), "BLUE" | "RED" | "PINK" | "GREEN" | "TURQUOISE" | "YELLOW" | "NEUTRAL" | "DEFAULT")
444    {
445        return Err(fail(line, format!("COLOR={c} is not a colour")));
446    }
447    let hilight = ops.word("HILIGHT")?;
448    if let Some(h) = &hilight
449        && !matches!(h.as_str(), "OFF" | "BLINK" | "REVERSE" | "UNDERLINE")
450    {
451        return Err(fail(line, format!("HILIGHT={h} is not a highlight")));
452    }
453    Ok(Field {
454        name,
455        line: line_no,
456        column,
457        length: length as u16,
458        attrb,
459        initial,
460        picin,
461        picout,
462        occurs,
463        group,
464        justify_right: if has("RIGHT") || has("LEFT") { has("RIGHT") } else { attrb.numeric },
465        fill_zero: if has("ZERO") || has("BLANK") { has("ZERO") } else { attrb.numeric },
466        color,
467        hilight,
468    })
469}
470
471/// POS as a 0-based offset or (line,column); without POS the field follows the previous one.
472fn position(ops: &Operands, map: &Map) ->Result<(u16, u16), Error> {
473    let bad = || fail(ops.line, "POS is an offset within the map, or (line,column) inside it");
474    let (lines, columns) = (u32::from(map.lines), u32::from(map.columns));
475    let offset = match ops.words("POS") {
476        None => map.fields.last().map_or(0, |f| (u32::from(f.line) - 1) * columns + u32::from(f.column) - 1 + u32::from(f.occurs) * (u32::from(f.length) + 1)),
477        Some(v) => match v.iter().map(|s| s.parse::<u32>()).collect::<Result<Vec<_>, _>>().map_err(|_| bad())?[..] {
478            [n] => n,
479            [l, c] if (1..=lines).contains(&l) && (1..=columns).contains(&c) => (l - 1) * columns + c - 1,
480            _ => return Err(bad()),
481        },
482    };
483    if offset >= lines * columns {
484        return Err(bad());
485    }
486    Ok(((offset / columns + 1) as u16, (offset % columns + 1) as u16))
487}
488
489/// The COBOL a program COPYs for the mapset: each map's input and output structures.
490pub fn symbolic_map(mapset: &Mapset) -> String {
491    let mut out = String::new();
492    for map in &mapset.maps {
493        if mapset.mode != Mode::Out {
494            structure(&mut out, map, true, mapset.mode);
495        }
496        if mapset.mode != Mode::In {
497            structure(&mut out, map, false, mapset.mode);
498        }
499    }
500    out
501}
502
503/// One COBOL data entry; an entry that would pass column 72 puts its clauses on the next line.
504fn entry(out: &mut String, level: usize, name: &str, clauses: &str) {
505    let indent = if level == 1 { 7 } else { 11 + 3 * (level - 2) };
506    let sep = if level == 1 { "  " } else { " " };
507    let head = format!("{:indent$}{level:02}{sep}{name}", "");
508    let line = if clauses.is_empty() { format!("{head}.") } else { format!("{head} {clauses}.") };
509    if line.len() <= 72 || clauses.is_empty() {
510        let _ = writeln!(out, "{line}");
511    } else {
512        let _ = writeln!(out, "{head}\n{:width$}{clauses}.", "", width = indent + 4);
513    }
514}
515
516fn structure(out: &mut String, map: &Map, input: bool, mode: Mode) {
517    let side = if input { 'I' } else { 'O' };
518    if input || mode == Mode::Out {
519        entry(out, 1, &format!("{}{side}", map.name), "");
520    } else {
521        entry(out, 1, &format!("{}O", map.name), &format!("REDEFINES {}I", map.name));
522    }
523    if map.tioapfx {
524        entry(out, 2, "FILLER", "PIC X(12)");
525    }
526    let mut group: Option<&str> = None;
527    for f in map.fields.iter().filter(|f| f.name.is_some()) {
528        let (level, lead) = match &f.group {
529            Some(g) => {
530                let first = group != Some(g.as_str());
531                if first {
532                    entry(out, 2, g, "");
533                }
534                group = Some(g);
535                (3, first)
536            }
537            None => {
538                group = None;
539                if f.occurs > 1 {
540                    let name = format!("{}{}", f.name.as_deref().unwrap_or_default(), if input { 'D' } else { 'G' });
541                    entry(out, 2, &name, &format!("OCCURS {}", f.occurs));
542                    (3, true)
543                } else {
544                    (2, true)
545                }
546            }
547        };
548        if input {
549            input_items(out, map, f, level, lead);
550        } else {
551            output_items(out, map, f, level, lead, mode);
552        }
553    }
554}
555
556fn input_items(out: &mut String, map: &Map, f: &Field, level: usize, lead: bool) {
557    let n = f.name.as_deref().unwrap_or_default();
558    if lead {
559        entry(out, level, &format!("{n}L"), "COMP PIC S9(4)");
560        entry(out, level, &format!("{n}F"), "PICTURE X");
561        entry(out, level, "FILLER", &format!("REDEFINES {n}F"));
562        entry(out, level + 1, &format!("{n}A"), "PICTURE X");
563        let k = extended_attributes(map).len();
564        if k > 0 {
565            entry(out, level, "FILLER", &format!("PICTURE X({k})"));
566        }
567    }
568    let pic = f.picin.clone().unwrap_or_else(|| format!("X({})", f.length));
569    entry(out, level, &format!("{n}I"), &format!("PIC {pic}"));
570}
571
572fn output_items(out: &mut String, map: &Map, f: &Field, level: usize, lead: bool, mode: Mode) {
573    let n = f.name.as_deref().unwrap_or_default();
574    if lead {
575        if mode == Mode::InOut {
576            entry(out, level, "FILLER", "PICTURE X(3)");
577        } else {
578            entry(out, level, "FILLER", "PICTURE X(2)");
579            entry(out, level, &format!("{n}A"), "PICTURE X");
580        }
581        for (a, s, _) in EXTENDED {
582            if map.dsatts.iter().any(|d| d == a) {
583                entry(out, level, &format!("{n}{s}"), "PICTURE X");
584            }
585        }
586    }
587    let pic = f.picout.clone().unwrap_or_else(|| format!("X({})", f.length));
588    entry(out, level, &format!("{n}O"), &format!("PIC {pic}"));
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn slots_match_the_symbolic_map() {
597        let sets = parse(&realistic()).unwrap();
598        let map = &sets[0].maps[0];
599        let slots = slots(map, true);
600        let k = extended_attributes(map).len();
601        let first = slots[0];
602        assert_eq!(first.length_at(), Some(if map.tioapfx { 12 } else { 0 }));
603        assert_eq!(first.data, first.length_at().unwrap() + 3 + k);
604        for pair in slots.windows(2) {
605            let (a, b) = (pair[0], pair[1]);
606            let gap = if b.control.is_some() { 3 + k } else { 0 };
607            assert_eq!(b.data, a.data + a.size + gap);
608        }
609    }
610
611    /// A line with X in column 72.
612    fn cont(line: &str) -> String {
613        format!("{line:<71}X\n")
614    }
615
616    fn realistic() -> String {
617        let mut s = String::new();
618        s.push_str("* Demo mapset\n         PRINT NOGEN\n");
619        s.push_str(&cont("DEMOSET  DFHMSD TYPE=&SYSPARM,MODE=INOUT,LANG=COBOL,STORAGE=AUTO,"));
620        s.push_str("               CTRL=(FREEKB,FRSET),DSATTS=(COLOR,HILIGHT)   SEQ00010\n");
621        s.push_str("DEMO     DFHMDI SIZE=(24,80),LINE=1,COLUMN=1\n");
622        s.push_str(&cont("         DFHMDF POS=(1,30),LENGTH=15,ATTRB=(ASKIP,BRT),"));
623        s.push_str("               INITIAL='ACCOUNT ENQUIRY'\n");
624        s.push_str(&cont("NAME     DFHMDF POS=(3,10),LENGTH=20,ATTRB=(UNPROT,IC,FSET),COLOR=RED,"));
625        s.push_str("               HILIGHT=UNDERLINE,INITIAL='O''NEIL'\n");
626        s.push_str(&cont("AMT      DFHMDF POS=165,ATTRB=(NUM,BRT),PICIN='9(5)V99',"));
627        s.push_str("               PICOUT='ZZ,ZZ9.99',JUSTIFY=(RIGHT,ZERO)\n");
628        s.push_str("ROW      DFHMDF POS=(6,2),LENGTH=8,OCCURS=3\n");
629        s.push_str("ADDR1    DFHMDF POS=(10,2),LENGTH=20,GRPNAME=ADDR,ATTRB=(UNPROT)\n");
630        s.push_str("ADDR2    DFHMDF POS=(11,2),LENGTH=20,GRPNAME=ADDR\n");
631        s.push_str("         DFHMSD TYPE=FINAL\n         END\n");
632        s
633    }
634
635    #[test]
636    fn a_realistic_mapset_parses() {
637        let sets = parse(&realistic()).unwrap();
638        assert_eq!(sets.len(), 1);
639        let set = &sets[0];
640        assert_eq!((set.name.as_str(), set.mode), ("DEMOSET", Mode::InOut));
641        assert_eq!(set.ctrl, ["FREEKB", "FRSET"]);
642        let map = &set.maps[0];
643        assert_eq!((map.name.as_str(), map.lines, map.columns), ("DEMO", 24, 80));
644        assert!(map.tioapfx);
645        assert_eq!(map.dsatts, ["COLOR", "HILIGHT"]);
646        assert_eq!(map.fields.len(), 6);
647
648        let title = &map.fields[0];
649        assert_eq!((title.name.clone(), title.line, title.column, title.length), (None, 1, 30, 15));
650        assert_eq!(title.initial, Some(Initial::Text("ACCOUNT ENQUIRY".into())));
651        assert_eq!((title.attrb.protection, title.attrb.intensity), (Protection::Askip, Intensity::Brt));
652
653        let name = &map.fields[1];
654        assert_eq!(name.name.as_deref(), Some("NAME"));
655        assert_eq!((name.line, name.column), (3, 10));
656        assert!(name.attrb.cursor && name.attrb.fset && name.attrb.protection == Protection::Unprot);
657        assert_eq!((name.color.as_deref(), name.hilight.as_deref()), (Some("RED"), Some("UNDERLINE")));
658        assert_eq!(name.initial, Some(Initial::Text("O'NEIL".into())));
659        assert!(!name.justify_right && !name.fill_zero);
660
661        let amt = &map.fields[2];
662        assert_eq!((amt.line, amt.column), (3, 6));
663        assert_eq!((amt.picin.as_deref(), amt.picout.as_deref()), (Some("9(5)V99"), Some("ZZ,ZZ9.99")));
664        assert_eq!(amt.length, 9);
665        assert!(amt.attrb.numeric && amt.justify_right && amt.fill_zero);
666
667        assert_eq!(map.fields[3].occurs, 3);
668        assert_eq!(map.fields[4].group.as_deref(), Some("ADDR"));
669        assert_eq!(map.fields[5].group.as_deref(), Some("ADDR"));
670    }
671
672    #[test]
673    fn zero_length_is_for_an_unlabelled_field_only() {
674        let src = |label: &str| format!("M DFHMSD TYPE=MAP\nM1 DFHMDI SIZE=(24,80)\n{label:<9}DFHMDF POS=(6,33),LENGTH=0,ATTRB=ASKIP\n");
675        let map = &parse(&src("")).unwrap()[0].maps[0];
676        assert_eq!((map.fields[0].name.clone(), map.fields[0].length), (None, 0));
677        let err = parse(&src("AMT")).unwrap_err();
678        assert!(err.to_string().contains("LENGTH=0 is allowed only on an unlabelled field"), "{err}");
679    }
680
681    #[test]
682    fn a_quoted_string_continues_on_the_next_line() {
683        let head = "         DFHMDF POS=1,LENGTH=20,INITIAL='";
684        let fill = "A".repeat(71 - head.len());
685        let src = format!("M DFHMSD TYPE=MAP\nM1 DFHMDI\n{}{}", cont(&format!("{head}{fill}")), "               BC'\n");
686        let map = &parse(&src).unwrap()[0].maps[0];
687        assert_eq!(map.fields[0].initial, Some(Initial::Text(format!("{fill}BC"))));
688    }
689
690    #[test]
691    fn xinit_gives_bytes_and_several_mapsets_are_kept() {
692        let src = "S1 DFHMSD TYPE=MAP\nM1 DFHMDI\n   DFHMDF POS=1,LENGTH=2,XINIT=C1C2\n   DFHMSD TYPE=FINAL\nS2 DFHMSD TYPE=MAP,MODE=IN\n   DFHMSD TYPE=FINAL\n";
693        let sets = parse(src).unwrap();
694        assert_eq!(sets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["S1", "S2"]);
695        assert_eq!(sets[0].maps[0].fields[0].initial, Some(Initial::Bytes(vec![0xC1, 0xC2])));
696        assert_eq!(sets[1].mode, Mode::In);
697        assert_eq!(sets[0].mode, Mode::Out);
698    }
699
700    fn two_field_map() -> Mapset {
701        let src = "MAPS DFHMSD TYPE=MAP,MODE=INOUT,STORAGE=AUTO,DSATTS=(COLOR)\nMAP1 DFHMDI SIZE=(24,80)\nNAME DFHMDF POS=(1,1),LENGTH=10\nAMT DFHMDF POS=(2,1),PICIN='9(5)',PICOUT='ZZZZ9'\n DFHMSD TYPE=FINAL\n";
702        parse(src).unwrap().remove(0)
703    }
704
705    #[test]
706    fn the_symbolic_map_of_a_two_field_map() {
707        let expected = "\n       01  MAP1I.
708           02 FILLER PIC X(12).
709           02 NAMEL COMP PIC S9(4).
710           02 NAMEF PICTURE X.
711           02 FILLER REDEFINES NAMEF.
712              03 NAMEA PICTURE X.
713           02 FILLER PICTURE X(1).
714           02 NAMEI PIC X(10).
715           02 AMTL COMP PIC S9(4).
716           02 AMTF PICTURE X.
717           02 FILLER REDEFINES AMTF.
718              03 AMTA PICTURE X.
719           02 FILLER PICTURE X(1).
720           02 AMTI PIC 9(5).
721       01  MAP1O REDEFINES MAP1I.
722           02 FILLER PIC X(12).
723           02 FILLER PICTURE X(3).
724           02 NAMEC PICTURE X.
725           02 NAMEO PIC X(10).
726           02 FILLER PICTURE X(3).
727           02 AMTC PICTURE X.
728           02 AMTO PIC ZZZZ9.
729";
730        assert_eq!(symbolic_map(&two_field_map()).lines().collect::<Vec<_>>(), expected[1..].lines().collect::<Vec<_>>());
731    }
732
733    #[test]
734    fn output_only_occurs_and_groups() {
735        let src = "MAPS DFHMSD TYPE=MAP,TIOAPFX=NO\nM1 DFHMDI\nROW DFHMDF POS=1,LENGTH=4,OCCURS=2\nA1 DFHMDF POS=100,LENGTH=3,GRPNAME=G\nA2 DFHMDF POS=120,LENGTH=5,GRPNAME=G\n DFHMSD TYPE=FINAL\n";
736        let mapset = parse(src).unwrap().remove(0);
737        let expected = "\n       01  M1O.
738           02 ROWG OCCURS 2.
739              03 FILLER PICTURE X(2).
740              03 ROWA PICTURE X.
741              03 ROWO PIC X(4).
742           02 G.
743              03 FILLER PICTURE X(2).
744              03 A1A PICTURE X.
745              03 A1O PIC X(3).
746              03 A2O PIC X(5).
747";
748        assert_eq!(symbolic_map(&mapset), &expected[1..]);
749        let input = symbolic_map(&Mapset { mode: Mode::In, ..mapset });
750        assert!(input.contains("           02 ROWD OCCURS 2.\n              03 ROWL COMP PIC S9(4).\n"), "{input}");
751        assert!(input.contains("           02 G.\n              03 A1L COMP PIC S9(4).") && input.contains("              03 A2I PIC X(5).\n"), "{input}");
752        assert!(!input.contains("A2L") && input.lines().all(|l| l.len() <= 72));
753    }
754
755    fn library(name: &str, files: &[(&str, String)]) -> Libraries {
756        let dir = std::env::temp_dir().join(format!("ironwork-bms-{}-{name}", std::process::id()));
757        std::fs::create_dir_all(&dir).unwrap();
758        for (file, text) in files {
759            std::fs::write(dir.join(file), text).unwrap();
760        }
761        Libraries::new(vec![dir])
762    }
763
764    /// Offsets of each level-02 item under the 01 `record` of the working storage.
765    fn offsets(program: &crate::ast::Program, record: &str) -> Vec<(String, u32)> {
766        let mut at = 0;
767        let mut inside = false;
768        let mut parent = 0;
769        let mut out: Vec<(String, u32)> = Vec::new();
770        for e in &program.working_storage {
771            match e.level {
772                1 => inside = e.name.as_deref() == Some(record),
773                2 if inside => {
774                    let size = if e.usage.is_some() { 2 } else { e.picture.as_deref().map_or(0, picture_size) };
775                    let name = e.name.clone().unwrap_or_default();
776                    match &e.redefines {
777                        Some(target) => {
778                            parent = out.iter().find(|(n, _)| n == target).unwrap().1;
779                            out.push((name, parent));
780                        }
781                        None => {
782                            parent = at;
783                            out.push((name, at));
784                            at += size;
785                        }
786                    }
787                }
788                3 if inside => out.push((e.name.clone().unwrap_or_default(), parent)),
789                _ => {}
790            }
791        }
792        out
793    }
794
795    #[test]
796    fn a_program_copies_a_mapset_and_gets_its_layout() {
797        let bms = "DEMOMAP DFHMSD TYPE=&SYSPARM,MODE=INOUT,STORAGE=AUTO\nM1 DFHMDI SIZE=(24,80)\nNAME DFHMDF POS=(1,1),LENGTH=10\n DFHMSD TYPE=FINAL\n".to_owned();
798        let libs = library("layout", &[("DEMOMAP.bms", bms)]);
799        let program = "       IDENTIFICATION DIVISION.\n       PROGRAM-ID. P.\n       DATA DIVISION.\n       WORKING-STORAGE SECTION.\n           COPY DEMOMAP.\n       PROCEDURE DIVISION.\n           GOBACK.\n";
800        let p = crate::parse_with(program, &libs).unwrap();
801        let at = |n: &str| offsets(&p, "M1I").into_iter().find(|(name, _)| name == n).unwrap().1;
802        assert_eq!((at("NAMEL"), at("NAMEF"), at("NAMEA"), at("NAMEI")), (12, 14, 14, 15));
803        assert!(p.working_storage.iter().any(|e| e.name.as_deref() == Some("M1O") && e.redefines.as_deref() == Some("M1I")));
804        assert_eq!(find_mapset(&libs, "DEMOMAP").unwrap().unwrap().maps[0].fields[0].name.as_deref(), Some("NAME"));
805        assert!(find_mapset(&libs, "NOPE").is_none());
806    }
807
808    #[test]
809    fn copy_of_a_broken_mapset_names_the_file() {
810        let libs = library("broken", &[("BAD.bms", "BAD DFHMSD TYPE=MAP\n FROBNICATE X=1\n".to_owned())]);
811        let err = crate::parse_with("       COPY BAD.\n", &libs).unwrap_err();
812        assert!(err.message.contains("unknown macro FROBNICATE") && err.message.contains("BAD.bms"), "{err}");
813    }
814
815    #[test]
816    fn dfhaid_has_values() {
817        let src = "       IDENTIFICATION DIVISION.\n       PROGRAM-ID. P.\n       DATA DIVISION.\n       WORKING-STORAGE SECTION.\n           COPY DFHAID.\n           COPY DFHBMSCA.\n       PROCEDURE DIVISION.\n           GOBACK.\n";
818        let p = crate::parse_with(src, &Libraries::default()).unwrap();
819        let entry = |n: &str| p.working_storage.iter().find(|e| e.name.as_deref() == Some(n)).unwrap();
820        assert_eq!(entry("DFHENTER").value, Some(crate::ast::Literal::Hex(vec![0x7D])));
821        assert_eq!(entry("DFHPF24").value, Some(crate::ast::Literal::Hex(vec![0x4C])));
822        assert_eq!(entry("DFHBMASK").value, Some(crate::ast::Literal::Hex(vec![0xF0])));
823        let erase = entry("DFHERASE");
824        assert_eq!((erase.level, erase.condition_values.len()), (88, 2));
825    }
826
827    #[test]
828    fn errors_point_at_the_line() {
829        let e = |src: &str| parse(src).unwrap_err();
830        assert!(e("S DFHMSD TYPE=MAP\n WIBBLE\n").message.contains("unknown macro WIBBLE"));
831        let outside = e("* c\nF DFHMDF POS=1,LENGTH=1\n");
832        assert_eq!(outside.pos.line, 2);
833        assert!(outside.message.contains("outside"));
834        let bad = e("S DFHMSD TYPE=MAP\nM DFHMDI SIZE=(2,10)\nF DFHMDF POS=(3,1),LENGTH=1\n");
835        assert_eq!(bad.pos.line, 3);
836        assert!(bad.message.contains("POS"));
837        assert!(e("S DFHMSD TYPE=MAP\nM DFHMDI\nF DFHMDF POS=ABC,LENGTH=1\n").message.contains("POS"));
838        assert!(e("S DFHMSD TYPE=MAP\nTOOLONGNAME DFHMDI\n").message.contains("longer than 7"));
839        assert!(e("S DFHMSD TYPE=MAP\nM DFHMDI\nF DFHMDF POS=1,LENGTH=1,OCCURS=2,GRPNAME=G\n").message.contains("GRPNAME"));
840    }
841}