seq_runtime/
file.rs

1//! File I/O Operations for Seq
2//!
3//! Provides file reading operations for Seq programs.
4//!
5//! # Usage from Seq
6//!
7//! ```seq
8//! "config.json" file-slurp  # ( String -- String ) read entire file
9//! "config.json" file-exists?  # ( String -- Int ) 1 if exists, 0 otherwise
10//! "data.txt" [ process-line ] file-for-each-line+  # ( String Quotation -- String Int )
11//! ```
12//!
13//! # Example
14//!
15//! ```seq
16//! : main ( -- Int )
17//!   "config.json" file-exists? if
18//!     "config.json" file-slurp write_line
19//!   else
20//!     "File not found" write_line
21//!   then
22//!   0
23//! ;
24//! ```
25
26use crate::stack::{Stack, pop, push};
27use crate::value::Value;
28use std::fs::{self, File};
29use std::io::{BufRead, BufReader};
30use std::path::Path;
31
32/// Read entire file contents as a string
33///
34/// Stack effect: ( String -- String )
35///
36/// Takes a file path, reads the entire file, and returns its contents.
37/// Panics if the file cannot be read (doesn't exist, no permission, not UTF-8, etc.)
38///
39/// # Safety
40/// - `stack` must be a valid, non-null stack pointer with a String value on top
41/// - Caller must ensure stack is not concurrently modified
42#[unsafe(no_mangle)]
43pub unsafe extern "C" fn patch_seq_file_slurp(stack: Stack) -> Stack {
44    assert!(!stack.is_null(), "file-slurp: stack is empty");
45
46    let (rest, value) = unsafe { pop(stack) };
47
48    match value {
49        Value::String(path) => {
50            let contents = fs::read_to_string(path.as_str()).unwrap_or_else(|e| {
51                panic!("file-slurp: failed to read '{}': {}", path.as_str(), e)
52            });
53
54            unsafe { push(rest, Value::String(contents.into())) }
55        }
56        _ => panic!("file-slurp: expected String path on stack, got {:?}", value),
57    }
58}
59
60/// Check if a file exists
61///
62/// Stack effect: ( String -- Int )
63///
64/// Takes a file path and returns 1 if the file exists, 0 otherwise.
65///
66/// # Safety
67/// - `stack` must be a valid, non-null stack pointer with a String value on top
68/// - Caller must ensure stack is not concurrently modified
69#[unsafe(no_mangle)]
70pub unsafe extern "C" fn patch_seq_file_exists(stack: Stack) -> Stack {
71    assert!(!stack.is_null(), "file-exists?: stack is empty");
72
73    let (rest, value) = unsafe { pop(stack) };
74
75    match value {
76        Value::String(path) => {
77            let exists = if Path::new(path.as_str()).exists() {
78                1i64
79            } else {
80                0i64
81            };
82
83            unsafe { push(rest, Value::Int(exists)) }
84        }
85        _ => panic!(
86            "file-exists?: expected String path on stack, got {:?}",
87            value
88        ),
89    }
90}
91
92/// Read entire file contents as a string, with error handling
93///
94/// Stack effect: ( String -- String Int )
95///
96/// Takes a file path, attempts to read the entire file.
97/// Returns (contents 1) on success, or ("" 0) on failure.
98/// Failure cases: file not found, permission denied, not valid UTF-8, etc.
99///
100/// # Safety
101/// - `stack` must be a valid, non-null stack pointer with a String value on top
102/// - Caller must ensure stack is not concurrently modified
103#[unsafe(no_mangle)]
104pub unsafe extern "C" fn patch_seq_file_slurp_safe(stack: Stack) -> Stack {
105    assert!(!stack.is_null(), "file-slurp-safe: stack is empty");
106
107    let (rest, value) = unsafe { pop(stack) };
108
109    match value {
110        Value::String(path) => match fs::read_to_string(path.as_str()) {
111            Ok(contents) => {
112                let stack = unsafe { push(rest, Value::String(contents.into())) };
113                unsafe { push(stack, Value::Int(1)) }
114            }
115            Err(_) => {
116                let stack = unsafe { push(rest, Value::String("".into())) };
117                unsafe { push(stack, Value::Int(0)) }
118            }
119        },
120        _ => panic!(
121            "file-slurp-safe: expected String path on stack, got {:?}",
122            value
123        ),
124    }
125}
126
127/// Process each line of a file with a quotation
128///
129/// Stack effect: ( String Quotation -- String Int )
130///
131/// Opens the file, calls the quotation with each line (including newline),
132/// then closes the file.
133///
134/// Returns:
135/// - Success: ( "" 1 )
136/// - Error: ( "error message" 0 )
137///
138/// The quotation should have effect ( String -- ), receiving each line
139/// and consuming it. Empty files return success without calling the quotation.
140///
141/// # Line Ending Normalization
142///
143/// Line endings are normalized to `\n` regardless of platform. Windows-style
144/// `\r\n` endings are converted to `\n`. This ensures consistent behavior
145/// when processing files across different operating systems.
146///
147/// # Example
148///
149/// ```seq
150/// "data.txt" [ string-chomp process-line ] file-for-each-line+
151/// if
152///     "Done processing" write_line
153/// else
154///     "Error: " swap string-concat write_line
155/// then
156/// ```
157///
158/// # Safety
159/// - `stack` must be a valid, non-null stack pointer
160/// - Top of stack must be a Quotation or Closure
161/// - Second on stack must be a String (file path)
162#[unsafe(no_mangle)]
163pub unsafe extern "C" fn patch_seq_file_for_each_line_plus(stack: Stack) -> Stack {
164    assert!(!stack.is_null(), "file-for-each-line+: stack is empty");
165
166    // Pop quotation
167    let (stack, quot_value) = unsafe { pop(stack) };
168
169    // Pop path
170    let (stack, path_value) = unsafe { pop(stack) };
171    let path = match path_value {
172        Value::String(s) => s,
173        _ => panic!(
174            "file-for-each-line+: expected String path, got {:?}",
175            path_value
176        ),
177    };
178
179    // Open file
180    let file = match File::open(path.as_str()) {
181        Ok(f) => f,
182        Err(e) => {
183            // Return error: ( "error message" 0 )
184            let stack = unsafe { push(stack, Value::String(e.to_string().into())) };
185            return unsafe { push(stack, Value::Int(0)) };
186        }
187    };
188
189    // Extract function pointer and optionally closure environment
190    let (wrapper, env_data, env_len): (usize, *const Value, usize) = match quot_value {
191        Value::Quotation { wrapper, .. } => {
192            if wrapper == 0 {
193                panic!("file-for-each-line+: quotation wrapper function pointer is null");
194            }
195            (wrapper, std::ptr::null(), 0)
196        }
197        Value::Closure { fn_ptr, ref env } => {
198            if fn_ptr == 0 {
199                panic!("file-for-each-line+: closure function pointer is null");
200            }
201            (fn_ptr, env.as_ptr(), env.len())
202        }
203        _ => panic!(
204            "file-for-each-line+: expected Quotation or Closure, got {:?}",
205            quot_value
206        ),
207    };
208
209    // Read lines and call quotation/closure for each
210    let reader = BufReader::new(file);
211    let mut current_stack = stack;
212
213    for line_result in reader.lines() {
214        match line_result {
215            Ok(mut line_str) => {
216                // `BufReader::lines()` strips all line endings (\n, \r\n, \r)
217                // We add back \n to match read_line behavior and ensure consistent newlines
218                line_str.push('\n');
219
220                // Push line onto stack
221                current_stack = unsafe { push(current_stack, Value::String(line_str.into())) };
222
223                // Call the quotation or closure
224                if env_data.is_null() {
225                    // Quotation: just stack -> stack
226                    let fn_ref: unsafe extern "C" fn(Stack) -> Stack =
227                        unsafe { std::mem::transmute(wrapper) };
228                    current_stack = unsafe { fn_ref(current_stack) };
229                } else {
230                    // Closure: stack, env_ptr, env_len -> stack
231                    let fn_ref: unsafe extern "C" fn(Stack, *const Value, usize) -> Stack =
232                        unsafe { std::mem::transmute(wrapper) };
233                    current_stack = unsafe { fn_ref(current_stack, env_data, env_len) };
234                }
235
236                // Yield to scheduler for cooperative multitasking
237                may::coroutine::yield_now();
238            }
239            Err(e) => {
240                // I/O error mid-file
241                let stack = unsafe { push(current_stack, Value::String(e.to_string().into())) };
242                return unsafe { push(stack, Value::Int(0)) };
243            }
244        }
245    }
246
247    // Success: ( "" 1 )
248    let stack = unsafe { push(current_stack, Value::String("".into())) };
249    unsafe { push(stack, Value::Int(1)) }
250}
251
252// Public re-exports
253pub use patch_seq_file_exists as file_exists;
254pub use patch_seq_file_for_each_line_plus as file_for_each_line_plus;
255pub use patch_seq_file_slurp as file_slurp;
256pub use patch_seq_file_slurp_safe as file_slurp_safe;
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use std::io::Write;
262    use tempfile::NamedTempFile;
263
264    #[test]
265    fn test_file_slurp() {
266        // Create a temporary file with known contents
267        let mut temp_file = NamedTempFile::new().unwrap();
268        writeln!(temp_file, "Hello, file!").unwrap();
269        let path = temp_file.path().to_str().unwrap().to_string();
270
271        unsafe {
272            let stack = std::ptr::null_mut();
273            let stack = push(stack, Value::String(path.into()));
274            let stack = patch_seq_file_slurp(stack);
275
276            let (stack, value) = pop(stack);
277            match value {
278                Value::String(s) => assert_eq!(s.as_str().trim(), "Hello, file!"),
279                _ => panic!("Expected String"),
280            }
281            assert!(stack.is_null());
282        }
283    }
284
285    #[test]
286    fn test_file_exists_true() {
287        let temp_file = NamedTempFile::new().unwrap();
288        let path = temp_file.path().to_str().unwrap().to_string();
289
290        unsafe {
291            let stack = std::ptr::null_mut();
292            let stack = push(stack, Value::String(path.into()));
293            let stack = patch_seq_file_exists(stack);
294
295            let (stack, value) = pop(stack);
296            assert_eq!(value, Value::Int(1));
297            assert!(stack.is_null());
298        }
299    }
300
301    #[test]
302    fn test_file_exists_false() {
303        unsafe {
304            let stack = std::ptr::null_mut();
305            let stack = push(stack, Value::String("/nonexistent/path/to/file.txt".into()));
306            let stack = patch_seq_file_exists(stack);
307
308            let (stack, value) = pop(stack);
309            assert_eq!(value, Value::Int(0));
310            assert!(stack.is_null());
311        }
312    }
313
314    #[test]
315    fn test_file_slurp_utf8() {
316        let mut temp_file = NamedTempFile::new().unwrap();
317        write!(temp_file, "Hello, δΈ–η•Œ! 🌍").unwrap();
318        let path = temp_file.path().to_str().unwrap().to_string();
319
320        unsafe {
321            let stack = std::ptr::null_mut();
322            let stack = push(stack, Value::String(path.into()));
323            let stack = patch_seq_file_slurp(stack);
324
325            let (stack, value) = pop(stack);
326            match value {
327                Value::String(s) => assert_eq!(s.as_str(), "Hello, δΈ–η•Œ! 🌍"),
328                _ => panic!("Expected String"),
329            }
330            assert!(stack.is_null());
331        }
332    }
333
334    #[test]
335    fn test_file_slurp_empty() {
336        let temp_file = NamedTempFile::new().unwrap();
337        let path = temp_file.path().to_str().unwrap().to_string();
338
339        unsafe {
340            let stack = std::ptr::null_mut();
341            let stack = push(stack, Value::String(path.into()));
342            let stack = patch_seq_file_slurp(stack);
343
344            let (stack, value) = pop(stack);
345            match value {
346                Value::String(s) => assert_eq!(s.as_str(), ""),
347                _ => panic!("Expected String"),
348            }
349            assert!(stack.is_null());
350        }
351    }
352
353    #[test]
354    fn test_file_slurp_safe_success() {
355        let mut temp_file = NamedTempFile::new().unwrap();
356        writeln!(temp_file, "Safe read!").unwrap();
357        let path = temp_file.path().to_str().unwrap().to_string();
358
359        unsafe {
360            let stack = std::ptr::null_mut();
361            let stack = push(stack, Value::String(path.into()));
362            let stack = patch_seq_file_slurp_safe(stack);
363
364            let (stack, success) = pop(stack);
365            let (stack, contents) = pop(stack);
366            assert_eq!(success, Value::Int(1));
367            match contents {
368                Value::String(s) => assert_eq!(s.as_str().trim(), "Safe read!"),
369                _ => panic!("Expected String"),
370            }
371            assert!(stack.is_null());
372        }
373    }
374
375    #[test]
376    fn test_file_slurp_safe_not_found() {
377        unsafe {
378            let stack = std::ptr::null_mut();
379            let stack = push(stack, Value::String("/nonexistent/path/to/file.txt".into()));
380            let stack = patch_seq_file_slurp_safe(stack);
381
382            let (stack, success) = pop(stack);
383            let (stack, contents) = pop(stack);
384            assert_eq!(success, Value::Int(0));
385            match contents {
386                Value::String(s) => assert_eq!(s.as_str(), ""),
387                _ => panic!("Expected String"),
388            }
389            assert!(stack.is_null());
390        }
391    }
392
393    #[test]
394    fn test_file_slurp_safe_empty_file() {
395        let temp_file = NamedTempFile::new().unwrap();
396        let path = temp_file.path().to_str().unwrap().to_string();
397
398        unsafe {
399            let stack = std::ptr::null_mut();
400            let stack = push(stack, Value::String(path.into()));
401            let stack = patch_seq_file_slurp_safe(stack);
402
403            let (stack, success) = pop(stack);
404            let (stack, contents) = pop(stack);
405            assert_eq!(success, Value::Int(1)); // Empty file is still success
406            match contents {
407                Value::String(s) => assert_eq!(s.as_str(), ""),
408                _ => panic!("Expected String"),
409            }
410            assert!(stack.is_null());
411        }
412    }
413}