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