gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

⎇ ab56b35e9f946c3a5c80a10912e77f048ddc00de crates/modules/charka/charka-lexer/src/lib.rs Rust · 462 líneas · 15.1 KB Historia Quién tocó qué crudo

feat(charka): charka-lexer — tokenizador de COBOL ab56b35e9 · sergio ·

1//! `charka-lexer` — tokenizador de COBOL.
2//!
3//! Primera etapa del transpilador COBOL→Rust: convierte el texto fuente
4//! en una secuencia de [`Token`]. El lexer es **deliberadamente tonto**
5//! — no conoce keywords ni la cláusula `PICTURE`; emite `Word` para todo
6//! identificador y deja la clasificación al parser. COBOL es
7//! case-insensitive: el `text` de un `Word` va en su caja original y
8//! quien matchee keywords debe normalizar.
9//!
10//! Soporta los dos formatos de fuente:
11//! - **Fijo** (`SourceFormat::Fixed`) — la tarjeta de 80 columnas:
12//!   cols 1-6 área de secuencia, col 7 indicadora (`*`/`/` comentario,
13//!   `D` debugging), cols 8-72 código, 73-80 identificación.
14//! - **Libre** (`SourceFormat::Free`) — la línea entera es código; `*`
15//!   o `*>` al inicio (tras espacios) es comentario.
16//!
17//! Limitación conocida (v1): no soporta continuación de literales entre
18//! líneas (indicador `-` en col 7) — esos casos se tratan como código
19//! normal. Es un subconjunto COBOL'85; el hito intermedio del plan.
20
21#![forbid(unsafe_code)]
22
23use thiserror::Error;
24
25/// Formato del código fuente COBOL.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SourceFormat {
28    /// Formato fijo tradicional (tarjeta de 80 columnas).
29    Fixed,
30    /// Formato libre: la línea entera es código.
31    Free,
32}
33
34/// Clase de un token.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum TokenKind {
37    /// Palabra COBOL: keyword o identificador (puede llevar guiones
38    /// internos, p. ej. `WORKING-STORAGE`).
39    Word,
40    /// Literal numérico sin signo (`42`, `3.14`). El signo, si lo hay,
41    /// es un `Symbol` aparte.
42    Number,
43    /// Literal de texto, con las comillas dobladas ya colapsadas.
44    String,
45    /// El punto `.` — terminador de sentencia/párrafo en COBOL.
46    Period,
47    /// Cualquier otro símbolo: `( ) , ; :` y los operadores
48    /// `+ - * / ** = < > <= >= <>`. El símbolo concreto va en `text`.
49    Symbol,
50}
51
52/// Un token con su posición en el fuente (línea y columna 1-based).
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Token {
55    pub kind: TokenKind,
56    /// Lexema. `Word`: caja original. `String`: valor ya decodificado.
57    /// `Number`/`Symbol`/`Period`: los caracteres tal cual.
58    pub text: String,
59    /// Línea 1-based.
60    pub line: u32,
61    /// Columna 1-based del primer carácter del token.
62    pub col: u32,
63}
64
65/// Error de tokenización, con la posición donde ocurrió.
66#[derive(Debug, Clone, PartialEq, Eq, Error)]
67pub enum LexError {
68    #[error("línea {line}:{col}: literal de texto sin cerrar")]
69    UnterminatedString { line: u32, col: u32 },
70    #[error("línea {line}:{col}: carácter inesperado {ch:?}")]
71    UnexpectedChar { line: u32, col: u32, ch: char },
72}
73
74/// Tokeniza un fuente COBOL completo. Falla con el primer [`LexError`].
75pub fn lex(source: &str, format: SourceFormat) -> Result<Vec<Token>, LexError> {
76    let mut tokens = Vec::new();
77    for (idx, raw) in source.lines().enumerate() {
78        let line = (idx + 1) as u32;
79        if let Some((content, base_col)) = prepare_line(raw, format) {
80            lex_line(&content, line, base_col, &mut tokens)?;
81        }
82    }
83    Ok(tokens)
84}
85
86/// Extrae el área de código de una línea según el formato. `None` si la
87/// línea entera se descarta (comentario, debugging). El `u32` es la
88/// columna 1-based del primer carácter del contenido devuelto.
89fn prepare_line(raw: &str, format: SourceFormat) -> Option<(String, u32)> {
90    match format {
91        SourceFormat::Fixed => {
92            let chars: Vec<char> = raw.chars().collect();
93            // Col 7 (índice 6): área indicadora.
94            match chars.get(6).copied().unwrap_or(' ') {
95                '*' | '/' => return None, // comentario / salto de página
96                'D' | 'd' => return None, // línea de debugging — v1 la omite
97                _ => {}
98            }
99            // Cols 8-72 (índices 7..72) = 65 columnas de código.
100            let content: String = chars.iter().skip(7).take(65).collect();
101            Some((content, 8))
102        }
103        SourceFormat::Free => {
104            let trimmed = raw.trim_start();
105            if trimmed.starts_with('*') {
106                return None; // `*` o `*>` al inicio: comentario
107            }
108            Some((raw.to_string(), 1))
109        }
110    }
111}
112
113/// Tokeniza una línea ya recortada al área de código.
114fn lex_line(content: &str, line: u32, base_col: u32, out: &mut Vec<Token>) -> Result<(), LexError> {
115    let chars: Vec<char> = content.chars().collect();
116    let mut i = 0;
117    while i < chars.len() {
118        let c = chars[i];
119        let col = base_col + i as u32;
120        if c.is_whitespace() {
121            i += 1;
122            continue;
123        }
124        if c == '\'' || c == '"' {
125            let (value, next) = lex_string(&chars, i, c, line, col)?;
126            out.push(Token {
127                kind: TokenKind::String,
128                text: value,
129                line,
130                col,
131            });
132            i = next;
133        } else if c.is_ascii_digit() {
134            let (text, next) = lex_number(&chars, i);
135            out.push(Token {
136                kind: TokenKind::Number,
137                text,
138                line,
139                col,
140            });
141            i = next;
142        } else if c.is_ascii_alphabetic() {
143            let (text, next) = lex_word(&chars, i);
144            out.push(Token {
145                kind: TokenKind::Word,
146                text,
147                line,
148                col,
149            });
150            i = next;
151        } else if c == '.' {
152            out.push(Token {
153                kind: TokenKind::Period,
154                text: ".".into(),
155                line,
156                col,
157            });
158            i += 1;
159        } else if let Some(op) = two_char_op(&chars, i) {
160            out.push(Token {
161                kind: TokenKind::Symbol,
162                text: op.into(),
163                line,
164                col,
165            });
166            i += 2;
167        } else if "()+-*/=<>,;:".contains(c) {
168            out.push(Token {
169                kind: TokenKind::Symbol,
170                text: c.to_string(),
171                line,
172                col,
173            });
174            i += 1;
175        } else {
176            return Err(LexError::UnexpectedChar { line, col, ch: c });
177        }
178    }
179    Ok(())
180}
181
182/// Lee un literal de texto desde la comilla de apertura. Una comilla
183/// doblada dentro del literal representa una comilla literal.
184fn lex_string(
185    chars: &[char],
186    start: usize,
187    quote: char,
188    line: u32,
189    col: u32,
190) -> Result<(String, usize), LexError> {
191    let mut value = String::new();
192    let mut i = start + 1;
193    while i < chars.len() {
194        if chars[i] == quote {
195            if chars.get(i + 1) == Some(&quote) {
196                value.push(quote); // comilla doblada → comilla literal
197                i += 2;
198            } else {
199                return Ok((value, i + 1)); // comilla de cierre
200            }
201        } else {
202            value.push(chars[i]);
203            i += 1;
204        }
205    }
206    Err(LexError::UnterminatedString { line, col })
207}
208
209/// Lee un literal numérico sin signo. El punto decimal sólo cuenta si
210/// lo sigue un dígito — sino es el terminador `.`.
211fn lex_number(chars: &[char], start: usize) -> (String, usize) {
212    let mut i = start;
213    while i < chars.len() && chars[i].is_ascii_digit() {
214        i += 1;
215    }
216    if i + 1 < chars.len() && chars[i] == '.' && chars[i + 1].is_ascii_digit() {
217        i += 1;
218        while i < chars.len() && chars[i].is_ascii_digit() {
219            i += 1;
220        }
221    }
222    (chars[start..i].iter().collect(), i)
223}
224
225/// Lee una palabra COBOL: empieza con letra, sigue con alfanuméricos y
226/// guiones internos (un guión sólo si lo sigue un alfanumérico).
227fn lex_word(chars: &[char], start: usize) -> (String, usize) {
228    let mut i = start + 1;
229    while i < chars.len() {
230        let c = chars[i];
231        // Alfanumérico, o un guión interno (sólo si lo sigue otro
232        // alfanumérico — `WORKING-STORAGE`, no un `MOVE-` colgante).
233        let word_char = c.is_ascii_alphanumeric()
234            || (c == '-' && chars.get(i + 1).is_some_and(|n| n.is_ascii_alphanumeric()));
235        if !word_char {
236            break;
237        }
238        i += 1;
239    }
240    (chars[start..i].iter().collect(), i)
241}
242
243/// Reconoce un operador de dos caracteres en la posición `i`.
244fn two_char_op(chars: &[char], i: usize) -> Option<&'static str> {
245    let a = *chars.get(i)?;
246    let b = *chars.get(i + 1)?;
247    match (a, b) {
248        ('*', '*') => Some("**"),
249        ('<', '=') => Some("<="),
250        ('>', '=') => Some(">="),
251        ('<', '>') => Some("<>"),
252        _ => None,
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    /// Helper: lexa y devuelve `(kind, text)` por token.
261    fn kinds(src: &str, fmt: SourceFormat) -> Vec<(TokenKind, String)> {
262        lex(src, fmt)
263            .expect("lex OK")
264            .into_iter()
265            .map(|t| (t.kind, t.text))
266            .collect()
267    }
268
269    #[test]
270    fn simple_sentence_free() {
271        let toks = kinds("MOVE 5 TO WS-COUNT.", SourceFormat::Free);
272        assert_eq!(
273            toks,
274            vec![
275                (TokenKind::Word, "MOVE".into()),
276                (TokenKind::Number, "5".into()),
277                (TokenKind::Word, "TO".into()),
278                (TokenKind::Word, "WS-COUNT".into()),
279                (TokenKind::Period, ".".into()),
280            ]
281        );
282    }
283
284    #[test]
285    fn hyphenated_word_kept_whole() {
286        let toks = kinds("WORKING-STORAGE SECTION.", SourceFormat::Free);
287        assert_eq!(toks[0], (TokenKind::Word, "WORKING-STORAGE".into()));
288        assert_eq!(toks[1], (TokenKind::Word, "SECTION".into()));
289    }
290
291    #[test]
292    fn word_case_is_preserved() {
293        let toks = kinds("Move x", SourceFormat::Free);
294        assert_eq!(toks[0].1, "Move");
295        assert_eq!(toks[1].1, "x");
296    }
297
298    #[test]
299    fn string_literal_value_decoded() {
300        let toks = kinds("'hola mundo'", SourceFormat::Free);
301        assert_eq!(toks, vec![(TokenKind::String, "hola mundo".into())]);
302    }
303
304    #[test]
305    fn doubled_quote_is_literal_quote() {
306        let toks = kinds("'it''s ok'", SourceFormat::Free);
307        assert_eq!(toks, vec![(TokenKind::String, "it's ok".into())]);
308    }
309
310    #[test]
311    fn double_quoted_string() {
312        let toks = kinds("\"abc\"", SourceFormat::Free);
313        assert_eq!(toks, vec![(TokenKind::String, "abc".into())]);
314    }
315
316    #[test]
317    fn number_with_decimal_vs_trailing_period() {
318        // `3.14` es un número; `5.` es número 5 + terminador.
319        assert_eq!(
320            kinds("3.14", SourceFormat::Free),
321            vec![(TokenKind::Number, "3.14".into())]
322        );
323        assert_eq!(
324            kinds("5.", SourceFormat::Free),
325            vec![
326                (TokenKind::Number, "5".into()),
327                (TokenKind::Period, ".".into()),
328            ]
329        );
330    }
331
332    #[test]
333    fn two_and_one_char_operators() {
334        let toks = kinds("A <= B >= C <> D ** E + F", SourceFormat::Free);
335        let syms: Vec<&str> = toks
336            .iter()
337            .filter(|(k, _)| *k == TokenKind::Symbol)
338            .map(|(_, t)| t.as_str())
339            .collect();
340        assert_eq!(syms, vec!["<=", ">=", "<>", "**", "+"]);
341    }
342
343    #[test]
344    fn parens_and_separators() {
345        let toks = kinds("PIC X(20), Y;", SourceFormat::Free);
346        let syms: Vec<&str> = toks
347            .iter()
348            .filter(|(k, _)| *k == TokenKind::Symbol)
349            .map(|(_, t)| t.as_str())
350            .collect();
351        assert_eq!(syms, vec!["(", ")", ",", ";"]);
352    }
353
354    #[test]
355    fn fixed_format_ignores_sequence_and_id_areas() {
356        // Cols 1-6 secuencia, col 7 espacio, cols 8.. código, 73+ id.
357        let mut line = String::new();
358        line.push_str("000100"); // cols 1-6: secuencia
359        line.push(' '); // col 7: indicador
360        line.push_str("    MOVE 1 TO X."); // código desde col 8
361                                           // Rellenar hasta col 73 y agregar área de identificación.
362        while line.chars().count() < 72 {
363            line.push(' ');
364        }
365        line.push_str("PROG0001"); // cols 73-80: ignoradas
366        let toks = kinds(&line, SourceFormat::Fixed);
367        assert_eq!(
368            toks,
369            vec![
370                (TokenKind::Word, "MOVE".into()),
371                (TokenKind::Number, "1".into()),
372                (TokenKind::Word, "TO".into()),
373                (TokenKind::Word, "X".into()),
374                (TokenKind::Period, ".".into()),
375            ]
376        );
377    }
378
379    #[test]
380    fn fixed_format_comment_line_skipped() {
381        let comment = "000100*  esto es un comentario y no debe tokenizar";
382        let code = "000200     DISPLAY 'HI'.";
383        let src = format!("{comment}\n{code}");
384        let toks = kinds(&src, SourceFormat::Fixed);
385        assert_eq!(
386            toks,
387            vec![
388                (TokenKind::Word, "DISPLAY".into()),
389                (TokenKind::String, "HI".into()),
390                (TokenKind::Period, ".".into()),
391            ]
392        );
393    }
394
395    #[test]
396    fn free_format_comment_line_skipped() {
397        let src = "* un comentario\n*> otro comentario\nDISPLAY 1.";
398        let toks = kinds(src, SourceFormat::Free);
399        assert_eq!(toks.len(), 3); // DISPLAY 1 .
400        assert_eq!(toks[0], (TokenKind::Word, "DISPLAY".into()));
401    }
402
403    #[test]
404    fn line_and_column_are_tracked() {
405        let src = "MOVE 1\n  TO X.";
406        let toks = lex(src, SourceFormat::Free).unwrap();
407        assert_eq!((toks[0].line, toks[0].col), (1, 1)); // MOVE
408        assert_eq!((toks[1].line, toks[1].col), (1, 6)); // 1
409        assert_eq!((toks[2].line, toks[2].col), (2, 3)); // TO
410        assert_eq!((toks[3].line, toks[3].col), (2, 6)); // X
411    }
412
413    #[test]
414    fn unterminated_string_is_an_error() {
415        let err = lex("MOVE 'sin cerrar", SourceFormat::Free).unwrap_err();
416        assert!(matches!(err, LexError::UnterminatedString { line: 1, .. }));
417    }
418
419    #[test]
420    fn unexpected_char_is_an_error() {
421        let err = lex("MOVE 5 ! X", SourceFormat::Free).unwrap_err();
422        assert!(matches!(
423            err,
424            LexError::UnexpectedChar {
425                ch: '!',
426                line: 1,
427                ..
428            }
429        ));
430    }
431
432    #[test]
433    fn empty_source_yields_no_tokens() {
434        assert!(lex("", SourceFormat::Free).unwrap().is_empty());
435        assert!(lex("\n\n   \n", SourceFormat::Free).unwrap().is_empty());
436    }
437
438    #[test]
439    fn realistic_paragraph() {
440        let src = "\
441ADD-TOTALS.
442    COMPUTE WS-TOTAL = WS-A + WS-B.
443    IF WS-TOTAL > 100
444        DISPLAY 'GRANDE'
445    END-IF.";
446        let toks = lex(src, SourceFormat::Free).unwrap();
447        // Arranca con el nombre del párrafo y su punto.
448        assert_eq!(toks[0].text, "ADD-TOTALS");
449        assert_eq!(toks[1].kind, TokenKind::Period);
450        // Hay un literal y el operador `>` y `=` en el medio.
451        assert!(toks
452            .iter()
453            .any(|t| t.kind == TokenKind::String && t.text == "GRANDE"));
454        assert!(toks
455            .iter()
456            .any(|t| t.kind == TokenKind::Symbol && t.text == ">"));
457        assert!(toks
458            .iter()
459            .any(|t| t.kind == TokenKind::Symbol && t.text == "="));
460        assert!(toks.iter().any(|t| t.text == "END-IF"));
461    }
462}