⎇ b052c41e3c93a271dfb75b40f0c7b79d1ae9632c
crates/apps/charka/src/main.rs
Historia
Quién tocó qué
crudo
feat(charka): CLI del transpilador — transpile / scaffold / run / check
1//! `charka` — la CLI del transpilador COBOL → Rust.
2//!
3//! Envuelve el pipeline (lexer → parser → IR → codegen) y el validador
4//! en sombra en cuatro comandos:
5//!
6//! - `transpile` — emite el código Rust de un fuente COBOL.
7//! - `scaffold` — genera un crate Rust completo y compilable.
8//! - `run` — ejecuta el programa (intérprete sombra) y lo imprime.
9//! - `check` — ejecuta y compara la salida contra un archivo dado.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::ExitCode;
14
15use anyhow::{Context, Result};
16use charka_ir::{Ir, PerformTarget, Stmt};
17use clap::{Parser, Subcommand};
18
19/// Ruta a `charka-runtime`, fijada al compilar — el crate generado por
20/// `scaffold` la usa como dependencia.
21const RUNTIME_PATH: &str = concat!(
22 env!("CARGO_MANIFEST_DIR"),
23 "/../../modules/charka/charka-runtime"
24);
25
26/// El transpilador de COBOL a Rust.
27#[derive(Parser)]
28#[command(name = "charka", version, about = "Transpilador COBOL → Rust")]
29struct Cli {
30 #[command(subcommand)]
31 command: Command,
32}
33
34#[derive(Subcommand)]
35enum Command {
36 /// Transpila un fuente COBOL a código Rust.
37 Transpile {
38 /// El fuente COBOL (.cob), en formato libre.
39 input: PathBuf,
40 /// Archivo de salida; si se omite, va a la salida estándar.
41 #[arg(short, long)]
42 output: Option<PathBuf>,
43 },
44 /// Genera un crate Rust completo y compilable.
45 Scaffold {
46 /// El fuente COBOL (.cob).
47 input: PathBuf,
48 /// El directorio del crate a crear.
49 #[arg(short, long)]
50 output: PathBuf,
51 },
52 /// Ejecuta un programa COBOL (intérprete sombra) y muestra su salida.
53 Run {
54 /// El fuente COBOL (.cob).
55 input: PathBuf,
56 },
57 /// Ejecuta un programa y compara su salida con un archivo esperado.
58 Check {
59 /// El fuente COBOL (.cob).
60 input: PathBuf,
61 /// El archivo con la salida esperada.
62 #[arg(short, long)]
63 expect: PathBuf,
64 },
65}
66
67fn main() -> ExitCode {
68 match dispatch(Cli::parse().command) {
69 Ok(code) => code,
70 Err(err) => {
71 eprintln!("charka: {err:#}");
72 ExitCode::FAILURE
73 }
74 }
75}
76
77fn dispatch(command: Command) -> Result<ExitCode> {
78 match command {
79 Command::Transpile { input, output } => transpile(&input, output.as_deref()),
80 Command::Scaffold { input, output } => scaffold(&input, &output),
81 Command::Run { input } => run(&input),
82 Command::Check { input, expect } => check(&input, &expect),
83 }
84}
85
86// ── Comandos ──────────────────────────────────────────────────────
87
88fn transpile(input: &Path, output: Option<&Path>) -> Result<ExitCode> {
89 let rust = charka_codegen::generate(&load_ir(input)?);
90 match output {
91 Some(path) => {
92 fs::write(path, rust)
93 .with_context(|| format!("no se pudo escribir {}", path.display()))?;
94 eprintln!("charka: escrito {}", path.display());
95 }
96 None => print!("{rust}"),
97 }
98 Ok(ExitCode::SUCCESS)
99}
100
101fn scaffold(input: &Path, output: &Path) -> Result<ExitCode> {
102 let ir = load_ir(input)?;
103 let rust = charka_codegen::generate(&ir);
104 let name = crate_name(input);
105
106 fs::create_dir_all(output.join("src"))
107 .with_context(|| format!("no se pudo crear {}", output.display()))?;
108 fs::write(output.join("src/main.rs"), rust)?;
109 fs::write(output.join("Cargo.toml"), cargo_toml(&name))?;
110
111 eprintln!("charka: crate «{name}» generado en {}", output.display());
112 eprintln!(
113 " cargo run --manifest-path {}",
114 output.join("Cargo.toml").display()
115 );
116 warn_unknowns(&ir);
117 Ok(ExitCode::SUCCESS)
118}
119
120fn run(input: &Path) -> Result<ExitCode> {
121 let ir = load_ir(input)?;
122 let outcome = charka_shadow::interpret(&ir);
123 for line in &outcome.lines {
124 println!("{line}");
125 }
126 warn_unknowns(&ir);
127 if outcome.halt == charka_shadow::Halt::StepLimit {
128 eprintln!("charka: aviso — se agotó el tope de pasos (¿un bucle sin fin?)");
129 return Ok(ExitCode::FAILURE);
130 }
131 Ok(ExitCode::SUCCESS)
132}
133
134fn check(input: &Path, expect: &Path) -> Result<ExitCode> {
135 let ir = load_ir(input)?;
136 let outcome = charka_shadow::interpret(&ir);
137 let expected = fs::read_to_string(expect)
138 .with_context(|| format!("no se pudo leer {}", expect.display()))?;
139
140 let got: Vec<&str> = outcome.lines.iter().map(|l| l.trim_end()).collect();
141 let want: Vec<&str> = expected.lines().map(|l| l.trim_end()).collect();
142
143 if got == want {
144 println!("charka: OK — {} líneas coinciden", got.len());
145 Ok(ExitCode::SUCCESS)
146 } else {
147 eprintln!("charka: FALLA — la salida difiere de {}", expect.display());
148 report_diff(&got, &want);
149 Ok(ExitCode::FAILURE)
150 }
151}
152
153// ── Apoyo ─────────────────────────────────────────────────────────
154
155/// Lee un fuente COBOL y lo lleva hasta el IR.
156fn load_ir(input: &Path) -> Result<Ir> {
157 let source = fs::read_to_string(input)
158 .with_context(|| format!("no se pudo leer {}", input.display()))?;
159 let tokens =
160 charka_lexer::lex(&source, charka_lexer::SourceFormat::Free).context("error de léxico")?;
161 let program = charka_parser::parse(&tokens).context("error de parseo")?;
162 Ok(charka_ir::lower(&program))
163}
164
165/// El `Cargo.toml` de un crate generado por `scaffold`.
166fn cargo_toml(name: &str) -> String {
167 format!(
168 "[package]\n\
169 name = \"{name}\"\n\
170 version = \"0.1.0\"\n\
171 edition = \"2021\"\n\
172 \n\
173 [[bin]]\n\
174 name = \"{name}\"\n\
175 path = \"src/main.rs\"\n\
176 \n\
177 [dependencies]\n\
178 charka-runtime = {{ path = \"{RUNTIME_PATH}\" }}\n\
179 \n\
180 [workspace]\n"
181 )
182}
183
184/// Un nombre de crate válido derivado del nombre del archivo fuente.
185fn crate_name(input: &Path) -> String {
186 let stem = input
187 .file_stem()
188 .and_then(|s| s.to_str())
189 .unwrap_or("programa");
190 let mut name: String = stem
191 .chars()
192 .map(|c| {
193 if c.is_ascii_alphanumeric() {
194 c.to_ascii_lowercase()
195 } else {
196 '_'
197 }
198 })
199 .collect();
200 if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
201 name = format!("cobol_{name}");
202 }
203 name
204}
205
206/// Avisa de los verbos COBOL que el transpilador no soporta todavía.
207fn warn_unknowns(ir: &Ir) {
208 let mut verbs = Vec::new();
209 for proc in &ir.procedures {
210 collect_unknowns(&proc.body, &mut verbs);
211 }
212 if verbs.is_empty() {
213 return;
214 }
215 verbs.sort();
216 verbs.dedup();
217 eprintln!(
218 "charka: aviso — verbos no transpilados (se omitieron): {}",
219 verbs.join(", ")
220 );
221}
222
223/// Recoge los verbos de los `Stmt::Unknown`, incluso los anidados.
224fn collect_unknowns(stmts: &[Stmt], out: &mut Vec<String>) {
225 for s in stmts {
226 match s {
227 Stmt::Unknown { verb, .. } => out.push(verb.clone()),
228 Stmt::If {
229 then_branch,
230 else_branch,
231 ..
232 } => {
233 collect_unknowns(then_branch, out);
234 collect_unknowns(else_branch, out);
235 }
236 Stmt::Perform(p) => {
237 if let PerformTarget::Inline(body) = &p.target {
238 collect_unknowns(body, out);
239 }
240 }
241 _ => {}
242 }
243 }
244}
245
246/// Imprime las líneas en que la salida obtenida difiere de la esperada.
247fn report_diff(got: &[&str], want: &[&str]) {
248 for i in 0..got.len().max(want.len()) {
249 let g = got.get(i).copied().unwrap_or("<falta>");
250 let w = want.get(i).copied().unwrap_or("<falta>");
251 if g != w {
252 eprintln!(" línea {}:", i + 1);
253 eprintln!(" obtenido: {g}");
254 eprintln!(" esperado: {w}");
255 }
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn ir_of(src: &str) -> Ir {
264 let toks = charka_lexer::lex(src, charka_lexer::SourceFormat::Free).unwrap();
265 charka_ir::lower(&charka_parser::parse(&toks).unwrap())
266 }
267
268 #[test]
269 fn crate_name_is_sanitized() {
270 assert_eq!(crate_name(Path::new("/x/06-nomina.cob")), "cobol_06_nomina");
271 assert_eq!(crate_name(Path::new("PAYROLL.CBL")), "payroll");
272 }
273
274 #[test]
275 fn cargo_toml_names_the_crate_and_the_runtime() {
276 let toml = cargo_toml("demo");
277 assert!(toml.contains("name = \"demo\""));
278 assert!(toml.contains("charka-runtime"));
279 assert!(toml.contains("[workspace]"));
280 }
281
282 #[test]
283 fn unknown_verbs_are_collected() {
284 let ir = ir_of(
285 "PROCEDURE DIVISION.\n\
286 MAIN.\n\
287 INSPECT WS-X TALLYING WS-N FOR ALL ' '.\n",
288 );
289 let mut verbs = Vec::new();
290 for proc in &ir.procedures {
291 collect_unknowns(&proc.body, &mut verbs);
292 }
293 assert_eq!(verbs, vec!["INSPECT".to_string()]);
294 }
295
296 #[test]
297 fn known_program_has_no_unknowns() {
298 let ir = ir_of("PROCEDURE DIVISION.\nMAIN.\n DISPLAY 'OK'.\n STOP RUN.\n");
299 let mut verbs = Vec::new();
300 for proc in &ir.procedures {
301 collect_unknowns(&proc.body, &mut verbs);
302 }
303 assert!(verbs.is_empty());
304 }
305}