Skip to main content

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