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