|
| 1 | +use std::env; |
| 2 | +use std::fs::{self, File}; |
| 3 | +use std::io::{self, Write}; |
| 4 | +use std::path::PathBuf; |
| 5 | + |
| 6 | +/// Extracts fish ASCII art blocks from the original Perl Asciiquarium script and |
| 7 | +/// generates a Rust module that exposes them as &'static str constants plus a |
| 8 | +/// helper to build Vec<FishArt>. |
| 9 | +/// |
| 10 | +/// Usage: |
| 11 | +/// cargo run --bin extract_fish [input_perl_path] [output_rust_path] |
| 12 | +/// |
| 13 | +/// Defaults: |
| 14 | +/// input_perl_path = archive/original/asciiquarium |
| 15 | +/// output_rust_path = src/widgets/generated_fish_assets.rs |
| 16 | +/// |
| 17 | +/// Strategy: |
| 18 | +/// - Locate `sub add_new_fish` and `sub add_old_fish` in the Perl script. |
| 19 | +/// - Within each, find `my @fish_image = (` and parse quoted blocks (q{...} or q#...#). |
| 20 | +/// - The fish arrays alternate [art, color_mask, art, color_mask, ...]. |
| 21 | +/// We extract only the art blocks at even indices (0, 2, 4, ...). |
| 22 | +/// - Generate a Rust module with: |
| 23 | +/// - const FISH_N: &str = "..."; |
| 24 | +/// - pub fn get_generated_fish_assets() -> Vec<FishArt> { ... } |
| 25 | +/// - a local measure function to compute width/height |
| 26 | +fn main() -> io::Result<()> { |
| 27 | + let (input_path, output_path) = parse_args(); |
| 28 | + |
| 29 | + let input = fs::read_to_string(&input_path)?; |
| 30 | + let mut all_art: Vec<String> = Vec::new(); |
| 31 | + |
| 32 | + for func in ["add_new_fish", "add_old_fish"] { |
| 33 | + let blocks = extract_fish_blocks_from_function(&input, func); |
| 34 | + all_art.extend(blocks); |
| 35 | + } |
| 36 | + |
| 37 | + if all_art.is_empty() { |
| 38 | + eprintln!("No fish art blocks found. Check the input file path and format."); |
| 39 | + } |
| 40 | + |
| 41 | + let generated = generate_rust_module(&all_art); |
| 42 | + |
| 43 | + // Ensure parent dir exists |
| 44 | + if let Some(parent) = output_path.parent() { |
| 45 | + if !parent.exists() { |
| 46 | + fs::create_dir_all(parent)?; |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + let mut file = File::create(&output_path)?; |
| 51 | + file.write_all(generated.as_bytes())?; |
| 52 | + |
| 53 | + println!( |
| 54 | + "Extracted {} fish art blocks into: {}", |
| 55 | + all_art.len(), |
| 56 | + output_path.display() |
| 57 | + ); |
| 58 | + |
| 59 | + Ok(()) |
| 60 | +} |
| 61 | + |
| 62 | +fn parse_args() -> (PathBuf, PathBuf) { |
| 63 | + let mut args = env::args().skip(1); |
| 64 | + let input = args |
| 65 | + .next() |
| 66 | + .map(PathBuf::from) |
| 67 | + .unwrap_or_else(|| PathBuf::from("archive/original/asciiquarium")); |
| 68 | + let output = args |
| 69 | + .next() |
| 70 | + .map(PathBuf::from) |
| 71 | + .unwrap_or_else(|| PathBuf::from("src/widgets/generated_fish_assets.rs")); |
| 72 | + (input, output) |
| 73 | +} |
| 74 | + |
| 75 | +/// Extract fish art blocks from a specific Perl function: |
| 76 | +/// - Locates `sub <func_name>` |
| 77 | +/// - Finds `my @fish_image = (` |
| 78 | +/// - Collects q{...} or q#...# blocks until `);` |
| 79 | +/// - Returns only the even-indexed blocks (0, 2, 4, ...) |
| 80 | +fn extract_fish_blocks_from_function(input: &str, func_name: &str) -> Vec<String> { |
| 81 | + let mut results = Vec::new(); |
| 82 | + |
| 83 | + // Locate function start |
| 84 | + let func_marker = format!("sub {}", func_name); |
| 85 | + let Some(func_start_idx) = input.find(&func_marker) else { |
| 86 | + return results; |
| 87 | + }; |
| 88 | + |
| 89 | + // Search from function start for the fish_image array declaration |
| 90 | + let array_marker = "my @fish_image"; |
| 91 | + let array_start_rel = input[func_start_idx..].find(array_marker); |
| 92 | + let Some(array_start_rel) = array_start_rel else { |
| 93 | + return results; |
| 94 | + }; |
| 95 | + let array_start = func_start_idx + array_start_rel; |
| 96 | + |
| 97 | + // From array start, find the opening '(' and then parse until the matching ');' |
| 98 | + let after_array = &input[array_start..]; |
| 99 | + let Some(paren_idx_rel) = after_array.find('(') else { |
| 100 | + return results; |
| 101 | + }; |
| 102 | + let cursor = array_start + paren_idx_rel + 1; // position after '(' |
| 103 | + |
| 104 | + // We will scan line-by-line for q-blocks, until we reach a line containing ");" |
| 105 | + let mut in_block = false; |
| 106 | + let mut block_delim = BlockDelim::Brace; // default; will set per block |
| 107 | + let mut current_block: Vec<String> = Vec::new(); |
| 108 | + |
| 109 | + for line in input[cursor..].lines() { |
| 110 | + let trimmed = line.trim_start(); |
| 111 | + |
| 112 | + // End of array |
| 113 | + if !in_block && trimmed.contains(");") { |
| 114 | + break; |
| 115 | + } |
| 116 | + |
| 117 | + if !in_block { |
| 118 | + // Start of a q-block? |
| 119 | + // We support q{...} and q#...# patterns commonly used in the original file. |
| 120 | + if trimmed.starts_with("q{") { |
| 121 | + in_block = true; |
| 122 | + block_delim = BlockDelim::Brace; |
| 123 | + // If there is content after q{ on the same line, capture it (rare in file) |
| 124 | + if let Some(rest) = trimmed.strip_prefix("q{") { |
| 125 | + // If same-line close occurs (unlikely in fish arrays), handle it |
| 126 | + if rest.contains("},") { |
| 127 | + let before_close = rest.split("},").next().unwrap_or(""); |
| 128 | + if !before_close.is_empty() { |
| 129 | + current_block.push(before_close.to_string()); |
| 130 | + } |
| 131 | + // End block immediately |
| 132 | + results.push(current_block.join("\n")); |
| 133 | + current_block.clear(); |
| 134 | + in_block = false; |
| 135 | + } else { |
| 136 | + // Typically, content starts on the next line; we still store remainder if any |
| 137 | + if !rest.is_empty() { |
| 138 | + current_block.push(rest.to_string()); |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + } else if trimmed.starts_with("q#") { |
| 143 | + in_block = true; |
| 144 | + block_delim = BlockDelim::Hash; |
| 145 | + if let Some(rest) = trimmed.strip_prefix("q#") { |
| 146 | + if rest.contains("#,") { |
| 147 | + let before_close = rest.split("#,").next().unwrap_or(""); |
| 148 | + if !before_close.is_empty() { |
| 149 | + current_block.push(before_close.to_string()); |
| 150 | + } |
| 151 | + results.push(current_block.join("\n")); |
| 152 | + current_block.clear(); |
| 153 | + in_block = false; |
| 154 | + } else if !rest.is_empty() { |
| 155 | + current_block.push(rest.to_string()); |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + // otherwise ignore non-q lines |
| 160 | + } else { |
| 161 | + // Inside a q-block: collect lines until delimiter close |
| 162 | + let close_hit = match block_delim { |
| 163 | + BlockDelim::Brace => trimmed == "}," || trimmed.ends_with("},"), |
| 164 | + BlockDelim::Hash => trimmed == "#," || trimmed.ends_with("#,"), |
| 165 | + }; |
| 166 | + |
| 167 | + if close_hit { |
| 168 | + // End current block. We don't include the closing line. |
| 169 | + let art = current_block.join("\n"); |
| 170 | + results.push(art); |
| 171 | + current_block.clear(); |
| 172 | + in_block = false; |
| 173 | + } else { |
| 174 | + current_block.push(line.to_string()); |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + // results contains [art, mask, art, mask, ...]; keep only even indices |
| 180 | + results |
| 181 | + .into_iter() |
| 182 | + .enumerate() |
| 183 | + .filter_map(|(i, s)| { |
| 184 | + if i % 2 == 0 { |
| 185 | + Some(trim_trailing_newlines(&s)) |
| 186 | + } else { |
| 187 | + None |
| 188 | + } |
| 189 | + }) |
| 190 | + .collect() |
| 191 | +} |
| 192 | + |
| 193 | +#[derive(Clone, Copy)] |
| 194 | +enum BlockDelim { |
| 195 | + Brace, |
| 196 | + Hash, |
| 197 | +} |
| 198 | + |
| 199 | +/// Trim up to one trailing newline for a cleaner const block, but preserve interior newlines. |
| 200 | +fn trim_trailing_newlines(s: &str) -> String { |
| 201 | + let mut out = s.to_string(); |
| 202 | + while out.ends_with('\n') { |
| 203 | + out.pop(); |
| 204 | + } |
| 205 | + out |
| 206 | +} |
| 207 | + |
| 208 | +/// Generate the Rust module source containing constants and a helper function. |
| 209 | +/// |
| 210 | +/// The generated module is self-contained and does not depend on the existing |
| 211 | +/// `asciiquarium_assets` module. It exposes: |
| 212 | +/// - const FISH_N: &str = "..."; |
| 213 | +/// - pub fn get_generated_fish_assets() -> Vec<FishArt> |
| 214 | +/// - a local `measure_art` function. |
| 215 | +fn generate_rust_module(art_blocks: &[String]) -> String { |
| 216 | + let mut out = String::new(); |
| 217 | + |
| 218 | + out.push_str("//! AUTO-GENERATED FILE: Do not edit by hand.\n"); |
| 219 | + out.push_str("//!\n"); |
| 220 | + out.push_str( |
| 221 | + "//! Generated by `src/bin/extract_fish.rs` from the original Perl Asciiquarium script.\n", |
| 222 | + ); |
| 223 | + out.push_str( |
| 224 | + "//! This module provides fish ASCII assets as &'static str constants and a helper\n", |
| 225 | + ); |
| 226 | + out.push_str("//! to build Vec<FishArt> for use with the Asciiquarium widget.\n\n"); |
| 227 | + |
| 228 | + out.push_str("use super::asciiquarium::FishArt;\n\n"); |
| 229 | + |
| 230 | + for (i, art) in art_blocks.iter().enumerate() { |
| 231 | + let const_name = format!("FISH_{:04}", i + 1); |
| 232 | + let escaped = escape_for_rust_string(art); |
| 233 | + out.push_str(&format!( |
| 234 | + "pub const {}: &str = \"{}\";\n", |
| 235 | + const_name, escaped |
| 236 | + )); |
| 237 | + } |
| 238 | + |
| 239 | + out.push_str("\nfn measure_art(art: &str) -> (usize, usize) {\n"); |
| 240 | + out.push_str(" let mut max_w = 0usize;\n"); |
| 241 | + out.push_str(" let mut h = 0usize;\n"); |
| 242 | + out.push_str(" for line in art.lines() {\n"); |
| 243 | + out.push_str(" let w = line.chars().count();\n"); |
| 244 | + out.push_str(" if w > max_w { max_w = w; }\n"); |
| 245 | + out.push_str(" h += 1;\n"); |
| 246 | + out.push_str(" }\n"); |
| 247 | + out.push_str(" (max_w.max(1), h.max(1))\n"); |
| 248 | + out.push_str("}\n\n"); |
| 249 | + |
| 250 | + out.push_str("pub fn get_generated_fish_assets() -> Vec<FishArt> {\n"); |
| 251 | + out.push_str(" let mut out = Vec::new();\n"); |
| 252 | + if !art_blocks.is_empty() { |
| 253 | + out.push_str(" let arts: &[&str] = &[\n"); |
| 254 | + for i in 0..art_blocks.len() { |
| 255 | + out.push_str(&format!(" FISH_{:04},\n", i + 1)); |
| 256 | + } |
| 257 | + out.push_str(" ];\n"); |
| 258 | + out.push_str(" for art in arts {\n"); |
| 259 | + out.push_str(" let (w, h) = measure_art(art);\n"); |
| 260 | + out.push_str(" out.push(FishArt { art, width: w, height: h });\n"); |
| 261 | + out.push_str(" }\n"); |
| 262 | + } |
| 263 | + out.push_str(" out\n"); |
| 264 | + out.push_str("}\n"); |
| 265 | + |
| 266 | + out |
| 267 | +} |
| 268 | + |
| 269 | +/// Escape a string for inclusion in a standard Rust string literal. |
| 270 | +/// - Escapes backslashes and double quotes |
| 271 | +/// - Converts CRLF to LF |
| 272 | +/// - Preserves newlines as \\n |
| 273 | +fn escape_for_rust_string(s: &str) -> String { |
| 274 | + let mut out = String::with_capacity(s.len() * 2); |
| 275 | + for ch in s.replace("\r\n", "\n").chars() { |
| 276 | + match ch { |
| 277 | + '\\' => out.push_str("\\\\"), |
| 278 | + '\"' => out.push_str("\\\""), |
| 279 | + '\n' => out.push_str("\\n"), |
| 280 | + _ => out.push(ch), |
| 281 | + } |
| 282 | + } |
| 283 | + out |
| 284 | +} |
| 285 | + |
| 286 | +#[cfg(test)] |
| 287 | +mod tests { |
| 288 | + use super::*; |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn test_trim_trailing_newlines() { |
| 292 | + assert_eq!(trim_trailing_newlines("abc\n"), "abc"); |
| 293 | + assert_eq!(trim_trailing_newlines("abc\n\n"), "abc"); |
| 294 | + assert_eq!(trim_trailing_newlines("abc"), "abc"); |
| 295 | + } |
| 296 | + |
| 297 | + #[test] |
| 298 | + fn test_escape_for_rust_string() { |
| 299 | + let s = "a\\b\"c\nd"; |
| 300 | + let e = escape_for_rust_string(s); |
| 301 | + assert_eq!(e, "a\\\\b\\\"c\\nd"); |
| 302 | + } |
| 303 | + |
| 304 | + #[test] |
| 305 | + fn test_generate_empty_module() { |
| 306 | + let m = generate_rust_module(&[]); |
| 307 | + assert!(m.contains("get_generated_fish_assets")); |
| 308 | + // Should not include arts array if empty |
| 309 | + assert!(!m.contains("let arts: &[&str] = &[")); |
| 310 | + } |
| 311 | +} |
0 commit comments