tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

feat(charka): charka-parser — COBOL'85 (subconjunto) a AST

Segunda etapa del transpilador: Vec<Token> -> Program. Alcance v1 = el
esqueleto del programa.

- parse(&[Token]) -> Result<Program, ParseError>. 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<Paragraph> 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 <noreply@anthropic.com>

d3cdbb2d2d5bb34442417328aabb9235fcff5c05 · ruwaq sergio · (5 killa ñawpaq) · tayta ab56b35e9 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 39

crates/modules/charka/charka-parser/src/lib.rs

7 qillqakuna · +773 −7

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, ParseError>`.
+- `Program { program_id, data: Vec<DataItem>, paragraphs: Vec<Paragraph> }`.
+- **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<Paragraph>`, 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<Token>` — 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<String>,
+    /// Los ítems raíz de la DATA division (cada `01`/`77` con su árbol).
+    pub data: Vec<DataItem>,
+    /// Los párrafos de la PROCEDURE division, en orden de aparición.
+    pub paragraphs: Vec<Paragraph>,
+}
+
+/// 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<String>,
+    /// Cláusula `VALUE`: literal numérico (con signo), constante
+    /// figurativa en mayúsculas, o literal de texto entre comillas.
+    pub value: Option<String>,
+    /// Ítems subordinados (de nivel numérico mayor).
+    pub children: Vec<DataItem>,
+}
+
+/// 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<Sentence>,
+}
+
+/// 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<Token>,
+}
+
+/// 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<Program, ParseError> {
+    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<Vec<DataItem>, 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<u8, ParseError> {
+    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<DataItem, ParseError> {
+    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<String>, 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<DataItem>) -> Vec<DataItem> {
+    let mut roots: Vec<DataItem> = Vec::new();
+    let mut stack: Vec<DataItem> = 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<DataItem>, 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<Paragraph> {
+    let mut paras: Vec<Paragraph> = 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<String> {
+    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<Vec<Token>> {
+    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<usize> {
+    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<String> {
+    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<Token>` → `Program` (AST). Alcance v1: el esqueleto del
+programa.
+
+- `parse(&[Token]) -> Result<Program, ParseError>`. 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<Paragraph>`, 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"}}