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