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