Skip to main content

submilli_engine/stdlib/fs/
declaration.rs

1//! The `submilli:fs` package declaration — the LLM-facing type surface
2//! (functions, `Stat` / `Peek` / `DirEntry` / `Info` / `MountInfo` / `FileWriter`).
3
4use std::collections::BTreeMap;
5
6use crate::{
7    Dispatch, MethodSig, PackageDeclaration, Param, PropertySig, Span, Type, TypeKind, TypeSymbol,
8    ValueKind, ValueSymbol,
9};
10
11use super::MODULE_NAME;
12
13pub fn package_declaration() -> PackageDeclaration {
14    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
15
16    let path_param = Param::new("path", Type::String);
17    let bytes_or_undefined = Type::union(vec![Type::Uint8Array, Type::Undefined]);
18    let string_or_undefined = Type::union(vec![Type::String, Type::Undefined]);
19
20    insert_stat_interface(&mut defs);
21    insert_peek_interface(&mut defs);
22    insert_file_writer_interface(&mut defs);
23    insert_dir_entry_interface(&mut defs);
24    insert_mount_info_interface(&mut defs);
25    insert_info_interface(&mut defs);
26
27    insert_fn(
28        &mut defs,
29        "cwd",
30        Vec::new(),
31        Type::String,
32        "/** The absolute guest working directory used by relative paths. Shared by the program and packages; no capability required. Defaults to `/`. */",
33    );
34    insert_fn(
35        &mut defs,
36        "maxReadSize",
37        Vec::new(),
38        Type::Number,
39        "/**\n * Maximum number of bytes `read` / `readText` will load whole; past this they return `undefined`. Also the upper bound on `readBytes(path, offset, length)`'s `length`. Configured by the harness per Store; defaults to the 50 MB tier memory cap.\n */",
40    );
41    insert_fn(
42        &mut defs,
43        "info",
44        Vec::new(),
45        Type::InterfaceRef {
46            mangled: crate::mangle::package_symbol(MODULE_NAME, "Info"),
47            package: crate::Package(MODULE_NAME.to_string()),
48            name: "Info".to_string(),
49            args: Vec::new(),
50        },
51        "/**\n * The active VFS configuration: the root's `mode` (`\"none\"` / `\"ephemeral\"` / `\"per_session\"` / `\"named\"`), `access`, `volume` and `sizeLimit` (the cap on the bytes its files may hold, or `-1` when none applies), plus `mounts`, the named volumes grafted below the root. Deterministic; no capability required. The host directories backing the VFS are never exposed.\n */",
52    );
53    insert_fn(
54        &mut defs,
55        "exists",
56        vec![path_param.clone()],
57        Type::Boolean,
58        "/**\n * Returns `true` iff `path` resolves to a filesystem entry under the VFS root. Symlinks count as existing; broken symlinks count as not existing (mirrors `std::path::exists`).\n * @param path File path under the VFS root.\n * @capability fs.stat { path }\n */",
59    );
60    insert_fn(
61        &mut defs,
62        "summary",
63        vec![path_param.clone()],
64        Type::String,
65        "/**\n * Describe `path` in a few lines of text, for a program that needs to see what is there before deciding what to read. A directory lists its direct children — directories first, then by name — each with its kind, size and modification time, under a count line; an empty directory says `empty directory`. A file reports its size and modification time, and a missing path says `does not exist` rather than throwing. Shows at most 100 entries and counts the rest; iterate `list(path, false)` to visit all of them.\n * @param path File or directory path under the VFS root.\n * @capability fs.list { path, recursive }\n */",
66    );
67    insert_fn(
68        &mut defs,
69        "isEmpty",
70        vec![path_param.clone()],
71        Type::Boolean,
72        "/**\n * Returns `true` when the directory at `path` has no entries, or the file at `path` has zero bytes. Throws when `path` does not exist — call `exists(path)` first if it may not.\n * @param path File or directory path under the VFS root.\n * @capability fs.list { path, recursive }\n */",
73    );
74    insert_fn(
75        &mut defs,
76        "size",
77        vec![path_param.clone()],
78        Type::Number,
79        "/**\n * File size in bytes. Traps on directories or missing paths — call `exists(path)` first or use `stat(path)` if you need to handle absence.\n * @param path File path under the VFS root.\n * @capability fs.stat { path }\n */",
80    );
81    insert_fn(
82        &mut defs,
83        "stat",
84        vec![path_param.clone()],
85        Type::Union(vec![
86            Type::InterfaceRef {
87                mangled: crate::mangle::package_symbol(MODULE_NAME, "Stat"),
88                package: crate::Package(MODULE_NAME.to_string()),
89                name: "Stat".to_string(),
90                args: Vec::new(),
91            },
92            Type::Undefined,
93        ]),
94        "/**\n * Metadata about an entry under the VFS root. Returns `undefined` when the path does not exist; otherwise returns a `Stat` carrying `kind` / `size` / `modifiedAt`.\n * @param path File path under the VFS root.\n * @capability fs.stat { path }\n */",
95    );
96    insert_fn(
97        &mut defs,
98        "peek",
99        vec![path_param.clone()],
100        Type::InterfaceRef {
101            mangled: crate::mangle::package_symbol(MODULE_NAME, "Peek"),
102            package: crate::Package(MODULE_NAME.to_string()),
103            name: "Peek".to_string(),
104            args: Vec::new(),
105        },
106        "/**\n * Quick file-only inspection: preview (first ~256 bytes decoded UTF-8 lossy), detected `encoding` (`utf-8` / `utf-16le` / `utf-16be` / `latin1`), `lineEnding` (`lf` / `crlf`), and total `size`. Traps on directories / missing paths.\n * @param path File path under the VFS root.\n * @capability fs.stat { path }\n */",
107    );
108    insert_fn(
109        &mut defs,
110        "read",
111        vec![path_param.clone()],
112        bytes_or_undefined.clone(),
113        "/**\n * Read the whole file as a `Uint8Array`. Returns `undefined` when the file is larger than `maxReadSize()` — the typechecker forces narrowing before use. Traps on missing paths or directories.\n * @param path File path under the VFS root.\n * @capability fs.read { path }\n */",
114    );
115    insert_fn(
116        &mut defs,
117        "readText",
118        vec![path_param.clone()],
119        string_or_undefined.clone(),
120        "/**\n * Read the whole file as a UTF-8 string. Returns `undefined` when the file is larger than `maxReadSize()`. Strips a leading UTF-8 BOM by default; invalid bytes are replaced by U+FFFD.\n * @param path File path under the VFS root.\n * @capability fs.read { path }\n */",
121    );
122    insert_fn(
123        &mut defs,
124        "write",
125        vec![path_param.clone(), Param::new("content", Type::Uint8Array)],
126        Type::Void,
127        "/**\n * Write `content` to `path` atomically (temp-file + rename). Throws when the parent directory does not exist — agents must `mkdir(parent, { recursive: true })` first.\n * @param path File path under the VFS root.\n * @param content Bytes to write.\n * @capability fs.write { path, length: number }\n */",
128    );
129    insert_fn(
130        &mut defs,
131        "writeText",
132        vec![path_param.clone(), Param::new("content", Type::String)],
133        Type::Void,
134        "/**\n * UTF-8 atomic write. Same parent-directory rule as `write`.\n * @param path File path under the VFS root.\n * @param content Text to write as UTF-8.\n * @capability fs.write { path, length: number }\n */",
135    );
136    insert_fn(
137        &mut defs,
138        "append",
139        vec![path_param.clone(), Param::new("content", Type::Uint8Array)],
140        Type::Void,
141        "/**\n * Append `content` to `path`. Creates the file if missing; throws when the parent directory does not exist. NOT atomic — concurrent writers race.\n * @param path File path under the VFS root.\n * @param content Bytes to append.\n * @capability fs.write { path, length: number }\n */",
142    );
143    insert_fn(
144        &mut defs,
145        "appendText",
146        vec![path_param.clone(), Param::new("content", Type::String)],
147        Type::Void,
148        "/**\n * UTF-8 append. Same parent-directory rule as `append`.\n * @param path File path under the VFS root.\n * @param content Text to append as UTF-8.\n * @capability fs.write { path, length: number }\n */",
149    );
150    insert_fn(
151        &mut defs,
152        "readBytes",
153        vec![
154            path_param.clone(),
155            Param::new("offset", Type::Number),
156            Param::new("length", Type::Number),
157        ],
158        Type::Uint8Array,
159        "/**\n * Random-access byte-range read. Traps if `length > maxReadSize()`, if `offset` is negative, or on I/O errors. The returned `Uint8Array` may be shorter than `length` if the request runs past EOF.\n * @param path File path under the VFS root.\n * @param offset Zero-based byte offset.\n * @param length Maximum number of bytes to read.\n * @capability fs.read { path, length }\n */",
160    );
161    insert_fn(
162        &mut defs,
163        "mkdir",
164        vec![path_param.clone(), Param::new("recursive", Type::Boolean)],
165        Type::Void,
166        "/**\n * Create the directory at `path`. `recursive=true` creates intermediate directories (like `mkdir -p`); `recursive=false` throws when the parent is missing or the target already exists. Until options objects land, the flag is positional.\n * @param path Directory path under the VFS root.\n * @param recursive Whether to create missing parents.\n * @capability fs.mkdir { path, recursive }\n */",
167    );
168    insert_fn(
169        &mut defs,
170        "remove",
171        vec![path_param.clone(), Param::new("recursive", Type::Boolean)],
172        Type::Void,
173        "/**\n * Remove the entry at `path`. Files unconditionally; directories require `recursive=true` (matches `rm -rf`). Without the flag, a non-empty directory throws.\n * @param path File or directory path under the VFS root.\n * @param recursive Whether to remove directories recursively.\n * @capability fs.remove { path, recursive }\n */",
174    );
175    insert_fn(
176        &mut defs,
177        "move",
178        vec![
179            Param::new("from", Type::String),
180            Param::new("to", Type::String),
181        ],
182        Type::Void,
183        "/**\n * Move / rename. Atomic within one volume. Between volumes (the root and a mount, or two mounts — see `info().mounts`) it copies, then removes the source, so it is not atomic: if removing the source fails, the destination is kept and the call throws with both copies present. Replacing a file in another volume needs room there for both versions until the move completes, and a link the copy cannot reproduce fails the move before the source is touched.\n * @param from Source path under the VFS root.\n * @param to Destination path under the VFS root.\n * @capability fs.move { from, to }\n */",
184    );
185    insert_fn(
186        &mut defs,
187        "copy",
188        vec![
189            Param::new("from", Type::String),
190            Param::new("to", Type::String),
191            Param::new("recursive", Type::Boolean),
192        ],
193        Type::Void,
194        "/**\n * Copy `from` to `to`. Directories require `recursive=true`. Symlinks are copied as links (the link target is preserved; the file it points at is not followed) so a writable VFS area can't be used to exfiltrate the target.\n * @param from Source path under the VFS root.\n * @param to Destination path under the VFS root.\n * @param recursive Whether to copy directories recursively.\n * @capability fs.copy { from, to, recursive }\n */",
195    );
196    insert_fn(
197        &mut defs,
198        "writer",
199        vec![path_param.clone()],
200        Type::InterfaceRef {
201            mangled: crate::mangle::package_symbol(MODULE_NAME, "FileWriter"),
202            package: crate::Package(MODULE_NAME.to_string()),
203            name: "FileWriter".to_string(),
204            args: Vec::new(),
205        },
206        "/**\n * Open `path` as an append-only `FileWriter`. The writer buffers internally and only commits on `close()` (atomic temp-file + rename) — half-written outputs from a crashed transform don't leak. Throws when the parent directory does not exist.\n * @param path Destination path under the VFS root.\n * @capability fs.write { path }\n */",
207    );
208    insert_fn(
209        &mut defs,
210        "lines",
211        vec![path_param.clone()],
212        Type::InterfaceRef {
213            mangled: crate::mangle::prelude("Iterator"),
214            package: crate::Package::prelude(),
215            name: "Iterator".to_string(),
216            args: vec![Type::String],
217        },
218        "/**\n * Stream the file at `path` as a constant-memory line iterator. Each `next()` returns one line stripped of its trailing CR/LF. A leading UTF-8 BOM on the first line is dropped. The file descriptor is released when the iterator is closed (a `for...of` loop closes it on exit, including `break`/`throw`) or, failing that, when it is garbage-collected.\n * @param path File path under the VFS root.\n * @capability fs.read { path }\n */",
219    );
220    insert_fn(
221        &mut defs,
222        "bytes",
223        vec![path_param.clone(), Param::new("chunkSize", Type::Number)],
224        Type::InterfaceRef {
225            mangled: crate::mangle::prelude("Iterator"),
226            package: crate::Package::prelude(),
227            name: "Iterator".to_string(),
228            args: vec![Type::Uint8Array],
229        },
230        "/**\n * Stream the file at `path` as a constant-memory byte iterator. Each `next()` returns up to `chunkSize` bytes; the last chunk may be shorter. Sized for large binary inputs that don't fit `maxReadSize`.\n * @param path File path under the VFS root.\n * @param chunkSize Maximum bytes per iterator chunk.\n * @capability fs.read { path, chunkSize }\n */",
231    );
232    insert_fn(
233        &mut defs,
234        "list",
235        vec![path_param.clone(), Param::new("recursive", Type::Boolean)],
236        Type::InterfaceRef {
237            mangled: crate::mangle::prelude("Iterator"),
238            package: crate::Package::prelude(),
239            name: "Iterator".to_string(),
240            args: vec![Type::InterfaceRef {
241                mangled: crate::mangle::package_symbol(MODULE_NAME, "DirEntry"),
242                package: crate::Package(MODULE_NAME.to_string()),
243                name: "DirEntry".to_string(),
244                args: Vec::new(),
245            }],
246        },
247        "/**\n * List the children of the directory at `path` as a constant-memory iterator of `DirEntry`. `recursive=true` walks descendants as well; `recursive=false` yields only direct children.\n *\n * Order: a directory is always yielded before its contents, and a directory's whole subtree is yielded as one consecutive run — no entry from outside a subtree is interleaved into it. Down to 32 levels of nesting that is exactly depth-first (matching `find` / `os.walk`); deeper than that the walk runs out of directory handles and a subdirectory's contents follow the rest of its parent's entries instead of coming directly after it, so don't depend on strict depth-first order past that depth. Siblings come in whatever order the filesystem reports — not sorted.\n *\n * Symlinks surface as `kind=\"symlink\"` and are not followed. A recursive walk is best-effort: a subdirectory it cannot read — or, past a fixed ceiling on how many postponed descents one walk holds, cannot afford to remember — is skipped and the walk continues, so a listing is not proof that a path is absent.\n * @param path Directory path under the VFS root.\n * @param recursive Whether to walk descendants recursively.\n * @capability fs.list { path, recursive }\n */",
248    );
249
250    defs
251}
252
253fn insert_fn(defs: &mut PackageDeclaration, name: &str, params: Vec<Param>, ret: Type, doc: &str) {
254    defs.values.insert(
255        name.to_string(),
256        ValueSymbol {
257            name: name.to_string(),
258            mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
259            declaration_span: Span::at(crate::FileId::FS),
260            kind: ValueKind::Function {
261                generics: Vec::new(),
262                params,
263                ret,
264                type_predicate: None,
265                doc: crate::doc(crate::FileId::FS, doc),
266            },
267        },
268    );
269}
270
271fn insert_stat_interface(defs: &mut PackageDeclaration) {
272    let mut properties = BTreeMap::new();
273    insert_property(
274        &mut properties,
275        "kind",
276        Type::String,
277        "/** One of `\"file\"` / `\"directory\"` / `\"symlink\"` / `\"other\"`. Symlinks are reported as `\"symlink\"` without dereferencing. */",
278    );
279    insert_property(
280        &mut properties,
281        "size",
282        Type::Number,
283        "/** File size in bytes. `0` for non-files (directories, symlinks, other). */",
284    );
285    insert_property(
286        &mut properties,
287        "modifiedAt",
288        Type::Number,
289        "/** Last-modified timestamp in milliseconds since the Unix epoch. `0` when the underlying filesystem doesn't report one. */",
290    );
291    defs.types.insert(
292        "Stat".to_string(),
293        TypeSymbol {
294            name: "Stat".to_string(),
295            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Stat"),
296            declaration_span: Span::at(crate::FileId::FS),
297            kind: TypeKind::Interface { index: None,
298                generics: Vec::new(),
299                methods: BTreeMap::new(),
300                properties,
301                dispatch: Dispatch::Direct,
302                doc: crate::doc(crate::FileId::FS,
303                    "/** Filesystem metadata returned by `stat(path)`. Constructed only by `stat`; user code reads `kind` / `size` / `modifiedAt` via property access. */",
304                ),
305            },
306        },
307    );
308}
309
310fn insert_peek_interface(defs: &mut PackageDeclaration) {
311    let mut properties = BTreeMap::new();
312    insert_property(
313        &mut properties,
314        "preview",
315        Type::String,
316        "/** First ~256 bytes of the file decoded as UTF-8 lossy. Use `lines` / `read` for full content. */",
317    );
318    insert_property(
319        &mut properties,
320        "encoding",
321        Type::String,
322        "/** Detected text encoding: `\"utf-8\"` / `\"utf-16le\"` / `\"utf-16be\"` / `\"latin1\"`. BOM-driven when a BOM is present; falls back to a UTF-8 validity check then `latin1`. */",
323    );
324    insert_property(
325        &mut properties,
326        "lineEnding",
327        Type::String,
328        "/** `\"crlf\"` if a CRLF sequence appears in the preview; `\"lf\"` otherwise (including files with no line terminators yet). */",
329    );
330    insert_property(
331        &mut properties,
332        "size",
333        Type::Number,
334        "/** Total file size in bytes — same value as `size(path)`. */",
335    );
336    defs.types.insert(
337        "Peek".to_string(),
338        TypeSymbol {
339            name: "Peek".to_string(),
340            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Peek"),
341            declaration_span: Span::at(crate::FileId::FS),
342            kind: TypeKind::Interface { index: None,
343                generics: Vec::new(),
344                methods: BTreeMap::new(),
345                properties,
346                dispatch: Dispatch::Direct,
347                doc: crate::doc(crate::FileId::FS,
348                    "/** Lightweight file-only inspection — preview + transport metadata. Constructed only by `peek`; user code reads fields via property access. */",
349                ),
350            },
351        },
352    );
353}
354
355fn insert_dir_entry_interface(defs: &mut PackageDeclaration) {
356    let mut properties = BTreeMap::new();
357    insert_property(
358        &mut properties,
359        "kind",
360        Type::String,
361        "/** `\"file\"` / `\"directory\"` / `\"symlink\"` / `\"other\"`. */",
362    );
363    insert_property(
364        &mut properties,
365        "name",
366        Type::String,
367        "/** Basename of the entry (no parent path). */",
368    );
369    insert_property(
370        &mut properties,
371        "path",
372        Type::String,
373        "/** Full guest-visible path under the VFS root, with a leading `/`. */",
374    );
375    insert_property(
376        &mut properties,
377        "size",
378        Type::Number,
379        "/** File size in bytes; `0` for non-files. */",
380    );
381    defs.types.insert(
382        "DirEntry".to_string(),
383        TypeSymbol {
384            name: "DirEntry".to_string(),
385            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DirEntry"),
386            declaration_span: Span::at(crate::FileId::FS),
387            kind: TypeKind::Interface { index: None,
388                generics: Vec::new(),
389                methods: BTreeMap::new(),
390                properties,
391                dispatch: Dispatch::Direct,
392                doc: crate::doc(crate::FileId::FS,
393                    "/** Directory entry yielded by `list(path, recursive)`. Properties: `kind` / `name` / `path` / `size`. Constructed only by the iterator. */",
394                ),
395            },
396        },
397    );
398}
399
400fn insert_info_interface(defs: &mut PackageDeclaration) {
401    let mut properties = BTreeMap::new();
402    insert_property(
403        &mut properties,
404        "mode",
405        Type::String,
406        "/** The root's mode: `\"none\"` / `\"ephemeral\"` / `\"per_session\"` / `\"named\"`. */",
407    );
408    insert_property(
409        &mut properties,
410        "sizeLimit",
411        Type::Number,
412        "/** The most bytes the files in the root may hold, or `-1` when no limit applies. A write that would pass it throws a `QuotaExceededError`. A named volume's limit is shared with every session and blueprint that uses it. */",
413    );
414    insert_property(
415        &mut properties,
416        "access",
417        Type::String,
418        "/** `\"read_write\"`, or `\"read_only\"` when every write to the root throws a `PermissionDeniedError`. */",
419    );
420    insert_property(
421        &mut properties,
422        "volume",
423        Type::String,
424        "/** The named volume backing the root under `mode: \"named\"`; `\"\"` otherwise, and for a directory `submilli run --vfs` exposes. */",
425    );
426    insert_property(
427        &mut properties,
428        "mounts",
429        Type::Array(Box::new(mount_info_type())),
430        "/** The named volumes mounted below the root, sorted by path; empty when there are none. Paths under a mount's `path` read and write that volume. */",
431    );
432    defs.types.insert(
433        "Info".to_string(),
434        TypeSymbol {
435            name: "Info".to_string(),
436            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Info"),
437            declaration_span: Span::at(crate::FileId::FS),
438            kind: TypeKind::Interface { index: None,
439                generics: Vec::new(),
440                methods: BTreeMap::new(),
441                properties,
442                dispatch: Dispatch::Direct,
443                doc: crate::doc(crate::FileId::FS,
444                    "/** VFS configuration returned by `info()`: the root's `mode`, `access`, `volume` and `sizeLimit` (`-1` when no limit applies), plus its `mounts`. Never carries a host path. */",
445                ),
446            },
447        },
448    );
449}
450
451fn mount_info_type() -> Type {
452    Type::InterfaceRef {
453        mangled: crate::mangle::package_symbol(MODULE_NAME, "MountInfo"),
454        package: crate::Package(MODULE_NAME.to_string()),
455        name: "MountInfo".to_string(),
456        args: Vec::new(),
457    }
458}
459
460fn insert_mount_info_interface(defs: &mut PackageDeclaration) {
461    let mut properties = BTreeMap::new();
462    insert_property(
463        &mut properties,
464        "path",
465        Type::String,
466        "/** The guest path the volume is mounted at, such as `/memory`. */",
467    );
468    insert_property(
469        &mut properties,
470        "mode",
471        Type::String,
472        "/** Always `\"named\"`: mounts are named volumes the server declares. */",
473    );
474    insert_property(
475        &mut properties,
476        "volume",
477        Type::String,
478        "/** The volume's name. Its files persist across runs and sessions, and other blueprints that mount it see them. */",
479    );
480    insert_property(
481        &mut properties,
482        "access",
483        Type::String,
484        "/** `\"read_write\"`, or `\"read_only\"` when every write under `path` throws a `PermissionDeniedError`. */",
485    );
486    insert_property(
487        &mut properties,
488        "sizeLimit",
489        Type::Number,
490        "/** The most bytes the volume may hold, shared with everyone who mounts it, or `-1` when no limit applies. */",
491    );
492    defs.types.insert(
493        "MountInfo".to_string(),
494        TypeSymbol {
495            name: "MountInfo".to_string(),
496            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "MountInfo"),
497            declaration_span: Span::at(crate::FileId::FS),
498            kind: TypeKind::Interface { index: None,
499                generics: Vec::new(),
500                methods: BTreeMap::new(),
501                properties,
502                dispatch: Dispatch::Direct,
503                doc: crate::doc(crate::FileId::FS,
504                    "/** One volume mounted below the VFS root, as `info().mounts` lists it. Mount points cannot be removed or moved. Never carries a host path. */",
505                ),
506            },
507        },
508    );
509}
510
511fn insert_file_writer_interface(defs: &mut PackageDeclaration) {
512    let mut methods = BTreeMap::new();
513    methods.insert(
514        "close".to_string(),
515        MethodSig {
516            optional: false,
517            generics: Vec::new(),
518            params: Vec::new(),
519            ret: Type::Void,
520            predicate: None,
521            doc: crate::doc(crate::FileId::FS,
522                "/** Flush the internal buffer, fsync the temp file, and atomically rename it over the destination. Subsequent method calls on this writer trap — close once. */",
523            ),
524        },
525    );
526    methods.insert(
527        "writeBytes".to_string(),
528        MethodSig {
529            optional: false,
530            generics: Vec::new(),
531            params: vec![Param::new("bytes", Type::Uint8Array)],
532            ret: Type::Void,
533            predicate: None,
534            doc: crate::doc(crate::FileId::FS,
535                "/** Append `bytes` to the buffer. Crash before `close()` means nothing reaches `final_path`. */",
536            ),
537        },
538    );
539    methods.insert(
540        "writeLine".to_string(),
541        MethodSig {
542            optional: false,
543            generics: Vec::new(),
544            params: vec![Param::new("line", Type::String)],
545            ret: Type::Void,
546            predicate: None,
547            doc: crate::doc(crate::FileId::FS,
548                "/** Append `line` followed by `\\n` (LF). Pair with `appendText` for CRLF-aware output. */",
549            ),
550        },
551    );
552    defs.types.insert(
553        "FileWriter".to_string(),
554        TypeSymbol {
555            name: "FileWriter".to_string(),
556            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "FileWriter"),
557            declaration_span: Span::at(crate::FileId::FS),
558            kind: TypeKind::Interface { index: None,
559                generics: Vec::new(),
560                methods,
561                properties: BTreeMap::new(),
562                dispatch: Dispatch::Direct,
563                doc: crate::doc(crate::FileId::FS,
564                    "/** Append-only writer returned by `writer(path)`. Internal buffer + atomic temp-file + rename on `close()`. Construct only via `writer`; user code uses `writeLine` / `writeBytes` / `close`. */",
565                ),
566            },
567        },
568    );
569}
570
571fn insert_property(
572    properties: &mut BTreeMap<String, PropertySig>,
573    name: &str,
574    ty: Type,
575    doc: &str,
576) {
577    properties.insert(
578        name.to_string(),
579        PropertySig {
580            ty,
581            readonly: true,
582            intrinsic: false,
583            optional: false,
584            doc: crate::doc(crate::FileId::FS, doc),
585        },
586    );
587}