seqc/
script.rs

1//! Script mode for running .seq files directly
2//!
3//! Enables `.seq` files to run directly with shebangs:
4//! ```bash
5//! #!/usr/bin/env seqc
6//! : main ( -- Int ) "Hello from script!" io.write-line 0 ;
7//! ```
8//!
9//! Running `seqc script.seq arg1 arg2` or `./script.seq` (with shebang) will:
10//! 1. Detect script mode (first arg is a `.seq` file)
11//! 2. Compile with `-O0` for fast compilation
12//! 3. Cache compiled binary (keyed by source + include hashes)
13//! 4. Run cached binary or compile -> cache -> run
14//! 5. Pass remaining argv to the script
15
16use crate::CompilerConfig;
17use crate::config::OptimizationLevel;
18use crate::parser::Parser;
19use crate::resolver::{Resolver, find_stdlib};
20use crate::stdlib_embed;
21use sha2::{Digest, Sha256};
22use std::ffi::OsString;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26/// Get cache directory: $XDG_CACHE_HOME/seq/ or ~/.cache/seq/
27pub fn get_cache_dir() -> Option<PathBuf> {
28    // Try XDG_CACHE_HOME first
29    if let Ok(xdg_cache) = std::env::var("XDG_CACHE_HOME") {
30        let path = PathBuf::from(xdg_cache);
31        if path.is_absolute() {
32            return Some(path.join("seq"));
33        }
34    }
35
36    // Fall back to ~/.cache/seq/
37    if let Ok(home) = std::env::var("HOME") {
38        return Some(PathBuf::from(home).join(".cache").join("seq"));
39    }
40
41    None
42}
43
44/// Compute cache key from source + all transitive includes
45///
46/// Algorithm:
47/// 1. Hash main source file content
48/// 2. Sort and hash all filesystem includes
49/// 3. Sort and hash all embedded stdlib modules
50/// 4. Combine into final SHA-256 hex string
51pub fn compute_cache_key(
52    source_path: &Path,
53    source_files: &[PathBuf],
54    embedded_modules: &[String],
55) -> Result<String, String> {
56    let mut hasher = Sha256::new();
57
58    // Hash the main source file content
59    let main_content =
60        fs::read(source_path).map_err(|e| format!("Failed to read source file: {}", e))?;
61    hasher.update(&main_content);
62
63    // Sort and hash all filesystem includes
64    let mut sorted_files: Vec<_> = source_files.iter().collect();
65    sorted_files.sort();
66    for file in sorted_files {
67        if file != source_path {
68            // Don't double-hash the main file
69            let content = fs::read(file)
70                .map_err(|e| format!("Failed to read included file '{}': {}", file.display(), e))?;
71            hasher.update(&content);
72        }
73    }
74
75    // Sort and hash all embedded stdlib modules
76    let mut sorted_modules: Vec<_> = embedded_modules.iter().collect();
77    sorted_modules.sort();
78    for module_name in sorted_modules {
79        if let Some(content) = stdlib_embed::get_stdlib(module_name) {
80            hasher.update(content.as_bytes());
81        }
82    }
83
84    let hash = hasher.finalize();
85    Ok(hex::encode(hash))
86}
87
88/// Strip shebang line from source if present
89///
90/// Replaces the first line with a comment if it starts with `#!`
91/// so that line numbers in error messages remain correct.
92fn strip_shebang(source: &str) -> std::borrow::Cow<'_, str> {
93    if source.starts_with("#!") {
94        // Replace shebang with comment of same length to preserve line numbers
95        if let Some(newline_pos) = source.find('\n') {
96            let mut result = String::with_capacity(source.len());
97            result.push('#');
98            result.push_str(&" ".repeat(newline_pos - 1));
99            result.push_str(&source[newline_pos..]);
100            std::borrow::Cow::Owned(result)
101        } else {
102            // Single line file with just shebang
103            std::borrow::Cow::Borrowed("#")
104        }
105    } else {
106        std::borrow::Cow::Borrowed(source)
107    }
108}
109
110/// Prepare a script for execution: parse, resolve includes, and compile if needed.
111/// Returns the path to the cached binary.
112///
113/// # Symlink Behavior
114///
115/// The source path is canonicalized, which resolves symlinks to their target.
116/// This means the same script accessed via different symlinks will share one
117/// cache entry (based on the resolved path's content hash).
118fn prepare_script(source_path: &Path) -> Result<PathBuf, String> {
119    // Canonicalize the source path
120    let source_path = source_path.canonicalize().map_err(|e| {
121        format!(
122            "Failed to find source file '{}': {}",
123            source_path.display(),
124            e
125        )
126    })?;
127
128    // Get cache directory
129    let cache_dir =
130        get_cache_dir().ok_or_else(|| "Could not determine cache directory".to_string())?;
131
132    // Parse the source to find includes (strip shebang if present)
133    let source_raw = fs::read_to_string(&source_path)
134        .map_err(|e| format!("Failed to read source file: {}", e))?;
135    let source = strip_shebang(&source_raw);
136
137    let mut parser = Parser::new(&source);
138    let program = parser.parse()?;
139
140    // Resolve includes to get list of dependencies
141    let (source_files, embedded_modules) = if !program.includes.is_empty() {
142        let stdlib_path = find_stdlib();
143        let mut resolver = Resolver::new(stdlib_path);
144        let result = resolver.resolve(&source_path, program)?;
145        (result.source_files, result.embedded_modules)
146    } else {
147        (vec![source_path.clone()], Vec::new())
148    };
149
150    // Compute cache key (use raw source for consistent hashing)
151    let cache_key = compute_cache_key(&source_path, &source_files, &embedded_modules)?;
152    let cached_binary = cache_dir.join(&cache_key);
153
154    // Check if cached binary exists
155    if cached_binary.exists() {
156        return Ok(cached_binary);
157    }
158
159    // Create cache directory if needed
160    fs::create_dir_all(&cache_dir)
161        .map_err(|e| format!("Failed to create cache directory: {}", e))?;
162
163    // Use process ID in temp file name to avoid collisions between parallel compilations
164    let pid = std::process::id();
165    let temp_binary = cache_dir.join(format!("{}.{}.tmp", cache_key, pid));
166    let temp_source = cache_dir.join(format!("{}.{}.seq", cache_key, pid));
167
168    // Write preprocessed source to a temp file for compilation
169    fs::write(&temp_source, source.as_ref())
170        .map_err(|e| format!("Failed to write temp source: {}", e))?;
171
172    // Compile with -O0 for fast compilation
173    let config = CompilerConfig::new().with_optimization_level(OptimizationLevel::O0);
174
175    let compile_result =
176        crate::compile_file_with_config(&temp_source, &temp_binary, false, &config);
177
178    // Clean up temp source file
179    fs::remove_file(&temp_source).ok();
180
181    // Handle compilation result
182    if let Err(e) = compile_result {
183        // Clean up temp binary on compilation failure
184        fs::remove_file(&temp_binary).ok();
185        return Err(e);
186    }
187
188    // Try to atomically move to final location
189    // If another process already created the cached binary, that's fine - use it
190    if fs::rename(&temp_binary, &cached_binary).is_err() {
191        // Rename failed - check if cached binary now exists (race with another process)
192        if cached_binary.exists() {
193            // Another process won the race, clean up our temp and use theirs
194            fs::remove_file(&temp_binary).ok();
195        } else {
196            // Rename failed for another reason, clean up and report error
197            fs::remove_file(&temp_binary).ok();
198            return Err("Failed to cache compiled binary".to_string());
199        }
200    }
201
202    Ok(cached_binary)
203}
204
205/// Run a .seq script (compile if needed, then exec)
206///
207/// This function does not return on success - it execs the compiled binary.
208/// On error, it returns an Err with the error message.
209#[cfg(unix)]
210pub fn run_script(
211    source_path: &Path,
212    args: &[OsString],
213) -> Result<std::convert::Infallible, String> {
214    use std::os::unix::process::CommandExt;
215
216    let cached_binary = prepare_script(source_path)?;
217
218    // Exec the cached binary with script args
219    let err = std::process::Command::new(&cached_binary).args(args).exec();
220
221    // If we get here, exec failed
222    Err(format!("Failed to execute script: {}", err))
223}
224
225/// Run a .seq script on non-Unix platforms (spawn + wait instead of exec)
226#[cfg(not(unix))]
227pub fn run_script(
228    source_path: &Path,
229    args: &[OsString],
230) -> Result<std::convert::Infallible, String> {
231    let cached_binary = prepare_script(source_path)?;
232
233    // Spawn the cached binary and wait for it
234    let status = std::process::Command::new(&cached_binary)
235        .args(args)
236        .status()
237        .map_err(|e| format!("Failed to execute script: {}", e))?;
238
239    std::process::exit(status.code().unwrap_or(1));
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serial_test::serial;
246
247    #[test]
248    #[serial]
249    fn test_get_cache_dir_with_xdg() {
250        // Save original env vars
251        let orig_xdg = std::env::var("XDG_CACHE_HOME").ok();
252        let orig_home = std::env::var("HOME").ok();
253
254        // SAFETY: These tests must run serially (use cargo test -- --test-threads=1)
255        // to avoid race conditions with other tests modifying environment variables.
256        unsafe {
257            // Test with XDG_CACHE_HOME set
258            std::env::set_var("XDG_CACHE_HOME", "/tmp/test-xdg-cache");
259        }
260        let cache_dir = get_cache_dir();
261        assert!(cache_dir.is_some());
262        assert_eq!(cache_dir.unwrap(), PathBuf::from("/tmp/test-xdg-cache/seq"));
263
264        // Restore original env vars
265        // SAFETY: Restoring environment to original state
266        unsafe {
267            match orig_xdg {
268                Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
269                None => std::env::remove_var("XDG_CACHE_HOME"),
270            }
271            match orig_home {
272                Some(v) => std::env::set_var("HOME", v),
273                None => std::env::remove_var("HOME"),
274            }
275        }
276    }
277
278    #[test]
279    #[serial]
280    fn test_get_cache_dir_fallback_to_home() {
281        // Save original env vars
282        let orig_xdg = std::env::var("XDG_CACHE_HOME").ok();
283        let orig_home = std::env::var("HOME").ok();
284
285        // SAFETY: These tests must run serially (use cargo test -- --test-threads=1)
286        // to avoid race conditions with other tests modifying environment variables.
287        unsafe {
288            // Clear XDG_CACHE_HOME, set HOME
289            std::env::remove_var("XDG_CACHE_HOME");
290            std::env::set_var("HOME", "/tmp/test-home");
291        }
292        let cache_dir = get_cache_dir();
293        assert!(cache_dir.is_some());
294        assert_eq!(
295            cache_dir.unwrap(),
296            PathBuf::from("/tmp/test-home/.cache/seq")
297        );
298
299        // Restore original env vars
300        // SAFETY: Restoring environment to original state
301        unsafe {
302            match orig_xdg {
303                Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
304                None => std::env::remove_var("XDG_CACHE_HOME"),
305            }
306            match orig_home {
307                Some(v) => std::env::set_var("HOME", v),
308                None => std::env::remove_var("HOME"),
309            }
310        }
311    }
312
313    #[test]
314    fn test_compute_cache_key_deterministic() {
315        use tempfile::tempdir;
316
317        let temp = tempdir().unwrap();
318        let source = temp.path().join("test.seq");
319        fs::write(&source, ": main ( -- Int ) 42 ;").unwrap();
320
321        let key1 = compute_cache_key(&source, std::slice::from_ref(&source), &[]).unwrap();
322        let key2 = compute_cache_key(&source, std::slice::from_ref(&source), &[]).unwrap();
323
324        assert_eq!(key1, key2);
325        assert_eq!(key1.len(), 64); // SHA-256 hex is 64 chars
326    }
327
328    #[test]
329    fn test_compute_cache_key_changes_with_content() {
330        use tempfile::tempdir;
331
332        let temp = tempdir().unwrap();
333        let source = temp.path().join("test.seq");
334
335        fs::write(&source, ": main ( -- Int ) 42 ;").unwrap();
336        let key1 = compute_cache_key(&source, std::slice::from_ref(&source), &[]).unwrap();
337
338        fs::write(&source, ": main ( -- Int ) 43 ;").unwrap();
339        let key2 = compute_cache_key(&source, std::slice::from_ref(&source), &[]).unwrap();
340
341        assert_ne!(key1, key2);
342    }
343
344    #[test]
345    fn test_compute_cache_key_includes_embedded_modules() {
346        use tempfile::tempdir;
347
348        let temp = tempdir().unwrap();
349        let source = temp.path().join("test.seq");
350        fs::write(&source, ": main ( -- Int ) 42 ;").unwrap();
351
352        let key1 = compute_cache_key(&source, std::slice::from_ref(&source), &[]).unwrap();
353        let key2 = compute_cache_key(
354            &source,
355            std::slice::from_ref(&source),
356            &["imath".to_string()],
357        )
358        .unwrap();
359
360        assert_ne!(key1, key2);
361    }
362
363    #[test]
364    fn test_strip_shebang_with_shebang() {
365        let source = "#!/usr/bin/env seqc\n: main ( -- Int ) 42 ;";
366        let stripped = strip_shebang(source);
367        // Should start with # (comment) not #!
368        assert!(stripped.starts_with('#'));
369        assert!(!stripped.starts_with("#!"));
370        // Should preserve the second line
371        assert!(stripped.contains(": main ( -- Int ) 42 ;"));
372        // Should preserve line count (same length before newline)
373        assert_eq!(stripped.matches('\n').count(), source.matches('\n').count());
374    }
375
376    #[test]
377    fn test_strip_shebang_without_shebang() {
378        let source = ": main ( -- Int ) 42 ;";
379        let stripped = strip_shebang(source);
380        // Should be unchanged
381        assert_eq!(stripped.as_ref(), source);
382    }
383
384    #[test]
385    fn test_strip_shebang_with_comment() {
386        let source = "# This is a comment\n: main ( -- Int ) 42 ;";
387        let stripped = strip_shebang(source);
388        // Should be unchanged (# is not #!)
389        assert_eq!(stripped.as_ref(), source);
390    }
391
392    #[test]
393    fn test_strip_shebang_only_shebang() {
394        let source = "#!/usr/bin/env seqc";
395        let stripped = strip_shebang(source);
396        // Single line file with just shebang becomes just #
397        assert_eq!(stripped.as_ref(), "#");
398    }
399}