uxn_tal/chocolatal/mod.rs
1#![allow(clippy::all)]
2// Chocolatal: TAL Preprocessor for uxn-tal
3use std::ffi::OsStr;
4//
5// This module provides preprocessing for Uxn TAL assembly files, inspired by preprocess-tal.sh.
6// It operates purely on &str, performing file inclusion, label/prefix expansion, lambda/loop label generation,
7// path rewriting, and special token rewrites. See README.md for details and comments at the end of this file.
8use std::env;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug)]
13pub enum PreprocessError {
14 Io(std::io::Error),
15 RecursionLimit,
16 Other(String),
17}
18
19pub type Result<T> = std::result::Result<T, PreprocessError>;
20
21macro_rules! debug {
22 ($($arg:tt)*) => {
23 if env::var("CHOCOLATAL_DEBUG")
24 .map(|v| v == "1")
25 .unwrap_or(true) {
26 eprintln!("[chocolatal:debug] {}", format!($($arg)*));
27 }
28 }
29}
30
31/// Preprocess a TAL source file, returning a preprocessed string.
32/// This function operates on raw text, not tokens.
33pub fn preprocess(input: &str, path: &str, root_dir: &PathBuf) -> Result<String> {
34 let mut output = String::new();
35 let mut stack = Vec::new(); // For lambda/loop label tracking
36 let mut lambda_counter = 0;
37
38 // // Enable debug output if env var CHOCOLATAL_DEBUG=1
39 // let debug_enabled = env::var("CHOCOLATAL_DEBUG")
40 // .map(|v| v == "1")
41 // .unwrap_or(true);
42 // macro_rules! debug {
43 // ($($arg:tt)*) => {
44 // if debug_enabled {
45 // eprintln!("[chocolatal:debug] {}", format!($($arg)*));
46 // }
47 // }
48 // }
49
50 // Split input into tokens and separators (whitespace, newlines), preserving all separators
51 let mut token = String::new();
52 let mut sep = String::new();
53 let mut chars = input.chars().peekable();
54 let mut tokens = Vec::new();
55 while let Some(&c) = chars.peek() {
56 if c.is_whitespace() {
57 sep.push(c);
58 chars.next();
59 } else {
60 if !sep.is_empty() || !token.is_empty() {
61 if !token.is_empty() {
62 tokens.push((token.clone(), sep.clone()));
63 token.clear();
64 sep.clear();
65 } else if !sep.is_empty() {
66 // Handle leading whitespace as a "token" (for perfect preservation)
67 tokens.push((String::new(), sep.clone()));
68 sep.clear();
69 }
70 }
71 // Special case: if token is empty and c is '~', try to grab the whole include token
72 if token.is_empty() && c == '~' {
73 let mut incl = String::new();
74 incl.push('~');
75 chars.next();
76 while let Some(&nc) = chars.peek() {
77 if nc.is_whitespace() {
78 break;
79 }
80 incl.push(nc);
81 chars.next();
82 }
83 tokens.push((incl, sep.clone()));
84 sep.clear();
85 continue;
86 }
87 // General: accumulate all consecutive non-whitespace as a single token
88 while let Some(&nc) = chars.peek() {
89 if nc.is_whitespace() {
90 break;
91 }
92 token.push(nc);
93 chars.next();
94 }
95 tokens.push((token.clone(), sep.clone()));
96 token.clear();
97 sep.clear();
98 }
99 }
100 // Push any trailing token and/or separator
101 if !token.is_empty() || !sep.is_empty() {
102 tokens.push((token, sep));
103 }
104
105 let mut i = 0;
106 let mut prefix_stack: Vec<String> = Vec::new();
107 let mut current_prefix: String = String::new();
108 while i < tokens.len() {
109 let (ref tok, ref sep) = tokens[i];
110
111 // If we just did a prefix+include, push the prefix for the included block
112 // (This is a simplification: in the shell, prefix is set for the duration of the include)
113 // We use a stack to allow nested includes
114 if let Some(idx) = tok.find('~') {
115 let (prefix, rest) = tok.split_at(idx);
116 if !prefix.is_empty() && rest.len() > 1 {
117 let path_part = &rest[1..];
118 let label = std::path::Path::new(path_part)
119 .file_name()
120 .and_then(|f| f.to_str())
121 .map(|f| {
122 if f.ends_with(".tal") {
123 &f[..f.len() - 4]
124 } else {
125 f
126 }
127 })
128 .unwrap_or("");
129 let prefix_label = format!("{}{}", prefix, label);
130 prefix_stack.push(current_prefix.clone());
131 //current_prefix = prefix_label.clone();
132 let input_dir = std::path::Path::new(path)
133 .parent()
134 .map(|p| p.to_path_buf())
135 .unwrap_or_else(|| std::path::PathBuf::from("."));
136 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
137 let path_part = path_part.trim_start_matches("./").trim_start_matches('/');
138 let incl_pattern_cwd = cwd.join(path_part);
139 let incl_pattern_cwd_str = incl_pattern_cwd.to_str().unwrap_or("");
140 if incl_pattern_cwd.exists() {
141 process_include_pattern(
142 incl_pattern_cwd_str,
143 &mut output,
144 sep,
145 &prefix_label,
146 root_dir,
147 )?;
148 } else {
149 let incl_pattern = input_dir.join(path_part);
150 let incl_pattern_str = incl_pattern.to_str().unwrap_or("");
151 process_include_pattern(
152 incl_pattern_str,
153 &mut output,
154 sep,
155 &prefix_label,
156 root_dir,
157 )?;
158 }
159 current_prefix = prefix_stack.pop().unwrap_or_default();
160 i += 1;
161 continue;
162 } else if prefix.is_empty() && rest.len() > 1 {
163 // Plain ~file.tal include, no prefix context
164 let path_part = &rest[1..];
165 let input_dir = std::path::Path::new(path)
166 .parent()
167 .map(|p| p.to_path_buf())
168 .unwrap_or_else(|| std::path::PathBuf::from("."));
169 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
170 let path_part = path_part.trim_start_matches("./").trim_start_matches('/');
171 let incl_pattern_cwd = cwd.join(path_part);
172 let incl_pattern_cwd_str = incl_pattern_cwd.to_str().unwrap_or("");
173 if incl_pattern_cwd.exists() {
174 process_include_pattern(
175 incl_pattern_cwd_str,
176 &mut output,
177 sep,
178 path_part,
179 root_dir,
180 )?;
181 } else {
182 let incl_pattern = input_dir.join(path_part);
183 let incl_pattern_str = incl_pattern.to_str().unwrap_or("");
184 process_include_pattern(
185 incl_pattern_str,
186 &mut output,
187 sep,
188 path_part,
189 root_dir,
190 )?;
191 }
192 i += 1;
193 continue;
194 }
195 }
196
197 // Prefix+include: <prefix>~file.tal (e.g., &~dist.tal)
198 if let Some(idx) = tok.find('~') {
199 let (prefix, rest) = tok.split_at(idx);
200 if !prefix.is_empty() && rest.len() > 1 {
201 let path_part = &rest[1..];
202 if path_part.trim().is_empty() {
203 return Err(PreprocessError::Other(format!(
204 "Syntax error at {}:{}: Empty include path\nSource: ~",
205 path,
206 input[..input.find('~').unwrap_or(0)].lines().count() + 1
207 )));
208 }
209 // Use filename as label, strip .tal only if present
210 let label = Path::new(path_part)
211 .file_name()
212 .and_then(|f| f.to_str())
213 .map(|f| {
214 if f.ends_with(".tal") {
215 &f[..f.len() - 4]
216 } else {
217 f
218 }
219 })
220 .unwrap_or("");
221 // Compose prefix+label (e.g., &dist)
222 let prefix_label = format!("{}{}", prefix, label);
223 // Try to resolve include path: first try cwd, then relative to the parent of the current file
224 let input_dir = Path::new(path)
225 .parent()
226 .map(|p| p.to_path_buf())
227 .unwrap_or_else(|| PathBuf::from("."));
228 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
229 let path_part = path_part.trim_start_matches("./").trim_start_matches('/');
230 debug!(
231 "Prefix+include token: '{}', resolved path part: '{}', prefix_label: '{}'",
232 tok, path_part, prefix_label
233 );
234 // Try cwd first
235 let incl_pattern_cwd = cwd.join(path_part);
236 let incl_pattern_cwd_str = incl_pattern_cwd.to_str().unwrap_or("");
237 debug!(
238 "Including file(s) with pattern1: {}",
239 incl_pattern_cwd.display()
240 );
241 if incl_pattern_cwd.exists() {
242 process_include_pattern(
243 incl_pattern_cwd_str,
244 &mut output,
245 sep,
246 &prefix_label,
247 root_dir,
248 )?;
249 } else {
250 // Fallback: try relative to the parent of the current file
251 let incl_pattern = input_dir.join(path_part);
252 let incl_pattern_str = incl_pattern.to_str().unwrap_or("");
253 process_include_pattern(
254 incl_pattern_str,
255 &mut output,
256 sep,
257 &prefix_label,
258 root_dir,
259 )?;
260 }
261 i += 1;
262 continue;
263 }
264 }
265 // File inclusion: ~file.tal or ~*.tal (glob)
266 if tok.starts_with('~') {
267 let path_part = &tok[1..];
268 if path_part.trim().is_empty() {
269 return Err(PreprocessError::Other(format!(
270 "Syntax error at {}:{}: Empty include path\nSource: ~",
271 path,
272 // Try to get line number (approximate)
273 input[..input.find('~').unwrap_or(0)].lines().count() + 1
274 )));
275 }
276 // Try to resolve include path: first try cwd, then relative to the parent of the current file
277 let input_dir = Path::new(path)
278 .parent()
279 .map(|p| p.to_path_buf())
280 .unwrap_or_else(|| PathBuf::from("."));
281 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
282 let path_part = path_part.trim_start_matches("./").trim_start_matches('/');
283 debug!(
284 "Include token: '{}', resolved path part: '{}'",
285 tok, path_part
286 );
287
288 // Try cwd first
289 let incl_pattern_cwd = cwd.join(path_part);
290 let incl_pattern_cwd_str = incl_pattern_cwd.to_str().unwrap_or("");
291 debug!(
292 "root:{} Including file(s) with pattern2: {}",
293 root_dir.display(),
294 incl_pattern_cwd.display()
295 );
296 if incl_pattern_cwd.exists() {
297 process_include_pattern(
298 incl_pattern_cwd_str,
299 &mut output,
300 sep,
301 path_part,
302 root_dir,
303 )?;
304 } else {
305 // Fallback: try relative to the parent of the current file
306 let incl_pattern = input_dir.join(path_part);
307 let incl_pattern_str = incl_pattern.to_str().unwrap_or("");
308 process_include_pattern(incl_pattern_str, &mut output, sep, path_part, root_dir)?;
309 }
310 i += 1;
311 continue;
312 }
313 // Lambda/loop label generation: '>{'
314 if tok == ">{" {
315 lambda_counter += 1;
316 stack.push(lambda_counter);
317 debug!("Lambda open: ʎ{} (stack: {:?})", lambda_counter, stack);
318 output.push_str(&format!("&ʎ{}{}", lambda_counter, sep));
319 i += 1;
320 continue;
321 }
322 // Lambda/loop label close: |} ?} or }
323 if (tok == "|}" || tok == "?}" || tok == "}") && !stack.is_empty() {
324 let n = stack.pop().unwrap();
325 debug!("Lambda close: ʎ{} (stack: {:?})", n, stack);
326 output.push_str(&format!("&ʎ{}{}", n, sep));
327 i += 1;
328 continue;
329 }
330 // @. rewrite: replace with @<parent directory name>
331 if tok == "@." {
332 // Get the parent directory name (not the file stem)
333 let parent_name = Path::new(path)
334 .parent()
335 .and_then(|p| p.file_name())
336 .and_then(|s| s.to_str())
337 .unwrap_or("");
338 debug!(
339 "@. token found, rewriting to parent directory name: tok='{}', parent_name='{}'",
340 tok, parent_name
341 );
342 // std::process::exit(1);
343 output.push_str(&format!("@{}{}", parent_name, sep));
344 i += 1;
345 continue;
346 }
347 // /.' rewrite: replace with STH2kr <rest>
348 if tok.starts_with("/.") {
349 output.push_str(&format!("STH2kr {}{}", &tok[2..], sep));
350 i += 1;
351 continue;
352 }
353 // Faithful prefix/prefix-rewrite rules from preprocess-tal.sh
354 // 1. &&* or [',.;_-=!?|$']&&* => tok = tok[..tok.find("&&")] + "&" + &tok[tok.find("&&")+2..]
355 if let Some(idx) = tok.find("&&") {
356 let newtok = format!("{}&{}", &tok[..idx], &tok[idx + 2..]);
357 output.push_str(&format!("{}{}", newtok, sep));
358 i += 1;
359 continue;
360 }
361 // 2. [',.;_-=!?|$']&* => tok = tok[..tok.find("/&")] + "&" + current_prefix + &tok[tok.find("/&")+2..]
362 if let Some(idx) = tok.find("/&") {
363 let newtok = format!("{}&{}{}", &tok[..idx], current_prefix, &tok[idx + 2..]);
364 output.push_str(&format!("{}{}", newtok, sep));
365 i += 1;
366 continue;
367 }
368 // 3. &* => tok = tok[..tok.find("&")] + "&" + current_prefix + &tok[tok.find("&")+1..]
369 if let Some(idx) = tok.find('&') {
370 if idx == 0 && tok.len() > 1 {
371 let newtok = format!("&{}{}", current_prefix, &tok[idx + 1..]);
372 output.push_str(&format!("{}{}", newtok, sep));
373 i += 1;
374 continue;
375 }
376 }
377 // 4. //* or [',.;_-=!?|$']//* => tok = tok[..tok.find("//")] + "/" + &tok[tok.find("//")+2..]
378 if let Some(idx) = tok.find("//") {
379 let newtok = format!("{}/{}", &tok[..idx], &tok[idx + 2..]);
380 output.push_str(&format!("{}{}", newtok, sep));
381 i += 1;
382 continue;
383 }
384 // 5. [',.;_-=!?|$']/[^.]* => tok = tok[..tok.find("/")] + "/" + current_prefix + &tok[tok.find("/")+1..]
385 if let Some(idx) = tok.find('/') {
386 if idx > 0 && !tok[idx + 1..].starts_with('.') {
387 let newtok = format!("{}/{}{}", &tok[..idx], current_prefix, &tok[idx + 1..]);
388 output.push_str(&format!("{}{}", newtok, sep));
389 i += 1;
390 continue;
391 }
392 }
393 // 6. /[^.]* => tok = tok[..tok.find("/")] + "/" + current_prefix + &tok[tok.find("/")+1..]
394 if tok.starts_with('/') && tok.len() > 1 && !tok[1..].starts_with('.') {
395 let newtok = format!("/{}{}", current_prefix, &tok[1..]);
396 output.push_str(&format!("{}{}", newtok, sep));
397 i += 1;
398 continue;
399 }
400 //debug!("Token: '{}', Sep: '{}'", tok, sep);
401 // Faithful prefix/prefix-rewrite rules from preprocess-tal.sh
402 // 1. &&* or [',.;_-=!?|$']&&* => tok = tok[..tok.find("&&")] + "&" + &tok[tok.find("&&")+2..]
403 if let Some(idx) = tok.find("&&") {
404 let newtok = format!("{}&{}", &tok[..idx], &tok[idx + 2..]);
405 output.push_str(&format!("{}{}", newtok, sep));
406 i += 1;
407 continue;
408 }
409 // 2. [',.;_-=!?|$']&* => tok = tok[..tok.find("/&")] + "&" + prefix + &tok[tok.find("/&")+2..]
410 if let Some(idx) = tok.find("/&") {
411 // Use prefix from context if available (here, just use prefix_label if set)
412 let prefix = ""; // TODO: track prefix context if needed
413 let newtok = format!("{}&{}{}", &tok[..idx], prefix, &tok[idx + 2..]);
414 output.push_str(&format!("{}{}", newtok, sep));
415 i += 1;
416 continue;
417 }
418 // 3. &* => tok = tok[..tok.find("&")] + "&" + prefix + &tok[tok.find("&")+1..]
419 if let Some(idx) = tok.find('&') {
420 if idx == 0 && tok.len() > 1 {
421 let prefix = ""; // TODO: track prefix context if needed
422 let newtok = format!("&{}{}", prefix, &tok[idx + 1..]);
423 output.push_str(&format!("{}{}", newtok, sep));
424 i += 1;
425 continue;
426 }
427 }
428 // 4. //* or [',.;_-=!?|$']//* => tok = tok[..tok.find("//")] + "/" + &tok[tok.find("//")+2..]
429 if let Some(idx) = tok.find("//") {
430 let newtok = format!("{}/{}", &tok[..idx], &tok[idx + 2..]);
431 output.push_str(&format!("{}{}", newtok, sep));
432 i += 1;
433 continue;
434 }
435 // 5. [',.;_-=!?|$']/[^.]* => tok = tok[..tok.find("/")] + "/" + prefix + &tok[tok.find("/")+1..]
436 if let Some(idx) = tok.find('/') {
437 if idx > 0 && !tok[idx + 1..].starts_with('.') {
438 let prefix = ""; // TODO: track prefix context if needed
439 let newtok = format!("{}/{}{}", &tok[..idx], prefix, &tok[idx + 1..]);
440 output.push_str(&format!("{}{}", newtok, sep));
441 i += 1;
442 continue;
443 }
444 }
445 // 6. /[^.]* => tok = tok[..tok.find("/")] + "/" + prefix + &tok[tok.find("/")+1..]
446 if tok.starts_with('/') && tok.len() > 1 && !tok[1..].starts_with('.') {
447 let prefix = ""; // TODO: track prefix context if needed
448 let newtok = format!("/{}{}", prefix, &tok[1..]);
449 output.push_str(&format!("{}{}", newtok, sep));
450 i += 1;
451 continue;
452 }
453 // TODO: Add more token rewrites and path/prefix handling as needed
454 output.push_str(tok);
455 output.push_str(sep);
456 i += 1;
457 }
458 Ok(output)
459}
460
461fn process_include_pattern(
462 incl_pattern_str: &str,
463 output: &mut String,
464 sep: &String,
465 _path_part: &str,
466 root_dir: &PathBuf,
467) -> Result<()> {
468 // macro_rules! debug {
469 // ($($arg:tt)*) => {
470 // if debug_enabled {
471 // eprintln!("[chocolatal:debug] {}", format!($($arg)*));
472 // }
473 // }
474 // }
475 let input_path = Path::new(incl_pattern_str).to_path_buf();
476 debug!(
477 "root: {} Including file(s) with pattern3: {}",
478 root_dir.display(),
479 incl_pattern_str
480 );
481 if incl_pattern_str.contains('*')
482 || incl_pattern_str.contains('?')
483 || incl_pattern_str.contains('[')
484 {
485 // Split pattern into directory and filename pattern
486 let (dir, file_pat) = match incl_pattern_str.rfind(std::path::MAIN_SEPARATOR) {
487 Some(idx) => (&incl_pattern_str[..=idx], &incl_pattern_str[idx + 1..]),
488 None => ("./", incl_pattern_str),
489 };
490 // Remove the trailing separator from dir, if present
491 let dir_trimmed = if dir.ends_with(std::path::MAIN_SEPARATOR) && dir.len() > 1 {
492 &dir[..dir.len() - 1]
493 } else {
494 dir
495 };
496 let dir_path = Path::new(dir_trimmed);
497
498 // let file_pat_path = root_dir.join(file_pat);
499 // //debug!("Including file (from root_dir): {}", file_pat_path.display());
500 // if file_pat_path.exists() && file_pat_path.is_file() {
501 // debug!("Including file (from root_dir): {}", file_pat_path.display());
502 // if file_pat_path.extension().and_then(OsStr::to_str) == Some("tal") {
503 // let incl_pre = preprocess_include_file(&root_dir, &file_pat_path)?;
504 // output.push_str(&incl_pre);
505 // output.push_str(sep);
506 // } else {
507 // hexdump(output, sep, file_pat_path)?;
508 // }
509 // return Ok(());
510 // }
511
512 let read_dir = match fs::read_dir(dir_path) {
513 Ok(rd) => rd,
514 Err(e) => {
515 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
516 let file_pat_path = cwd.join(file_pat);
517 if file_pat_path.exists() && file_pat_path.is_file() {
518 debug!("Including file (from cwd): {}", file_pat_path.display());
519 if file_pat_path.extension().and_then(OsStr::to_str) == Some("tal") {
520 let incl_pre = preprocess_include_file(&cwd, &file_pat_path)?;
521 output.push_str(&incl_pre);
522 output.push_str(sep);
523 } else {
524 hexdump(output, sep, file_pat_path)?;
525 }
526 return Ok(());
527 }
528
529 return Err(PreprocessError::Other(format!(
530 "Failed to read directory '{}': {}",
531 dir_path.display(),
532 e
533 )));
534 }
535 };
536 Ok(for entry in read_dir {
537 match entry {
538 Ok(entry) => {
539 let path = entry.path();
540 let fname = entry.file_name();
541 let fname_str = fname.to_str().unwrap_or("");
542 if matches_pattern(fname_str, file_pat) && path.is_file() {
543 debug!("Including file: {}", path.display());
544 if path.extension().and_then(OsStr::to_str) == Some("tal") {
545 // Try to include using cwd as current_dir first, then fallback to original logic if fails
546 let incl_pre = match preprocess_include_file(&PathBuf::from("."), &path)
547 {
548 Ok(s) => s,
549 Err(_) =>
550 //preprocess_include_file(&input_path, &path)?,
551 {
552 match preprocess_include_file(&input_path, &path) {
553 Ok(s) => s,
554 Err(_) => preprocess_include_file(root_dir, &path)?,
555 }
556 }
557 };
558 output.push_str(&incl_pre);
559 output.push_str(sep);
560 } else {
561 hexdump(output, sep, input_path.clone())?;
562 }
563 }
564 }
565 Err(e) => {
566 debug!("Glob error: {}", e);
567 return Err(PreprocessError::Other(e.to_string()));
568 }
569 }
570 })
571 } else {
572 process_single_file(incl_pattern_str, output, sep, input_path, &root_dir)?;
573 Ok(())
574 }
575}
576
577fn process_single_file(
578 incl_pattern_str: &str,
579 output: &mut String,
580 sep: &String,
581 input_path: PathBuf,
582 root_dir: &PathBuf,
583) -> Result<()> {
584 // Enable debug output if env var CHOCOLATAL_DEBUG=1
585 let debug_enabled = env::var("CHOCOLATAL_DEBUG")
586 .map(|v| v == "1")
587 .unwrap_or(true);
588 macro_rules! debug {
589 ($($arg:tt)*) => {
590 if debug_enabled {
591 eprintln!("[chocolatal:debug] {}", format!($($arg)*));
592 }
593 }
594 }
595 debug!("Including file: {}", incl_pattern_str);
596 Ok(
597 if input_path.extension().map(|e| e == "tal").unwrap_or(false) {
598 // .tal: preprocess as text
599 // Try to read the file, if not found, try the same filename in all parent directories up to and including cwd
600 let filename = input_path.file_name().map(|f| f.to_os_string());
601 let mut tried_paths = Vec::new();
602 let mut try_path = input_path.clone();
603 let incl_str = loop {
604 match fs::read_to_string(&try_path) {
605 Ok(s) => break s,
606 Err(e) => {
607 // Try root_dir as a fallback if not already tried
608 if let Some(ref fname) = filename {
609 if !tried_paths.contains(&root_dir.join(fname)) {
610 let root_path = root_dir.join(fname);
611 if !tried_paths.contains(&root_path) && root_path != try_path {
612 debug!(
613 "Trying filename in root_dir fallback: {}",
614 root_path.display()
615 );
616 debug!("(was trying: {})", try_path.display());
617 try_path = root_dir
618 .join(try_path.file_name().unwrap_or(&OsStr::new("")));
619 debug!("(was trying: {})", try_path.display());
620 continue;
621 }
622 }
623 }
624 tried_paths.push(try_path.clone());
625 // Try all parent directories up to and including cwd with the same filename
626 if let Some(fname) = &filename {
627 let cwd =
628 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
629 let mut tried_cwd = false;
630 loop {
631 let mut parent_opt = match try_path.parent() {
632 Some(parent) => Some(parent.to_path_buf()),
633 None => {
634 // If no parent, try the directory itself (for root or relative files)
635 if try_path.is_dir() {
636 Some(try_path.clone())
637 } else {
638 None
639 }
640 }
641 };
642 if let Some(ref parent) = parent_opt {
643 // if parent == root_dir {
644 // debug!("Reached root directory: {}", parent.display());
645 // break;
646
647 // }
648 debug!("Trying parent directory: {}", parent.display());
649 if parent.display().to_string().is_empty() {
650 debug!("Reached root directory (empty path).");
651 parent_opt = Some(root_dir.clone());
652 }
653 } else {
654 debug!("No parent directory found.");
655 }
656 if let Some(parent) = parent_opt {
657 // Stop if we've reached root or already tried this parent
658 if parent == try_path || tried_paths.contains(&parent) {
659 break;
660 }
661 let parent_path = parent.join(fname);
662 if tried_paths.contains(&parent_path) {
663 break;
664 }
665 debug!("Trying parent directory: {}", parent_path.display());
666 try_path = parent_path.clone();
667 tried_paths.push(try_path.clone());
668 // If we've reached cwd, mark as tried
669 if parent.canonicalize().ok() == cwd.canonicalize().ok() {
670 tried_cwd = true;
671 break;
672 }
673 continue;
674 } else {
675 eprintln!("Reached root directory without finding file.");
676 break;
677 }
678 }
679 // As a last resort, try just the filename in cwd if not already tried
680 if !tried_cwd {
681 let cwd_path = cwd.join(fname);
682 if !tried_paths.contains(&cwd_path) && cwd_path != try_path {
683 debug!(
684 "Trying filename in cwd last resort: {}",
685 cwd_path.display()
686 );
687 try_path = cwd_path;
688 continue;
689 }
690 }
691 }
692 return Err(PreprocessError::Other(format!(
693 "chocolatal::Failed to read file '{}': {}",
694 input_path.display(),
695 e
696 )));
697 }
698 }
699 };
700 let incl_pre = preprocess(&incl_str, input_path.to_str().unwrap_or(""), &root_dir)?;
701 let incl_pre = incl_pre.trim_end();
702 output.push_str(&incl_pre);
703 output.push_str(sep);
704 } else {
705 hexdump(output, sep, input_path)?;
706 },
707 )
708}
709
710fn hexdump(output: &mut String, sep: &String, input_path: PathBuf) -> Result<()> {
711 let bytes = match fs::read(&input_path) {
712 Ok(b) => b,
713 Err(e) => {
714 return Err(PreprocessError::Other(format!(
715 "Failed to read file '{}': {}",
716 input_path.display(),
717 e
718 )));
719 }
720 };
721 let mut line = String::new();
722 for chunk in bytes.chunks(16) {
723 line.clear();
724 for (j, group) in chunk.chunks(2).enumerate() {
725 if j > 0 {
726 line.push(' ');
727 }
728 for b in group {
729 line.push_str(&format!("{:02x}", b));
730 }
731 }
732 output.push_str(&line);
733 output.push('\n');
734 }
735 output.push_str(sep);
736 Ok(())
737}
738
739fn preprocess_include_file(current_dir: &PathBuf, path: &PathBuf) -> Result<String> {
740 // let rel_path = path.strip_prefix(current_dir).unwrap_or(path);
741 // let rel_str = rel_path.to_str().unwrap_or("");
742 // let rel_str = rel_str
743 // .strip_prefix("./")
744 // .or(rel_str.strip_prefix('/'))
745 // .unwrap_or(rel_str);
746 // let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
747 // eprintln!("Including file curr: {} (path): {} (rewritten path): {} cwd: {}", current_dir.display(), path.display(), rel_str, cwd.display());
748 let incl_str = match fs::read_to_string(&path) {
749 //(&rel_str) {
750 Ok(s) => s,
751 Err(e) => {
752 return Err(PreprocessError::Other(format!(
753 "chocolatal: Failed to read file '{}': {}",
754 path.display(),
755 e
756 )));
757 }
758 };
759 let incl_str = incl_str.trim_end();
760 let incl_pre = preprocess(&incl_str, &path.display().to_string(), current_dir)?;
761 Ok(incl_pre)
762}
763
764/// Runs the deluge docker container and returns the output as a String.
765pub fn deluge_preprocess(_input_path: &str) -> std::io::Result<String> {
766 // pub fn deluge_preprocess() -> std::io::Result<String> {
767 use std::env;
768 use std::process::Command;
769 let cwd = env::current_dir()?;
770 let volume = format!("{}:/workspace", cwd.display());
771 let args = [
772 "run",
773 "--rm",
774 "-v",
775 &volume,
776 "-w",
777 "/workspace",
778 "alpine",
779 "sh",
780 "./preprocess-tal.sh",
781 "deluge/main.tal",
782 ];
783 eprintln!(
784 "[deluge_preprocess:debug] Running: docker {:?}\n cwd: {}",
785 args,
786 cwd.display()
787 );
788 let output = Command::new("docker").args(&args).output()?;
789 // eprintln!("[deluge_preprocess:debug] stdout:\n{}", String::from_utf8_lossy(&output.stdout));
790 // eprintln!("[deluge_preprocess:debug] stderr:\n{}", String::from_utf8_lossy(&output.stderr));
791 Ok(String::from_utf8_lossy(&output.stdout).to_string())
792}
793
794// Glob pattern: include all matching files
795// Implement simple globbing for patterns like "*.tal" or "foo?.tal"
796// Only supports '*' and '?' wildcards in the filename, not directories.
797
798fn matches_pattern(name: &str, pattern: &str) -> bool {
799 // Only supports '*' and '?' wildcards, no character classes.
800 let (mut np, mut pp) = (0, 0);
801 let (nlen, plen) = (name.len(), pattern.len());
802 let (name, pattern) = (name.as_bytes(), pattern.as_bytes());
803 let mut star = None;
804 while np < nlen {
805 if pp < plen && (pattern[pp] == b'?' || pattern[pp] == name[np]) {
806 np += 1;
807 pp += 1;
808 } else if pp < plen && pattern[pp] == b'*' {
809 star = Some((np, pp));
810 pp += 1;
811 } else if let Some((snp, spp)) = star {
812 np = snp + 1;
813 pp = spp + 1;
814 star = Some((np, spp));
815 } else {
816 return false;
817 }
818 }
819 while pp < plen && pattern[pp] == b'*' {
820 pp += 1;
821 }
822 pp == plen
823}
824
825// --- Standalone CLI for Chocolatal Preprocessor ---
826// This allows you to compile and run this file directly with rustc, for quick testing or scripting.
827// Usage: rustc -o chocolatal_preprocessor src/chocolatal/mod.rs && ./chocolatal_preprocessor <input.tal> [output.tal]
828
829#[cfg(not(test))]
830#[allow(dead_code)]
831fn main() {
832 use std::env;
833
834 use std::fs;
835 use std::io::{self, Read, Write};
836 use std::process;
837 let root_dir = &std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
838 let args: Vec<String> = env::args().collect();
839 if args.len() < 2 {
840 eprintln!(
841 "Usage: {} <input1.tal> [input2.tal ...] [output.tal|-]",
842 args[0]
843 );
844 eprintln!(" If no input files or '-' is given, reads from stdin. If no output is given or '-' is given, writes to stdout.");
845 process::exit(1);
846 }
847
848 // Determine output path: if last arg is not a .tal file and not '-', treat as output
849 let (input_files, output_path): (Vec<&str>, Option<&str>) = {
850 if args.len() == 2 {
851 // One arg: could be input or '-'
852 if args[1] == "-" {
853 (vec!["-"], None)
854 } else if args[1].ends_with(".tal") {
855 (vec![&args[1]], None)
856 } else {
857 (vec!["-"], Some(&args[1]))
858 }
859 } else {
860 let last = &args[args.len() - 1];
861 if last == "-" || last.ends_with(".tal") {
862 (args[1..].iter().map(|s| s.as_str()).collect(), None)
863 } else {
864 (
865 args[1..args.len() - 1].iter().map(|s| s.as_str()).collect(),
866 Some(last.as_str()),
867 )
868 }
869 }
870 };
871
872 // Read and concatenate all input files (or stdin)
873 let mut input = String::new();
874 let mut input_path = String::new();
875 for (i, file) in input_files.iter().enumerate() {
876 let (content, path) = if *file == "-" {
877 let mut buf = String::new();
878 if let Err(e) = io::stdin().read_to_string(&mut buf) {
879 eprintln!("Failed to read from stdin: {}", e);
880 process::exit(1);
881 }
882 (buf, "<stdin>".to_string())
883 } else {
884 let content = match fs::read_to_string(file) {
885 Ok(s) => s,
886 Err(e) => {
887 eprintln!("Failed to read {}: {}", file, e);
888 process::exit(1);
889 }
890 };
891 (content, file.to_string())
892 };
893 if i > 0 {
894 input.push_str("\n"); // Separate files with newline
895 }
896 input.push_str(&content);
897 if input_path.is_empty() {
898 input_path = path;
899 }
900 }
901
902 match preprocess(&input, &input_path, root_dir) {
903 Ok(result) => {
904 match output_path {
905 Some(out) if out != "-" => {
906 if let Err(e) = fs::write(out, &result) {
907 eprintln!("Failed to write {}: {}", out, e);
908 process::exit(1);
909 }
910 }
911 _ => {
912 // Write to stdout
913 if let Err(e) = io::stdout().write_all(result.as_bytes()) {
914 eprintln!("Failed to write to stdout: {}", e);
915 process::exit(1);
916 }
917 }
918 }
919 }
920 Err(e) => {
921 eprintln!("Preprocess error: {:?}", e);
922 process::exit(1);
923 }
924 }
925}
926
927// # Running with rust-script or as a CLI
928//
929// supports any number of .tal input files (or - for stdin), concatenates and preprocesses them in order, and writes to the specified output or stdout.
930//
931// This file is designed to be executable directly using [`rust-script`](https://rust-script.org/) or as a compiled binary.
932// The shebang (`#!/usr/bin/env rust-script`) and the embedded Cargo manifest allow you to run it as a script:
933//
934// ```sh
935// rust-script src/chocolatal/mod.rs file1.tal file2.tal ... [output.tal|-]
936// ```
937//
938// - You can specify as many input `.tal` files as you want; they will be concatenated in order and preprocessed as one.
939// - If any input is `-`, it will read from stdin at that position.
940// - If the last argument is not a `.tal` file or `-`, it is treated as the output file. Otherwise, output goes to stdout.
941//
942// ## Example usage
943//
944// ```sh
945// rust-script src/chocolatal/mod.rs foo.tal bar.tal baz.tal > preprocessed.tal
946// rust-script src/chocolatal/mod.rs foo.tal - bar.tal output.tal
947// rust-script src/chocolatal/mod.rs - output.tal
948// ```
949//
950// ## Dependencies
951//
952// The embedded manifest ensures that the `glob` crate is available when running with rust-script.
953//
954// ## Notes
955//
956// - You can set the environment variable `CHOCOLATAL_DEBUG=1` to enable debug output to stderr.
957// - This script can also be compiled and run as a standalone binary (see the comments at the end of the file).
958/*
959To build and run this file directly with rustc (bypassing Cargo):
960
961 git clone https://github.com/rust-lang/glob.git
962 # For Linux/macOS:
963 rustc --crate-type=lib glob/src/lib.rs -o libglob.rlib
964 rustc -L . -o chocolatal_preprocessor src/chocolatal/mod.rs --extern glob=libglob.rlib
965
966 # For Windows (PowerShell or CMD):
967 rustc --crate-type=lib glob\src\lib.rs -o libglob.rlib
968 rustc -L . -o chocolatal_preprocessor.exe src\chocolatal\mod.rs --extern glob=libglob.rlib
969
970 # Read from a file, write to stdout:
971 ./chocolatal_preprocessor input.tal > output.tal
972
973 # Read from stdin, write to stdout:
974 cat input.tal | ./chocolatal_preprocessor - > output.tal
975
976 # Read from a file, write to a file:
977 ./chocolatal_preprocessor input.tal output.tal
978
979 # Read from stdin, write to a file:
980 cat input.tal | ./chocolatal_preprocessor - output.tal
981*/
982
983// MIT License - Copyright 2025 @notchoc, David Horner