From d3cdbb2d2d5bb34442417328aabb9235fcff5c05 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 20:04:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(charka):=20charka-parser=20=E2=80=94=20COB?= =?UTF-8?q?OL'85=20(subconjunto)=20a=20AST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segunda etapa del transpilador: Vec -> Program. Alcance v1 = el esqueleto del programa. - parse(&[Token]) -> Result. AST: Program (program_id, data, paragraphs), DataItem, Paragraph, Sentence. - Particiona el flujo en las 4 divisions por sus encabezados; extrae el PROGRAM-ID de la IDENTIFICATION. - DATA division -> árbol de DataItem: nivel, nombre, PICTURE reensamblado (S9 ( 5 ) V99 -> S9(5)V99) y VALUE. Anida por número de nivel (01/77 raíces, 88 cuelga del precedente). - PROCEDURE division -> Vec con Sentence de tokens crudos (sin parseo de statement). Sentencias previas al primer encabezado van a un párrafo implícito "". - Tolerante: salta SECTION, FD/SD y cláusulas que no sean PIC/VALUE. - 15 tests verdes; fmt + clippy limpios. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 8 + Cargo.toml | 1 + crates/modules/charka/SDD.md | 34 +- .../modules/charka/charka-parser/Cargo.toml | 13 + .../modules/charka/charka-parser/src/lib.rs | 694 ++++++++++++++++++ docs/changelog/charka.md | 28 + nakui-ui-state.jsonl | 2 + 7 files changed, 773 insertions(+), 7 deletions(-) create mode 100644 crates/modules/charka/charka-parser/Cargo.toml create mode 100644 crates/modules/charka/charka-parser/src/lib.rs create mode 100644 nakui-ui-state.jsonl diff --git a/Cargo.lock b/Cargo.lock index 4866052..7a9fdaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2322,6 +2322,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "charka-parser" +version = "0.1.0" +dependencies = [ + "charka-lexer", + "thiserror 2.0.18", +] + [[package]] name = "chasqui-card" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b827bc2..9006621 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,6 +158,7 @@ members = [ # ============================================================ "crates/modules/charka/charka-bcd", "crates/modules/charka/charka-lexer", + "crates/modules/charka/charka-parser", # ============================================================ # modules/mirada/ — Compositor Wayland diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md index be0e69a..722d541 100644 --- a/crates/modules/charka/SDD.md +++ b/crates/modules/charka/SDD.md @@ -10,8 +10,9 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes. | crate | tipo | rol | | -------------- | ---- | ------------------------------------------------------------ | -| `charka-bcd` | lib | Aritmética decimal de punto fijo con semántica COBOL: `Picture`, `Decimal`, redondeo, `ON SIZE ERROR` | -| `charka-lexer` | lib | Tokenizador COBOL: formato fijo (tarjeta de 80 columnas) y libre | +| `charka-bcd` | lib | Aritmética decimal de punto fijo con semántica COBOL: `Picture`, `Decimal`, redondeo, `ON SIZE ERROR` | +| `charka-lexer` | lib | Tokenizador COBOL: formato fijo (tarjeta de 80 columnas) y libre | +| `charka-parser` | lib | Parser COBOL'85 (subconjunto): tokens → AST (`Program`) | ## charka-bcd @@ -43,16 +44,35 @@ Primera etapa del pipeline: texto COBOL → secuencia de `Token`. - Limitación v1: sin continuación de literales entre líneas (indicador `-`). +## charka-parser + +Segunda etapa: tokens → AST. Alcance v1 — el **esqueleto del programa**. + +- `parse(&[Token]) -> Result`. +- `Program { program_id, data: Vec, paragraphs: Vec }`. +- **DATA division** → árbol de `DataItem` (`level`, `name`, `picture`, + `value`, `children`). Reensambla la cláusula `PICTURE` desde sus + tokens (`S9` `(` `5` `)` `V99` → `S9(5)V99`); anida por número de + nivel (01 y 77 son raíces; 88 cuelga del ítem precedente). +- **PROCEDURE division** → `Vec`, cada `Paragraph` con sus + `Sentence` (tokens crudos — sin parseo a nivel de statement). Las + sentencias previas al primer encabezado van a un párrafo implícito + de nombre "". +- Tolerante: salta encabezados de `SECTION`, entradas `FD`/`SD` y + cláusulas de datos que no sean `PICTURE`/`VALUE`. `ParseError` sólo + ante nivel inválido o dato sin nombre. +- Limitación v1: no parsea statements, ni la ENVIRONMENT division, ni + CICS / SQL embebido / dialectos IBM. + ## Estado -`charka-bcd` (22 tests) y `charka-lexer` (17 tests) implementados y -verdes. **Pendiente** — el resto del transpilador (Fase D del plan -macro): +`charka-bcd` (22 tests), `charka-lexer` (17 tests) y `charka-parser` +(15 tests) implementados y verdes. **Pendiente** — el resto del +transpilador (Fase D del plan macro): | crate pendiente | rol | | ----------------- | ---------------------------------------------------- | -| `charka-parser` | parser COBOL'85 → AST (luego CICS + SQL embebido) | -| `charka-ir` | representación intermedia | +| `charka-ir` | representación intermedia (parseo de statements) | | `charka-codegen` | emisión de Rust | | `charka-shadow` | validador en sombra (original vs transpilado) | | `charka-runtime` | runtime determinista (sobre `charka-bcd`) | diff --git a/crates/modules/charka/charka-parser/Cargo.toml b/crates/modules/charka/charka-parser/Cargo.toml new file mode 100644 index 0000000..1955e1e --- /dev/null +++ b/crates/modules/charka/charka-parser/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "charka-parser" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "charka-parser — parser de COBOL'85 (subconjunto) a AST: divisiones, modelo de datos (DATA division) y párrafos del PROCEDURE." + +[dependencies] +charka-lexer = { path = "../charka-lexer" } +thiserror = { workspace = true } diff --git a/crates/modules/charka/charka-parser/src/lib.rs b/crates/modules/charka/charka-parser/src/lib.rs new file mode 100644 index 0000000..49a63b0 --- /dev/null +++ b/crates/modules/charka/charka-parser/src/lib.rs @@ -0,0 +1,694 @@ +//! `charka-parser` — parser de COBOL'85 (subconjunto) a AST. +//! +//! Segunda etapa del transpilador COBOL→Rust: consume los [`Token`] del +//! `charka-lexer` y produce un [`Program`]. El alcance de la v1 es el +//! **esqueleto del programa**: +//! +//! - Las cuatro divisiones (`IDENTIFICATION`, `ENVIRONMENT`, `DATA`, +//! `PROCEDURE`) y el `PROGRAM-ID`. +//! - La **DATA division** como un árbol de [`DataItem`]: número de +//! nivel, nombre, cláusula `PICTURE` reensamblada y `VALUE`. +//! - La **PROCEDURE division** como una lista de [`Paragraph`], cada +//! uno con sus [`Sentence`] (los tokens crudos de cada sentencia). +//! +//! Lo que **no** hace todavía: el parseo a nivel de statement (cada +//! sentencia queda como `Vec` — es trabajo de `charka-ir`), la +//! ENVIRONMENT division, CICS, SQL embebido y los dialectos IBM. Son +//! las etapas siguientes del plan. +//! +//! El parser es tolerante: salta lo que no entiende (encabezados de +//! `SECTION`, entradas `FD`/`SD`, cláusulas de datos que no sean +//! `PICTURE`/`VALUE`) en vez de fallar. Sólo emite [`ParseError`] ante +//! un número de nivel inválido o un dato sin nombre. + +#![forbid(unsafe_code)] + +use thiserror::Error; + +pub use charka_lexer::{Token, TokenKind}; + +/// Un programa COBOL parseado: el esqueleto de sus cuatro divisiones. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Program { + /// El `PROGRAM-ID` de la IDENTIFICATION division, si está presente. + pub program_id: Option, + /// Los ítems raíz de la DATA division (cada `01`/`77` con su árbol). + pub data: Vec, + /// Los párrafos de la PROCEDURE division, en orden de aparición. + pub paragraphs: Vec, +} + +/// Un ítem de datos de la DATA division: un número de nivel, un nombre +/// y, opcionalmente, las cláusulas `PICTURE` y `VALUE`. Los ítems de +/// mayor nivel numérico cuelgan como `children` del que los contiene. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataItem { + /// Número de nivel: 01-49 (jerárquico), 66 (`RENAMES`), 77 + /// (elemental independiente) u 88 (nombre de condición). + pub level: u8, + /// Nombre del dato, normalizado a mayúsculas (`FILLER` incluido). + pub name: String, + /// Cláusula `PICTURE` reensamblada (`S9(5)V99`), si la hay. + pub picture: Option, + /// Cláusula `VALUE`: literal numérico (con signo), constante + /// figurativa en mayúsculas, o literal de texto entre comillas. + pub value: Option, + /// Ítems subordinados (de nivel numérico mayor). + pub children: Vec, +} + +/// Un párrafo de la PROCEDURE division. El párrafo implícito que +/// agrupa las sentencias previas al primer encabezado tiene `name` "". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Paragraph { + /// Nombre del párrafo en mayúsculas; "" para el párrafo implícito. + pub name: String, + /// Sentencias del párrafo, en orden. + pub sentences: Vec, +} + +/// Una sentencia: los tokens entre dos puntos terminadores. La v1 no +/// parsea a nivel de statement — eso es trabajo de `charka-ir`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sentence { + pub tokens: Vec, +} + +/// Error de parseo, con la línea del token donde se detectó. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ParseError { + #[error("línea {line}: número de nivel inválido {found:?} (esperado 01-49, 66, 77 u 88)")] + BadLevel { line: u32, found: String }, + #[error("línea {line}: se esperaba el nombre de un dato tras el número de nivel")] + ExpectedDataName { line: u32 }, +} + +/// Las cuatro divisiones de un programa COBOL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DivKind { + Identification, + Environment, + Data, + Procedure, +} + +/// Parsea una secuencia de tokens COBOL en un [`Program`]. +pub fn parse(tokens: &[Token]) -> Result { + let divs = find_divisions(tokens); + let mut program = Program::default(); + for (idx, &(kind, start)) in divs.iter().enumerate() { + // El cuerpo de la división va desde el punto que cierra su + // encabezado hasta el comienzo de la división siguiente. + let lo = match period_after(tokens, start) { + Some(p) => p + 1, + None => tokens.len(), + }; + let next = divs.get(idx + 1).map(|&(_, s)| s).unwrap_or(tokens.len()); + let body = &tokens[lo..next.max(lo)]; + match kind { + DivKind::Identification => parse_identification(body, &mut program), + DivKind::Environment => {} // la v1 ignora la ENVIRONMENT division + DivKind::Data => program.data = parse_data(body)?, + DivKind::Procedure => program.paragraphs = parse_procedure(body), + } + } + Ok(program) +} + +/// Localiza los encabezados de división (`X DIVISION`) y devuelve, en +/// orden, su clase y el índice del token de la palabra de división. +fn find_divisions(tokens: &[Token]) -> Vec<(DivKind, usize)> { + let mut divs = Vec::new(); + let mut i = 0; + while i + 1 < tokens.len() { + if kw(tokens.get(i + 1)).as_deref() == Some("DIVISION") { + let kind = match kw(tokens.get(i)).as_deref() { + Some("IDENTIFICATION") | Some("ID") => Some(DivKind::Identification), + Some("ENVIRONMENT") => Some(DivKind::Environment), + Some("DATA") => Some(DivKind::Data), + Some("PROCEDURE") => Some(DivKind::Procedure), + _ => None, + }; + if let Some(k) = kind { + divs.push((k, i)); + i += 2; + continue; + } + } + i += 1; + } + divs +} + +/// Extrae el `PROGRAM-ID` del cuerpo de la IDENTIFICATION division. +fn parse_identification(body: &[Token], program: &mut Program) { + for (i, t) in body.iter().enumerate() { + if kw(Some(t)).as_deref() == Some("PROGRAM-ID") { + // `PROGRAM-ID. NAME.` — el punto tras `PROGRAM-ID` es opcional. + let mut j = i + 1; + if body.get(j).map(|t| t.kind) == Some(TokenKind::Period) { + j += 1; + } + if let Some(name) = body.get(j) { + program.program_id = Some(match name.kind { + TokenKind::Word => name.text.to_uppercase(), + TokenKind::String => name.text.clone(), + _ => continue, + }); + } + return; + } + } +} + +/// Parsea el cuerpo de la DATA division en un árbol de [`DataItem`]. +fn parse_data(body: &[Token]) -> Result, ParseError> { + let mut flat = Vec::new(); + for sent in split_sentences(body) { + let Some(first) = sent.first() else { continue }; + // Sólo las sentencias que arrancan con un número de nivel son + // entradas de datos; los encabezados de SECTION y las entradas + // FD/SD empiezan con palabra y se ignoran. + if first.kind != TokenKind::Number { + continue; + } + let level = parse_level(first)?; + flat.push(parse_data_entry(level, &sent)?); + } + Ok(build_tree(flat)) +} + +/// Valida que el token sea un número de nivel COBOL (01-49, 66, 77, 88). +fn parse_level(t: &Token) -> Result { + let bad = || ParseError::BadLevel { + line: t.line, + found: t.text.clone(), + }; + let n: u8 = t.text.parse().map_err(|_| bad())?; + if (1..=49).contains(&n) || n == 66 || n == 77 || n == 88 { + Ok(n) + } else { + Err(bad()) + } +} + +/// Parsea una sola entrada de datos (una sentencia ya sin el punto). +fn parse_data_entry(level: u8, sent: &[Token]) -> Result { + let line = sent[0].line; + let name_tok = sent.get(1).ok_or(ParseError::ExpectedDataName { line })?; + if name_tok.kind != TokenKind::Word { + return Err(ParseError::ExpectedDataName { + line: name_tok.line, + }); + } + let name = name_tok.text.to_uppercase(); + + let mut picture = None; + let mut value = None; + let mut i = 2; + while i < sent.len() { + match kw(sent.get(i)).as_deref() { + Some("PIC") | Some("PICTURE") => { + i += 1; + if kw(sent.get(i)).as_deref() == Some("IS") { + i += 1; + } + let (pic, next) = collect_picture(sent, i); + if picture.is_none() && !pic.is_empty() { + picture = Some(pic); + } + i = next; + } + Some("VALUE") => { + i += 1; + if matches!(kw(sent.get(i)).as_deref(), Some("IS") | Some("ARE")) { + i += 1; + } + let (val, next) = collect_value(sent, i); + if value.is_none() { + value = val; + } + i = next; + } + _ => i += 1, + } + } + + Ok(DataItem { + level, + name, + picture, + value, + children: Vec::new(), + }) +} + +/// Reensambla la cláusula PICTURE: concatena el texto de los tokens +/// (`S9` `(` `5` `)` `V99` → `S9(5)V99`) hasta toparse con otra +/// cláusula de datos. El resultado va en mayúsculas. +fn collect_picture(sent: &[Token], start: usize) -> (String, usize) { + let mut s = String::new(); + let mut i = start; + while let Some(t) = sent.get(i) { + if let Some(w) = kw(Some(t)) { + if is_data_clause_kw(&w) { + break; + } + } + s.push_str(&t.text); + i += 1; + } + (s.to_uppercase(), i) +} + +/// Captura el literal de una cláusula VALUE: un signo opcional seguido +/// de un número, una constante figurativa o un literal de texto (que se +/// devuelve entre comillas simples para distinguirlo de una palabra). +fn collect_value(sent: &[Token], start: usize) -> (Option, usize) { + let mut i = start; + let mut s = String::new(); + if let Some(t) = sent.get(i) { + if t.kind == TokenKind::Symbol && (t.text == "+" || t.text == "-") { + s.push_str(&t.text); + i += 1; + } + } + if let Some(t) = sent.get(i) { + match t.kind { + TokenKind::Number => { + s.push_str(&t.text); + i += 1; + } + TokenKind::String => { + s.push('\''); + s.push_str(&t.text); + s.push('\''); + i += 1; + } + TokenKind::Word => { + // Constante figurativa: ZERO, ZEROS, SPACE, SPACES, + // HIGH-VALUE, LOW-VALUE, QUOTES, NULL... + s.push_str(&t.text.to_uppercase()); + i += 1; + } + TokenKind::Period | TokenKind::Symbol => {} + } + } + if s.is_empty() { + (None, i) + } else { + (Some(s), i) + } +} + +/// ¿Es `w` una palabra que abre una cláusula de datos? Marca el final +/// de la cláusula PICTURE. +fn is_data_clause_kw(w: &str) -> bool { + matches!( + w, + "VALUE" + | "OCCURS" + | "REDEFINES" + | "RENAMES" + | "USAGE" + | "COMP" + | "COMP-1" + | "COMP-2" + | "COMP-3" + | "COMP-4" + | "COMP-5" + | "COMPUTATIONAL" + | "COMPUTATIONAL-1" + | "COMPUTATIONAL-2" + | "COMPUTATIONAL-3" + | "COMPUTATIONAL-4" + | "COMPUTATIONAL-5" + | "BINARY" + | "PACKED-DECIMAL" + | "SIGN" + | "JUSTIFIED" + | "JUST" + | "BLANK" + | "SYNCHRONIZED" + | "SYNC" + | "DISPLAY" + ) +} + +/// Convierte la lista plana de ítems en un árbol según los niveles: un +/// ítem cuelga del anterior cuyo nivel sea estrictamente menor. Los +/// niveles 01 y 77 son siempre raíces. +fn build_tree(flat: Vec) -> Vec { + let mut roots: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + for item in flat { + if item.level == 1 || item.level == 77 { + while let Some(done) = stack.pop() { + attach(&mut stack, &mut roots, done); + } + } else { + // Cerrar los hermanos y descendientes ya completos. + while stack.last().is_some_and(|top| top.level >= item.level) { + let done = stack.pop().unwrap(); + attach(&mut stack, &mut roots, done); + } + } + stack.push(item); + } + while let Some(done) = stack.pop() { + attach(&mut stack, &mut roots, done); + } + roots +} + +/// Engancha un ítem completo: como hijo del que esté en el tope de la +/// pila, o como raíz si la pila está vacía. +fn attach(stack: &mut [DataItem], roots: &mut Vec, item: DataItem) { + if let Some(parent) = stack.last_mut() { + parent.children.push(item); + } else { + roots.push(item); + } +} + +/// Parsea el cuerpo de la PROCEDURE division en párrafos. +fn parse_procedure(body: &[Token]) -> Vec { + let mut paras: Vec = Vec::new(); + let mut current = Paragraph { + name: String::new(), + sentences: Vec::new(), + }; + let mut current_named = false; + for sent in split_sentences(body) { + if let Some(name) = paragraph_header_name(&sent) { + // Cerrar el párrafo en curso si tiene identidad o contenido. + if current_named || !current.sentences.is_empty() { + paras.push(std::mem::replace( + &mut current, + Paragraph { + name: String::new(), + sentences: Vec::new(), + }, + )); + } + current.name = name; + current_named = true; + } else { + current.sentences.push(Sentence { tokens: sent }); + } + } + if current_named || !current.sentences.is_empty() { + paras.push(current); + } + paras +} + +/// ¿Es esta sentencia un encabezado de párrafo o de sección? Lo es si +/// es una sola palabra (un nombre de párrafo) o `NOMBRE SECTION`. Se +/// excluyen `EXIT`/`GOBACK`/`CONTINUE`, que de una sola palabra son +/// statements, no encabezados. +fn paragraph_header_name(sent: &[Token]) -> Option { + match sent { + [w] if w.kind == TokenKind::Word => { + let name = w.text.to_uppercase(); + if matches!(name.as_str(), "EXIT" | "GOBACK" | "CONTINUE") { + None + } else { + Some(name) + } + } + [w, s] if w.kind == TokenKind::Word && kw(Some(s)).as_deref() == Some("SECTION") => { + Some(w.text.to_uppercase()) + } + _ => None, + } +} + +/// Parte un tramo de tokens en sentencias por el punto terminador. El +/// punto se descarta; las sentencias vacías no se emiten. +fn split_sentences(body: &[Token]) -> Vec> { + let mut out = Vec::new(); + let mut cur = Vec::new(); + for t in body { + if t.kind == TokenKind::Period { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } else { + cur.push(t.clone()); + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// El índice del primer `Period` en `tokens` a partir de `start`. +fn period_after(tokens: &[Token], start: usize) -> Option { + tokens + .iter() + .enumerate() + .skip(start) + .find(|(_, t)| t.kind == TokenKind::Period) + .map(|(i, _)| i) +} + +/// Si el token es una palabra, su texto en mayúsculas (COBOL es +/// case-insensitive). `None` para cualquier otra clase de token o +/// posición ausente. +fn kw(t: Option<&Token>) -> Option { + match t { + Some(t) if t.kind == TokenKind::Word => Some(t.text.to_uppercase()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use charka_lexer::{lex, SourceFormat}; + + /// Helper: lexa el fuente en formato libre y lo parsea. + fn parse_src(src: &str) -> Program { + let toks = lex(src, SourceFormat::Free).expect("lex OK"); + parse(&toks).expect("parse OK") + } + + #[test] + fn empty_input_yields_empty_program() { + let p = parse(&[]).unwrap(); + assert_eq!(p, Program::default()); + assert!(p.program_id.is_none()); + assert!(p.data.is_empty()); + assert!(p.paragraphs.is_empty()); + } + + #[test] + fn captures_program_id() { + let p = parse_src( + "IDENTIFICATION DIVISION.\n\ + PROGRAM-ID. PAYROLL.\n", + ); + assert_eq!(p.program_id.as_deref(), Some("PAYROLL")); + } + + #[test] + fn id_division_short_form_and_case_normalized() { + let p = parse_src("ID DIVISION.\nPROGRAM-ID. hello.\n"); + assert_eq!(p.program_id.as_deref(), Some("HELLO")); + } + + #[test] + fn flat_data_items() { + let p = parse_src( + "DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 77 WS-COUNT PIC 9(3) VALUE 0.\n\ + 77 WS-NAME PIC X(20).\n", + ); + assert_eq!(p.data.len(), 2); + assert_eq!(p.data[0].name, "WS-COUNT"); + assert_eq!(p.data[0].level, 77); + assert_eq!(p.data[0].picture.as_deref(), Some("9(3)")); + assert_eq!(p.data[0].value.as_deref(), Some("0")); + assert_eq!(p.data[1].name, "WS-NAME"); + assert_eq!(p.data[1].picture.as_deref(), Some("X(20)")); + assert!(p.data[1].value.is_none()); + } + + #[test] + fn data_item_nesting() { + let p = parse_src( + "DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 01 CUSTOMER-RECORD.\n\ + 05 CUST-NAME PIC X(20).\n\ + 05 CUST-ADDR.\n\ + 10 STREET PIC X(30).\n\ + 10 CITY PIC X(20).\n", + ); + assert_eq!(p.data.len(), 1); + let rec = &p.data[0]; + assert_eq!(rec.name, "CUSTOMER-RECORD"); + assert_eq!(rec.children.len(), 2); + assert_eq!(rec.children[0].name, "CUST-NAME"); + let addr = &rec.children[1]; + assert_eq!(addr.name, "CUST-ADDR"); + assert_eq!(addr.children.len(), 2); + assert_eq!(addr.children[0].name, "STREET"); + assert_eq!(addr.children[1].name, "CITY"); + } + + #[test] + fn picture_clause_reassembled() { + let p = parse_src( + "DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 01 WS-AMOUNT PIC S9(5)V99.\n", + ); + assert_eq!(p.data[0].picture.as_deref(), Some("S9(5)V99")); + } + + #[test] + fn picture_and_name_lowercase_normalized() { + let p = parse_src( + "DATA DIVISION.\n\ + 01 ws-x pic x(4).\n", + ); + assert_eq!(p.data[0].name, "WS-X"); + assert_eq!(p.data[0].picture.as_deref(), Some("X(4)")); + } + + #[test] + fn value_variants() { + let p = parse_src( + "DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 01 WS-TEXT PIC X(5) VALUE 'BOB'.\n\ + 01 WS-ZERO PIC 9(3) VALUE ZERO.\n\ + 01 WS-NEG PIC S9(3) VALUE -5.\n", + ); + assert_eq!(p.data[0].value.as_deref(), Some("'BOB'")); + assert_eq!(p.data[1].value.as_deref(), Some("ZERO")); + assert_eq!(p.data[2].value.as_deref(), Some("-5")); + } + + #[test] + fn level_88_condition_names_nest() { + let p = parse_src( + "DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 01 WS-STATUS PIC X.\n\ + 88 STATUS-OK VALUE 'O'.\n\ + 88 STATUS-BAD VALUE 'B'.\n", + ); + let st = &p.data[0]; + assert_eq!(st.name, "WS-STATUS"); + assert_eq!(st.children.len(), 2); + assert_eq!(st.children[0].level, 88); + assert_eq!(st.children[0].name, "STATUS-OK"); + assert_eq!(st.children[0].value.as_deref(), Some("'O'")); + assert_eq!(st.children[1].name, "STATUS-BAD"); + } + + #[test] + fn filler_data_items() { + let p = parse_src( + "DATA DIVISION.\n\ + 01 HEADER-LINE.\n\ + 05 FILLER PIC X(10) VALUE 'NAME'.\n\ + 05 FILLER PIC X(10) VALUE 'AGE'.\n", + ); + assert_eq!(p.data[0].children.len(), 2); + assert_eq!(p.data[0].children[0].name, "FILLER"); + assert_eq!(p.data[0].children[1].name, "FILLER"); + } + + #[test] + fn bad_level_number_is_error() { + let toks = lex( + "DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 99 WS-X PIC X.\n", + SourceFormat::Free, + ) + .unwrap(); + let err = parse(&toks).unwrap_err(); + assert!(matches!(err, ParseError::BadLevel { found, .. } if found == "99")); + } + + #[test] + fn procedure_paragraphs() { + let p = parse_src( + "PROCEDURE DIVISION.\n\ + MAIN-PARA.\n\ + DISPLAY 'HELLO'.\n\ + PERFORM SUB-PARA.\n\ + STOP RUN.\n\ + SUB-PARA.\n\ + DISPLAY 'SUB'.\n", + ); + assert_eq!(p.paragraphs.len(), 2); + assert_eq!(p.paragraphs[0].name, "MAIN-PARA"); + assert_eq!(p.paragraphs[0].sentences.len(), 3); + assert_eq!(p.paragraphs[1].name, "SUB-PARA"); + assert_eq!(p.paragraphs[1].sentences.len(), 1); + // La sentencia conserva sus tokens crudos. + assert_eq!(p.paragraphs[0].sentences[0].tokens[0].text, "DISPLAY"); + } + + #[test] + fn implicit_paragraph_for_leading_statements() { + let p = parse_src( + "PROCEDURE DIVISION.\n\ + DISPLAY 'FIRST'.\n\ + MAIN-PARA.\n\ + STOP RUN.\n", + ); + assert_eq!(p.paragraphs.len(), 2); + assert_eq!(p.paragraphs[0].name, ""); + assert_eq!(p.paragraphs[0].sentences.len(), 1); + assert_eq!(p.paragraphs[1].name, "MAIN-PARA"); + assert_eq!(p.paragraphs[1].sentences.len(), 1); + } + + #[test] + fn procedure_section_header_recognized() { + let p = parse_src( + "PROCEDURE DIVISION.\n\ + INIT-SECTION SECTION.\n\ + DISPLAY 'X'.\n", + ); + assert_eq!(p.paragraphs.len(), 1); + assert_eq!(p.paragraphs[0].name, "INIT-SECTION"); + assert_eq!(p.paragraphs[0].sentences.len(), 1); + } + + #[test] + fn full_program_end_to_end() { + let p = parse_src( + "IDENTIFICATION DIVISION.\n\ + PROGRAM-ID. ADDER.\n\ + ENVIRONMENT DIVISION.\n\ + DATA DIVISION.\n\ + WORKING-STORAGE SECTION.\n\ + 01 WS-A PIC 9(3) VALUE 10.\n\ + 01 WS-B PIC 9(3) VALUE 32.\n\ + 01 WS-TOTAL PIC 9(4).\n\ + PROCEDURE DIVISION.\n\ + MAIN-PARA.\n\ + ADD WS-A TO WS-B GIVING WS-TOTAL.\n\ + DISPLAY WS-TOTAL.\n\ + STOP RUN.\n", + ); + assert_eq!(p.program_id.as_deref(), Some("ADDER")); + assert_eq!(p.data.len(), 3); + assert_eq!(p.data[0].value.as_deref(), Some("10")); + assert_eq!(p.data[2].name, "WS-TOTAL"); + assert!(p.data[2].value.is_none()); + assert_eq!(p.paragraphs.len(), 1); + assert_eq!(p.paragraphs[0].name, "MAIN-PARA"); + assert_eq!(p.paragraphs[0].sentences.len(), 3); + } +} diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md index db2d908..7de7539 100644 --- a/docs/changelog/charka.md +++ b/docs/changelog/charka.md @@ -3,6 +3,34 @@ Transpilador COBOL → Rust. El módulo más grande del ecosistema (Fase D del plan macro) — el parser COBOL completo es un esfuerzo multi-mes. +### feat(charka-parser): parser COBOL'85 → AST + +Crate nuevo `crates/modules/charka/charka-parser` — la segunda etapa del +pipeline: `Vec` → `Program` (AST). Alcance v1: el esqueleto del +programa. + +- `parse(&[Token]) -> Result`. El AST: `Program` + (`program_id`, `data`, `paragraphs`), `DataItem`, `Paragraph`, + `Sentence`. +- Particiona el flujo de tokens en las cuatro divisiones por sus + encabezados (`X DIVISION`); de la IDENTIFICATION extrae el + `PROGRAM-ID`. +- **DATA division** → árbol de `DataItem`: número de nivel, nombre, + cláusula `PICTURE` reensamblada (`S9` `(` `5` `)` `V99` → + `S9(5)V99`) y `VALUE`. Anida por número de nivel — 01 y 77 son + raíces, 88 cuelga del ítem precedente. +- **PROCEDURE division** → `Vec`, cada párrafo con sus + `Sentence` (tokens crudos; sin parseo a nivel de statement, eso es + `charka-ir`). Las sentencias previas al primer encabezado van a un + párrafo implícito de nombre "". +- Parser tolerante: salta encabezados de `SECTION`, entradas `FD`/`SD` + y cláusulas de datos que no sean `PICTURE`/`VALUE`. `ParseError` sólo + ante un número de nivel inválido o un dato sin nombre. +- 15 tests: PROGRAM-ID (forma larga y `ID` corta), ítems planos y + anidados, reensamblado de PICTURE, variantes de VALUE, niveles 88, + `FILLER`, párrafos y párrafo implícito, encabezado de sección, + programa completo de punta a punta, nivel inválido. + ### feat(charka-lexer): tokenizador de COBOL Crate nuevo `crates/modules/charka/charka-lexer` — la primera etapa del diff --git a/nakui-ui-state.jsonl b/nakui-ui-state.jsonl new file mode 100644 index 0000000..f6b040a --- /dev/null +++ b/nakui-ui-state.jsonl @@ -0,0 +1,2 @@ +{"kind":"seed","seq":0,"entity":"Cliente","id":"b300a04f-36e6-4d22-99d9-b036c1b7b0db","data":{"nombre":"\\xc","email":"sad","empresa":"ww","id":"b300a04f-36e6-4d22-99d9-b036c1b7b0db"}} +{"kind":"seed","seq":1,"entity":"Cliente","id":"50129365-f780-43a5-9704-e33dbe8da27e","data":{"nombre":"sds","email":"sas","empresa":"asd","id":"50129365-f780-43a5-9704-e33dbe8da27e"}}