tawasuyu

tawasuyu/brahman

Rust 334TOML 129Markdown 39JSON 174745 Definiciones

⎇ 3902763daa94397756ff2394116daef97814090a crates/modules/charka/charka-parser/src/lib.rs Rust · 727 líneas · 24.0 KB Historia Quién tocó qué crudo

feat(charka): OCCURS — tablas y referencias con subíndice 3902763da · sergio ·

1//! `charka-parser` — parser de COBOL'85 (subconjunto) a AST.
2//!
3//! Segunda etapa del transpilador COBOL→Rust: consume los [`Token`] del
4//! `charka-lexer` y produce un [`Program`]. El alcance de la v1 es el
5//! **esqueleto del programa**:
6//!
7//! - Las cuatro divisiones (`IDENTIFICATION`, `ENVIRONMENT`, `DATA`,
8//!   `PROCEDURE`) y el `PROGRAM-ID`.
9//! - La **DATA division** como un árbol de [`DataItem`]: número de
10//!   nivel, nombre, cláusula `PICTURE` reensamblada y `VALUE`.
11//! - La **PROCEDURE division** como una lista de [`Paragraph`], cada
12//!   uno con sus [`Sentence`] (los tokens crudos de cada sentencia).
13//!
14//! Lo que **no** hace todavía: el parseo a nivel de statement (cada
15//! sentencia queda como `Vec<Token>` — es trabajo de `charka-ir`), la
16//! ENVIRONMENT division, CICS, SQL embebido y los dialectos IBM. Son
17//! las etapas siguientes del plan.
18//!
19//! El parser es tolerante: salta lo que no entiende (encabezados de
20//! `SECTION`, entradas `FD`/`SD`, cláusulas de datos que no sean
21//! `PICTURE`/`VALUE`) en vez de fallar. Sólo emite [`ParseError`] ante
22//! un número de nivel inválido o un dato sin nombre.
23
24#![forbid(unsafe_code)]
25
26use thiserror::Error;
27
28pub use charka_lexer::{Token, TokenKind};
29
30/// Un programa COBOL parseado: el esqueleto de sus cuatro divisiones.
31#[derive(Debug, Clone, PartialEq, Eq, Default)]
32pub struct Program {
33    /// El `PROGRAM-ID` de la IDENTIFICATION division, si está presente.
34    pub program_id: Option<String>,
35    /// Los ítems raíz de la DATA division (cada `01`/`77` con su árbol).
36    pub data: Vec<DataItem>,
37    /// Los párrafos de la PROCEDURE division, en orden de aparición.
38    pub paragraphs: Vec<Paragraph>,
39}
40
41/// Un ítem de datos de la DATA division: un número de nivel, un nombre
42/// y, opcionalmente, las cláusulas `PICTURE` y `VALUE`. Los ítems de
43/// mayor nivel numérico cuelgan como `children` del que los contiene.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DataItem {
46    /// Número de nivel: 01-49 (jerárquico), 66 (`RENAMES`), 77
47    /// (elemental independiente) u 88 (nombre de condición).
48    pub level: u8,
49    /// Nombre del dato, normalizado a mayúsculas (`FILLER` incluido).
50    pub name: String,
51    /// Cláusula `PICTURE` reensamblada (`S9(5)V99`), si la hay.
52    pub picture: Option<String>,
53    /// Cláusula `VALUE`: literal numérico (con signo), constante
54    /// figurativa en mayúsculas, o literal de texto entre comillas.
55    pub value: Option<String>,
56    /// Cláusula `OCCURS n [TIMES]`: el dato es una tabla de `n`
57    /// elementos. `None` si es un dato escalar.
58    pub occurs: Option<u32>,
59    /// Ítems subordinados (de nivel numérico mayor).
60    pub children: Vec<DataItem>,
61}
62
63/// Un párrafo de la PROCEDURE division. El párrafo implícito que
64/// agrupa las sentencias previas al primer encabezado tiene `name` "".
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Paragraph {
67    /// Nombre del párrafo en mayúsculas; "" para el párrafo implícito.
68    pub name: String,
69    /// Sentencias del párrafo, en orden.
70    pub sentences: Vec<Sentence>,
71}
72
73/// Una sentencia: los tokens entre dos puntos terminadores. La v1 no
74/// parsea a nivel de statement — eso es trabajo de `charka-ir`.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Sentence {
77    pub tokens: Vec<Token>,
78}
79
80/// Error de parseo, con la línea del token donde se detectó.
81#[derive(Debug, Clone, PartialEq, Eq, Error)]
82pub enum ParseError {
83    #[error("línea {line}: número de nivel inválido {found:?} (esperado 01-49, 66, 77 u 88)")]
84    BadLevel { line: u32, found: String },
85    #[error("línea {line}: se esperaba el nombre de un dato tras el número de nivel")]
86    ExpectedDataName { line: u32 },
87}
88
89/// Las cuatro divisiones de un programa COBOL.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum DivKind {
92    Identification,
93    Environment,
94    Data,
95    Procedure,
96}
97
98/// Parsea una secuencia de tokens COBOL en un [`Program`].
99pub fn parse(tokens: &[Token]) -> Result<Program, ParseError> {
100    let divs = find_divisions(tokens);
101    let mut program = Program::default();
102    for (idx, &(kind, start)) in divs.iter().enumerate() {
103        // El cuerpo de la división va desde el punto que cierra su
104        // encabezado hasta el comienzo de la división siguiente.
105        let lo = match period_after(tokens, start) {
106            Some(p) => p + 1,
107            None => tokens.len(),
108        };
109        let next = divs.get(idx + 1).map(|&(_, s)| s).unwrap_or(tokens.len());
110        let body = &tokens[lo..next.max(lo)];
111        match kind {
112            DivKind::Identification => parse_identification(body, &mut program),
113            DivKind::Environment => {} // la v1 ignora la ENVIRONMENT division
114            DivKind::Data => program.data = parse_data(body)?,
115            DivKind::Procedure => program.paragraphs = parse_procedure(body),
116        }
117    }
118    Ok(program)
119}
120
121/// Localiza los encabezados de división (`X DIVISION`) y devuelve, en
122/// orden, su clase y el índice del token de la palabra de división.
123fn find_divisions(tokens: &[Token]) -> Vec<(DivKind, usize)> {
124    let mut divs = Vec::new();
125    let mut i = 0;
126    while i + 1 < tokens.len() {
127        if kw(tokens.get(i + 1)).as_deref() == Some("DIVISION") {
128            let kind = match kw(tokens.get(i)).as_deref() {
129                Some("IDENTIFICATION") | Some("ID") => Some(DivKind::Identification),
130                Some("ENVIRONMENT") => Some(DivKind::Environment),
131                Some("DATA") => Some(DivKind::Data),
132                Some("PROCEDURE") => Some(DivKind::Procedure),
133                _ => None,
134            };
135            if let Some(k) = kind {
136                divs.push((k, i));
137                i += 2;
138                continue;
139            }
140        }
141        i += 1;
142    }
143    divs
144}
145
146/// Extrae el `PROGRAM-ID` del cuerpo de la IDENTIFICATION division.
147fn parse_identification(body: &[Token], program: &mut Program) {
148    for (i, t) in body.iter().enumerate() {
149        if kw(Some(t)).as_deref() == Some("PROGRAM-ID") {
150            // `PROGRAM-ID. NAME.` — el punto tras `PROGRAM-ID` es opcional.
151            let mut j = i + 1;
152            if body.get(j).map(|t| t.kind) == Some(TokenKind::Period) {
153                j += 1;
154            }
155            if let Some(name) = body.get(j) {
156                program.program_id = Some(match name.kind {
157                    TokenKind::Word => name.text.to_uppercase(),
158                    TokenKind::String => name.text.clone(),
159                    _ => continue,
160                });
161            }
162            return;
163        }
164    }
165}
166
167/// Parsea el cuerpo de la DATA division en un árbol de [`DataItem`].
168fn parse_data(body: &[Token]) -> Result<Vec<DataItem>, ParseError> {
169    let mut flat = Vec::new();
170    for sent in split_sentences(body) {
171        let Some(first) = sent.first() else { continue };
172        // Sólo las sentencias que arrancan con un número de nivel son
173        // entradas de datos; los encabezados de SECTION y las entradas
174        // FD/SD empiezan con palabra y se ignoran.
175        if first.kind != TokenKind::Number {
176            continue;
177        }
178        let level = parse_level(first)?;
179        flat.push(parse_data_entry(level, &sent)?);
180    }
181    Ok(build_tree(flat))
182}
183
184/// Valida que el token sea un número de nivel COBOL (01-49, 66, 77, 88).
185fn parse_level(t: &Token) -> Result<u8, ParseError> {
186    let bad = || ParseError::BadLevel {
187        line: t.line,
188        found: t.text.clone(),
189    };
190    let n: u8 = t.text.parse().map_err(|_| bad())?;
191    if (1..=49).contains(&n) || n == 66 || n == 77 || n == 88 {
192        Ok(n)
193    } else {
194        Err(bad())
195    }
196}
197
198/// Parsea una sola entrada de datos (una sentencia ya sin el punto).
199fn parse_data_entry(level: u8, sent: &[Token]) -> Result<DataItem, ParseError> {
200    let line = sent[0].line;
201    let name_tok = sent.get(1).ok_or(ParseError::ExpectedDataName { line })?;
202    if name_tok.kind != TokenKind::Word {
203        return Err(ParseError::ExpectedDataName {
204            line: name_tok.line,
205        });
206    }
207    let name = name_tok.text.to_uppercase();
208
209    let mut picture = None;
210    let mut value = None;
211    let mut occurs = None;
212    let mut i = 2;
213    while i < sent.len() {
214        match kw(sent.get(i)).as_deref() {
215            Some("OCCURS") => {
216                i += 1;
217                if let Some(t) = sent.get(i) {
218                    if t.kind == TokenKind::Number {
219                        if occurs.is_none() {
220                            occurs = t.text.parse::<u32>().ok();
221                        }
222                        i += 1;
223                    }
224                }
225                if kw(sent.get(i)).as_deref() == Some("TIMES") {
226                    i += 1;
227                }
228            }
229            Some("PIC") | Some("PICTURE") => {
230                i += 1;
231                if kw(sent.get(i)).as_deref() == Some("IS") {
232                    i += 1;
233                }
234                let (pic, next) = collect_picture(sent, i);
235                if picture.is_none() && !pic.is_empty() {
236                    picture = Some(pic);
237                }
238                i = next;
239            }
240            Some("VALUE") => {
241                i += 1;
242                if matches!(kw(sent.get(i)).as_deref(), Some("IS") | Some("ARE")) {
243                    i += 1;
244                }
245                let (val, next) = collect_value(sent, i);
246                if value.is_none() {
247                    value = val;
248                }
249                i = next;
250            }
251            _ => i += 1,
252        }
253    }
254
255    Ok(DataItem {
256        level,
257        name,
258        picture,
259        value,
260        occurs,
261        children: Vec::new(),
262    })
263}
264
265/// Reensambla la cláusula PICTURE: concatena el texto de los tokens
266/// (`S9` `(` `5` `)` `V99` → `S9(5)V99`) hasta toparse con otra
267/// cláusula de datos. El resultado va en mayúsculas.
268fn collect_picture(sent: &[Token], start: usize) -> (String, usize) {
269    let mut s = String::new();
270    let mut i = start;
271    while let Some(t) = sent.get(i) {
272        if let Some(w) = kw(Some(t)) {
273            if is_data_clause_kw(&w) {
274                break;
275            }
276        }
277        s.push_str(&t.text);
278        i += 1;
279    }
280    (s.to_uppercase(), i)
281}
282
283/// Captura el literal de una cláusula VALUE: un signo opcional seguido
284/// de un número, una constante figurativa o un literal de texto (que se
285/// devuelve entre comillas simples para distinguirlo de una palabra).
286fn collect_value(sent: &[Token], start: usize) -> (Option<String>, usize) {
287    let mut i = start;
288    let mut s = String::new();
289    if let Some(t) = sent.get(i) {
290        if t.kind == TokenKind::Symbol && (t.text == "+" || t.text == "-") {
291            s.push_str(&t.text);
292            i += 1;
293        }
294    }
295    if let Some(t) = sent.get(i) {
296        match t.kind {
297            TokenKind::Number => {
298                s.push_str(&t.text);
299                i += 1;
300            }
301            TokenKind::String => {
302                s.push('\'');
303                s.push_str(&t.text);
304                s.push('\'');
305                i += 1;
306            }
307            TokenKind::Word => {
308                // Constante figurativa: ZERO, ZEROS, SPACE, SPACES,
309                // HIGH-VALUE, LOW-VALUE, QUOTES, NULL...
310                s.push_str(&t.text.to_uppercase());
311                i += 1;
312            }
313            TokenKind::Period | TokenKind::Symbol => {}
314        }
315    }
316    if s.is_empty() {
317        (None, i)
318    } else {
319        (Some(s), i)
320    }
321}
322
323/// ¿Es `w` una palabra que abre una cláusula de datos? Marca el final
324/// de la cláusula PICTURE.
325fn is_data_clause_kw(w: &str) -> bool {
326    matches!(
327        w,
328        "VALUE"
329            | "OCCURS"
330            | "REDEFINES"
331            | "RENAMES"
332            | "USAGE"
333            | "COMP"
334            | "COMP-1"
335            | "COMP-2"
336            | "COMP-3"
337            | "COMP-4"
338            | "COMP-5"
339            | "COMPUTATIONAL"
340            | "COMPUTATIONAL-1"
341            | "COMPUTATIONAL-2"
342            | "COMPUTATIONAL-3"
343            | "COMPUTATIONAL-4"
344            | "COMPUTATIONAL-5"
345            | "BINARY"
346            | "PACKED-DECIMAL"
347            | "SIGN"
348            | "JUSTIFIED"
349            | "JUST"
350            | "BLANK"
351            | "SYNCHRONIZED"
352            | "SYNC"
353            | "DISPLAY"
354    )
355}
356
357/// Convierte la lista plana de ítems en un árbol según los niveles: un
358/// ítem cuelga del anterior cuyo nivel sea estrictamente menor. Los
359/// niveles 01 y 77 son siempre raíces.
360fn build_tree(flat: Vec<DataItem>) -> Vec<DataItem> {
361    let mut roots: Vec<DataItem> = Vec::new();
362    let mut stack: Vec<DataItem> = Vec::new();
363    for item in flat {
364        if item.level == 1 || item.level == 77 {
365            while let Some(done) = stack.pop() {
366                attach(&mut stack, &mut roots, done);
367            }
368        } else {
369            // Cerrar los hermanos y descendientes ya completos.
370            while stack.last().is_some_and(|top| top.level >= item.level) {
371                let done = stack.pop().unwrap();
372                attach(&mut stack, &mut roots, done);
373            }
374        }
375        stack.push(item);
376    }
377    while let Some(done) = stack.pop() {
378        attach(&mut stack, &mut roots, done);
379    }
380    roots
381}
382
383/// Engancha un ítem completo: como hijo del que esté en el tope de la
384/// pila, o como raíz si la pila está vacía.
385fn attach(stack: &mut [DataItem], roots: &mut Vec<DataItem>, item: DataItem) {
386    if let Some(parent) = stack.last_mut() {
387        parent.children.push(item);
388    } else {
389        roots.push(item);
390    }
391}
392
393/// Parsea el cuerpo de la PROCEDURE division en párrafos.
394fn parse_procedure(body: &[Token]) -> Vec<Paragraph> {
395    let mut paras: Vec<Paragraph> = Vec::new();
396    let mut current = Paragraph {
397        name: String::new(),
398        sentences: Vec::new(),
399    };
400    let mut current_named = false;
401    for sent in split_sentences(body) {
402        if let Some(name) = paragraph_header_name(&sent) {
403            // Cerrar el párrafo en curso si tiene identidad o contenido.
404            if current_named || !current.sentences.is_empty() {
405                paras.push(std::mem::replace(
406                    &mut current,
407                    Paragraph {
408                        name: String::new(),
409                        sentences: Vec::new(),
410                    },
411                ));
412            }
413            current.name = name;
414            current_named = true;
415        } else {
416            current.sentences.push(Sentence { tokens: sent });
417        }
418    }
419    if current_named || !current.sentences.is_empty() {
420        paras.push(current);
421    }
422    paras
423}
424
425/// ¿Es esta sentencia un encabezado de párrafo o de sección? Lo es si
426/// es una sola palabra (un nombre de párrafo) o `NOMBRE SECTION`. Se
427/// excluyen `EXIT`/`GOBACK`/`CONTINUE`, que de una sola palabra son
428/// statements, no encabezados.
429fn paragraph_header_name(sent: &[Token]) -> Option<String> {
430    match sent {
431        [w] if w.kind == TokenKind::Word => {
432            let name = w.text.to_uppercase();
433            if matches!(name.as_str(), "EXIT" | "GOBACK" | "CONTINUE") {
434                None
435            } else {
436                Some(name)
437            }
438        }
439        [w, s] if w.kind == TokenKind::Word && kw(Some(s)).as_deref() == Some("SECTION") => {
440            Some(w.text.to_uppercase())
441        }
442        _ => None,
443    }
444}
445
446/// Parte un tramo de tokens en sentencias por el punto terminador. El
447/// punto se descarta; las sentencias vacías no se emiten.
448fn split_sentences(body: &[Token]) -> Vec<Vec<Token>> {
449    let mut out = Vec::new();
450    let mut cur = Vec::new();
451    for t in body {
452        if t.kind == TokenKind::Period {
453            if !cur.is_empty() {
454                out.push(std::mem::take(&mut cur));
455            }
456        } else {
457            cur.push(t.clone());
458        }
459    }
460    if !cur.is_empty() {
461        out.push(cur);
462    }
463    out
464}
465
466/// El índice del primer `Period` en `tokens` a partir de `start`.
467fn period_after(tokens: &[Token], start: usize) -> Option<usize> {
468    tokens
469        .iter()
470        .enumerate()
471        .skip(start)
472        .find(|(_, t)| t.kind == TokenKind::Period)
473        .map(|(i, _)| i)
474}
475
476/// Si el token es una palabra, su texto en mayúsculas (COBOL es
477/// case-insensitive). `None` para cualquier otra clase de token o
478/// posición ausente.
479fn kw(t: Option<&Token>) -> Option<String> {
480    match t {
481        Some(t) if t.kind == TokenKind::Word => Some(t.text.to_uppercase()),
482        _ => None,
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use charka_lexer::{lex, SourceFormat};
490
491    /// Helper: lexa el fuente en formato libre y lo parsea.
492    fn parse_src(src: &str) -> Program {
493        let toks = lex(src, SourceFormat::Free).expect("lex OK");
494        parse(&toks).expect("parse OK")
495    }
496
497    #[test]
498    fn empty_input_yields_empty_program() {
499        let p = parse(&[]).unwrap();
500        assert_eq!(p, Program::default());
501        assert!(p.program_id.is_none());
502        assert!(p.data.is_empty());
503        assert!(p.paragraphs.is_empty());
504    }
505
506    #[test]
507    fn captures_program_id() {
508        let p = parse_src(
509            "IDENTIFICATION DIVISION.\n\
510             PROGRAM-ID. PAYROLL.\n",
511        );
512        assert_eq!(p.program_id.as_deref(), Some("PAYROLL"));
513    }
514
515    #[test]
516    fn id_division_short_form_and_case_normalized() {
517        let p = parse_src("ID DIVISION.\nPROGRAM-ID. hello.\n");
518        assert_eq!(p.program_id.as_deref(), Some("HELLO"));
519    }
520
521    #[test]
522    fn flat_data_items() {
523        let p = parse_src(
524            "DATA DIVISION.\n\
525             WORKING-STORAGE SECTION.\n\
526             77 WS-COUNT PIC 9(3) VALUE 0.\n\
527             77 WS-NAME  PIC X(20).\n",
528        );
529        assert_eq!(p.data.len(), 2);
530        assert_eq!(p.data[0].name, "WS-COUNT");
531        assert_eq!(p.data[0].level, 77);
532        assert_eq!(p.data[0].picture.as_deref(), Some("9(3)"));
533        assert_eq!(p.data[0].value.as_deref(), Some("0"));
534        assert_eq!(p.data[1].name, "WS-NAME");
535        assert_eq!(p.data[1].picture.as_deref(), Some("X(20)"));
536        assert!(p.data[1].value.is_none());
537    }
538
539    #[test]
540    fn data_item_nesting() {
541        let p = parse_src(
542            "DATA DIVISION.\n\
543             WORKING-STORAGE SECTION.\n\
544             01 CUSTOMER-RECORD.\n\
545                05 CUST-NAME PIC X(20).\n\
546                05 CUST-ADDR.\n\
547                   10 STREET PIC X(30).\n\
548                   10 CITY   PIC X(20).\n",
549        );
550        assert_eq!(p.data.len(), 1);
551        let rec = &p.data[0];
552        assert_eq!(rec.name, "CUSTOMER-RECORD");
553        assert_eq!(rec.children.len(), 2);
554        assert_eq!(rec.children[0].name, "CUST-NAME");
555        let addr = &rec.children[1];
556        assert_eq!(addr.name, "CUST-ADDR");
557        assert_eq!(addr.children.len(), 2);
558        assert_eq!(addr.children[0].name, "STREET");
559        assert_eq!(addr.children[1].name, "CITY");
560    }
561
562    #[test]
563    fn picture_clause_reassembled() {
564        let p = parse_src(
565            "DATA DIVISION.\n\
566             WORKING-STORAGE SECTION.\n\
567             01 WS-AMOUNT PIC S9(5)V99.\n",
568        );
569        assert_eq!(p.data[0].picture.as_deref(), Some("S9(5)V99"));
570    }
571
572    #[test]
573    fn picture_and_name_lowercase_normalized() {
574        let p = parse_src(
575            "DATA DIVISION.\n\
576             01 ws-x pic x(4).\n",
577        );
578        assert_eq!(p.data[0].name, "WS-X");
579        assert_eq!(p.data[0].picture.as_deref(), Some("X(4)"));
580    }
581
582    #[test]
583    fn value_variants() {
584        let p = parse_src(
585            "DATA DIVISION.\n\
586             WORKING-STORAGE SECTION.\n\
587             01 WS-TEXT  PIC X(5)  VALUE 'BOB'.\n\
588             01 WS-ZERO  PIC 9(3)  VALUE ZERO.\n\
589             01 WS-NEG   PIC S9(3) VALUE -5.\n",
590        );
591        assert_eq!(p.data[0].value.as_deref(), Some("'BOB'"));
592        assert_eq!(p.data[1].value.as_deref(), Some("ZERO"));
593        assert_eq!(p.data[2].value.as_deref(), Some("-5"));
594    }
595
596    #[test]
597    fn level_88_condition_names_nest() {
598        let p = parse_src(
599            "DATA DIVISION.\n\
600             WORKING-STORAGE SECTION.\n\
601             01 WS-STATUS PIC X.\n\
602                88 STATUS-OK  VALUE 'O'.\n\
603                88 STATUS-BAD VALUE 'B'.\n",
604        );
605        let st = &p.data[0];
606        assert_eq!(st.name, "WS-STATUS");
607        assert_eq!(st.children.len(), 2);
608        assert_eq!(st.children[0].level, 88);
609        assert_eq!(st.children[0].name, "STATUS-OK");
610        assert_eq!(st.children[0].value.as_deref(), Some("'O'"));
611        assert_eq!(st.children[1].name, "STATUS-BAD");
612    }
613
614    #[test]
615    fn filler_data_items() {
616        let p = parse_src(
617            "DATA DIVISION.\n\
618             01 HEADER-LINE.\n\
619                05 FILLER PIC X(10) VALUE 'NAME'.\n\
620                05 FILLER PIC X(10) VALUE 'AGE'.\n",
621        );
622        assert_eq!(p.data[0].children.len(), 2);
623        assert_eq!(p.data[0].children[0].name, "FILLER");
624        assert_eq!(p.data[0].children[1].name, "FILLER");
625    }
626
627    #[test]
628    fn occurs_clause_captured() {
629        let p = parse_src(
630            "DATA DIVISION.\n\
631             WORKING-STORAGE SECTION.\n\
632             01 WS-TABLA.\n\
633                05 WS-ELEM PIC 9(3) OCCURS 10 TIMES.\n",
634        );
635        let elem = &p.data[0].children[0];
636        assert_eq!(elem.name, "WS-ELEM");
637        assert_eq!(elem.occurs, Some(10));
638        assert_eq!(elem.picture.as_deref(), Some("9(3)"));
639    }
640
641    #[test]
642    fn bad_level_number_is_error() {
643        let toks = lex(
644            "DATA DIVISION.\n\
645             WORKING-STORAGE SECTION.\n\
646             99 WS-X PIC X.\n",
647            SourceFormat::Free,
648        )
649        .unwrap();
650        let err = parse(&toks).unwrap_err();
651        assert!(matches!(err, ParseError::BadLevel { found, .. } if found == "99"));
652    }
653
654    #[test]
655    fn procedure_paragraphs() {
656        let p = parse_src(
657            "PROCEDURE DIVISION.\n\
658             MAIN-PARA.\n\
659                 DISPLAY 'HELLO'.\n\
660                 PERFORM SUB-PARA.\n\
661                 STOP RUN.\n\
662             SUB-PARA.\n\
663                 DISPLAY 'SUB'.\n",
664        );
665        assert_eq!(p.paragraphs.len(), 2);
666        assert_eq!(p.paragraphs[0].name, "MAIN-PARA");
667        assert_eq!(p.paragraphs[0].sentences.len(), 3);
668        assert_eq!(p.paragraphs[1].name, "SUB-PARA");
669        assert_eq!(p.paragraphs[1].sentences.len(), 1);
670        // La sentencia conserva sus tokens crudos.
671        assert_eq!(p.paragraphs[0].sentences[0].tokens[0].text, "DISPLAY");
672    }
673
674    #[test]
675    fn implicit_paragraph_for_leading_statements() {
676        let p = parse_src(
677            "PROCEDURE DIVISION.\n\
678                 DISPLAY 'FIRST'.\n\
679             MAIN-PARA.\n\
680                 STOP RUN.\n",
681        );
682        assert_eq!(p.paragraphs.len(), 2);
683        assert_eq!(p.paragraphs[0].name, "");
684        assert_eq!(p.paragraphs[0].sentences.len(), 1);
685        assert_eq!(p.paragraphs[1].name, "MAIN-PARA");
686        assert_eq!(p.paragraphs[1].sentences.len(), 1);
687    }
688
689    #[test]
690    fn procedure_section_header_recognized() {
691        let p = parse_src(
692            "PROCEDURE DIVISION.\n\
693             INIT-SECTION SECTION.\n\
694                 DISPLAY 'X'.\n",
695        );
696        assert_eq!(p.paragraphs.len(), 1);
697        assert_eq!(p.paragraphs[0].name, "INIT-SECTION");
698        assert_eq!(p.paragraphs[0].sentences.len(), 1);
699    }
700
701    #[test]
702    fn full_program_end_to_end() {
703        let p = parse_src(
704            "IDENTIFICATION DIVISION.\n\
705             PROGRAM-ID. ADDER.\n\
706             ENVIRONMENT DIVISION.\n\
707             DATA DIVISION.\n\
708             WORKING-STORAGE SECTION.\n\
709             01 WS-A     PIC 9(3) VALUE 10.\n\
710             01 WS-B     PIC 9(3) VALUE 32.\n\
711             01 WS-TOTAL PIC 9(4).\n\
712             PROCEDURE DIVISION.\n\
713             MAIN-PARA.\n\
714                 ADD WS-A TO WS-B GIVING WS-TOTAL.\n\
715                 DISPLAY WS-TOTAL.\n\
716                 STOP RUN.\n",
717        );
718        assert_eq!(p.program_id.as_deref(), Some("ADDER"));
719        assert_eq!(p.data.len(), 3);
720        assert_eq!(p.data[0].value.as_deref(), Some("10"));
721        assert_eq!(p.data[2].name, "WS-TOTAL");
722        assert!(p.data[2].value.is_none());
723        assert_eq!(p.paragraphs.len(), 1);
724        assert_eq!(p.paragraphs[0].name, "MAIN-PARA");
725        assert_eq!(p.paragraphs[0].sentences.len(), 3);
726    }
727}