From b3278bdb0cccadf52a05c09cf2975027e498cbc4 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 22:47:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(charka):=20E/S=20de=20ficheros=20=E2=80=94?= =?UTF-8?q?=20SELECT/FD/OPEN/READ/WRITE/CLOSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El gran hueco que faltaba para el COBOL real: el procesamiento de ficheros secuenciales. Una rebanada vertical por los seis crates. - charka-parser: la ENVIRONMENT division ya no se ignora — se parsea FILE-CONTROL (SELECT name ASSIGN TO "ruta"); del FILE SECTION se asocia cada FD con su registro 01. Program::files. - charka-runtime: tipo CobFile — un fichero «line sequential» (cada registro una línea). Lectura: carga a memoria. Escritura: acumula y vuelca al cerrar. - charka-ir: Ir::files y los statements Open/Close/Read/Write. READ lleva sus bloques AT END / NOT AT END. - charka-codegen: un campo CobFile por fichero en el struct Program; los verbos emiten llamadas al runtime. - charka-shadow: el intérprete hace E/S de ficheros real. - Corpus: programa nuevo 18-fichero — escribe tres líneas, las relee con READ ... AT END y las muestra. Verificado: el intérprete sombra y el crate compilado por scaffold dan la misma salida. Alcance v1: organización line sequential; sin ficheros indexados ni relativos, sin FILE STATUS. Tests: charka-parser 17, charka-runtime 19, charka-ir 30, charka-codegen 25, charka-shadow 23. fmt + clippy limpios. Co-Authored-By: Claude Opus 4.7 --- crates/apps/charka/src/main.rs | 8 ++ crates/modules/charka/SDD.md | 18 ++- .../modules/charka/charka-codegen/src/lib.rs | 36 ++++++ .../modules/charka/charka-codegen/src/stmt.rs | 83 ++++++++++++- .../modules/charka/charka-codegen/src/sym.rs | 41 ++++++- crates/modules/charka/charka-ir/src/ast.rs | 28 ++++- crates/modules/charka/charka-ir/src/lib.rs | 37 +++++- crates/modules/charka/charka-ir/src/stmt.rs | 80 ++++++++++++- .../modules/charka/charka-parser/src/lib.rs | 112 ++++++++++++++++-- .../modules/charka/charka-runtime/src/file.rs | 102 ++++++++++++++++ .../modules/charka/charka-runtime/src/lib.rs | 2 + .../charka/charka-shadow/src/interp.rs | 74 +++++++++++- .../modules/charka/charka-shadow/src/lib.rs | 1 + crates/modules/charka/corpus/18-fichero.cob | 35 ++++++ .../modules/charka/corpus/18-fichero.expected | 4 + crates/modules/charka/corpus/README.md | 1 + docs/changelog/charka.md | 23 ++++ 17 files changed, 663 insertions(+), 22 deletions(-) create mode 100644 crates/modules/charka/charka-runtime/src/file.rs create mode 100644 crates/modules/charka/corpus/18-fichero.cob create mode 100644 crates/modules/charka/corpus/18-fichero.expected diff --git a/crates/apps/charka/src/main.rs b/crates/apps/charka/src/main.rs index 90ffe25..aada11e 100644 --- a/crates/apps/charka/src/main.rs +++ b/crates/apps/charka/src/main.rs @@ -239,6 +239,14 @@ fn collect_unknowns(stmts: &[Stmt], out: &mut Vec) { } collect_unknowns(other, out); } + Stmt::Read { + at_end, + not_at_end, + .. + } => { + collect_unknowns(at_end, out); + collect_unknowns(not_at_end, out); + } Stmt::Perform(p) => { if let PerformTarget::Inline(body) = &p.target { collect_unknowns(body, out); diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md index 664e6f6..9e2e894 100644 --- a/crates/modules/charka/SDD.md +++ b/crates/modules/charka/SDD.md @@ -111,7 +111,9 @@ Tercera etapa: `Program` → `Ir`. Aquí se parsea cada `Sentence` cruda miembros (`DataModel::groups`). - `SET cond... TO TRUE` — la cara de escritura de los nombres de condición (nivel 88): asigna a su dato padre el valor del 88. -- Fuera de alcance v1: E/S de ficheros, CICS, SQL embebido. +- E/S de ficheros: `Stmt::Open`/`Close`/`Read`/`Write` y `Ir::files` + (de `SELECT` + `FD`). Sólo organización «line sequential». +- Fuera de alcance v1: CICS, SQL embebido. ## charka-runtime @@ -128,6 +130,9 @@ COBOL en tiempo de ejecución. justifica a la izquierda y rellena/trunca; `fill` mueve figurativas (`SPACES`, `ZEROS`). - `cobol_text_cmp` — comparación alfanumérica con relleno de espacios. +- `CobFile` — un fichero «line sequential»: `open_input` carga las + líneas a memoria, `open_output` acumula, `read`/`write` operan línea + a línea, `close` vuelca a disco lo escrito. - Reexporta `Decimal`/`Picture`/`Rounding` de `charka-bcd` para que el código generado sólo necesite `use charka_runtime::*;`. @@ -158,8 +163,10 @@ del programa COBOL. - Verificado de punta a punta: un programa COBOL de demostración transpila a Rust que compila contra `charka-runtime` y produce la salida correcta. +- E/S de ficheros «line sequential»: un campo `CobFile` por fichero; + `OPEN`/`CLOSE`/`READ`/`WRITE` emiten llamadas a `charka-runtime`. - Fuera de alcance v1: grupos como campo propio, `REDEFINES`, - `OCCURS` de grupo, E/S de ficheros. + `OCCURS` de grupo. ## charka-shadow @@ -182,8 +189,8 @@ que corre el `Ir` directamente sobre `charka-runtime`, sin compilar. ## El corpus -`crates/modules/charka/corpus/` — 17 programas COBOL graduados -(`01-hola` … `17-rangopar`), cada uno con su `.expected`. Ejercita el +`crates/modules/charka/corpus/` — 18 programas COBOL graduados +(`01-hola` … `18-fichero`), cada uno con su `.expected`. Ejercita el pipeline completo de punta a punta. Ver su `README.md`. ## La CLI @@ -206,4 +213,5 @@ concuerdan. Próximo hito mayor: salir del subconjunto COBOL'85 puro hacia CICS, SQL embebido y los dialectos IBM Enterprise; ampliar el codegen -(grupos como campo, `REDEFINES`, `OCCURS` de grupo, E/S de ficheros). +(grupos como campo, `REDEFINES`, `OCCURS` de grupo, ficheros +indexados, `CALL` a subprogramas). diff --git a/crates/modules/charka/charka-codegen/src/lib.rs b/crates/modules/charka/charka-codegen/src/lib.rs index 2c0be88..a728d42 100644 --- a/crates/modules/charka/charka-codegen/src/lib.rs +++ b/crates/modules/charka/charka-codegen/src/lib.rs @@ -81,6 +81,9 @@ fn emit_struct(em: &mut Emitter, sym: &Symbols) { }; em.line(&format!("{}: {ty},", f.ident)); } + for fs in &sym.files { + em.line(&format!("{}: CobFile,", fs.ident)); + } em.dedent(); em.line("}"); em.blank(); @@ -99,6 +102,13 @@ fn emit_impl(em: &mut Emitter, sym: &Symbols, ir: &Ir) { for f in &sym.fields { em.line(&format!("{}: {},", f.ident, field_init(f))); } + for fs in &sym.files { + em.line(&format!( + "{}: CobFile::new({}),", + fs.ident, + rust_str(&fs.path) + )); + } em.dedent(); em.line("}"); em.dedent(); @@ -438,6 +448,32 @@ mod tests { assert!(out.matches("self.p_b();").count() >= 2); } + #[test] + fn file_io_emits_open_read_write_close() { + let out = gen("ENVIRONMENT DIVISION.\n\ + INPUT-OUTPUT SECTION.\n\ + FILE-CONTROL.\n\ + SELECT ARCH ASSIGN TO 'x.dat'.\n\ + DATA DIVISION.\n\ + FILE SECTION.\n\ + FD ARCH.\n\ + 01 REG PIC X(10).\n\ + PROCEDURE DIVISION.\n\ + MAIN.\n\ + OPEN OUTPUT ARCH.\n\ + WRITE REG FROM 'HI'.\n\ + CLOSE ARCH.\n\ + OPEN INPUT ARCH.\n\ + READ ARCH AT END CONTINUE END-READ.\n\ + CLOSE ARCH.\n"); + assert!(out.contains("file_arch: CobFile,")); + assert!(out.contains("CobFile::new(\"x.dat\")")); + assert!(out.contains("self.file_arch.open_output();")); + assert!(out.contains("self.file_arch.write(")); + assert!(out.contains("self.file_arch.read()")); + assert!(out.contains("self.file_arch.close();")); + } + #[test] fn empty_program_still_compiles_shape() { let out = gen(""); diff --git a/crates/modules/charka/charka-codegen/src/stmt.rs b/crates/modules/charka/charka-codegen/src/stmt.rs index dbe7e81..1b1f3d7 100644 --- a/crates/modules/charka/charka-codegen/src/stmt.rs +++ b/crates/modules/charka/charka-codegen/src/stmt.rs @@ -2,8 +2,8 @@ //! una o varias líneas de código Rust sobre `charka-runtime`. use charka_ir::{ - CmpOp, Cond, InspectOp, Operand, Perform, PerformControl, PerformTarget, Stmt, WhenBranch, - WhenTest, + CmpOp, Cond, FileMode, InspectOp, Operand, Perform, PerformControl, PerformTarget, Stmt, + WhenBranch, WhenTest, }; use crate::emit::Emitter; @@ -88,6 +88,14 @@ pub(crate) fn emit_stmt(em: &mut Emitter, sym: &Symbols, stmt: &Stmt) { Stmt::Inspect { target, op } => emit_inspect(em, sym, target, op), Stmt::Initialize { targets } => emit_initialize(em, sym, targets), Stmt::SetTrue { conditions } => emit_set_true(em, sym, conditions), + Stmt::Open { mode, files } => emit_open(em, sym, *mode, files), + Stmt::Close { files } => emit_close(em, sym, files), + Stmt::Read { + file, + at_end, + not_at_end, + } => emit_read(em, sym, file, at_end, not_at_end), + Stmt::Write { record, from } => emit_write(em, sym, record, from.as_ref()), Stmt::Perform(p) => emit_perform(em, sym, p), Stmt::GoTo { target } => { em.line(&format!( @@ -493,6 +501,77 @@ fn emit_initialize(em: &mut Emitter, sym: &Symbols, targets: &[Operand]) { } } +/// `OPEN {INPUT|OUTPUT} files...` +fn emit_open(em: &mut Emitter, sym: &Symbols, mode: FileMode, files: &[String]) { + let method = match mode { + FileMode::Input => "open_input", + FileMode::Output => "open_output", + }; + for f in files { + match sym.file(f) { + Some(fs) => em.line(&format!("self.{}.{method}();", fs.ident)), + None => em.line("// charka: OPEN de fichero no resuelto"), + } + } +} + +/// `CLOSE files...` +fn emit_close(em: &mut Emitter, sym: &Symbols, files: &[String]) { + for f in files { + match sym.file(f) { + Some(fs) => em.line(&format!("self.{}.close();", fs.ident)), + None => em.line("// charka: CLOSE de fichero no resuelto"), + } + } +} + +/// `READ file [AT END ...] [NOT AT END ...]` — lee la línea siguiente +/// en el registro del fichero. +fn emit_read(em: &mut Emitter, sym: &Symbols, file: &str, at_end: &[Stmt], not_at_end: &[Stmt]) { + let Some(fs) = sym.file(file) else { + em.line("// charka: READ de fichero no resuelto"); + return; + }; + let record_ident = sym.lookup(&fs.record).map(|r| r.ident.clone()); + em.line(&format!("match self.{}.read() {{", fs.ident)); + em.indent(); + em.line("Some(__line) => {"); + em.indent(); + if let Some(rec) = &record_ident { + em.line(&format!("self.{rec}.store(__line.as_str());")); + } + emit_block(em, sym, not_at_end); + em.dedent(); + em.line("}"); + em.line("None => {"); + em.indent(); + emit_block(em, sym, at_end); + em.dedent(); + em.line("}"); + em.dedent(); + em.line("}"); +} + +/// `WRITE record [FROM from]` — escribe el registro en su fichero. +fn emit_write(em: &mut Emitter, sym: &Symbols, record: &str, from: Option<&Operand>) { + if let Some(src) = from { + if let Some((lref, _)) = field_ref(sym, &Operand::Data(record.to_string())) { + em.line(&format!("{lref}.store({});", operand_str(sym, src))); + } + } + match sym.file_of_record(record) { + Some(fs) => { + if let Some(rec) = sym.lookup(record) { + em.line(&format!( + "self.{}.write(&self.{}.display());", + fs.ident, rec.ident + )); + } + } + None => em.line("// charka: WRITE de registro no resuelto"), + } +} + /// `SET cond... TO TRUE` — asigna a cada dato padre el valor que hace /// verdadero su nombre de condición (nivel 88). fn emit_set_true(em: &mut Emitter, sym: &Symbols, conditions: &[String]) { diff --git a/crates/modules/charka/charka-codegen/src/sym.rs b/crates/modules/charka/charka-codegen/src/sym.rs index c18d764..1fb56fa 100644 --- a/crates/modules/charka/charka-codegen/src/sym.rs +++ b/crates/modules/charka/charka-codegen/src/sym.rs @@ -24,8 +24,20 @@ pub(crate) struct Field { pub occurs: Option, } -/// Los campos del programa, sus nombres de condición, sus grupos y -/// sus párrafos. +/// Un fichero del programa generado. +pub(crate) struct FileSym { + /// Nombre COBOL del fichero. + pub cobol: String, + /// Identificador Rust del campo `CobFile` (prefijo `file_`). + pub ident: String, + /// Ruta a la que está asignado. + pub path: String, + /// Nombre COBOL del registro asociado (su `FD`). + pub record: String, +} + +/// Los campos del programa, sus nombres de condición, sus grupos, sus +/// párrafos y sus ficheros. pub(crate) struct Symbols { pub fields: Vec, by_name: HashMap, @@ -33,6 +45,8 @@ pub(crate) struct Symbols { groups: HashMap>, /// Los párrafos en orden: `(nombre COBOL, nombre de método Rust)`. pub paragraphs: Vec<(String, String)>, + /// Los ficheros declarados. + pub files: Vec, } impl Symbols { @@ -79,15 +93,38 @@ impl Symbols { (proc.name.to_uppercase(), method) }) .collect(); + let files = ir + .files + .iter() + .map(|f| FileSym { + cobol: f.name.clone(), + ident: format!("file_{}", sanitize_ident(&f.name)), + path: f.path.clone(), + record: f.record.clone(), + }) + .collect(); Self { fields, by_name, conditions, groups, paragraphs, + files, } } + /// Busca un fichero por su nombre COBOL. + pub(crate) fn file(&self, name: &str) -> Option<&FileSym> { + let up = name.to_uppercase(); + self.files.iter().find(|f| f.cobol == up) + } + + /// Busca el fichero cuyo registro `FD` es `record`. + pub(crate) fn file_of_record(&self, record: &str) -> Option<&FileSym> { + let up = record.to_uppercase(); + self.files.iter().find(|f| f.record == up) + } + /// Los métodos a llamar para un `PERFORM name [THRU thru]`: el /// rango de párrafos desde `name` hasta `thru` inclusive. pub(crate) fn paragraph_range(&self, name: &str, thru: Option<&str>) -> Vec { diff --git a/crates/modules/charka/charka-ir/src/ast.rs b/crates/modules/charka/charka-ir/src/ast.rs index 5492dc1..95c9957 100644 --- a/crates/modules/charka/charka-ir/src/ast.rs +++ b/crates/modules/charka/charka-ir/src/ast.rs @@ -1,7 +1,7 @@ //! Los tipos del IR: el programa COBOL con su PROCEDURE division ya //! parseada a instrucciones tipadas. -pub use charka_parser::{DataItem, Token}; +pub use charka_parser::{DataItem, FileEntry, Token}; /// Un programa COBOL en representación intermedia. #[derive(Debug, Clone, PartialEq, Default)] @@ -14,6 +14,8 @@ pub struct Ir { /// El modelo de datos resuelto: los datos elementales aplanados y /// los nombres de condición (nivel 88). pub model: crate::model::DataModel, + /// Los ficheros declarados (`SELECT` + `FD`). + pub files: Vec, /// Los párrafos del PROCEDURE, con sus statements ya tipados. pub procedures: Vec, } @@ -191,6 +193,21 @@ pub enum Stmt { /// `SET cond-name... TO TRUE` — hace verdaderos esos nombres de /// condición (nivel 88): asigna a su dato padre el valor del 88. SetTrue { conditions: Vec }, + /// `OPEN {INPUT|OUTPUT} files...` + Open { mode: FileMode, files: Vec }, + /// `CLOSE files...` + Close { files: Vec }, + /// `READ file [AT END at_end] [NOT AT END not_at_end] [END-READ]` + Read { + file: String, + at_end: Vec, + not_at_end: Vec, + }, + /// `WRITE record [FROM from]` + Write { + record: String, + from: Option, + }, /// `PERFORM ...` — ver [`Perform`]. Perform(Perform), /// `GO TO target` @@ -208,6 +225,15 @@ pub enum Stmt { Unknown { verb: String, tokens: Vec }, } +/// El modo de apertura de un fichero. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileMode { + /// `OPEN INPUT` — para lectura. + Input, + /// `OPEN OUTPUT` — para escritura (crea el fichero de cero). + Output, +} + /// La operación de un `INSPECT`. #[derive(Debug, Clone, PartialEq)] pub enum InspectOp { diff --git a/crates/modules/charka/charka-ir/src/lib.rs b/crates/modules/charka/charka-ir/src/lib.rs index b1bd14c..06de61b 100644 --- a/crates/modules/charka/charka-ir/src/lib.rs +++ b/crates/modules/charka/charka-ir/src/lib.rs @@ -19,7 +19,8 @@ //! condiciones `AND`/`OR`/`NOT`), `EVALUATE`/`WHEN`, `STRING`, //! `UNSTRING`, `INSPECT`, `PERFORM` (fuera de línea, en línea, //! `TIMES`, `UNTIL`, `VARYING`), `GO TO`, `STOP RUN`, `GOBACK`, -//! `EXIT`, `CONTINUE`. Fuera de alcance: E/S de ficheros, CICS y SQL. +//! `EXIT`, `CONTINUE`, E/S de ficheros (`OPEN`/`READ`/`WRITE`/`CLOSE`). +//! Fuera de alcance: CICS y SQL embebido. #![forbid(unsafe_code)] @@ -58,6 +59,7 @@ pub fn lower(program: &Program) -> Ir { program_id: program.program_id.clone().unwrap_or_default(), data: program.data.clone(), model: model::resolve_data(&program.data), + files: program.files.clone(), procedures, } } @@ -471,6 +473,39 @@ mod tests { } } + #[test] + fn file_io_statements_parse() { + let program = ir("ENVIRONMENT DIVISION.\n\ + INPUT-OUTPUT SECTION.\n\ + FILE-CONTROL.\n\ + SELECT ARCH ASSIGN TO 'datos.dat'.\n\ + DATA DIVISION.\n\ + FILE SECTION.\n\ + FD ARCH.\n\ + 01 REG PIC X(20).\n\ + PROCEDURE DIVISION.\n\ + MAIN.\n\ + OPEN OUTPUT ARCH.\n\ + WRITE REG FROM 'HOLA'.\n\ + CLOSE ARCH.\n\ + OPEN INPUT ARCH.\n\ + READ ARCH AT END CONTINUE NOT AT END DISPLAY REG END-READ.\n\ + CLOSE ARCH.\n"); + assert_eq!(program.files.len(), 1); + assert_eq!(program.files[0].record, "REG"); + let body = &program.procedures[0].body; + assert!(matches!( + body[0], + Stmt::Open { + mode: FileMode::Output, + .. + } + )); + assert!(matches!(&body[1], Stmt::Write { record, .. } if record == "REG")); + assert!(matches!(body[2], Stmt::Close { .. })); + assert!(matches!(&body[4], Stmt::Read { not_at_end, .. } if not_at_end.len() == 1)); + } + #[test] fn several_statements_in_one_sentence() { let b = body("MOVE 1 TO X DISPLAY X STOP RUN."); diff --git a/crates/modules/charka/charka-ir/src/stmt.rs b/crates/modules/charka/charka-ir/src/stmt.rs index b364b91..574a1c7 100644 --- a/crates/modules/charka/charka-ir/src/stmt.rs +++ b/crates/modules/charka/charka-ir/src/stmt.rs @@ -6,7 +6,8 @@ use charka_parser::TokenKind; use crate::ast::{ - InspectOp, Operand, Perform, PerformControl, PerformTarget, Stmt, WhenBranch, WhenTest, + FileMode, InspectOp, Operand, Perform, PerformControl, PerformTarget, Stmt, WhenBranch, + WhenTest, }; use crate::cursor::{parse_operand, Cursor}; use crate::expr::{parse_cond, parse_expr}; @@ -46,6 +47,10 @@ fn parse_one_stmt(c: &mut Cursor, stops: &[&str]) -> Stmt { "INSPECT" => parse_inspect(c), "INITIALIZE" => parse_initialize(c), "SET" => parse_set(c), + "OPEN" => parse_open(c), + "CLOSE" => parse_close(c), + "READ" => parse_read(c), + "WRITE" => parse_write(c), "PERFORM" => parse_perform(c), "GO" => parse_goto(c), "STOP" => parse_stop(c), @@ -418,6 +423,79 @@ fn parse_initialize(c: &mut Cursor) -> Stmt { Stmt::Initialize { targets } } +fn parse_open(c: &mut Cursor) -> Stmt { + c.bump(); // OPEN + let mode = if c.eat_word("OUTPUT") || c.eat_word("EXTEND") { + FileMode::Output + } else { + c.eat_word("INPUT"); + c.eat_word("I-O"); + FileMode::Input + }; + let mut files = Vec::new(); + while let Some(w) = c.peek_word() { + if is_boundary(&w) || matches!(w.as_str(), "INPUT" | "OUTPUT" | "EXTEND" | "I-O") { + break; + } + c.bump(); + files.push(w); + } + skip_to_stmt_boundary(c); + Stmt::Open { mode, files } +} + +fn parse_close(c: &mut Cursor) -> Stmt { + c.bump(); // CLOSE + let mut files = Vec::new(); + while let Some(name) = parse_one_name(c) { + files.push(name); + } + Stmt::Close { files } +} + +fn parse_read(c: &mut Cursor) -> Stmt { + c.bump(); // READ + let file = parse_one_name(c).unwrap_or_default(); + c.eat_word("NEXT"); + c.eat_word("RECORD"); + if c.eat_word("INTO") { + let _ = parse_operand(c); // `READ ... INTO`: la v1 lo ignora + } + let mut at_end = Vec::new(); + let mut not_at_end = Vec::new(); + loop { + if c.eat_word("AT") { + c.eat_word("END"); + at_end = parse_statements(c, &["NOT", "END-READ"]); + } else if c.eat_word("NOT") { + c.eat_word("AT"); + c.eat_word("END"); + not_at_end = parse_statements(c, &["END-READ"]); + } else { + break; + } + } + c.eat_word("END-READ"); + Stmt::Read { + file, + at_end, + not_at_end, + } +} + +fn parse_write(c: &mut Cursor) -> Stmt { + c.bump(); // WRITE + let record = parse_one_name(c).unwrap_or_default(); + let from = if c.eat_word("FROM") { + Some(parse_operand(c)) + } else { + None + }; + skip_to_stmt_boundary(c); // p. ej. `AFTER ADVANCING` + c.eat_word("END-WRITE"); + Stmt::Write { record, from } +} + fn parse_inspect(c: &mut Cursor) -> Stmt { c.bump(); // INSPECT let target = parse_operand(c); diff --git a/crates/modules/charka/charka-parser/src/lib.rs b/crates/modules/charka/charka-parser/src/lib.rs index 3a22132..dc5b0e8 100644 --- a/crates/modules/charka/charka-parser/src/lib.rs +++ b/crates/modules/charka/charka-parser/src/lib.rs @@ -34,10 +34,21 @@ pub struct Program { pub program_id: Option, /// Los ítems raíz de la DATA division (cada `01`/`77` con su árbol). pub data: Vec, + /// Los ficheros declarados (`SELECT` + `FD`). + pub files: Vec, /// Los párrafos de la PROCEDURE division, en orden de aparición. pub paragraphs: Vec, } +/// Un fichero declarado: su nombre lógico, la ruta a la que se asigna +/// (`ASSIGN TO`) y el dato de registro asociado (el `01` bajo su `FD`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FileEntry { + pub name: String, + pub path: String, + pub record: String, +} + /// 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. @@ -110,8 +121,17 @@ pub fn parse(tokens: &[Token]) -> Result { 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::Environment => parse_environment(body, &mut program), + DivKind::Data => { + let (data, fd_records) = parse_data(body)?; + program.data = data; + // Asocia cada `FD` con el registro `01` que le sigue. + for (fd, record) in fd_records { + if let Some(f) = program.files.iter_mut().find(|f| f.name == fd) { + f.record = record; + } + } + } DivKind::Procedure => program.paragraphs = parse_procedure(body), } } @@ -165,20 +185,76 @@ fn parse_identification(body: &[Token], program: &mut Program) { } /// Parsea el cuerpo de la DATA division en un árbol de [`DataItem`]. -fn parse_data(body: &[Token]) -> Result, ParseError> { +/// Parsea la cláusula `FILE-CONTROL` de la ENVIRONMENT division: cada +/// `SELECT name ASSIGN TO "ruta"` se registra como un fichero. +fn parse_environment(body: &[Token], program: &mut Program) { + for sent in split_sentences(body) { + if kw(sent.first()).as_deref() != Some("SELECT") { + continue; + } + let Some(name_tok) = sent.get(1) else { + continue; + }; + if name_tok.kind != TokenKind::Word { + continue; + } + let mut path = String::new(); + let mut i = 2; + while i < sent.len() { + if kw(sent.get(i)).as_deref() == Some("ASSIGN") { + i += 1; + if kw(sent.get(i)).as_deref() == Some("TO") { + i += 1; + } + if let Some(t) = sent.get(i) { + path = t.text.clone(); + } + break; + } + i += 1; + } + program.files.push(FileEntry { + name: name_tok.text.to_uppercase(), + path, + record: String::new(), + }); + } +} + +/// El resultado de parsear la DATA division: el árbol de datos y las +/// parejas `(FD, registro)` — el `01` que sigue a cada `FD`. +type DataResult = (Vec, Vec<(String, String)>); + +/// Parsea la DATA division. +fn parse_data(body: &[Token]) -> Result { let mut flat = Vec::new(); + let mut fd_records = Vec::new(); + let mut pending_fd: Option = None; 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. + // Las sentencias que arrancan con un número de nivel son + // entradas de datos; las demás son encabezados de SECTION o + // de `FD`/`SD`. if first.kind != TokenKind::Number { + if matches!(kw(Some(first)).as_deref(), Some("FD") | Some("SD")) { + pending_fd = sent + .get(1) + .filter(|t| t.kind == TokenKind::Word) + .map(|t| t.text.to_uppercase()); + } continue; } let level = parse_level(first)?; - flat.push(parse_data_entry(level, &sent)?); + let entry = parse_data_entry(level, &sent)?; + // El primer `01` tras un `FD` es su registro. + if level == 1 { + if let Some(fd) = pending_fd.take() { + fd_records.push((fd, entry.name.clone())); + } + } + flat.push(entry); } - Ok(build_tree(flat)) + Ok((build_tree(flat), fd_records)) } /// Valida que el token sea un número de nivel COBOL (01-49, 66, 77, 88). @@ -624,6 +700,26 @@ mod tests { assert_eq!(p.data[0].children[1].name, "FILLER"); } + #[test] + fn select_and_fd_captured() { + let p = parse_src( + "ENVIRONMENT DIVISION.\n\ + INPUT-OUTPUT SECTION.\n\ + FILE-CONTROL.\n\ + SELECT CLIENTES ASSIGN TO 'clientes.dat'.\n\ + DATA DIVISION.\n\ + FILE SECTION.\n\ + FD CLIENTES.\n\ + 01 REG-CLIENTE PIC X(40).\n\ + WORKING-STORAGE SECTION.\n\ + 01 WS-FIN PIC X.\n", + ); + assert_eq!(p.files.len(), 1); + assert_eq!(p.files[0].name, "CLIENTES"); + assert_eq!(p.files[0].path, "clientes.dat"); + assert_eq!(p.files[0].record, "REG-CLIENTE"); + } + #[test] fn occurs_clause_captured() { let p = parse_src( diff --git a/crates/modules/charka/charka-runtime/src/file.rs b/crates/modules/charka/charka-runtime/src/file.rs new file mode 100644 index 0000000..4b09cbe --- /dev/null +++ b/crates/modules/charka/charka-runtime/src/file.rs @@ -0,0 +1,102 @@ +//! `CobFile` — un fichero secuencial de líneas para el runtime COBOL. + +use std::collections::VecDeque; + +/// Un fichero de organización «line sequential»: cada registro es una +/// línea de texto. La lectura carga el fichero entero a memoria; la +/// escritura acumula líneas y las vuelca al cerrar. +#[derive(Debug)] +pub struct CobFile { + path: String, + state: State, +} + +#[derive(Debug)] +enum State { + Closed, + /// Abierto para lectura: las líneas que faltan por leer. + Reading(VecDeque), + /// Abierto para escritura: las líneas acumuladas. + Writing(Vec), +} + +impl CobFile { + /// Un fichero nuevo, cerrado, asignado a la ruta `path`. + pub fn new(path: &str) -> Self { + Self { + path: path.to_string(), + state: State::Closed, + } + } + + /// `OPEN INPUT`: carga el fichero a memoria. Si no existe, queda + /// abierto y vacío (la primera lectura dará fin de fichero). + pub fn open_input(&mut self) { + let lines = std::fs::read_to_string(&self.path) + .map(|s| s.lines().map(str::to_string).collect()) + .unwrap_or_default(); + self.state = State::Reading(lines); + } + + /// `OPEN OUTPUT`: empieza un fichero nuevo y vacío. + pub fn open_output(&mut self) { + self.state = State::Writing(Vec::new()); + } + + /// `READ`: la siguiente línea, o `None` en fin de fichero. + pub fn read(&mut self) -> Option { + match &mut self.state { + State::Reading(lines) => lines.pop_front(), + _ => None, + } + } + + /// `WRITE`: agrega una línea (sólo si está abierto para escritura). + pub fn write(&mut self, line: &str) { + if let State::Writing(buf) = &mut self.state { + buf.push(line.to_string()); + } + } + + /// `CLOSE`: si estaba escribiendo, vuelca el contenido al disco. + pub fn close(&mut self) { + if let State::Writing(buf) = &self.state { + let body: String = buf.iter().map(|l| format!("{l}\n")).collect(); + let _ = std::fs::write(&self.path, body); + } + self.state = State::Closed; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_then_read_roundtrip() { + let path = std::env::temp_dir().join("charka-cobfile-test.dat"); + let path = path.to_str().unwrap(); + + let mut f = CobFile::new(path); + f.open_output(); + f.write("PRIMERA"); + f.write("SEGUNDA"); + f.close(); + + let mut g = CobFile::new(path); + g.open_input(); + assert_eq!(g.read().as_deref(), Some("PRIMERA")); + assert_eq!(g.read().as_deref(), Some("SEGUNDA")); + assert_eq!(g.read(), None); // fin de fichero + g.close(); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn missing_file_reads_as_empty() { + let mut f = CobFile::new("/charka/no/existe/jamas.dat"); + f.open_input(); + assert_eq!(f.read(), None); + } +} diff --git a/crates/modules/charka/charka-runtime/src/lib.rs b/crates/modules/charka/charka-runtime/src/lib.rs index 1ed5b93..0d86920 100644 --- a/crates/modules/charka/charka-runtime/src/lib.rs +++ b/crates/modules/charka/charka-runtime/src/lib.rs @@ -17,10 +17,12 @@ #![forbid(unsafe_code)] +mod file; mod num; mod text; pub use charka_bcd::{Decimal, Picture, Rounding}; +pub use file::CobFile; pub use num::Num; pub use text::Text; diff --git a/crates/modules/charka/charka-shadow/src/interp.rs b/crates/modules/charka/charka-shadow/src/interp.rs index 02975ea..5eb0e22 100644 --- a/crates/modules/charka/charka-shadow/src/interp.rs +++ b/crates/modules/charka/charka-shadow/src/interp.rs @@ -8,10 +8,10 @@ use std::collections::HashMap; use charka_ir::{ - BinOp, CmpOp, Cond, ConditionName, Expr, Figurative, InspectOp, Ir, Operand, Perform, + BinOp, CmpOp, Cond, ConditionName, Expr, Figurative, FileMode, InspectOp, Ir, Operand, Perform, PerformControl, PerformTarget, Stmt, WhenTest, }; -use charka_runtime::{cobol_text_cmp, Decimal, Num, Rounding, Text}; +use charka_runtime::{cobol_text_cmp, CobFile, Decimal, Num, Rounding, Text}; use crate::field::{build_fields, Cell}; @@ -39,6 +39,7 @@ pub(crate) struct Machine<'a> { fields: HashMap, para_index: HashMap, conditions: HashMap, + files: HashMap, pub output: Vec, budget: u64, pub step_limit_hit: bool, @@ -58,11 +59,17 @@ impl<'a> Machine<'a> { .iter() .map(|c| (c.name.clone(), c.clone())) .collect(); + let files = ir + .files + .iter() + .map(|f| (f.name.to_uppercase(), CobFile::new(&f.path))) + .collect(); Self { ir, fields: build_fields(&ir.model), para_index, conditions, + files, output: Vec::new(), budget: STEP_BUDGET, step_limit_hit: false, @@ -320,6 +327,69 @@ impl<'a> Machine<'a> { } Flow::Normal } + Stmt::Open { mode, files } => { + for f in files { + if let Some(cf) = self.files.get_mut(&f.to_uppercase()) { + match mode { + FileMode::Input => cf.open_input(), + FileMode::Output => cf.open_output(), + } + } + } + Flow::Normal + } + Stmt::Close { files } => { + for f in files { + if let Some(cf) = self.files.get_mut(&f.to_uppercase()) { + cf.close(); + } + } + Flow::Normal + } + Stmt::Read { + file, + at_end, + not_at_end, + } => { + let line = self + .files + .get_mut(&file.to_uppercase()) + .and_then(|cf| cf.read()); + match line { + Some(text) => { + let record = self + .ir + .files + .iter() + .find(|f| f.name.eq_ignore_ascii_case(file)) + .map(|f| f.record.clone()); + if let Some(rec) = record { + self.store_text(&Operand::Data(rec), &text); + } + self.exec_block(not_at_end) + } + None => self.exec_block(at_end), + } + } + Stmt::Write { record, from } => { + if let Some(src) = from { + let text = self.eval_text(src); + self.store_text(&Operand::Data(record.clone()), &text); + } + let file = self + .ir + .files + .iter() + .find(|f| f.record.eq_ignore_ascii_case(record)) + .map(|f| f.name.to_uppercase()); + if let Some(file) = file { + let line = self.eval_text(&Operand::Data(record.clone())); + if let Some(cf) = self.files.get_mut(&file) { + cf.write(&line); + } + } + Flow::Normal + } Stmt::Perform(p) => self.exec_perform(p), Stmt::GoTo { target } => { // Aproximación: ejecuta el destino y sale del párrafo. diff --git a/crates/modules/charka/charka-shadow/src/lib.rs b/crates/modules/charka/charka-shadow/src/lib.rs index 1efab6b..4329f06 100644 --- a/crates/modules/charka/charka-shadow/src/lib.rs +++ b/crates/modules/charka/charka-shadow/src/lib.rs @@ -126,6 +126,7 @@ mod tests { corpus_test!(corpus_15_resetear, "15-resetear"); corpus_test!(corpus_16_bandera, "16-bandera"); corpus_test!(corpus_17_rangopar, "17-rangopar"); + corpus_test!(corpus_18_fichero, "18-fichero"); #[test] fn empty_source_runs_clean() { diff --git a/crates/modules/charka/corpus/18-fichero.cob b/crates/modules/charka/corpus/18-fichero.cob new file mode 100644 index 0000000..b7e0457 --- /dev/null +++ b/crates/modules/charka/corpus/18-fichero.cob @@ -0,0 +1,35 @@ +* corpus charka — nivel 7: E/S de ficheros (escribir y releer) +IDENTIFICATION DIVISION. +PROGRAM-ID. FICHERO. +ENVIRONMENT DIVISION. +INPUT-OUTPUT SECTION. +FILE-CONTROL. + SELECT DATOS ASSIGN TO '/tmp/charka-corpus-18.dat' + ORGANIZATION IS LINE SEQUENTIAL. +DATA DIVISION. +FILE SECTION. +FD DATOS. +01 REGISTRO PIC X(20). +WORKING-STORAGE SECTION. +01 WS-FIN PIC X VALUE 'N'. + 88 FIN-DATOS VALUE 'S'. +01 WS-CONT PIC 9(3) VALUE 0. +PROCEDURE DIVISION. +MAIN. + OPEN OUTPUT DATOS. + WRITE REGISTRO FROM 'PRIMERA LINEA'. + WRITE REGISTRO FROM 'SEGUNDA LINEA'. + WRITE REGISTRO FROM 'TERCERA LINEA'. + CLOSE DATOS. + OPEN INPUT DATOS. + PERFORM UNTIL FIN-DATOS + READ DATOS + AT END SET FIN-DATOS TO TRUE + NOT AT END + ADD 1 TO WS-CONT + DISPLAY REGISTRO + END-READ + END-PERFORM. + CLOSE DATOS. + DISPLAY 'LINEAS LEIDAS = ' WS-CONT. + STOP RUN. diff --git a/crates/modules/charka/corpus/18-fichero.expected b/crates/modules/charka/corpus/18-fichero.expected new file mode 100644 index 0000000..3a45fec --- /dev/null +++ b/crates/modules/charka/corpus/18-fichero.expected @@ -0,0 +1,4 @@ +PRIMERA LINEA +SEGUNDA LINEA +TERCERA LINEA +LINEAS LEIDAS = 003 diff --git a/crates/modules/charka/corpus/README.md b/crates/modules/charka/corpus/README.md index 1c947f8..639c2da 100644 --- a/crates/modules/charka/corpus/README.md +++ b/crates/modules/charka/corpus/README.md @@ -26,6 +26,7 @@ salida correcta, una línea por `DISPLAY`. | `15-resetear` | 6 | `INITIALIZE` — resetear datos y grupos | | `16-bandera` | 5 | `SET` de nombres de condición (nivel 88) a `TRUE` | | `17-rangopar` | 5 | `PERFORM ... THRU` — un rango de párrafos | +| `18-fichero` | 7 | E/S de ficheros: `SELECT`/`FD`/`OPEN`/`READ`/`WRITE`| ## Formato diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md index 9ed626a..4c77013 100644 --- a/docs/changelog/charka.md +++ b/docs/changelog/charka.md @@ -3,6 +3,29 @@ 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): E/S de ficheros — SELECT / FD / OPEN / READ / WRITE / CLOSE + +El gran hueco que faltaba para el COBOL real: el procesamiento de +ficheros secuenciales. Una rebanada vertical por los seis crates. + +- `charka-parser`: la ENVIRONMENT division ya no se ignora — se + parsea `FILE-CONTROL` (`SELECT name ASSIGN TO "ruta"`); del FILE + SECTION se asocia cada `FD` con su registro `01`. `Program::files`. +- `charka-runtime`: tipo `CobFile` — un fichero «line sequential» + (cada registro una línea). Lectura: carga a memoria. Escritura: + acumula y vuelca al cerrar. +- `charka-ir`: `Ir::files` y los statements `Open`/`Close`/`Read`/ + `Write`. `READ` lleva sus bloques `AT END` / `NOT AT END`. +- `charka-codegen`: un campo `CobFile` por fichero en el `struct + Program`; los verbos emiten llamadas al runtime. +- `charka-shadow`: el intérprete hace E/S de ficheros real. +- Corpus: programa nuevo `18-fichero` — escribe tres líneas a un + fichero, lo cierra, lo relee con `READ ... AT END` y las muestra. + Verificado: el intérprete sombra y el crate compilado dan la misma + salida. +- Alcance v1: organización «line sequential»; sin ficheros indexados + ni relativos, sin `FILE STATUS`. + ### feat(charka): PERFORM ... THRU como rango real de párrafos `PERFORM A THRU C` ejecuta A, B y C; antes el transpilador sólo