Skip to main content

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::seqstring::global_bytes;
27use crate::stack::{Stack, pop, push};
28use crate::value::{Value, VariantData};
29use std::fs::{self, File, OpenOptions};
30use std::io::{BufRead, BufReader, Write};
31use std::path::Path;
32use std::sync::Arc;
33
34/// Path conversion idiom: paths are inherently text on the OS APIs we
35/// target (Linux/macOS POSIX, which expose `&str` via Rust's `Path`),
36/// so non-UTF-8 path bytes can't be handed to the OS as-is.
37/// `SeqString::as_str_or_empty()` returns `""` for non-UTF-8 input,
38/// which routes the call through the OS error path and produces the
39/// standard `(empty, false)` failure tuple — same observable result
40/// as if we'd validated upfront. Helper kept for readability.
41fn path_str(s: &crate::seqstring::SeqString) -> &str {
42    s.as_str_or_empty()
43}
44
45/// Read entire file contents as a string
46///
47/// Stack effect: ( String -- String Bool )
48///
49/// Takes a file path, attempts to read the entire file.
50/// Returns (contents true) on success, or ("" false) on failure.
51/// Errors are values, not crashes.
52/// Panics only for internal bugs (wrong stack type).
53///
54/// # Safety
55/// - `stack` must be a valid, non-null stack pointer with a String value on top
56/// - Caller must ensure stack is not concurrently modified
57#[unsafe(no_mangle)]
58pub unsafe extern "C" fn patch_seq_file_slurp(stack: Stack) -> Stack {
59    assert!(!stack.is_null(), "file-slurp: stack is empty");
60
61    let (rest, value) = unsafe { pop(stack) };
62
63    match value {
64        // Read the file as raw bytes — `fs::read` returns `Vec<u8>` and
65        // imposes no UTF-8 requirement, so binary file slurp now works.
66        // Wrap the bytes directly into a byte-clean SeqString.
67        Value::String(path) => match fs::read(path_str(&path)) {
68            Ok(contents) => {
69                let stack = unsafe { push(rest, Value::String(global_bytes(contents))) };
70                unsafe { push(stack, Value::Bool(true)) }
71            }
72            Err(_) => {
73                let stack = unsafe { push(rest, Value::String("".into())) };
74                unsafe { push(stack, Value::Bool(false)) }
75            }
76        },
77        _ => panic!("file-slurp: expected String path on stack, got {:?}", value),
78    }
79}
80
81/// Check if a file exists
82///
83/// Stack effect: ( String -- Int )
84///
85/// Takes a file path and returns 1 if the file exists, 0 otherwise.
86///
87/// # Safety
88/// - `stack` must be a valid, non-null stack pointer with a String value on top
89/// - Caller must ensure stack is not concurrently modified
90#[unsafe(no_mangle)]
91pub unsafe extern "C" fn patch_seq_file_exists(stack: Stack) -> Stack {
92    assert!(!stack.is_null(), "file-exists?: stack is empty");
93
94    let (rest, value) = unsafe { pop(stack) };
95
96    match value {
97        Value::String(path) => {
98            let exists = Path::new(path_str(&path)).exists();
99            unsafe { push(rest, Value::Bool(exists)) }
100        }
101        _ => panic!(
102            "file-exists?: expected String path on stack, got {:?}",
103            value
104        ),
105    }
106}
107
108/// Process each line of a file with a quotation
109///
110/// Stack effect: ( String Quotation -- String Int )
111///
112/// Opens the file, calls the quotation with each line (including newline),
113/// then closes the file.
114///
115/// Returns:
116/// - Success: ( "" 1 )
117/// - Error: ( "error message" 0 )
118///
119/// The quotation should have effect ( String -- ), receiving each line
120/// and consuming it. Empty files return success without calling the quotation.
121///
122/// # Line Ending Normalization
123///
124/// Line endings are normalized to `\n` regardless of platform. Windows-style
125/// `\r\n` endings are converted to `\n`. This ensures consistent behavior
126/// when processing files across different operating systems.
127///
128/// # Example
129///
130/// ```seq
131/// "data.txt" [ string-chomp process-line ] file-for-each-line+
132/// if
133///     "Done processing" write_line
134/// else
135///     "Error: " swap string-concat write_line
136/// then
137/// ```
138///
139/// # Safety
140/// - `stack` must be a valid, non-null stack pointer
141/// - Top of stack must be a Quotation or Closure
142/// - Second on stack must be a String (file path)
143#[unsafe(no_mangle)]
144pub unsafe extern "C" fn patch_seq_file_for_each_line_plus(stack: Stack) -> Stack {
145    assert!(!stack.is_null(), "file-for-each-line+: stack is empty");
146
147    // Pop quotation
148    let (stack, quot_value) = unsafe { pop(stack) };
149
150    // Pop path
151    let (stack, path_value) = unsafe { pop(stack) };
152    let path = match path_value {
153        Value::String(s) => s,
154        _ => panic!(
155            "file-for-each-line+: expected String path, got {:?}",
156            path_value
157        ),
158    };
159
160    // Open file
161    let file = match File::open(path_str(&path)) {
162        Ok(f) => f,
163        Err(e) => {
164            // Return error: ( "error message" 0 )
165            let stack = unsafe { push(stack, Value::String(e.to_string().into())) };
166            return unsafe { push(stack, Value::Int(0)) };
167        }
168    };
169
170    // Extract function pointer and optionally closure environment
171    let (wrapper, env_data, env_len): (usize, *const Value, usize) = match quot_value {
172        Value::Quotation { wrapper, .. } => {
173            if wrapper == 0 {
174                panic!("file-for-each-line+: quotation wrapper function pointer is null");
175            }
176            (wrapper, std::ptr::null(), 0)
177        }
178        Value::Closure { fn_ptr, ref env } => {
179            if fn_ptr == 0 {
180                panic!("file-for-each-line+: closure function pointer is null");
181            }
182            (fn_ptr, env.as_ptr(), env.len())
183        }
184        _ => panic!(
185            "file-for-each-line+: expected Quotation or Closure, got {:?}",
186            quot_value
187        ),
188    };
189
190    // Read lines and call quotation/closure for each
191    let reader = BufReader::new(file);
192    let mut current_stack = stack;
193
194    for line_result in reader.lines() {
195        match line_result {
196            Ok(mut line_str) => {
197                // `BufReader::lines()` strips all line endings (\n, \r\n, \r)
198                // We add back \n to match read_line behavior and ensure consistent newlines
199                line_str.push('\n');
200
201                // Push line onto stack
202                current_stack = unsafe { push(current_stack, Value::String(line_str.into())) };
203
204                // Call the quotation or closure
205                if env_data.is_null() {
206                    // Quotation: just stack -> stack
207                    let fn_ref: unsafe extern "C" fn(Stack) -> Stack =
208                        unsafe { std::mem::transmute(wrapper) };
209                    current_stack = unsafe { fn_ref(current_stack) };
210                } else {
211                    // Closure: stack, env_ptr, env_len -> stack
212                    let fn_ref: unsafe extern "C" fn(Stack, *const Value, usize) -> Stack =
213                        unsafe { std::mem::transmute(wrapper) };
214                    current_stack = unsafe { fn_ref(current_stack, env_data, env_len) };
215                }
216
217                // Yield to scheduler for cooperative multitasking
218                may::coroutine::yield_now();
219            }
220            Err(e) => {
221                // I/O error mid-file
222                let stack = unsafe { push(current_stack, Value::String(e.to_string().into())) };
223                return unsafe { push(stack, Value::Bool(false)) };
224            }
225        }
226    }
227
228    // Success: ( "" true )
229    let stack = unsafe { push(current_stack, Value::String("".into())) };
230    unsafe { push(stack, Value::Bool(true)) }
231}
232
233/// Write string to file (creates or overwrites)
234///
235/// Stack effect: ( String String -- Bool )
236///
237/// Takes content and path, writes content to file.
238/// Creates the file if it doesn't exist, overwrites if it does.
239/// Returns true on success, false on failure.
240///
241/// # Safety
242/// - `stack` must be a valid, non-null stack pointer
243/// - Top of stack must be path (String), second must be content (String)
244#[unsafe(no_mangle)]
245pub unsafe extern "C" fn patch_seq_file_spit(stack: Stack) -> Stack {
246    assert!(!stack.is_null(), "file.spit: stack is empty");
247
248    // Pop path (top of stack)
249    let (stack, path_value) = unsafe { pop(stack) };
250    let path = match path_value {
251        Value::String(s) => s,
252        _ => panic!("file.spit: expected String path, got {:?}", path_value),
253    };
254
255    // Pop content
256    let (stack, content_value) = unsafe { pop(stack) };
257    let content = match content_value {
258        Value::String(s) => s,
259        _ => panic!(
260            "file.spit: expected String content, got {:?}",
261            content_value
262        ),
263    };
264
265    // Content is byte-clean — `fs::write` accepts any `AsRef<[u8]>`
266    // so we don't need UTF-8 validation here. Binary file write works.
267    match fs::write(path_str(&path), content.as_bytes()) {
268        Ok(()) => unsafe { push(stack, Value::Bool(true)) },
269        Err(_) => unsafe { push(stack, Value::Bool(false)) },
270    }
271}
272
273/// Append string to file (creates if doesn't exist)
274///
275/// Stack effect: ( String String -- Bool )
276///
277/// Takes content and path, appends content to file.
278/// Creates the file if it doesn't exist.
279/// Returns true on success, false on failure.
280///
281/// # Safety
282/// - `stack` must be a valid, non-null stack pointer
283/// - Top of stack must be path (String), second must be content (String)
284#[unsafe(no_mangle)]
285pub unsafe extern "C" fn patch_seq_file_append(stack: Stack) -> Stack {
286    assert!(!stack.is_null(), "file.append: stack is empty");
287
288    // Pop path (top of stack)
289    let (stack, path_value) = unsafe { pop(stack) };
290    let path = match path_value {
291        Value::String(s) => s,
292        _ => panic!("file.append: expected String path, got {:?}", path_value),
293    };
294
295    // Pop content
296    let (stack, content_value) = unsafe { pop(stack) };
297    let content = match content_value {
298        Value::String(s) => s,
299        _ => panic!(
300            "file.append: expected String content, got {:?}",
301            content_value
302        ),
303    };
304
305    let result = OpenOptions::new()
306        .create(true)
307        .append(true)
308        .open(path_str(&path))
309        .and_then(|mut file| file.write_all(content.as_bytes()));
310
311    match result {
312        Ok(()) => unsafe { push(stack, Value::Bool(true)) },
313        Err(_) => unsafe { push(stack, Value::Bool(false)) },
314    }
315}
316
317/// Delete a file
318///
319/// Stack effect: ( String -- Bool )
320///
321/// Takes a file path and deletes the file.
322/// Returns true on success, false on failure (including if file doesn't exist).
323///
324/// # Safety
325/// - `stack` must be a valid, non-null stack pointer
326/// - Top of stack must be path (String)
327#[unsafe(no_mangle)]
328pub unsafe extern "C" fn patch_seq_file_delete(stack: Stack) -> Stack {
329    assert!(!stack.is_null(), "file.delete: stack is empty");
330
331    let (stack, path_value) = unsafe { pop(stack) };
332    let path = match path_value {
333        Value::String(s) => s,
334        _ => panic!("file.delete: expected String path, got {:?}", path_value),
335    };
336
337    match fs::remove_file(path_str(&path)) {
338        Ok(()) => unsafe { push(stack, Value::Bool(true)) },
339        Err(_) => unsafe { push(stack, Value::Bool(false)) },
340    }
341}
342
343/// Get file size in bytes
344///
345/// Stack effect: ( String -- Int Bool )
346///
347/// Takes a file path and returns (size, success).
348/// Returns (size, true) on success, (0, false) on failure.
349///
350/// # Safety
351/// - `stack` must be a valid, non-null stack pointer
352/// - Top of stack must be path (String)
353#[unsafe(no_mangle)]
354pub unsafe extern "C" fn patch_seq_file_size(stack: Stack) -> Stack {
355    assert!(!stack.is_null(), "file.size: stack is empty");
356
357    let (stack, path_value) = unsafe { pop(stack) };
358    let path = match path_value {
359        Value::String(s) => s,
360        _ => panic!("file.size: expected String path, got {:?}", path_value),
361    };
362
363    match fs::metadata(path_str(&path)) {
364        Ok(metadata) => {
365            let size = metadata.len() as i64;
366            let stack = unsafe { push(stack, Value::Int(size)) };
367            unsafe { push(stack, Value::Bool(true)) }
368        }
369        Err(_) => {
370            let stack = unsafe { push(stack, Value::Int(0)) };
371            unsafe { push(stack, Value::Bool(false)) }
372        }
373    }
374}
375
376// =============================================================================
377// Directory Operations
378// =============================================================================
379
380/// Check if a directory exists
381///
382/// Stack effect: ( String -- Bool )
383///
384/// Takes a path and returns true if it exists and is a directory.
385///
386/// # Safety
387/// - `stack` must be a valid, non-null stack pointer
388/// - Top of stack must be path (String)
389#[unsafe(no_mangle)]
390pub unsafe extern "C" fn patch_seq_dir_exists(stack: Stack) -> Stack {
391    assert!(!stack.is_null(), "dir.exists?: stack is empty");
392
393    let (stack, path_value) = unsafe { pop(stack) };
394    let path = match path_value {
395        Value::String(s) => s,
396        _ => panic!("dir.exists?: expected String path, got {:?}", path_value),
397    };
398
399    let exists = Path::new(path_str(&path)).is_dir();
400    unsafe { push(stack, Value::Bool(exists)) }
401}
402
403/// Create a directory (and parent directories if needed)
404///
405/// Stack effect: ( String -- Bool )
406///
407/// Takes a path and creates the directory and any missing parent directories.
408/// Returns true on success, false on failure.
409///
410/// # Safety
411/// - `stack` must be a valid, non-null stack pointer
412/// - Top of stack must be path (String)
413#[unsafe(no_mangle)]
414pub unsafe extern "C" fn patch_seq_dir_make(stack: Stack) -> Stack {
415    assert!(!stack.is_null(), "dir.make: stack is empty");
416
417    let (stack, path_value) = unsafe { pop(stack) };
418    let path = match path_value {
419        Value::String(s) => s,
420        _ => panic!("dir.make: expected String path, got {:?}", path_value),
421    };
422
423    match fs::create_dir_all(path_str(&path)) {
424        Ok(()) => unsafe { push(stack, Value::Bool(true)) },
425        Err(_) => unsafe { push(stack, Value::Bool(false)) },
426    }
427}
428
429/// Delete an empty directory
430///
431/// Stack effect: ( String -- Bool )
432///
433/// Takes a path and deletes the directory (must be empty).
434/// Returns true on success, false on failure.
435///
436/// # Safety
437/// - `stack` must be a valid, non-null stack pointer
438/// - Top of stack must be path (String)
439#[unsafe(no_mangle)]
440pub unsafe extern "C" fn patch_seq_dir_delete(stack: Stack) -> Stack {
441    assert!(!stack.is_null(), "dir.delete: stack is empty");
442
443    let (stack, path_value) = unsafe { pop(stack) };
444    let path = match path_value {
445        Value::String(s) => s,
446        _ => panic!("dir.delete: expected String path, got {:?}", path_value),
447    };
448
449    match fs::remove_dir(path_str(&path)) {
450        Ok(()) => unsafe { push(stack, Value::Bool(true)) },
451        Err(_) => unsafe { push(stack, Value::Bool(false)) },
452    }
453}
454
455/// List directory contents
456///
457/// Stack effect: ( String -- List Bool )
458///
459/// Takes a directory path and returns (list-of-names, success).
460/// Returns a list of filenames (strings) on success.
461///
462/// # Safety
463/// - `stack` must be a valid, non-null stack pointer
464/// - Top of stack must be path (String)
465#[unsafe(no_mangle)]
466pub unsafe extern "C" fn patch_seq_dir_list(stack: Stack) -> Stack {
467    assert!(!stack.is_null(), "dir.list: stack is empty");
468
469    let (stack, path_value) = unsafe { pop(stack) };
470    let path = match path_value {
471        Value::String(s) => s,
472        _ => panic!("dir.list: expected String path, got {:?}", path_value),
473    };
474
475    match fs::read_dir(path_str(&path)) {
476        Ok(entries) => {
477            let mut names: Vec<Value> = Vec::new();
478            for entry in entries.flatten() {
479                if let Some(name) = entry.file_name().to_str() {
480                    names.push(Value::String(name.to_string().into()));
481                }
482            }
483            let list = Value::Variant(Arc::new(VariantData::new(
484                crate::seqstring::global_string("List".to_string()),
485                names,
486            )));
487            let stack = unsafe { push(stack, list) };
488            unsafe { push(stack, Value::Bool(true)) }
489        }
490        Err(_) => {
491            let empty_list = Value::Variant(Arc::new(VariantData::new(
492                crate::seqstring::global_string("List".to_string()),
493                vec![],
494            )));
495            let stack = unsafe { push(stack, empty_list) };
496            unsafe { push(stack, Value::Bool(false)) }
497        }
498    }
499}
500
501// Public re-exports
502pub use patch_seq_dir_delete as dir_delete;
503pub use patch_seq_dir_exists as dir_exists;
504pub use patch_seq_dir_list as dir_list;
505pub use patch_seq_dir_make as dir_make;
506pub use patch_seq_file_append as file_append;
507pub use patch_seq_file_delete as file_delete;
508pub use patch_seq_file_exists as file_exists;
509pub use patch_seq_file_for_each_line_plus as file_for_each_line_plus;
510pub use patch_seq_file_size as file_size;
511pub use patch_seq_file_slurp as file_slurp;
512pub use patch_seq_file_spit as file_spit;
513
514#[cfg(test)]
515mod tests;