diff --git a/Cargo.lock b/Cargo.lock index 8b12689..4866052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2315,6 +2315,13 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "charka-lexer" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "chasqui-card" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b0be68d..b827bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ members = [ # modules/charka/ — Transpilador COBOL → Rust # ============================================================ "crates/modules/charka/charka-bcd", + "crates/modules/charka/charka-lexer", # ============================================================ # modules/mirada/ — Compositor Wayland diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md index 4849f7d..be0e69a 100644 --- a/crates/modules/charka/SDD.md +++ b/crates/modules/charka/SDD.md @@ -8,9 +8,10 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes. ## Crates -| crate | tipo | rol | -| ------------ | ---- | ------------------------------------------------------------ | -| `charka-bcd` | lib | Aritmética decimal de punto fijo con semántica COBOL: `Picture`, `Decimal`, redondeo, `ON SIZE ERROR` | +| 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 @@ -26,14 +27,30 @@ reproducir esa aritmética dígito a dígito. - Determinista, sin dependencias de plataforma — mismo programa, mismos dígitos, en cualquier máquina. +## charka-lexer + +Primera etapa del pipeline: texto COBOL → secuencia de `Token`. + +- **Lexer tonto**: no conoce keywords ni la cláusula `PICTURE` — emite + `Word` para todo identificador; la clasificación es del parser. +- Tokens: `Word` (con guiones internos, `WORKING-STORAGE`), `Number` + (sin signo), `String` (comillas dobladas colapsadas), `Period`, + `Symbol` (`( ) , ; :` y operadores `+ - * / ** = < > <= >= <>`). +- Dos formatos: **fijo** (cols 1-6 secuencia, 7 indicadora, 8-72 + código, 73-80 id) y **libre**. Comentarios por col 7 (`*`/`/`) o `*` + inicial en formato libre. +- Cada token lleva línea/columna 1-based. `LexError` tipado. +- Limitación v1: sin continuación de literales entre líneas + (indicador `-`). + ## Estado -`charka-bcd` implementado y verde (22 tests). **Pendiente** — el grueso -del transpilador (esfuerzo multi-mes, Fase D del plan macro): +`charka-bcd` (22 tests) y `charka-lexer` (17 tests) implementados y +verdes. **Pendiente** — el resto del transpilador (Fase D del plan +macro): | crate pendiente | rol | | ----------------- | ---------------------------------------------------- | -| `charka-lexer` | tokenizador COBOL (formato fijo de columnas) | | `charka-parser` | parser COBOL'85 → AST (luego CICS + SQL embebido) | | `charka-ir` | representación intermedia | | `charka-codegen` | emisión de Rust | diff --git a/crates/modules/charka/charka-lexer/Cargo.toml b/crates/modules/charka/charka-lexer/Cargo.toml new file mode 100644 index 0000000..4212db1 --- /dev/null +++ b/crates/modules/charka/charka-lexer/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "charka-lexer" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "charka-lexer — tokenizador de COBOL (formato fijo de columnas y libre) para el transpilador COBOL→Rust." + +[dependencies] +thiserror = { workspace = true } diff --git a/crates/modules/charka/charka-lexer/src/lib.rs b/crates/modules/charka/charka-lexer/src/lib.rs new file mode 100644 index 0000000..017ee34 --- /dev/null +++ b/crates/modules/charka/charka-lexer/src/lib.rs @@ -0,0 +1,462 @@ +//! `charka-lexer` — tokenizador de COBOL. +//! +//! Primera etapa del transpilador COBOL→Rust: convierte el texto fuente +//! en una secuencia de [`Token`]. El lexer es **deliberadamente tonto** +//! — no conoce keywords ni la cláusula `PICTURE`; emite `Word` para todo +//! identificador y deja la clasificación al parser. COBOL es +//! case-insensitive: el `text` de un `Word` va en su caja original y +//! quien matchee keywords debe normalizar. +//! +//! Soporta los dos formatos de fuente: +//! - **Fijo** (`SourceFormat::Fixed`) — la tarjeta de 80 columnas: +//! cols 1-6 área de secuencia, col 7 indicadora (`*`/`/` comentario, +//! `D` debugging), cols 8-72 código, 73-80 identificación. +//! - **Libre** (`SourceFormat::Free`) — la línea entera es código; `*` +//! o `*>` al inicio (tras espacios) es comentario. +//! +//! Limitación conocida (v1): no soporta continuación de literales entre +//! líneas (indicador `-` en col 7) — esos casos se tratan como código +//! normal. Es un subconjunto COBOL'85; el hito intermedio del plan. + +#![forbid(unsafe_code)] + +use thiserror::Error; + +/// Formato del código fuente COBOL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceFormat { + /// Formato fijo tradicional (tarjeta de 80 columnas). + Fixed, + /// Formato libre: la línea entera es código. + Free, +} + +/// Clase de un token. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + /// Palabra COBOL: keyword o identificador (puede llevar guiones + /// internos, p. ej. `WORKING-STORAGE`). + Word, + /// Literal numérico sin signo (`42`, `3.14`). El signo, si lo hay, + /// es un `Symbol` aparte. + Number, + /// Literal de texto, con las comillas dobladas ya colapsadas. + String, + /// El punto `.` — terminador de sentencia/párrafo en COBOL. + Period, + /// Cualquier otro símbolo: `( ) , ; :` y los operadores + /// `+ - * / ** = < > <= >= <>`. El símbolo concreto va en `text`. + Symbol, +} + +/// Un token con su posición en el fuente (línea y columna 1-based). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub kind: TokenKind, + /// Lexema. `Word`: caja original. `String`: valor ya decodificado. + /// `Number`/`Symbol`/`Period`: los caracteres tal cual. + pub text: String, + /// Línea 1-based. + pub line: u32, + /// Columna 1-based del primer carácter del token. + pub col: u32, +} + +/// Error de tokenización, con la posición donde ocurrió. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum LexError { + #[error("línea {line}:{col}: literal de texto sin cerrar")] + UnterminatedString { line: u32, col: u32 }, + #[error("línea {line}:{col}: carácter inesperado {ch:?}")] + UnexpectedChar { line: u32, col: u32, ch: char }, +} + +/// Tokeniza un fuente COBOL completo. Falla con el primer [`LexError`]. +pub fn lex(source: &str, format: SourceFormat) -> Result, LexError> { + let mut tokens = Vec::new(); + for (idx, raw) in source.lines().enumerate() { + let line = (idx + 1) as u32; + if let Some((content, base_col)) = prepare_line(raw, format) { + lex_line(&content, line, base_col, &mut tokens)?; + } + } + Ok(tokens) +} + +/// Extrae el área de código de una línea según el formato. `None` si la +/// línea entera se descarta (comentario, debugging). El `u32` es la +/// columna 1-based del primer carácter del contenido devuelto. +fn prepare_line(raw: &str, format: SourceFormat) -> Option<(String, u32)> { + match format { + SourceFormat::Fixed => { + let chars: Vec = raw.chars().collect(); + // Col 7 (índice 6): área indicadora. + match chars.get(6).copied().unwrap_or(' ') { + '*' | '/' => return None, // comentario / salto de página + 'D' | 'd' => return None, // línea de debugging — v1 la omite + _ => {} + } + // Cols 8-72 (índices 7..72) = 65 columnas de código. + let content: String = chars.iter().skip(7).take(65).collect(); + Some((content, 8)) + } + SourceFormat::Free => { + let trimmed = raw.trim_start(); + if trimmed.starts_with('*') { + return None; // `*` o `*>` al inicio: comentario + } + Some((raw.to_string(), 1)) + } + } +} + +/// Tokeniza una línea ya recortada al área de código. +fn lex_line(content: &str, line: u32, base_col: u32, out: &mut Vec) -> Result<(), LexError> { + let chars: Vec = content.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let col = base_col + i as u32; + if c.is_whitespace() { + i += 1; + continue; + } + if c == '\'' || c == '"' { + let (value, next) = lex_string(&chars, i, c, line, col)?; + out.push(Token { + kind: TokenKind::String, + text: value, + line, + col, + }); + i = next; + } else if c.is_ascii_digit() { + let (text, next) = lex_number(&chars, i); + out.push(Token { + kind: TokenKind::Number, + text, + line, + col, + }); + i = next; + } else if c.is_ascii_alphabetic() { + let (text, next) = lex_word(&chars, i); + out.push(Token { + kind: TokenKind::Word, + text, + line, + col, + }); + i = next; + } else if c == '.' { + out.push(Token { + kind: TokenKind::Period, + text: ".".into(), + line, + col, + }); + i += 1; + } else if let Some(op) = two_char_op(&chars, i) { + out.push(Token { + kind: TokenKind::Symbol, + text: op.into(), + line, + col, + }); + i += 2; + } else if "()+-*/=<>,;:".contains(c) { + out.push(Token { + kind: TokenKind::Symbol, + text: c.to_string(), + line, + col, + }); + i += 1; + } else { + return Err(LexError::UnexpectedChar { line, col, ch: c }); + } + } + Ok(()) +} + +/// Lee un literal de texto desde la comilla de apertura. Una comilla +/// doblada dentro del literal representa una comilla literal. +fn lex_string( + chars: &[char], + start: usize, + quote: char, + line: u32, + col: u32, +) -> Result<(String, usize), LexError> { + let mut value = String::new(); + let mut i = start + 1; + while i < chars.len() { + if chars[i] == quote { + if chars.get(i + 1) == Some("e) { + value.push(quote); // comilla doblada → comilla literal + i += 2; + } else { + return Ok((value, i + 1)); // comilla de cierre + } + } else { + value.push(chars[i]); + i += 1; + } + } + Err(LexError::UnterminatedString { line, col }) +} + +/// Lee un literal numérico sin signo. El punto decimal sólo cuenta si +/// lo sigue un dígito — sino es el terminador `.`. +fn lex_number(chars: &[char], start: usize) -> (String, usize) { + let mut i = start; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + if i + 1 < chars.len() && chars[i] == '.' && chars[i + 1].is_ascii_digit() { + i += 1; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + (chars[start..i].iter().collect(), i) +} + +/// Lee una palabra COBOL: empieza con letra, sigue con alfanuméricos y +/// guiones internos (un guión sólo si lo sigue un alfanumérico). +fn lex_word(chars: &[char], start: usize) -> (String, usize) { + let mut i = start + 1; + while i < chars.len() { + let c = chars[i]; + // Alfanumérico, o un guión interno (sólo si lo sigue otro + // alfanumérico — `WORKING-STORAGE`, no un `MOVE-` colgante). + let word_char = c.is_ascii_alphanumeric() + || (c == '-' && chars.get(i + 1).is_some_and(|n| n.is_ascii_alphanumeric())); + if !word_char { + break; + } + i += 1; + } + (chars[start..i].iter().collect(), i) +} + +/// Reconoce un operador de dos caracteres en la posición `i`. +fn two_char_op(chars: &[char], i: usize) -> Option<&'static str> { + let a = *chars.get(i)?; + let b = *chars.get(i + 1)?; + match (a, b) { + ('*', '*') => Some("**"), + ('<', '=') => Some("<="), + ('>', '=') => Some(">="), + ('<', '>') => Some("<>"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: lexa y devuelve `(kind, text)` por token. + fn kinds(src: &str, fmt: SourceFormat) -> Vec<(TokenKind, String)> { + lex(src, fmt) + .expect("lex OK") + .into_iter() + .map(|t| (t.kind, t.text)) + .collect() + } + + #[test] + fn simple_sentence_free() { + let toks = kinds("MOVE 5 TO WS-COUNT.", SourceFormat::Free); + assert_eq!( + toks, + vec![ + (TokenKind::Word, "MOVE".into()), + (TokenKind::Number, "5".into()), + (TokenKind::Word, "TO".into()), + (TokenKind::Word, "WS-COUNT".into()), + (TokenKind::Period, ".".into()), + ] + ); + } + + #[test] + fn hyphenated_word_kept_whole() { + let toks = kinds("WORKING-STORAGE SECTION.", SourceFormat::Free); + assert_eq!(toks[0], (TokenKind::Word, "WORKING-STORAGE".into())); + assert_eq!(toks[1], (TokenKind::Word, "SECTION".into())); + } + + #[test] + fn word_case_is_preserved() { + let toks = kinds("Move x", SourceFormat::Free); + assert_eq!(toks[0].1, "Move"); + assert_eq!(toks[1].1, "x"); + } + + #[test] + fn string_literal_value_decoded() { + let toks = kinds("'hola mundo'", SourceFormat::Free); + assert_eq!(toks, vec![(TokenKind::String, "hola mundo".into())]); + } + + #[test] + fn doubled_quote_is_literal_quote() { + let toks = kinds("'it''s ok'", SourceFormat::Free); + assert_eq!(toks, vec![(TokenKind::String, "it's ok".into())]); + } + + #[test] + fn double_quoted_string() { + let toks = kinds("\"abc\"", SourceFormat::Free); + assert_eq!(toks, vec![(TokenKind::String, "abc".into())]); + } + + #[test] + fn number_with_decimal_vs_trailing_period() { + // `3.14` es un número; `5.` es número 5 + terminador. + assert_eq!( + kinds("3.14", SourceFormat::Free), + vec![(TokenKind::Number, "3.14".into())] + ); + assert_eq!( + kinds("5.", SourceFormat::Free), + vec![ + (TokenKind::Number, "5".into()), + (TokenKind::Period, ".".into()), + ] + ); + } + + #[test] + fn two_and_one_char_operators() { + let toks = kinds("A <= B >= C <> D ** E + F", SourceFormat::Free); + let syms: Vec<&str> = toks + .iter() + .filter(|(k, _)| *k == TokenKind::Symbol) + .map(|(_, t)| t.as_str()) + .collect(); + assert_eq!(syms, vec!["<=", ">=", "<>", "**", "+"]); + } + + #[test] + fn parens_and_separators() { + let toks = kinds("PIC X(20), Y;", SourceFormat::Free); + let syms: Vec<&str> = toks + .iter() + .filter(|(k, _)| *k == TokenKind::Symbol) + .map(|(_, t)| t.as_str()) + .collect(); + assert_eq!(syms, vec!["(", ")", ",", ";"]); + } + + #[test] + fn fixed_format_ignores_sequence_and_id_areas() { + // Cols 1-6 secuencia, col 7 espacio, cols 8.. código, 73+ id. + let mut line = String::new(); + line.push_str("000100"); // cols 1-6: secuencia + line.push(' '); // col 7: indicador + line.push_str(" MOVE 1 TO X."); // código desde col 8 + // Rellenar hasta col 73 y agregar área de identificación. + while line.chars().count() < 72 { + line.push(' '); + } + line.push_str("PROG0001"); // cols 73-80: ignoradas + let toks = kinds(&line, SourceFormat::Fixed); + assert_eq!( + toks, + vec![ + (TokenKind::Word, "MOVE".into()), + (TokenKind::Number, "1".into()), + (TokenKind::Word, "TO".into()), + (TokenKind::Word, "X".into()), + (TokenKind::Period, ".".into()), + ] + ); + } + + #[test] + fn fixed_format_comment_line_skipped() { + let comment = "000100* esto es un comentario y no debe tokenizar"; + let code = "000200 DISPLAY 'HI'."; + let src = format!("{comment}\n{code}"); + let toks = kinds(&src, SourceFormat::Fixed); + assert_eq!( + toks, + vec![ + (TokenKind::Word, "DISPLAY".into()), + (TokenKind::String, "HI".into()), + (TokenKind::Period, ".".into()), + ] + ); + } + + #[test] + fn free_format_comment_line_skipped() { + let src = "* un comentario\n*> otro comentario\nDISPLAY 1."; + let toks = kinds(src, SourceFormat::Free); + assert_eq!(toks.len(), 3); // DISPLAY 1 . + assert_eq!(toks[0], (TokenKind::Word, "DISPLAY".into())); + } + + #[test] + fn line_and_column_are_tracked() { + let src = "MOVE 1\n TO X."; + let toks = lex(src, SourceFormat::Free).unwrap(); + assert_eq!((toks[0].line, toks[0].col), (1, 1)); // MOVE + assert_eq!((toks[1].line, toks[1].col), (1, 6)); // 1 + assert_eq!((toks[2].line, toks[2].col), (2, 3)); // TO + assert_eq!((toks[3].line, toks[3].col), (2, 6)); // X + } + + #[test] + fn unterminated_string_is_an_error() { + let err = lex("MOVE 'sin cerrar", SourceFormat::Free).unwrap_err(); + assert!(matches!(err, LexError::UnterminatedString { line: 1, .. })); + } + + #[test] + fn unexpected_char_is_an_error() { + let err = lex("MOVE 5 ! X", SourceFormat::Free).unwrap_err(); + assert!(matches!( + err, + LexError::UnexpectedChar { + ch: '!', + line: 1, + .. + } + )); + } + + #[test] + fn empty_source_yields_no_tokens() { + assert!(lex("", SourceFormat::Free).unwrap().is_empty()); + assert!(lex("\n\n \n", SourceFormat::Free).unwrap().is_empty()); + } + + #[test] + fn realistic_paragraph() { + let src = "\ +ADD-TOTALS. + COMPUTE WS-TOTAL = WS-A + WS-B. + IF WS-TOTAL > 100 + DISPLAY 'GRANDE' + END-IF."; + let toks = lex(src, SourceFormat::Free).unwrap(); + // Arranca con el nombre del párrafo y su punto. + assert_eq!(toks[0].text, "ADD-TOTALS"); + assert_eq!(toks[1].kind, TokenKind::Period); + // Hay un literal y el operador `>` y `=` en el medio. + assert!(toks + .iter() + .any(|t| t.kind == TokenKind::String && t.text == "GRANDE")); + assert!(toks + .iter() + .any(|t| t.kind == TokenKind::Symbol && t.text == ">")); + assert!(toks + .iter() + .any(|t| t.kind == TokenKind::Symbol && t.text == "=")); + assert!(toks.iter().any(|t| t.text == "END-IF")); + } +} diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md new file mode 100644 index 0000000..db2d908 --- /dev/null +++ b/docs/changelog/charka.md @@ -0,0 +1,33 @@ +# Changelog — charka + +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-lexer): tokenizador de COBOL + +Crate nuevo `crates/modules/charka/charka-lexer` — la primera etapa del +pipeline del transpilador: texto COBOL → secuencia de `Token`. + +- **Lexer deliberadamente tonto**: no conoce keywords ni la cláusula + `PICTURE`; emite `Word` para todo identificador y deja la + clasificación al parser. +- Tokens: `Word` (palabras COBOL con guiones internos), `Number` + (literal sin signo), `String` (comillas dobladas colapsadas), + `Period` (el `.` terminador), `Symbol` (paréntesis, separadores y + operadores `+ - * / ** = < > <= >= <>`). +- Dos formatos de fuente: **fijo** (la tarjeta de 80 columnas — cols + 1-6 secuencia, 7 indicadora, 8-72 código, 73-80 identificación) y + **libre**. Comentarios por la columna indicadora (`*`/`/`) o por `*` + inicial en formato libre. +- Cada `Token` lleva línea y columna 1-based; `LexError` tipado + (literal sin cerrar, carácter inesperado). +- Limitación v1 documentada: no soporta continuación de literales entre + líneas (indicador `-`). Subconjunto COBOL'85, el hito intermedio. +- 17 tests: sentencias, palabras con guiones, literales y comillas + dobladas, números vs terminador, operadores, ambos formatos, + tracking de posición, errores. + +### feat(charka-bcd): aritmética decimal con semántica COBOL + +(Pre-existente.) `Picture` + `Decimal` de punto fijo exacto — ver el +SDD del módulo. 22 tests.