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 = crate::stack::alloc_test_stack();
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        }
282    }
283
284    #[test]
285    fn test_file_exists_true() {
286        let temp_file = NamedTempFile::new().unwrap();
287        let path = temp_file.path().to_str().unwrap().to_string();
288
289        unsafe {
290            let stack = crate::stack::alloc_test_stack();
291            let stack = push(stack, Value::String(path.into()));
292            let stack = patch_seq_file_exists(stack);
293
294            let (_stack, value) = pop(stack);
295            assert_eq!(value, Value::Int(1));
296        }
297    }
298
299    #[test]
300    fn test_file_exists_false() {
301        unsafe {
302            let stack = crate::stack::alloc_test_stack();
303            let stack = push(stack, Value::String("/nonexistent/path/to/file.txt".into()));
304            let stack = patch_seq_file_exists(stack);
305
306            let (_stack, value) = pop(stack);
307            assert_eq!(value, Value::Int(0));
308        }
309    }
310
311    #[test]
312    fn test_file_slurp_utf8() {
313        let mut temp_file = NamedTempFile::new().unwrap();
314        write!(temp_file, "Hello, δΈ–η•Œ! 🌍").unwrap();
315        let path = temp_file.path().to_str().unwrap().to_string();
316
317        unsafe {
318            let stack = crate::stack::alloc_test_stack();
319            let stack = push(stack, Value::String(path.into()));
320            let stack = patch_seq_file_slurp(stack);
321
322            let (_stack, value) = pop(stack);
323            match value {
324                Value::String(s) => assert_eq!(s.as_str(), "Hello, δΈ–η•Œ! 🌍"),
325                _ => panic!("Expected String"),
326            }
327        }
328    }
329
330    #[test]
331    fn test_file_slurp_empty() {
332        let temp_file = NamedTempFile::new().unwrap();
333        let path = temp_file.path().to_str().unwrap().to_string();
334
335        unsafe {
336            let stack = crate::stack::alloc_test_stack();
337            let stack = push(stack, Value::String(path.into()));
338            let stack = patch_seq_file_slurp(stack);
339
340            let (_stack, value) = pop(stack);
341            match value {
342                Value::String(s) => assert_eq!(s.as_str(), ""),
343                _ => panic!("Expected String"),
344            }
345        }
346    }
347
348    #[test]
349    fn test_file_slurp_safe_success() {
350        let mut temp_file = NamedTempFile::new().unwrap();
351        writeln!(temp_file, "Safe read!").unwrap();
352        let path = temp_file.path().to_str().unwrap().to_string();
353
354        unsafe {
355            let stack = crate::stack::alloc_test_stack();
356            let stack = push(stack, Value::String(path.into()));
357            let stack = patch_seq_file_slurp_safe(stack);
358
359            let (stack, success) = pop(stack);
360            let (_stack, contents) = pop(stack);
361            assert_eq!(success, Value::Int(1));
362            match contents {
363                Value::String(s) => assert_eq!(s.as_str().trim(), "Safe read!"),
364                _ => panic!("Expected String"),
365            }
366        }
367    }
368
369    #[test]
370    fn test_file_slurp_safe_not_found() {
371        unsafe {
372            let stack = crate::stack::alloc_test_stack();
373            let stack = push(stack, Value::String("/nonexistent/path/to/file.txt".into()));
374            let stack = patch_seq_file_slurp_safe(stack);
375
376            let (stack, success) = pop(stack);
377            let (_stack, contents) = pop(stack);
378            assert_eq!(success, Value::Int(0));
379            match contents {
380                Value::String(s) => assert_eq!(s.as_str(), ""),
381                _ => panic!("Expected String"),
382            }
383        }
384    }
385
386    #[test]
387    fn test_file_slurp_safe_empty_file() {
388        let temp_file = NamedTempFile::new().unwrap();
389        let path = temp_file.path().to_str().unwrap().to_string();
390
391        unsafe {
392            let stack = crate::stack::alloc_test_stack();
393            let stack = push(stack, Value::String(path.into()));
394            let stack = patch_seq_file_slurp_safe(stack);
395
396            let (stack, success) = pop(stack);
397            let (_stack, contents) = pop(stack);
398            assert_eq!(success, Value::Int(1)); // Empty file is still success
399            match contents {
400                Value::String(s) => assert_eq!(s.as_str(), ""),
401                _ => panic!("Expected String"),
402            }
403        }
404    }
405}