From 398cd57b1dcc5c8370b41750b7682869df566014 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 9 Nov 2025 17:39:42 -0800 Subject: [PATCH] fs functions --- src/prelude/dict.ts | 2 +- src/prelude/fs.ts | 128 ++++++++ src/prelude/index.ts | 2 + src/prelude/load.ts | 4 +- src/prelude/tests/fs.test.ts | 329 ++++++++++++++++++++ src/prelude/tests/{load.ts => load.test.ts} | 18 +- 6 files changed, 472 insertions(+), 11 deletions(-) create mode 100644 src/prelude/fs.ts create mode 100644 src/prelude/tests/fs.test.ts rename src/prelude/tests/{load.ts => load.test.ts} (62%) diff --git a/src/prelude/dict.ts b/src/prelude/dict.ts index bf406e1..15b71f8 100644 --- a/src/prelude/dict.ts +++ b/src/prelude/dict.ts @@ -1,4 +1,4 @@ -import { type Value, toString, toValue } from 'reefvm' +import { type Value, toString } from 'reefvm' export const dict = { keys: (dict: Record) => Object.keys(dict), diff --git a/src/prelude/fs.ts b/src/prelude/fs.ts new file mode 100644 index 0000000..bb97a60 --- /dev/null +++ b/src/prelude/fs.ts @@ -0,0 +1,128 @@ +import { join, resolve, basename, dirname, extname } from 'path' +import { + readdirSync, mkdirSync, rmdirSync, + readFileSync, writeFileSync, appendFileSync, + rmSync, copyFileSync, + statSync, lstatSync, chmodSync, symlinkSync, readlinkSync, + watch +} from "fs" + +export const fs = { + // Directory operations + ls: (path: string) => readdirSync(path), + mkdir: (path: string) => mkdirSync(path, { recursive: true }), + rmdir: (path: string) => rmdirSync(path === '/' || path === '' ? '/tmp/*' : path, { recursive: true }), + pwd: () => process.cwd(), + cd: (path: string) => process.chdir(path), + + // Reading + read: (path: string) => readFileSync(path, 'utf-8'), + cat: (path: string) => { }, // added below + 'read-bytes': (path: string) => [...readFileSync(path)], + + // Writing + write: (path: string, content: string) => writeFileSync(path, content), + append: (path: string, content: string) => appendFileSync(path, content), + + // File operations + delete: (path: string) => rmSync(path), + rm: (path: string) => { }, // added below + copy: (from: string, to: string) => copyFileSync(from, to), + move: (from: string, to: string) => { + fs.copy(from, to) + fs.rm(from) + }, + mv: (from: string, to: string) => { }, // added below + + // Path operations + basename: (path: string) => basename(path), + dirname: (path: string) => dirname(path), + extname: (path: string) => extname(path), + join: (...paths: string[]) => join(...paths), + resolve: (...paths: string[]) => resolve(...paths), + + // File info + stat: (path: string) => { + try { + const stats = statSync(path) + const record = Object.fromEntries(Object.entries(stats)) + record['atime'] = record['atimeMs'] + record['ctime'] = record['ctimeMs'] + record['mtime'] = record['mtimeMs'] + + delete record['atimeMs'] + delete record['ctimeMs'] + delete record['mtimeMs'] + + return record + } catch { + return {} + } + + }, + 'exists?': (path: string) => { + try { + statSync(path) + return true + } + catch { + return false + } + }, + 'file?': (path: string) => { + try { return statSync(path).isFile() } + catch { return false } + }, + 'dir?': (path: string) => { + try { return statSync(path).isDirectory() } + catch { return false } + }, + 'symlink?': (path: string) => { + try { return lstatSync(path).isSymbolicLink() } + catch { return false } + }, + 'exec?': (path: string) => { + try { + const stats = statSync(path) + return !!(stats.mode & 0o111) + } + catch { return false } + }, + size: (path: string) => { + try { return statSync(path).size } + catch { return 0 } + }, + + // Permissions + chmod: (path: string, mode: number | string) => { + const numMode = typeof mode === 'string' ? parseInt(mode, 8) : mode + chmodSync(path, numMode) + }, + + // Symlinks + symlink: (target: string, path: string) => symlinkSync(target, path), + readlink: (path: string) => readlinkSync(path, 'utf-8'), + + // Other + glob: (pattern: string) => { + const dir = pattern.substring(0, pattern.lastIndexOf('/')) + const match = pattern.substring(pattern.lastIndexOf('/') + 1) + + if (!match.includes('*')) throw new Error('only * patterns supported') + + const ext = match.split('*').pop()! + return readdirSync(dir) + .filter((f) => f.endsWith(ext)) + .map((f) => join(dir, f)) + + }, + + watch: (path: string, callback: Function) => + watch(path, (event, filename) => callback(event, filename)), +} + + + ; (fs as any).cat = fs.read + ; (fs as any).mv = fs.move + ; (fs as any).cp = fs.copy + ; (fs as any).rm = fs.delete \ No newline at end of file diff --git a/src/prelude/index.ts b/src/prelude/index.ts index c153870..f92a2f5 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -6,6 +6,7 @@ import { } from 'reefvm' import { dict } from './dict' +import { fs } from './fs' import { json } from './json' import { load } from './load' import { list } from './list' @@ -14,6 +15,7 @@ import { str } from './str' export const globals = { dict, + fs, json, load, list, diff --git a/src/prelude/load.ts b/src/prelude/load.ts index 3f317c1..cd188a0 100644 --- a/src/prelude/load.ts +++ b/src/prelude/load.ts @@ -7,7 +7,9 @@ export const load = async function (this: VM, path: string): Promise { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) +}) + +afterEach(() => { + process.chdir(CWD) + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }) + } +}) + +describe('fs - directory operations', () => { + test('fs.ls lists directory contents', () => { + writeFileSync(join(TEST_DIR, 'file1.txt'), 'content1') + writeFileSync(join(TEST_DIR, 'file2.txt'), 'content2') + + const result = fs.ls(TEST_DIR) + expect(result).toContain('file1.txt') + expect(result).toContain('file2.txt') + }) + + test('fs.mkdir creates directory', () => { + const newDir = join(TEST_DIR, 'newdir') + fs.mkdir(newDir) + expect(existsSync(newDir)).toBe(true) + }) + + test('fs.rmdir removes empty directory', () => { + const dir = join(TEST_DIR, 'toremove') + mkdirSync(dir) + fs.rmdir(dir) + expect(existsSync(dir)).toBe(false) + }) + + test('fs.pwd returns current working directory', () => { + const result = fs.pwd() + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + test('fs.cd changes current working directory', () => { + const originalCwd = process.cwd() + fs.cd(TEST_DIR) + expect(process.cwd()).toBe(TEST_DIR) + process.chdir(originalCwd) // restore + }) +}) + +describe('fs - reading', () => { + test('fs.read reads file contents as string', () => { + const file = join(TEST_DIR, 'test.txt') + writeFileSync(file, 'hello world') + + const result = fs.read(file) + expect(result).toBe('hello world') + }) + + test('fs.cat is alias for fs.read', () => { + const file = join(TEST_DIR, 'test.txt') + writeFileSync(file, 'hello world') + + const result = fs.cat(file) + expect(result).toBe('hello world') + }) + + test('fs.read-bytes reads file as buffer', () => { + const file = join(TEST_DIR, 'test.bin') + writeFileSync(file, Buffer.from([1, 2, 3, 4])) + + const result = fs['read-bytes'](file) + expect(result).toBeInstanceOf(Array) + expect(result).toEqual([1, 2, 3, 4]) + }) +}) + +describe('fs - writing', () => { + test('fs.write writes string to file', async () => { + const file = join(TEST_DIR, 'output.txt') + fs.write(file, 'test content') + + const content = Bun.file(file).text() + expect(await content).toBe('test content') + }) + + test('fs.append appends to existing file', async () => { + const file = join(TEST_DIR, 'append.txt') + writeFileSync(file, 'first') + fs.append(file, ' second') + + const content = await Bun.file(file).text() + expect(content).toBe('first second') + }) +}) + +describe('fs - file operations', () => { + test('fs.rm removes file', () => { + const file = join(TEST_DIR, 'remove.txt') + writeFileSync(file, 'content') + + fs.rm(file) + expect(existsSync(file)).toBe(false) + }) + + test('fs.delete is alias for fs.rm', () => { + const file = join(TEST_DIR, 'delete.txt') + writeFileSync(file, 'content') + + fs.delete(file) + expect(existsSync(file)).toBe(false) + }) + + test('fs.copy copies file', async () => { + const src = join(TEST_DIR, 'source.txt') + const dest = join(TEST_DIR, 'dest.txt') + writeFileSync(src, 'content') + + fs.copy(src, dest) + expect(await Bun.file(dest).text()).toBe('content') + }) + + test('fs.cp is alias for fs.copy', async () => { + const src = join(TEST_DIR, 'source2.txt') + const dest = join(TEST_DIR, 'dest2.txt') + writeFileSync(src, 'content') + + fs.cp(src, dest) + expect(await Bun.file(dest).text()).toBe('content') + }) + + test('fs.move moves file', async () => { + const src = join(TEST_DIR, 'source.txt') + const dest = join(TEST_DIR, 'moved.txt') + writeFileSync(src, 'content') + + fs.move(src, dest) + expect(existsSync(src)).toBe(false) + expect(await Bun.file(dest).text()).toBe('content') + }) + + test('fs.mv is alias for fs.move', async () => { + const src = join(TEST_DIR, 'source2.txt') + const dest = join(TEST_DIR, 'moved2.txt') + writeFileSync(src, 'content') + + fs.mv(src, dest) + expect(existsSync(src)).toBe(false) + expect(await Bun.file(dest).text()).toBe('content') + }) +}) + +describe('fs - path operations', () => { + test('fs.basename extracts filename from path', () => { + expect(fs.basename('/path/to/file.txt')).toBe('file.txt') + expect(fs.basename('/path/to/dir/')).toBe('dir') + }) + + test('fs.dirname extracts directory from path', () => { + expect(fs.dirname('/path/to/file.txt')).toBe('/path/to') + expect(fs.dirname('/path/to/dir/')).toBe('/path/to') + }) + + test('fs.extname extracts file extension', () => { + expect(fs.extname('file.txt')).toBe('.txt') + expect(fs.extname('file.tar.gz')).toBe('.gz') + expect(fs.extname('noext')).toBe('') + }) + + test('fs.join joins path segments', () => { + expect(fs.join('path', 'to', 'file.txt')).toBe('path/to/file.txt') + expect(fs.join('/absolute', 'path')).toBe('/absolute/path') + }) + + test('fs.resolve resolves to absolute path', () => { + const result = fs.resolve('relative', 'path') + expect(result.startsWith('/')).toBe(true) + expect(result).toContain('relative') + }) +}) + +describe('fs - file info', () => { + test('fs.stat returns file stats', () => { + const file = join(TEST_DIR, 'stat.txt') + writeFileSync(file, 'content') + + const stats = fs.stat(file) + expect(stats).toHaveProperty('size') + expect(stats).toHaveProperty('mtime') + expect(stats.size).toBe(7) // 'content' is 7 bytes + }) + + test('fs.exists? checks if path exists', () => { + const file = join(TEST_DIR, 'exists.txt') + expect(fs['exists?'](file)).toBe(false) + + writeFileSync(file, 'content') + expect(fs['exists?'](file)).toBe(true) + }) + + test('fs.file? checks if path is a file', () => { + const file = join(TEST_DIR, 'isfile.txt') + writeFileSync(file, 'content') + + expect(fs['file?'](file)).toBe(true) + expect(fs['file?'](TEST_DIR)).toBe(false) + }) + + test('fs.dir? checks if path is a directory', () => { + const dir = join(TEST_DIR, 'isdir') + mkdirSync(dir) + + expect(fs['dir?'](dir)).toBe(true) + expect(fs['dir?'](join(TEST_DIR, 'isfile.txt'))).toBe(false) + }) + + test('fs.symlink? checks if path is a symbolic link', () => { + const file = join(TEST_DIR, 'target.txt') + const link = join(TEST_DIR, 'link.txt') + writeFileSync(file, 'content') + + fs.symlink(file, link) + expect(fs['symlink?'](link)).toBe(true) + expect(fs['symlink?'](file)).toBe(false) + }) + + test('fs.exec? checks if file is executable', () => { + const file = join(TEST_DIR, 'script.sh') + writeFileSync(file, '#!/bin/bash\necho hello') + + fs.chmod(file, 0o755) + expect(fs['exec?'](file)).toBe(true) + + fs.chmod(file, 0o644) + expect(fs['exec?'](file)).toBe(false) + }) + + test('fs.size returns file size in bytes', () => { + const file = join(TEST_DIR, 'sizeme.txt') + writeFileSync(file, 'content') + + expect(fs.size(file)).toBe(7) // 'content' is 7 bytes + }) +}) + +describe('fs - permissions', () => { + test('fs.chmod changes file permissions with octal number', () => { + const file = join(TEST_DIR, 'perms.txt') + writeFileSync(file, 'content') + + fs.chmod(file, 0o755) + expect(fs['exec?'](file)).toBe(true) + + fs.chmod(file, 0o644) + expect(fs['exec?'](file)).toBe(false) + }) + + test('fs.chmod changes file permissions with string', () => { + const file = join(TEST_DIR, 'perms2.txt') + writeFileSync(file, 'content') + + fs.chmod(file, '755') + expect(fs['exec?'](file)).toBe(true) + + fs.chmod(file, '644') + expect(fs['exec?'](file)).toBe(false) + }) +}) + +describe('fs - symlinks', () => { + test('fs.symlink creates symbolic link', () => { + const target = join(TEST_DIR, 'target.txt') + const link = join(TEST_DIR, 'link.txt') + writeFileSync(target, 'content') + + fs.symlink(target, link) + expect(fs['symlink?'](link)).toBe(true) + expect(fs.read(link)).toBe('content') + }) + + test('fs.readlink reads symbolic link target', () => { + const target = join(TEST_DIR, 'target.txt') + const link = join(TEST_DIR, 'link.txt') + writeFileSync(target, 'content') + + fs.symlink(target, link) + expect(fs.readlink(link)).toBe(target) + }) +}) + +describe('fs - other', () => { + test('fs.glob matches file patterns', () => { + writeFileSync(join(TEST_DIR, 'file1.txt'), '') + writeFileSync(join(TEST_DIR, 'file2.txt'), '') + writeFileSync(join(TEST_DIR, 'file3.md'), '') + + const result = fs.glob(join(TEST_DIR, '*.txt')) + expect(result).toHaveLength(2) + expect(result).toContain(join(TEST_DIR, 'file1.txt')) + expect(result).toContain(join(TEST_DIR, 'file2.txt')) + }) + + test('fs.watch calls callback on file change', async () => { + const file = join(TEST_DIR, 'watch.txt') + writeFileSync(file, 'initial') + + let called = false + const watcher = fs.watch(file, () => { called = true }) + + // Trigger change + await new Promise(resolve => setTimeout(resolve, 100)) + writeFileSync(file, 'updated') + + // Wait for watcher + await new Promise(resolve => setTimeout(resolve, 500)) + + expect(called).toBe(true) + watcher.close?.() + }) +}) \ No newline at end of file diff --git a/src/prelude/tests/load.ts b/src/prelude/tests/load.test.ts similarity index 62% rename from src/prelude/tests/load.ts rename to src/prelude/tests/load.test.ts index 7ce8172..f79326c 100644 --- a/src/prelude/tests/load.ts +++ b/src/prelude/tests/load.test.ts @@ -1,41 +1,41 @@ import { expect, describe, test } from 'bun:test' import { globals } from '#prelude' -describe('use', () => { +describe('loading a file', () => { test(`imports all a file's functions`, async () => { expect(` - math = load ./src/prelude/tests/math + math = load ./src/prelude/tests/math.sh math.double 4 `).toEvaluateTo(8, globals) expect(` - math = load ./src/prelude/tests/math + math = load ./src/prelude/tests/math.sh math.double (math.double 4) `).toEvaluateTo(16, globals) expect(` - math = load ./src/prelude/tests/math - dbl = math.double + math = load ./src/prelude/tests/math.sh + dbl = ref math.double dbl (dbl 2) `).toEvaluateTo(8, globals) expect(` - math = load ./src/prelude/tests/math + math = load ./src/prelude/tests/math.sh math.pi `).toEvaluateTo(3.14, globals) expect(` - math = load ./src/prelude/tests/math + math = load ./src/prelude/tests/math.sh math | at 🥧 `).toEvaluateTo(3.14159265359, globals) expect(` - math = load ./src/prelude/tests/math + math = load ./src/prelude/tests/math.sh math.🥧 `).toEvaluateTo(3.14159265359, globals) expect(` - math = load ./src/prelude/tests/math + math = load ./src/prelude/tests/math.sh math.add1 5 `).toEvaluateTo(6, globals) }) -- 2.50.1