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