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 Bool )
35///
36/// Takes a file path, attempts to read the entire file.
37/// Returns (contents true) on success, or ("" false) on failure.
38/// Errors are values, not crashes.
39/// Panics only for internal bugs (wrong stack type).
40///
41/// # Safety
42/// - `stack` must be a valid, non-null stack pointer with a String value on top
43/// - Caller must ensure stack is not concurrently modified
44#[unsafe(no_mangle)]
45pub unsafe extern "C" fn patch_seq_file_slurp(stack: Stack) -> Stack {
46    assert!(!stack.is_null(), "file-slurp: stack is empty");
47
48    let (rest, value) = unsafe { pop(stack) };
49
50    match value {
51        Value::String(path) => match fs::read_to_string(path.as_str()) {
52            Ok(contents) => {
53                let stack = unsafe { push(rest, Value::String(contents.into())) };
54                unsafe { push(stack, Value::Bool(true)) }
55            }
56            Err(_) => {
57                let stack = unsafe { push(rest, Value::String("".into())) };
58                unsafe { push(stack, Value::Bool(false)) }
59            }
60        },
61        _ => panic!("file-slurp: expected String path on stack, got {:?}", value),
62    }
63}
64
65/// Check if a file exists
66///
67/// Stack effect: ( String -- Int )
68///
69/// Takes a file path and returns 1 if the file exists, 0 otherwise.
70///
71/// # Safety
72/// - `stack` must be a valid, non-null stack pointer with a String value on top
73/// - Caller must ensure stack is not concurrently modified
74#[unsafe(no_mangle)]
75pub unsafe extern "C" fn patch_seq_file_exists(stack: Stack) -> Stack {
76    assert!(!stack.is_null(), "file-exists?: stack is empty");
77
78    let (rest, value) = unsafe { pop(stack) };
79
80    match value {
81        Value::String(path) => {
82            let exists = Path::new(path.as_str()).exists();
83            unsafe { push(rest, Value::Bool(exists)) }
84        }
85        _ => panic!(
86            "file-exists?: expected String path on stack, got {:?}",
87            value
88        ),
89    }
90}
91
92/// Process each line of a file with a quotation
93///
94/// Stack effect: ( String Quotation -- String Int )
95///
96/// Opens the file, calls the quotation with each line (including newline),
97/// then closes the file.
98///
99/// Returns:
100/// - Success: ( "" 1 )
101/// - Error: ( "error message" 0 )
102///
103/// The quotation should have effect ( String -- ), receiving each line
104/// and consuming it. Empty files return success without calling the quotation.
105///
106/// # Line Ending Normalization
107///
108/// Line endings are normalized to `\n` regardless of platform. Windows-style
109/// `\r\n` endings are converted to `\n`. This ensures consistent behavior
110/// when processing files across different operating systems.
111///
112/// # Example
113///
114/// ```seq
115/// "data.txt" [ string-chomp process-line ] file-for-each-line+
116/// if
117///     "Done processing" write_line
118/// else
119///     "Error: " swap string-concat write_line
120/// then
121/// ```
122///
123/// # Safety
124/// - `stack` must be a valid, non-null stack pointer
125/// - Top of stack must be a Quotation or Closure
126/// - Second on stack must be a String (file path)
127#[unsafe(no_mangle)]
128pub unsafe extern "C" fn patch_seq_file_for_each_line_plus(stack: Stack) -> Stack {
129    assert!(!stack.is_null(), "file-for-each-line+: stack is empty");
130
131    // Pop quotation
132    let (stack, quot_value) = unsafe { pop(stack) };
133
134    // Pop path
135    let (stack, path_value) = unsafe { pop(stack) };
136    let path = match path_value {
137        Value::String(s) => s,
138        _ => panic!(
139            "file-for-each-line+: expected String path, got {:?}",
140            path_value
141        ),
142    };
143
144    // Open file
145    let file = match File::open(path.as_str()) {
146        Ok(f) => f,
147        Err(e) => {
148            // Return error: ( "error message" 0 )
149            let stack = unsafe { push(stack, Value::String(e.to_string().into())) };
150            return unsafe { push(stack, Value::Int(0)) };
151        }
152    };
153
154    // Extract function pointer and optionally closure environment
155    let (wrapper, env_data, env_len): (usize, *const Value, usize) = match quot_value {
156        Value::Quotation { wrapper, .. } => {
157            if wrapper == 0 {
158                panic!("file-for-each-line+: quotation wrapper function pointer is null");
159            }
160            (wrapper, std::ptr::null(), 0)
161        }
162        Value::Closure { fn_ptr, ref env } => {
163            if fn_ptr == 0 {
164                panic!("file-for-each-line+: closure function pointer is null");
165            }
166            (fn_ptr, env.as_ptr(), env.len())
167        }
168        _ => panic!(
169            "file-for-each-line+: expected Quotation or Closure, got {:?}",
170            quot_value
171        ),
172    };
173
174    // Read lines and call quotation/closure for each
175    let reader = BufReader::new(file);
176    let mut current_stack = stack;
177
178    for line_result in reader.lines() {
179        match line_result {
180            Ok(mut line_str) => {
181                // `BufReader::lines()` strips all line endings (\n, \r\n, \r)
182                // We add back \n to match read_line behavior and ensure consistent newlines
183                line_str.push('\n');
184
185                // Push line onto stack
186                current_stack = unsafe { push(current_stack, Value::String(line_str.into())) };
187
188                // Call the quotation or closure
189                if env_data.is_null() {
190                    // Quotation: just stack -> stack
191                    let fn_ref: unsafe extern "C" fn(Stack) -> Stack =
192                        unsafe { std::mem::transmute(wrapper) };
193                    current_stack = unsafe { fn_ref(current_stack) };
194                } else {
195                    // Closure: stack, env_ptr, env_len -> stack
196                    let fn_ref: unsafe extern "C" fn(Stack, *const Value, usize) -> Stack =
197                        unsafe { std::mem::transmute(wrapper) };
198                    current_stack = unsafe { fn_ref(current_stack, env_data, env_len) };
199                }
200
201                // Yield to scheduler for cooperative multitasking
202                may::coroutine::yield_now();
203            }
204            Err(e) => {
205                // I/O error mid-file
206                let stack = unsafe { push(current_stack, Value::String(e.to_string().into())) };
207                return unsafe { push(stack, Value::Bool(false)) };
208            }
209        }
210    }
211
212    // Success: ( "" true )
213    let stack = unsafe { push(current_stack, Value::String("".into())) };
214    unsafe { push(stack, Value::Bool(true)) }
215}
216
217// Public re-exports
218pub use patch_seq_file_exists as file_exists;
219pub use patch_seq_file_for_each_line_plus as file_for_each_line_plus;
220pub use patch_seq_file_slurp as file_slurp;
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::io::Write;
226    use tempfile::NamedTempFile;
227
228    #[test]
229    fn test_file_slurp() {
230        // Create a temporary file with known contents
231        let mut temp_file = NamedTempFile::new().unwrap();
232        writeln!(temp_file, "Hello, file!").unwrap();
233        let path = temp_file.path().to_str().unwrap().to_string();
234
235        unsafe {
236            let stack = crate::stack::alloc_test_stack();
237            let stack = push(stack, Value::String(path.into()));
238            let stack = patch_seq_file_slurp(stack);
239
240            // file-slurp now returns (contents Bool)
241            let (stack, success) = pop(stack);
242            assert_eq!(success, Value::Bool(true));
243            let (_stack, value) = pop(stack);
244            match value {
245                Value::String(s) => assert_eq!(s.as_str().trim(), "Hello, file!"),
246                _ => panic!("Expected String"),
247            }
248        }
249    }
250
251    #[test]
252    fn test_file_exists_true() {
253        let temp_file = NamedTempFile::new().unwrap();
254        let path = temp_file.path().to_str().unwrap().to_string();
255
256        unsafe {
257            let stack = crate::stack::alloc_test_stack();
258            let stack = push(stack, Value::String(path.into()));
259            let stack = patch_seq_file_exists(stack);
260
261            let (_stack, value) = pop(stack);
262            assert_eq!(value, Value::Bool(true));
263        }
264    }
265
266    #[test]
267    fn test_file_exists_false() {
268        unsafe {
269            let stack = crate::stack::alloc_test_stack();
270            let stack = push(stack, Value::String("/nonexistent/path/to/file.txt".into()));
271            let stack = patch_seq_file_exists(stack);
272
273            let (_stack, value) = pop(stack);
274            assert_eq!(value, Value::Bool(false));
275        }
276    }
277
278    #[test]
279    fn test_file_slurp_utf8() {
280        let mut temp_file = NamedTempFile::new().unwrap();
281        write!(temp_file, "Hello, δΈ–η•Œ! 🌍").unwrap();
282        let path = temp_file.path().to_str().unwrap().to_string();
283
284        unsafe {
285            let stack = crate::stack::alloc_test_stack();
286            let stack = push(stack, Value::String(path.into()));
287            let stack = patch_seq_file_slurp(stack);
288
289            // file-slurp returns (contents Bool)
290            let (stack, success) = pop(stack);
291            assert_eq!(success, Value::Bool(true));
292            let (_stack, value) = pop(stack);
293            match value {
294                Value::String(s) => assert_eq!(s.as_str(), "Hello, δΈ–η•Œ! 🌍"),
295                _ => panic!("Expected String"),
296            }
297        }
298    }
299
300    #[test]
301    fn test_file_slurp_empty() {
302        let temp_file = NamedTempFile::new().unwrap();
303        let path = temp_file.path().to_str().unwrap().to_string();
304
305        unsafe {
306            let stack = crate::stack::alloc_test_stack();
307            let stack = push(stack, Value::String(path.into()));
308            let stack = patch_seq_file_slurp(stack);
309
310            // file-slurp returns (contents Bool)
311            let (stack, success) = pop(stack);
312            assert_eq!(success, Value::Bool(true)); // Empty file is still success
313            let (_stack, value) = pop(stack);
314            match value {
315                Value::String(s) => assert_eq!(s.as_str(), ""),
316                _ => panic!("Expected String"),
317            }
318        }
319    }
320
321    #[test]
322    fn test_file_slurp_not_found() {
323        unsafe {
324            let stack = crate::stack::alloc_test_stack();
325            let stack = push(stack, Value::String("/nonexistent/path/to/file.txt".into()));
326            let stack = patch_seq_file_slurp(stack);
327
328            let (stack, success) = pop(stack);
329            let (_stack, contents) = pop(stack);
330            assert_eq!(success, Value::Bool(false));
331            match contents {
332                Value::String(s) => assert_eq!(s.as_str(), ""),
333                _ => panic!("Expected String"),
334            }
335        }
336    }
337}