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
18    insert_recursive_options(&mut defs);
19    insert_stat_interface(&mut defs);
20    insert_peek_interface(&mut defs);
21    insert_file_writer_interface(&mut defs);
22    insert_dir_entry_interface(&mut defs);
23    insert_mount_info_interface(&mut defs);
24    insert_info_interface(&mut defs);
25
26    insert_fn(
27        &mut defs,
28        "cwd",
29        Vec::new(),
30        Type::String,
31        "/** The absolute guest working directory used by relative paths. Shared by the program and packages; no capability required. Defaults to `/`. */",
32    );
33    insert_fn(
34        &mut defs,
35        "maxReadSize",
36        Vec::new(),
37        Type::Number,
38        "/**\n * Maximum number of bytes `read` / `readText` will load whole; past this they throw. 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 */",
39    );
40    insert_fn(
41        &mut defs,
42        "info",
43        Vec::new(),
44        Type::InterfaceRef {
45            mangled: crate::mangle::package_symbol(MODULE_NAME, "Info"),
46            package: crate::Package(MODULE_NAME.to_string()),
47            name: "Info".to_string(),
48            args: Vec::new(),
49        },
50        "/**\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 */",
51    );
52    insert_fn(
53        &mut defs,
54        "exists",
55        vec![path_param.clone()],
56        Type::Boolean,
57        "/**\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 */",
58    );
59    insert_fn(
60        &mut defs,
61        "summary",
62        vec![path_param.clone()],
63        Type::String,
64        "/**\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)` to visit all of them.\n * @param path File or directory path under the VFS root.\n * @capability fs.list { path, recursive }\n */",
65    );
66    insert_fn(
67        &mut defs,
68        "isEmpty",
69        vec![path_param.clone()],
70        Type::Boolean,
71        "/**\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 */",
72    );
73    insert_fn(
74        &mut defs,
75        "size",
76        vec![path_param.clone()],
77        Type::Number,
78        "/**\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 */",
79    );
80    insert_fn(
81        &mut defs,
82        "stat",
83        vec![path_param.clone()],
84        Type::InterfaceRef {
85            mangled: crate::mangle::package_symbol(MODULE_NAME, "Stat"),
86            package: crate::Package(MODULE_NAME.to_string()),
87            name: "Stat".to_string(),
88            args: Vec::new(),
89        },
90        "/**\n * Metadata about an entry under the VFS root. Throws 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 */",
91    );
92    insert_fn(
93        &mut defs,
94        "peek",
95        vec![path_param.clone()],
96        Type::InterfaceRef {
97            mangled: crate::mangle::package_symbol(MODULE_NAME, "Peek"),
98            package: crate::Package(MODULE_NAME.to_string()),
99            name: "Peek".to_string(),
100            args: Vec::new(),
101        },
102        "/**\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 */",
103    );
104    insert_fn(
105        &mut defs,
106        "read",
107        vec![path_param.clone()],
108        Type::Uint8Array,
109        "/**\n * Read the whole file as a `Uint8Array`. Throws when the file is larger than `maxReadSize()`. Traps on missing paths or directories.\n * @param path File path under the VFS root.\n * @capability fs.read { path }\n */",
110    );
111    insert_fn(
112        &mut defs,
113        "readText",
114        vec![path_param.clone()],
115        Type::String,
116        "/**\n * Read the whole file as a UTF-8 string. Throws 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 */",
117    );
118    insert_fn(
119        &mut defs,
120        "write",
121        vec![path_param.clone(), Param::new("content", Type::Uint8Array)],
122        Type::Void,
123        "/**\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 */",
124    );
125    insert_fn(
126        &mut defs,
127        "writeText",
128        vec![path_param.clone(), Param::new("content", Type::String)],
129        Type::Void,
130        "/**\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 */",
131    );
132    insert_fn(
133        &mut defs,
134        "append",
135        vec![path_param.clone(), Param::new("content", Type::Uint8Array)],
136        Type::Void,
137        "/**\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 */",
138    );
139    insert_fn(
140        &mut defs,
141        "appendText",
142        vec![path_param.clone(), Param::new("content", Type::String)],
143        Type::Void,
144        "/**\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 */",
145    );
146    insert_fn(
147        &mut defs,
148        "readBytes",
149        vec![
150            path_param.clone(),
151            Param::new("offset", Type::Number),
152            Param::new("length", Type::Number),
153        ],
154        Type::Uint8Array,
155        "/**\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 */",
156    );
157    insert_fn(
158        &mut defs,
159        "mkdir",
160        vec![path_param.clone(), recursive_options_param()],
161        Type::Void,
162        "/**\n * Create the directory at `path`. `options.recursive=true` creates intermediate directories (like `mkdir -p`); `options.recursive=false` throws when the parent is missing or the target already exists. Pass `{ recursive: true }` to recurse; omitted options default to false.\n * @param path Directory path under the VFS root.\n * @param options Whether to create missing parents.\n * @capability fs.mkdir { path, recursive }\n */",
163    );
164    insert_fn(
165        &mut defs,
166        "remove",
167        vec![path_param.clone(), recursive_options_param()],
168        Type::Void,
169        "/**\n * Remove the entry at `path`. Files unconditionally; directories require `options.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 options Whether to remove directories recursively.\n * @capability fs.remove { path, recursive }\n */",
170    );
171    insert_fn(
172        &mut defs,
173        "move",
174        vec![
175            Param::new("from", Type::String),
176            Param::new("to", Type::String),
177        ],
178        Type::Void,
179        "/**\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 */",
180    );
181    insert_fn(
182        &mut defs,
183        "copy",
184        vec![
185            Param::new("from", Type::String),
186            Param::new("to", Type::String),
187            Param::new("recursive", Type::Boolean),
188        ],
189        Type::Void,
190        "/**\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 */",
191    );
192    insert_fn(
193        &mut defs,
194        "writer",
195        vec![path_param.clone()],
196        Type::InterfaceRef {
197            mangled: crate::mangle::package_symbol(MODULE_NAME, "FileWriter"),
198            package: crate::Package(MODULE_NAME.to_string()),
199            name: "FileWriter".to_string(),
200            args: Vec::new(),
201        },
202        "/**\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 */",
203    );
204    insert_fn(
205        &mut defs,
206        "lines",
207        vec![path_param.clone()],
208        Type::InterfaceRef {
209            mangled: crate::mangle::prelude("Iterator"),
210            package: crate::Package::prelude(),
211            name: "Iterator".to_string(),
212            args: vec![Type::String],
213        },
214        "/**\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 */",
215    );
216    insert_fn(
217        &mut defs,
218        "bytes",
219        vec![path_param.clone(), Param::new("chunkSize", Type::Number)],
220        Type::InterfaceRef {
221            mangled: crate::mangle::prelude("Iterator"),
222            package: crate::Package::prelude(),
223            name: "Iterator".to_string(),
224            args: vec![Type::Uint8Array],
225        },
226        "/**\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 */",
227    );
228    insert_fn(
229        &mut defs,
230        "list",
231        vec![path_param.clone(), recursive_options_param()],
232        Type::InterfaceRef {
233            mangled: crate::mangle::prelude("Iterator"),
234            package: crate::Package::prelude(),
235            name: "Iterator".to_string(),
236            args: vec![Type::InterfaceRef {
237                mangled: crate::mangle::package_symbol(MODULE_NAME, "DirEntry"),
238                package: crate::Package(MODULE_NAME.to_string()),
239                name: "DirEntry".to_string(),
240                args: Vec::new(),
241            }],
242        },
243        "/**\n * List the children of the directory at `path` as a constant-memory iterator of `DirEntry`. `options.recursive=true` walks descendants as well; `options.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 options Whether to walk descendants recursively.\n * @capability fs.list { path, recursive }\n */",
244    );
245
246    defs
247}
248
249fn insert_fn(defs: &mut PackageDeclaration, name: &str, params: Vec<Param>, ret: Type, doc: &str) {
250    defs.values.insert(
251        name.to_string(),
252        ValueSymbol {
253            name: name.to_string(),
254            mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
255            declaration_span: Span::at(crate::FileId::FS),
256            kind: ValueKind::Function {
257                generics: Default::default(),
258                params,
259                ret,
260                type_predicate: None,
261                doc: crate::doc(crate::FileId::FS, doc),
262            },
263        },
264    );
265}
266
267fn recursive_options_param() -> Param {
268    Param::with_default(
269        "options",
270        Type::union(vec![
271            Type::InterfaceRef {
272                mangled: crate::mangle::package_symbol(MODULE_NAME, "RecursiveOptions"),
273                package: crate::Package(MODULE_NAME.to_string()),
274                name: "RecursiveOptions".to_string(),
275                args: Vec::new(),
276            },
277            Type::Undefined,
278        ]),
279        crate::DefaultValue::Undefined,
280    )
281}
282
283fn insert_recursive_options(defs: &mut PackageDeclaration) {
284    let properties = BTreeMap::from([(
285        "recursive".to_string(),
286        PropertySig {
287            ty: Type::Boolean,
288            readonly: false,
289            intrinsic: false,
290            optional: true,
291            doc: crate::doc(
292                crate::FileId::FS,
293                "/** Include descendants or create missing parents. Defaults to false. */",
294            ),
295        },
296    )]);
297    defs.types.insert(
298        "RecursiveOptions".to_string(),
299        TypeSymbol {
300            name: "RecursiveOptions".to_string(),
301            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "RecursiveOptions"),
302            declaration_span: Span::at(crate::FileId::FS),
303            kind: TypeKind::Interface {
304                index: None,
305                generics: Default::default(),
306                methods: BTreeMap::new(),
307                properties,
308                dispatch: Dispatch::VTable,
309                doc: crate::doc(
310                    crate::FileId::FS,
311                    "/** Options for mkdir, remove and list. */",
312                ),
313            },
314        },
315    );
316}
317
318fn insert_stat_interface(defs: &mut PackageDeclaration) {
319    let mut properties = BTreeMap::new();
320    insert_property(
321        &mut properties,
322        "kind",
323        Type::String,
324        "/** One of `\"file\"` / `\"directory\"` / `\"symlink\"` / `\"other\"`. Symlinks are reported as `\"symlink\"` without dereferencing. */",
325    );
326    insert_property(
327        &mut properties,
328        "size",
329        Type::Number,
330        "/** File size in bytes. `0` for non-files (directories, symlinks, other). */",
331    );
332    insert_property(
333        &mut properties,
334        "modifiedAt",
335        Type::Number,
336        "/** Last-modified timestamp in milliseconds since the Unix epoch. `0` when the underlying filesystem doesn't report one. */",
337    );
338    defs.types.insert(
339        "Stat".to_string(),
340        TypeSymbol {
341            name: "Stat".to_string(),
342            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Stat"),
343            declaration_span: Span::at(crate::FileId::FS),
344            kind: TypeKind::Interface { index: None,
345                generics: Default::default(),
346                methods: BTreeMap::new(),
347                properties,
348                dispatch: Dispatch::VTable,
349                doc: crate::doc(crate::FileId::FS,
350                    "/** Filesystem metadata returned by `stat(path)`. Returned by `stat`; user code reads `kind` / `size` / `modifiedAt` via property access. */",
351                ),
352            },
353        },
354    );
355}
356
357fn insert_peek_interface(defs: &mut PackageDeclaration) {
358    let mut properties = BTreeMap::new();
359    insert_property(
360        &mut properties,
361        "preview",
362        Type::String,
363        "/** First ~256 bytes of the file decoded as UTF-8 lossy. Use `lines` / `read` for full content. */",
364    );
365    insert_property(
366        &mut properties,
367        "encoding",
368        Type::String,
369        "/** 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`. */",
370    );
371    insert_property(
372        &mut properties,
373        "lineEnding",
374        Type::String,
375        "/** `\"crlf\"` if a CRLF sequence appears in the preview; `\"lf\"` otherwise (including files with no line terminators yet). */",
376    );
377    insert_property(
378        &mut properties,
379        "size",
380        Type::Number,
381        "/** Total file size in bytes — same value as `size(path)`. */",
382    );
383    defs.types.insert(
384        "Peek".to_string(),
385        TypeSymbol {
386            name: "Peek".to_string(),
387            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Peek"),
388            declaration_span: Span::at(crate::FileId::FS),
389            kind: TypeKind::Interface { index: None,
390                generics: Default::default(),
391                methods: BTreeMap::new(),
392                properties,
393                dispatch: Dispatch::VTable,
394                doc: crate::doc(crate::FileId::FS,
395                    "/** Lightweight file-only inspection — preview + transport metadata. Returned by `peek`; user code reads fields via property access. */",
396                ),
397            },
398        },
399    );
400}
401
402fn insert_dir_entry_interface(defs: &mut PackageDeclaration) {
403    let mut properties = BTreeMap::new();
404    insert_property(
405        &mut properties,
406        "kind",
407        Type::String,
408        "/** `\"file\"` / `\"directory\"` / `\"symlink\"` / `\"other\"`. */",
409    );
410    insert_property(
411        &mut properties,
412        "name",
413        Type::String,
414        "/** Basename of the entry (no parent path). */",
415    );
416    insert_property(
417        &mut properties,
418        "path",
419        Type::String,
420        "/** Full guest-visible path under the VFS root, with a leading `/`. */",
421    );
422    insert_property(
423        &mut properties,
424        "size",
425        Type::Number,
426        "/** File size in bytes; `0` for non-files. */",
427    );
428    defs.types.insert(
429        "DirEntry".to_string(),
430        TypeSymbol {
431            name: "DirEntry".to_string(),
432            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DirEntry"),
433            declaration_span: Span::at(crate::FileId::FS),
434            kind: TypeKind::Interface { index: None,
435                generics: Default::default(),
436                methods: BTreeMap::new(),
437                properties,
438                dispatch: Dispatch::VTable,
439                doc: crate::doc(crate::FileId::FS,
440                    "/** Directory entry yielded by `list(path, options)`. Properties: `kind` / `name` / `path` / `size`. Returned by the iterator. */",
441                ),
442            },
443        },
444    );
445}
446
447fn insert_info_interface(defs: &mut PackageDeclaration) {
448    let mut properties = BTreeMap::new();
449    insert_property(
450        &mut properties,
451        "mode",
452        Type::String,
453        "/** The root's mode: `\"none\"` / `\"ephemeral\"` / `\"per_session\"` / `\"named\"`. */",
454    );
455    insert_property(
456        &mut properties,
457        "sizeLimit",
458        Type::Number,
459        "/** 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. */",
460    );
461    insert_property(
462        &mut properties,
463        "access",
464        Type::String,
465        "/** `\"read_write\"`, or `\"read_only\"` when every write to the root throws a `PermissionDeniedError`. */",
466    );
467    insert_property(
468        &mut properties,
469        "volume",
470        Type::String,
471        "/** The named volume backing the root under `mode: \"named\"`; `\"\"` otherwise, and for a directory `submilli run --vfs` exposes. */",
472    );
473    insert_property(
474        &mut properties,
475        "mounts",
476        Type::Array(Box::new(mount_info_type())),
477        "/** 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. */",
478    );
479    defs.types.insert(
480        "Info".to_string(),
481        TypeSymbol {
482            name: "Info".to_string(),
483            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Info"),
484            declaration_span: Span::at(crate::FileId::FS),
485            kind: TypeKind::Interface { index: None,
486                generics: Default::default(),
487                methods: BTreeMap::new(),
488                properties,
489                dispatch: Dispatch::VTable,
490                doc: crate::doc(crate::FileId::FS,
491                    "/** 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. */",
492                ),
493            },
494        },
495    );
496}
497
498fn mount_info_type() -> Type {
499    Type::InterfaceRef {
500        mangled: crate::mangle::package_symbol(MODULE_NAME, "MountInfo"),
501        package: crate::Package(MODULE_NAME.to_string()),
502        name: "MountInfo".to_string(),
503        args: Vec::new(),
504    }
505}
506
507fn insert_mount_info_interface(defs: &mut PackageDeclaration) {
508    let mut properties = BTreeMap::new();
509    insert_property(
510        &mut properties,
511        "path",
512        Type::String,
513        "/** The guest path the volume is mounted at, such as `/memory`. */",
514    );
515    insert_property(
516        &mut properties,
517        "mode",
518        Type::String,
519        "/** Always `\"named\"`: mounts are named volumes the server declares. */",
520    );
521    insert_property(
522        &mut properties,
523        "volume",
524        Type::String,
525        "/** The volume's name. Its files persist across runs and sessions, and other blueprints that mount it see them. */",
526    );
527    insert_property(
528        &mut properties,
529        "access",
530        Type::String,
531        "/** `\"read_write\"`, or `\"read_only\"` when every write under `path` throws a `PermissionDeniedError`. */",
532    );
533    insert_property(
534        &mut properties,
535        "sizeLimit",
536        Type::Number,
537        "/** The most bytes the volume may hold, shared with everyone who mounts it, or `-1` when no limit applies. */",
538    );
539    defs.types.insert(
540        "MountInfo".to_string(),
541        TypeSymbol {
542            name: "MountInfo".to_string(),
543            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "MountInfo"),
544            declaration_span: Span::at(crate::FileId::FS),
545            kind: TypeKind::Interface { index: None,
546                generics: Default::default(),
547                methods: BTreeMap::new(),
548                properties,
549                dispatch: Dispatch::VTable,
550                doc: crate::doc(crate::FileId::FS,
551                    "/** One volume mounted below the VFS root, as `info().mounts` lists it. Mount points cannot be removed or moved. Never carries a host path. */",
552                ),
553            },
554        },
555    );
556}
557
558fn insert_file_writer_interface(defs: &mut PackageDeclaration) {
559    let mut methods = BTreeMap::new();
560    methods.insert(
561        "close".to_string(),
562        MethodSig {
563            optional: false,
564            generics: Default::default(),
565            params: Vec::new(),
566            ret: Type::Void,
567            predicate: None,
568            doc: crate::doc(crate::FileId::FS,
569                "/** Flush the internal buffer, fsync the temp file, and atomically rename it over the destination. Subsequent method calls on this writer trap — close once. */",
570            ),
571        },
572    );
573    methods.insert(
574        "writeBytes".to_string(),
575        MethodSig {
576            optional: false,
577            generics: Default::default(),
578            params: vec![Param::new("bytes", Type::Uint8Array)],
579            ret: Type::Void,
580            predicate: None,
581            doc: crate::doc(crate::FileId::FS,
582                "/** Append `bytes` to the buffer. Crash before `close()` means nothing reaches `final_path`. */",
583            ),
584        },
585    );
586    methods.insert(
587        "writeLine".to_string(),
588        MethodSig {
589            optional: false,
590            generics: Default::default(),
591            params: vec![Param::new("line", Type::String)],
592            ret: Type::Void,
593            predicate: None,
594            doc: crate::doc(crate::FileId::FS,
595                "/** Append `line` followed by `\\n` (LF). Pair with `appendText` for CRLF-aware output. */",
596            ),
597        },
598    );
599    defs.types.insert(
600        "FileWriter".to_string(),
601        TypeSymbol {
602            name: "FileWriter".to_string(),
603            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "FileWriter"),
604            declaration_span: Span::at(crate::FileId::FS),
605            kind: TypeKind::Interface { index: None,
606                generics: Default::default(),
607                methods,
608                properties: BTreeMap::new(),
609                dispatch: Dispatch::Direct,
610                doc: crate::doc(crate::FileId::FS,
611                    "/** 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`. */",
612                ),
613            },
614        },
615    );
616}
617
618fn insert_property(
619    properties: &mut BTreeMap<String, PropertySig>,
620    name: &str,
621    ty: Type,
622    doc: &str,
623) {
624    properties.insert(
625        name.to_string(),
626        PropertySig {
627            ty,
628            readonly: true,
629            intrinsic: false,
630            optional: false,
631            doc: crate::doc(crate::FileId::FS, doc),
632        },
633    );
634}