tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

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, 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 <noreply@anthropic.com>

b3278bdb0cccadf52a05c09cf2975027e498cbc4 · by sergio · (5 months ago) · parent f250fd076 · browse the tree at this commit

Semantic delta · 46

crates/apps/charka/src/main.rs

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

crates/modules/charka/charka-codegen/src/stmt.rs

crates/modules/charka/charka-codegen/src/sym.rs

crates/modules/charka/charka-ir/src/ast.rs

  • new enum FileMode pub
  • signature changed struct Ir pub
  • signature changed enum Stmt pub

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

crates/modules/charka/charka-ir/src/stmt.rs

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

crates/modules/charka/charka-runtime/src/file.rs

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

crates/modules/charka/charka-shadow/src/interp.rs

17 files · +663 −22

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<String>) {
                 }
                 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<u32>,
 }
 
-/// 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<Field>,
     by_name: HashMap<String, usize>,
@@ -33,6 +45,8 @@ pub(crate) struct Symbols {
     groups: HashMap<String, Vec<String>>,
     /// Los párrafos en orden: `(nombre COBOL, nombre de método Rust)`.
     pub paragraphs: Vec<(String, String)>,
+    /// Los ficheros declarados.
+    pub files: Vec<FileSym>,
 }
 
 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<String> {
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<FileEntry>,
     /// Los párrafos del PROCEDURE, con sus statements ya tipados.
     pub procedures: Vec<Procedure>,
 }
@@ -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<String> },
+    /// `OPEN {INPUT|OUTPUT} files...`
+    Open { mode: FileMode, files: Vec<String> },
+    /// `CLOSE files...`
+    Close { files: Vec<String> },
+    /// `READ file [AT END at_end] [NOT AT END not_at_end] [END-READ]`
+    Read {
+        file: String,
+        at_end: Vec<Stmt>,
+        not_at_end: Vec<Stmt>,
+    },
+    /// `WRITE record [FROM from]`
+    Write {
+        record: String,
+        from: Option<Operand>,
+    },
     /// `PERFORM ...` — ver [`Perform`].
     Perform(Perform),
     /// `GO TO target`
@@ -208,6 +225,15 @@ pub enum Stmt {
     Unknown { verb: String, tokens: Vec<Token> },
 }
 
+/// 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<String>,
     /// Los ítems raíz de la DATA division (cada `01`/`77` con su árbol).
     pub data: Vec<DataItem>,
+    /// Los ficheros declarados (`SELECT` + `FD`).
+    pub files: Vec<FileEntry>,
     /// Los párrafos de la PROCEDURE division, en orden de aparición.
     pub paragraphs: Vec<Paragraph>,
 }
 
+/// 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<Program, ParseError> {
         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<Vec<DataItem>, 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<DataItem>, Vec<(String, String)>);
+
+/// Parsea la DATA division.
+fn parse_data(body: &[Token]) -> Result<DataResult, ParseError> {
     let mut flat = Vec::new();
+    let mut fd_records = Vec::new();
+    let mut pending_fd: Option<String> = 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<String>),
+    /// Abierto para escritura: las líneas acumuladas.
+    Writing(Vec<String>),
+}
+
+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<String> {
+        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<String, Cell>,
     para_index: HashMap<String, usize>,
     conditions: HashMap<String, ConditionName>,
+    files: HashMap<String, CobFile>,
     pub output: Vec<String>,
     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