From 6265dc69b3a2fa1846fb31d0753a2ce994f5937d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 4 May 2026 06:25:59 +0000 Subject: [PATCH 1/2] sync: import latest offloadmywork funee code Imports offloadmywork/funee main at 556ed57 onto netanelgilad/funee master, excluding .github/workflows/release.yml because the available OAuth credentials cannot push workflow-file changes. --- Cargo.toml | 2 +- Formula/funee.rb | 28 ++ HOST_FUNCTIONS_DESIGN.md | 257 +++++++++++ HOST_FUNCTIONS_SPEC.md | 398 ++++++++++++++++++ README.md | 177 +++++--- docs/RELEASE_PROCESS.md | 45 ++ docs/SELF_HOSTED_RUNTIME_SPEC.md | 88 ++++ examples/demo.ts | 13 +- examples/github-stats.ts | 12 +- examples/validator-demo.ts | 10 +- examples/watch-demo/debug.ts | 3 +- examples/watch-demo/math.test.ts | 10 +- funee-lib/filesystem/isFile.ts | 4 +- funee-lib/filesystem/lstat.ts | 4 +- funee-lib/filesystem/readFile.ts | 4 +- funee-lib/filesystem/readFileBinary.ts | 4 +- funee-lib/filesystem/readdir.ts | 4 +- funee-lib/filesystem/tempDir.ts | 9 +- funee-lib/filesystem/writeFile.ts | 4 +- funee-lib/filesystem/writeFileBinary.ts | 4 +- funee-lib/host/console.d.ts | 30 ++ funee-lib/host/crypto.d.ts | 22 + funee-lib/host/fs.d.ts | 79 ++++ funee-lib/host/http.d.ts | 28 ++ funee-lib/host/index.d.ts | 38 ++ funee-lib/host/process.d.ts | 133 ++++++ funee-lib/host/server.d.ts | 74 ++++ funee-lib/host/time.d.ts | 38 ++ funee-lib/host/watch.d.ts | 44 ++ funee-lib/http/fetch.ts | 5 +- funee-lib/http/httpFetch.ts | 19 +- funee-lib/http/httpRequest.ts | 28 +- funee-lib/index.ts | 79 +++- funee-lib/memoize/memoizeInFS.ts | 15 +- funee-lib/os/tmpdir.ts | 4 +- funee-lib/process/index.ts | 9 +- funee-lib/random/cryptoRandomString.ts | 8 +- funee-lib/server/serve.ts | 14 +- funee-lib/watcher/index.ts | 3 +- scripts/prepare-sut.sh | 16 + scripts/run-self-hosted.sh | 26 ++ src/execution_request/declaration.rs | 24 ++ .../get_references_from_declaration.rs | 2 + src/execution_request/source_graph.rs | 56 +++ .../source_graph_to_js_execution_code.rs | 95 ++++- src/execution_request/tests.rs | 98 +++++ src/main.rs | 18 +- tests/cli.test.ts | 13 +- tests/fixtures/macro/array_macro.ts | 17 + tests/fixtures/macro/conditional_macro.ts | 25 ++ .../conditional_macro_already_multiplied.ts | 24 ++ tests/fixtures/macro/debug_sequence_macro.ts | 22 + tests/fixtures/macro/introspection_macro.ts | 25 ++ tests/fixtures/macro/member_macro.ts | 17 + tests/fixtures/macro/multi_arg_compare.ts | 19 + tests/fixtures/macro/object_macro.ts | 17 + .../macro/references_introspection.ts | 18 + tests/fixtures/macro/references_values.ts | 3 + .../fixtures/macro/variadic_numeric_count.ts | 24 ++ tests/self-hosted/_sut.ts | 1 + tests/self-hosted/basic.ts | 75 +--- tests/self-hosted/misc.ts | 175 +++++++- 62 files changed, 2308 insertions(+), 252 deletions(-) create mode 100644 Formula/funee.rb create mode 100644 HOST_FUNCTIONS_DESIGN.md create mode 100644 HOST_FUNCTIONS_SPEC.md create mode 100644 docs/RELEASE_PROCESS.md create mode 100644 docs/SELF_HOSTED_RUNTIME_SPEC.md create mode 100644 funee-lib/host/console.d.ts create mode 100644 funee-lib/host/crypto.d.ts create mode 100644 funee-lib/host/fs.d.ts create mode 100644 funee-lib/host/http.d.ts create mode 100644 funee-lib/host/index.d.ts create mode 100644 funee-lib/host/process.d.ts create mode 100644 funee-lib/host/server.d.ts create mode 100644 funee-lib/host/time.d.ts create mode 100644 funee-lib/host/watch.d.ts create mode 100755 scripts/prepare-sut.sh create mode 100755 scripts/run-self-hosted.sh create mode 100644 tests/fixtures/macro/array_macro.ts create mode 100644 tests/fixtures/macro/conditional_macro.ts create mode 100644 tests/fixtures/macro/conditional_macro_already_multiplied.ts create mode 100644 tests/fixtures/macro/debug_sequence_macro.ts create mode 100644 tests/fixtures/macro/introspection_macro.ts create mode 100644 tests/fixtures/macro/member_macro.ts create mode 100644 tests/fixtures/macro/multi_arg_compare.ts create mode 100644 tests/fixtures/macro/object_macro.ts create mode 100644 tests/fixtures/macro/references_introspection.ts create mode 100644 tests/fixtures/macro/references_values.ts create mode 100644 tests/fixtures/macro/variadic_numeric_count.ts create mode 100644 tests/self-hosted/_sut.ts diff --git a/Cargo.toml b/Cargo.toml index 4d70d29..d94bf0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "funee" -version = "0.1.0" +version = "0.1.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/Formula/funee.rb b/Formula/funee.rb new file mode 100644 index 0000000..2572827 --- /dev/null +++ b/Formula/funee.rb @@ -0,0 +1,28 @@ +class Funee < Formula + desc "Rust-based TypeScript runtime with macros, HTTP imports, and tree-shaking" + homepage "https://github.com/offloadmywork/funee" + url "https://github.com/offloadmywork/funee/archive/refs/tags/v0.1.1.tar.gz" + sha256 "c594b1788b9f16ee81fe7dafefd417ee68925d01623e6741b5630c947ccfcf8e" + license "MIT" + + depends_on "rust" => :build + + def install + system "cargo", "build", "--release" + + libexec.install "funee-lib" + libexec.install "target/release/funee" + (bin/"funee").write_env_script libexec/"funee", FUNEE_LIB_PATH: libexec/"funee-lib/index.ts" + end + + test do + (testpath/"hello.ts").write <<~TS + import { log } from "host://console"; + export default () => { + log("hello from brew test"); + }; + TS + + assert_match "hello from brew test", shell_output("#{bin}/funee #{testpath}/hello.ts") + end +end diff --git a/HOST_FUNCTIONS_DESIGN.md b/HOST_FUNCTIONS_DESIGN.md new file mode 100644 index 0000000..a685aec --- /dev/null +++ b/HOST_FUNCTIONS_DESIGN.md @@ -0,0 +1,257 @@ +# Host Functions Design + +## Overview + +Host functions are capabilities provided by the runtime, not implemented in JavaScript. They represent interfaces that the host must fulfill. By making this explicit with a `host://` import scheme, we: + +1. **Clarify the runtime contract** — What the host must provide +2. **Enable alternative hosts** — Different runtimes can implement the same interfaces +3. **Separate concerns** — Pure library code vs host-dependent code +4. **Make dependencies visible** — Easy to see what host capabilities a module needs + +## Current Host Operations + +These are the Rust ops currently exposed to JavaScript: + +### File System (`host://fs`) +- `readFile(path: string): Promise` +- `readFileBinary(path: string): Promise` +- `writeFile(path: string, content: string): Promise` +- `writeFileBinary(path: string, content: Uint8Array): Promise` +- `isFile(path: string): Promise` +- `exists(path: string): Promise` +- `lstat(path: string): Promise` +- `mkdir(path: string): Promise` +- `readdir(path: string): Promise` +- `tmpdir(): string` + +### HTTP (`host://http`) +- `fetch(url: string, options?: RequestInit): Promise` + +### HTTP Server (`host://http/server`) +- `serve(options: ServeOptions): Server` + +### Process (`host://process`) +- `spawn(cmd: string[], options?: SpawnOptions): Process` + +### Timers (`host://time`) +- `setTimeout(callback: () => void, ms: number): number` +- `clearTimeout(id: number): void` +- `setInterval(callback: () => void, ms: number): number` +- `clearInterval(id: number): void` + +### Watch (`host://watch`) +- `watchFile(path: string): AsyncIterable` +- `watchDirectory(path: string): AsyncIterable` + +### Crypto (`host://crypto`) +- `randomBytes(length: number): Uint8Array` + +### Console (`host://console`) +- `log(...args: any[]): void` +- `debug(...args: any[]): void` + +## URL Scheme Design + +``` +host:/// +``` + +### Proposed Namespaces + +| Namespace | Description | Exports | +|-----------|-------------|---------| +| `host://fs` | File system operations | readFile, writeFile, isFile, exists, lstat, mkdir, readdir, tmpdir | +| `host://http` | HTTP client | fetch | +| `host://http/server` | HTTP server | serve | +| `host://process` | Subprocess spawning | spawn | +| `host://time` | Timers | setTimeout, clearTimeout, setInterval, clearInterval | +| `host://watch` | File watching | watchFile, watchDirectory | +| `host://crypto` | Cryptographic utilities | randomBytes | +| `host://console` | Logging | log, debug | + +### Import Examples + +```typescript +// Before (importing from "funee") +import { readFile, writeFile } from "funee"; +import { fetch } from "funee"; +import { serve } from "funee"; +import { spawn } from "funee"; +import { log, debug } from "funee"; + +// After (importing from host://) +import { readFile, writeFile, isFile } from "host://fs"; +import { fetch } from "host://http"; +import { serve } from "host://http/server"; +import { spawn } from "host://process"; +import { log, debug } from "host://console"; +import { setTimeout, clearTimeout } from "host://time"; +import { watchFile, watchDirectory } from "host://watch"; +import { randomBytes } from "host://crypto"; +``` + +## What Stays in "funee"? + +The `"funee"` import becomes pure JavaScript/TypeScript library code: + +```typescript +import { + // Macros (compile-time, not host functions) + closure, canonicalName, definition, + Closure, CanonicalName, Definition, createMacro, + + // Assertions (pure JS) + assertThat, is, not, both, contains, matches, + greaterThan, lessThan, + + // Testing framework (pure JS, uses host://time internally) + scenario, runScenarios, runScenariosWatch, + + // Streams/axax (pure JS async iterables) + fromArray, toArray, map, filter, reduce, pipe, + + // Utilities (pure JS) + join, // path joining is pure string manipulation +} from "funee"; +``` + +## Implementation Plan + +### Phase 1: Bundler Support for `host://` URLs + +1. **Modify `load_module.rs`** to recognize `host://` scheme +2. **Create host module stubs** that re-export from runtime bootstrap +3. **Update `http_loader.rs`** to handle `host://` specially (not HTTP fetch) + +### Phase 2: Create Host Module Definitions + +Create TypeScript declaration files for each host namespace: + +``` +funee-lib/ + host/ + fs.ts # host://fs + http.ts # host://http + server.ts # host://http/server + process.ts # host://process + time.ts # host://time + watch.ts # host://watch + crypto.ts # host://crypto + console.ts # host://console +``` + +### Phase 3: Update funee-lib + +1. **Move host function wrappers** to `funee-lib/host/*.ts` +2. **Update internal imports** in funee-lib to use `host://` +3. **Re-export from funee-lib/index.ts** for backward compatibility (deprecation period) + +### Phase 4: Update Tests & Examples + +1. Update all test fixtures to use `host://` imports +2. Update examples +3. Update README + +### Phase 5: Deprecation Path + +For backward compatibility: +```typescript +// funee-lib/index.ts +// @deprecated Use host://fs instead +export { readFile, writeFile } from "host://fs"; +// @deprecated Use host://http instead +export { fetch } from "host://http"; +``` + +## Alternative Considered: `runtime://` or `builtin://` + +| Scheme | Pros | Cons | +|--------|------|------| +| `host://` | Clear "provided by host" semantics | Could confuse with hostnames | +| `runtime://` | Clear it's runtime-provided | Longer | +| `builtin://` | Node.js precedent | Less clear about replaceability | +| `native://` | Clear it's native code | Could confuse with native modules | + +**Decision:** `host://` — Short, clear semantics about the host/guest boundary. + +## Type Safety + +Each `host://` module has full TypeScript types: + +```typescript +// host://fs types +export declare function readFile(path: string): Promise; +export declare function writeFile(path: string, content: string): Promise; +export declare function isFile(path: string): Promise; +// ... + +// host://http types +export declare function fetch( + url: string | URL, + init?: RequestInit +): Promise; + +// host://process types +export interface SpawnOptions { + cmd: string[]; + cwd?: string; + env?: Record; + stdin?: "piped" | "inherit" | "null"; + stdout?: "piped" | "inherit" | "null"; + stderr?: "piped" | "inherit" | "null"; +} +export declare function spawn(options: SpawnOptions): Process; +export declare function spawn(cmd: string, args?: string[]): Promise; +``` + +## Benefits + +1. **Explicit host dependency** — Easy to see what a module needs from the host +2. **Portable code** — Pure `"funee"` imports work anywhere +3. **Alternative runtimes** — Browser, edge workers, etc. can provide different `host://` implementations +4. **Tree-shaking** — Unused host modules aren't loaded +5. **Documentation** — The URL itself documents what capability is needed +6. **Testing** — Easy to mock `host://` imports in tests + +## Open Questions + +1. **Should timers be globals or imports?** — Currently `setTimeout` is a global. Keep as global for compatibility? +2. **Version in URL?** — `host://fs@1` for future breaking changes? +3. **Capability detection?** — `import { available } from "host://fs"` to check if host provides it? + +## Implementation Notes + +### Current Status + +The `host://` import scheme is now fully implemented: + +1. **Bundler recognizes `host://` URLs** — Handled in `load_module.rs` +2. **Host modules resolve to runtime bootstrap** — Each namespace maps to pre-loaded runtime code +3. **Backward compatibility preserved** — Old `"funee"` imports still work (re-export from host modules) + +### How It Works + +1. When the bundler encounters `import { readFile } from "host://fs"`, it recognizes the `host://` scheme +2. Instead of fetching from network, it resolves to the corresponding host module definition +3. The host module exports functions that call into Rust ops via deno_core's op system +4. The runtime bootstrap pre-loads all host module definitions before user code runs + +### File Structure + +``` +funee-lib/ + host/ + fs.ts # host://fs → re-exports from bootstrap + http.ts # host://http → re-exports from bootstrap + server.ts # host://http/server → re-exports from bootstrap + process.ts # host://process → re-exports from bootstrap + time.ts # host://time → re-exports from bootstrap + watch.ts # host://watch → re-exports from bootstrap + crypto.ts # host://crypto → re-exports from bootstrap + console.ts # host://console → re-exports from bootstrap +``` + +### Testing + +All `host://` imports are covered by self-hosted tests in `tests/self-hosted/`. These tests use the actual runtime and verify that host functions work correctly. diff --git a/HOST_FUNCTIONS_SPEC.md b/HOST_FUNCTIONS_SPEC.md new file mode 100644 index 0000000..aa0a848 --- /dev/null +++ b/HOST_FUNCTIONS_SPEC.md @@ -0,0 +1,398 @@ +# Host Functions Implementation Spec + +## Overview + +This document specifies exactly how `host://` imports are resolved and implemented. + +## URL Resolution + +### Scheme Detection + +In `load_module.rs`, when resolving an import: + +```rust +fn resolve_import(specifier: &str, referrer: &str) -> Result { + if specifier.starts_with("host://") { + // Return as-is, handled specially + return Ok(specifier.to_string()); + } + // ... existing resolution logic +} +``` + +### Host Module Mapping + +The bundler maps `host://` URLs to internal module definitions: + +| Import | Internal Module | +|--------|-----------------| +| `host://fs` | `[host:fs]` | +| `host://http` | `[host:http]` | +| `host://http/server` | `[host:http/server]` | +| `host://process` | `[host:process]` | +| `host://time` | `[host:time]` | +| `host://watch` | `[host:watch]` | +| `host://crypto` | `[host:crypto]` | +| `host://console` | `[host:console]` | + +### Module Loading + +Host modules are not loaded from disk or HTTP. Instead, the bundler generates synthetic modules that reference the runtime bootstrap. + +```rust +// In load_module.rs +fn load_module(uri: &str) -> Result { + if uri.starts_with("host://") { + return Ok(generate_host_module(uri)); + } + // ... existing loading logic +} + +fn generate_host_module(uri: &str) -> ModuleSource { + let namespace = uri.strip_prefix("host://").unwrap(); + // Return a synthetic module that re-exports from runtime + ModuleSource { + uri: uri.to_string(), + content: format!("export * from '[runtime:{}]';", namespace), + } +} +``` + +## Runtime Bootstrap Changes + +### Current Bootstrap Structure + +```javascript +// run_js.rs bootstrap +globalThis.log = (...args) => Deno.core.ops.op_log(args.join(" ")); +globalThis.fetch = async (url, init) => { ... }; +globalThis.setTimeout = (fn, ms) => { ... }; +// etc. +``` + +### New Bootstrap Structure + +Create namespaced exports that the generated host modules can reference: + +```javascript +// Runtime modules (injected as [runtime:X]) +const hostFs = { + readFile: async (path) => Deno.core.ops.op_fsReadFile(path), + writeFile: async (path, content) => Deno.core.ops.op_fsWriteFile(path, content), + // ... +}; + +const hostHttp = { + fetch: async (url, init) => { /* existing fetch impl */ }, +}; + +const hostHttpServer = { + serve: (options) => { /* existing serve impl */ }, +}; + +const hostProcess = { + spawn: (cmdOrOptions, args) => { /* existing spawn impl */ }, +}; + +const hostTime = { + setTimeout: (fn, ms) => { /* existing impl */ }, + clearTimeout: (id) => { /* existing impl */ }, + setInterval: (fn, ms) => { /* existing impl */ }, + clearInterval: (id) => { /* existing impl */ }, +}; + +const hostWatch = { + watchFile: (path) => { /* existing impl */ }, + watchDirectory: (path) => { /* existing impl */ }, +}; + +const hostCrypto = { + randomBytes: (length) => Deno.core.ops.op_randomBytes(length), +}; + +const hostConsole = { + log: (...args) => Deno.core.ops.op_log(args.join(" ")), + debug: (...args) => Deno.core.ops.op_debug(args.join(" ")), +}; + +// Register as runtime modules +globalThis.__hostModules = { + fs: hostFs, + http: hostHttp, + 'http/server': hostHttpServer, + process: hostProcess, + time: hostTime, + watch: hostWatch, + crypto: hostCrypto, + console: hostConsole, +}; +``` + +## Code Generation + +### Import Transformation + +When the bundler encounters: +```typescript +import { readFile, writeFile } from "host://fs"; +``` + +It generates: +```javascript +const { readFile, writeFile } = globalThis.__hostModules.fs; +``` + +### Alternative: Inline in Bundle Preamble + +Instead of runtime module lookup, the bundler could inline host module contents in the bundle preamble: + +```javascript +// Generated bundle +const __host_fs = { + readFile: async (path) => Deno.core.ops.op_fsReadFile(path), + // ... +}; +const __host_http = { + fetch: async (url, init) => { /* ... */ }, +}; + +// User code with transformed imports +const { readFile } = __host_fs; +const { fetch } = __host_http; +// ... +``` + +**Recommendation:** Use the inline approach — simpler, no runtime lookup overhead. + +## Declaration Files + +For TypeScript support, create `.d.ts` files for each host module: + +### `funee-lib/host/fs.d.ts` +```typescript +export declare function readFile(path: string): Promise; +export declare function readFileBinary(path: string): Promise; +export declare function writeFile(path: string, content: string): Promise; +export declare function writeFileBinary(path: string, content: Uint8Array): Promise; +export declare function isFile(path: string): Promise; +export declare function exists(path: string): Promise; +export declare function lstat(path: string): Promise<{ + size: number; + is_file: boolean; + is_directory: boolean; + modified_ms: number; +}>; +export declare function mkdir(path: string, options?: { recursive?: boolean }): Promise; +export declare function readdir(path: string): Promise; +export declare function tmpdir(): string; +``` + +### `funee-lib/host/http.d.ts` +```typescript +export declare function fetch( + input: string | URL | Request, + init?: RequestInit +): Promise; +``` + +### `funee-lib/host/server.d.ts` +```typescript +export interface ServeOptions { + port?: number; + hostname?: string; + handler: (request: Request) => Response | Promise; + onListen?: (info: { port: number; hostname: string }) => void; +} + +export interface Server { + port: number; + hostname: string; + shutdown(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export declare function serve(options: ServeOptions): Server; +export declare function createResponse(body?: string | null, init?: ResponseInit): Response; +export declare function createJsonResponse(data: unknown, init?: ResponseInit): Response; +``` + +### `funee-lib/host/process.d.ts` +```typescript +export interface SpawnOptions { + cmd: string[]; + cwd?: string; + env?: Record; + inheritEnv?: boolean; + stdin?: "piped" | "inherit" | "null"; + stdout?: "piped" | "inherit" | "null"; + stderr?: "piped" | "inherit" | "null"; +} + +export interface ProcessStatus { + success: boolean; + code: number | null; + signal: string | null; +} + +export interface CommandOutput { + status: ProcessStatus; + stdout: Uint8Array; + stderr: Uint8Array; + stdoutText(): string; + stderrText(): string; +} + +export interface Process { + readonly pid: number; + readonly status: Promise; + kill(signal?: string): void; + output(): Promise; + writeInput(data: string | Uint8Array): Promise; +} + +export declare function spawn(command: string, args?: string[]): Promise; +export declare function spawn(options: SpawnOptions): Process; +``` + +### `funee-lib/host/time.d.ts` +```typescript +export declare function setTimeout(callback: () => void, ms: number): number; +export declare function clearTimeout(id: number): void; +export declare function setInterval(callback: () => void, ms: number): number; +export declare function clearInterval(id: number): void; +``` + +### `funee-lib/host/watch.d.ts` +```typescript +export interface WatchEvent { + kind: "create" | "modify" | "remove" | "rename"; + paths: string[]; +} + +export declare function watchFile(path: string): AsyncIterable; +export declare function watchDirectory(path: string, options?: { recursive?: boolean }): AsyncIterable; +``` + +### `funee-lib/host/crypto.d.ts` +```typescript +export declare function randomBytes(length: number): Uint8Array; +``` + +### `funee-lib/host/console.d.ts` +```typescript +export declare function log(...args: unknown[]): void; +export declare function debug(...args: unknown[]): void; +``` + +## Backward Compatibility + +### Re-exports from "funee" + +For a deprecation period, `funee-lib/index.ts` re-exports host functions: + +```typescript +// funee-lib/index.ts + +// Host function re-exports (deprecated, use host:// imports) +/** @deprecated Import from "host://fs" instead */ +export { readFile, writeFile, isFile, exists, lstat, mkdir, readdir, tmpdir } from "host://fs"; + +/** @deprecated Import from "host://http" instead */ +export { fetch } from "host://http"; + +/** @deprecated Import from "host://http/server" instead */ +export { serve, createResponse, createJsonResponse } from "host://http/server"; + +/** @deprecated Import from "host://process" instead */ +export { spawn } from "host://process"; + +/** @deprecated Import from "host://console" instead */ +export { log, debug } from "host://console"; + +// Non-host exports (stay in funee) +export * from "./macros/index.ts"; +export * from "./assertions/index.ts"; +export * from "./validator/index.ts"; +// etc. +``` + +## Testing + +### Test: Host module resolution +```typescript +// tests/fixtures/host-imports/basic.ts +import { readFile } from "host://fs"; +import { log } from "host://console"; + +export default async () => { + log("Testing host://fs"); + const content = await readFile("./test.txt"); + log(`Read: ${content}`); +}; +``` + +### Test: Multiple host imports +```typescript +// tests/fixtures/host-imports/multi.ts +import { readFile, writeFile } from "host://fs"; +import { fetch } from "host://http"; +import { spawn } from "host://process"; +import { log } from "host://console"; + +export default async () => { + // Test fs + await writeFile("/tmp/test.txt", "hello"); + const content = await readFile("/tmp/test.txt"); + log(`fs: ${content}`); + + // Test spawn + const result = await spawn("echo", ["hi"]); + log(`spawn: ${result.stdoutText().trim()}`); +}; +``` + +### Test: Server with dispose +```typescript +// tests/fixtures/host-imports/server.ts +import { serve } from "host://http/server"; +import { fetch } from "host://http"; +import { log } from "host://console"; + +export default async () => { + await using server = serve({ + port: 0, + handler: () => new Response("ok"), + }); + + const res = await fetch(`http://localhost:${server.port}/`); + log(`status: ${res.status}`); +}; +``` + +## Implementation Tasks + +### Task 1: Bundler host:// support +- Modify `load_module.rs` to detect `host://` scheme +- Generate synthetic host modules +- Update import resolution + +### Task 2: Code generation +- Modify `source_graph_to_js_execution_code.rs` to handle host imports +- Inline host module implementations in bundle preamble + +### Task 3: Type declarations +- Create `funee-lib/host/*.d.ts` files +- Export types for IDE support + +### Task 4: Update funee-lib +- Move host function wrappers to use `host://` internally +- Add deprecated re-exports from main index + +### Task 5: Update tests +- Create host-import test fixtures +- Add to cli.test.ts +- Update self-hosted tests + +### Task 6: Update docs +- Update README +- Add migration guide diff --git a/README.md b/README.md index d91b161..887f412 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ funee is a TypeScript runtime designed for functional programming. It bundles an - **Compile-time macros** — Transform code at bundle time, not runtime - **HTTP imports** — Import directly from URLs (like Deno) +- **Host imports (`host://`)** — Explicit host-provided capabilities - **Declaration-level tree-shaking** — Only include what's actually used - **Full runtime** — HTTP server, fetch, filesystem, subprocess, timers - **`using` keyword support** — TypeScript 5.2+ explicit resource management @@ -22,11 +23,18 @@ cargo build --release # Binary at target/release/funee ``` +```bash +# Install from GitHub Releases (recommended for stable runner binaries) +# 1) Download funee-vX.Y.Z-.tar.gz from the Releases page +# 2) Extract and move bin/funee into your PATH +# 3) Keep funee-lib/ next to the binary's parent directory +``` + ## Quick Start ```typescript // hello.ts -import { log } from "funee"; +import { log } from "host://console"; export default () => { log("Hello, funee!"); @@ -49,12 +57,16 @@ funee --emit main.ts # Bypass HTTP cache and fetch fresh funee --reload main.ts + +# Print runtime version +funee --version ``` | Flag | Description | |------|-------------| | `--emit` | Print bundled JavaScript instead of executing | | `--reload` | Bypass HTTP cache, fetch fresh from network | +| `--version` | Print funee version and exit | ## Features @@ -63,7 +75,7 @@ funee --reload main.ts Create web servers with automatic resource cleanup: ```typescript -import { serve, createResponse, createJsonResponse } from "funee"; +import { serve, createResponse, createJsonResponse } from "host://http/server"; export default async () => { await using server = serve({ @@ -86,7 +98,7 @@ export default async () => { Web-standard fetch with full Response/Headers support: ```typescript -import { fetch } from "funee"; +import { fetch } from "host://http"; export default async () => { const res = await fetch("https://api.github.com/users/octocat"); @@ -100,7 +112,8 @@ export default async () => { Transform code at bundle time with full AST access: ```typescript -import { closure, Closure, log } from "funee"; +import { closure, Closure } from "funee"; +import { log } from "host://console"; // closure macro captures the expression as AST const add = closure((a: number, b: number) => a + b); @@ -137,7 +150,7 @@ export default () => add(1, 2); ### File System ```typescript -import { readFile, writeFile, isFile, readdir, tempDir } from "funee"; +import { readFile, writeFile, isFile, readdir, tempDir } from "host://fs"; export default async () => { // Disposable temp directory (auto-deletes on scope exit) @@ -154,7 +167,7 @@ export default async () => { ### Subprocess ```typescript -import { spawn } from "funee"; +import { spawn } from "host://process"; export default async () => { // Simple form — run and capture output @@ -178,6 +191,8 @@ export default async () => { ### Timers ```typescript +import { setTimeout, clearTimeout, setInterval, clearInterval } from "host://time"; + export default async () => { // setTimeout with cancellation const id = setTimeout(() => console.log("fired"), 1000); @@ -197,7 +212,8 @@ export default async () => { Re-run scenarios when referenced files change: ```typescript -import { scenario, runScenariosWatch, closure, assertThat, is, log } from "funee"; +import { scenario, runScenariosWatch, closure, assertThat, is } from "funee"; +import { log } from "host://console"; import { add } from "./math.ts"; const scenarios = [ @@ -220,30 +236,25 @@ export default async () => { ```typescript import { assertThat, is, not, both, contains, matches, - greaterThan, lessThan, scenario, runScenarios, log + greaterThan, lessThan, scenario, runScenarios, closure } from "funee"; +import { log } from "host://console"; const scenarios = [ scenario({ description: "string assertions", - verify: { - expression: async () => { - await assertThat("hello world", contains("world")); - await assertThat("test@example.com", matches(/^[\w]+@[\w]+\.\w+$/)); - }, - references: new Map(), - }, + verify: closure(async () => { + await assertThat("hello world", contains("world")); + await assertThat("test@example.com", matches(/^[\w]+@[\w]+\.\w+$/)); + }), }), scenario({ description: "numeric comparisons", - verify: { - expression: async () => { - await assertThat(10, greaterThan(5)); - await assertThat(3, lessThan(10)); - await assertThat(42, both(greaterThan(0), lessThan(100))); - }, - references: new Map(), - }, + verify: closure(async () => { + await assertThat(10, greaterThan(5)); + await assertThat(3, lessThan(10)); + await assertThat(42, both(greaterThan(0), lessThan(100))); + }), }), ]; @@ -286,49 +297,110 @@ export default () => used(); // Output: only `used` appears ``` -## Standard Library +## Import Structure + +funee uses a two-layer import system: + +### Host Imports (`host://`) + +Host-provided runtime capabilities — things that require the native runtime: + +```typescript +// File system +import { readFile, writeFile, isFile, lstat, readdir, mkdir, tmpdir, tempDir } from "host://fs"; + +// HTTP client +import { fetch, httpGetJSON, httpPostJSON } from "host://http"; + +// HTTP server +import { serve, createResponse, createJsonResponse } from "host://http/server"; + +// Subprocess +import { spawn } from "host://process"; + +// Timers +import { setTimeout, clearTimeout, setInterval, clearInterval } from "host://time"; + +// File watching +import { watchFile, watchDirectory } from "host://watch"; + +// Crypto +import { randomBytes } from "host://crypto"; + +// Console +import { log, debug } from "host://console"; +``` + +### Library Imports (`"funee"`) -Import from `"funee"`: +Pure JavaScript/TypeScript library code — works anywhere: ```typescript import { - // Core - log, debug, - - // Macros + // Macros (compile-time) Closure, CanonicalName, Definition, createMacro, closure, canonicalName, definition, - // HTTP - fetch, serve, createResponse, createJsonResponse, - httpGetJSON, httpPostJSON, - - // Filesystem - readFile, writeFile, isFile, lstat, readdir, mkdir, - join, tmpdir, tempDir, - - // Process - spawn, - - // Assertions + // Assertions (pure JS) assertThat, is, not, both, contains, matches, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, - // Testing + // Testing framework scenario, runScenarios, runScenariosWatch, - // Streams + // Streams (async iterables) fromArray, toArray, map, filter, reduce, pipe, fromString, toString, fromBuffer, toBuffer, // Utilities cryptoRandomString, someString, someDirectory, - - // Watch - watchFile, watchDirectory, + join, // path joining is pure string manipulation } from "funee"; ``` +### Why `host://`? + +The `host://` scheme makes host dependencies explicit: + +1. **Clear contract** — See exactly what runtime capabilities a module needs +2. **Portability** — Pure `"funee"` imports work in any JavaScript environment +3. **Alternative runtimes** — Browser, edge workers, etc. can provide different `host://` implementations +4. **Testing** — Easy to mock `host://` imports +5. **Tree-shaking** — Unused host modules aren't loaded + +### Migration Guide + +Moving from old-style imports to `host://`: + +```typescript +// ❌ Old way (deprecated) +import { readFile, writeFile } from "funee"; +import { fetch } from "funee"; +import { serve } from "funee"; +import { spawn } from "funee"; +import { log, debug } from "funee"; + +// ✅ New way +import { readFile, writeFile } from "host://fs"; +import { fetch } from "host://http"; +import { serve } from "host://http/server"; +import { spawn } from "host://process"; +import { log, debug } from "host://console"; +``` + +**Quick reference:** + +| Old import | New import | +|------------|------------| +| `readFile`, `writeFile`, `isFile`, `readdir`, `mkdir`, `tmpdir`, `tempDir` | `host://fs` | +| `fetch`, `httpGetJSON`, `httpPostJSON` | `host://http` | +| `serve`, `createResponse`, `createJsonResponse` | `host://http/server` | +| `spawn` | `host://process` | +| `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` | `host://time` | +| `watchFile`, `watchDirectory` | `host://watch` | +| `randomBytes` | `host://crypto` | +| `log`, `debug` | `host://console` | + ## Architecture Built in Rust using: @@ -348,10 +420,13 @@ The functional-only design (no classes) enables aggressive optimizations and cle cd tests && npm test # Run self-hosted tests (funee testing funee) -./target/release/funee tests/self-hosted/basic.ts -./target/release/funee tests/self-hosted/stdlib.ts -./target/release/funee tests/self-hosted/http.ts -./target/release/funee tests/self-hosted/misc.ts +./scripts/prepare-sut.sh +./scripts/run-self-hosted.sh + +# Optional: choose runner and SUT explicitly +FUNEE_RUNNER_BIN=/usr/local/bin/funee \ +FUNEE_SUT_BIN=$PWD/target/sut/funee \ +./scripts/run-self-hosted.sh # Run Rust unit tests cargo test @@ -360,6 +435,8 @@ cargo test cargo build --release ``` +Release workflow details: `docs/RELEASE_PROCESS.md`. + ## Test Status - ✅ **163 vitest tests** passing diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md new file mode 100644 index 0000000..c37666d --- /dev/null +++ b/docs/RELEASE_PROCESS.md @@ -0,0 +1,45 @@ +# Release Process + +## Goal + +Use a stable, published `funee` binary as the self-hosted test runner, while testing a freshly built local binary as the system under test (SUT). + +## Runner vs SUT + +- Runner: installed release binary (`funee` from GitHub Releases) +- SUT: local build copied to `./target/sut/funee` + +Commands: + +```bash +./scripts/prepare-sut.sh +./scripts/run-self-hosted.sh +``` + +Optional explicit binaries: + +```bash +FUNEE_RUNNER_BIN=/usr/local/bin/funee \ +FUNEE_SUT_BIN=$PWD/target/sut/funee \ +./scripts/run-self-hosted.sh +``` + +## Publishing + +`/.github/workflows/release.yml` publishes archives when pushing tags that match `v*`. + +Example: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +Each release archive includes: + +- `bin/funee` +- `funee-lib/` + +## Homebrew + +Start with GitHub Releases first. Add Homebrew after release artifacts are stable and checksums are part of the process. diff --git a/docs/SELF_HOSTED_RUNTIME_SPEC.md b/docs/SELF_HOSTED_RUNTIME_SPEC.md new file mode 100644 index 0000000..8458208 --- /dev/null +++ b/docs/SELF_HOSTED_RUNTIME_SPEC.md @@ -0,0 +1,88 @@ +# Self-Hosted Runtime Spec + +This document defines the runtime spec using self-hosted tests as the source of truth. + +The spec is exercised by scenarios in: + +- `/tests/self-hosted/basic.ts` +- `/tests/self-hosted/misc.ts` +- `/tests/self-hosted/http.ts` +- `/tests/self-hosted/stdlib.ts` + +For macro runtime behavior, the normative requirements below are anchored in +`/tests/self-hosted/misc.ts` and macro fixtures under `/tests/fixtures/macro`. + +## Macro Runtime Requirements + +1. `SPEC-MACRO-RUNTIME-001` +- Requirement: `Closure()` MUST normalize plain-object references into a `Map`. +- Scenario: `macro :: [SPEC-MACRO-RUNTIME-001] ...` + +2. `SPEC-MACRO-RUNTIME-002` +- Requirement: `Closure()` MUST preserve `Map` references as `Map`. +- Scenario: `macro :: [SPEC-MACRO-RUNTIME-002] ...` + +3. `SPEC-MACRO-RUNTIME-003` +- Requirement: Canonical reference records MUST expose `uri` and `name`. +- Scenario: `macro :: [SPEC-MACRO-RUNTIME-003] ...` + +4. `SPEC-MACRO-RUNTIME-004` +- Requirement: `Definition()` MUST produce `declaration` and `references`. +- Scenario: `macro :: [SPEC-MACRO-RUNTIME-004] ...` + +5. `SPEC-MACRO-EXPANSION-001` +- Requirement: `createMacro`-marked closures MUST expand at bundle time. +- Scenario: `macro :: [SPEC-MACRO-EXPANSION-001] ...` +- Fixture: `/tests/fixtures/macro/closure-macro.ts` + +6. `SPEC-MACRO-REFERENCES-001` +- Requirement: Cross-file identifiers used in captured closures MUST be tracked in references. +- Scenario: `macro :: [SPEC-MACRO-REFERENCES-001] ...` +- Fixture: `/tests/fixtures/macro/cross-file-ref/entry.ts` + +7. `SPEC-MACRO-EXEC-001` +- Requirement: Macro execution MUST support conditional branching based on captured expression shape. +- Scenario: `macro :: [SPEC-MACRO-EXEC-001] ...` +- Fixture: `/tests/fixtures/macro/conditional_macro.ts` + +8. `SPEC-MACRO-EXEC-002` +- Requirement: Macro execution MUST expose `arg.expression` for compile-time introspection. +- Scenario: `macro :: [SPEC-MACRO-EXEC-002] ...` +- Fixture: `/tests/fixtures/macro/introspection_macro.ts` + +9. `SPEC-MACRO-EXEC-003` +- Requirement: Macro execution MUST pass multiple closure arguments in order. +- Scenario: `macro :: [SPEC-MACRO-EXEC-003] ...` +- Fixture: `/tests/fixtures/macro/multi_arg_compare.ts` + +10. `SPEC-MACRO-EXEC-004` +- Requirement: Variadic macro signatures MUST receive all captured arguments. +- Scenario: `macro :: [SPEC-MACRO-EXEC-004] ...` +- Fixture: `/tests/fixtures/macro/variadic_numeric_count.ts` + +11. `SPEC-MACRO-REFERENCES-002` +- Requirement: Macro execution MUST expose `arg.references` for compile-time inspection. +- Scenario: `macro :: [SPEC-MACRO-REFERENCES-002] ...` +- Fixture: `/tests/fixtures/macro/references_introspection.ts` + +12. `SPEC-MACRO-EXEC-005` +- Requirement: Macro output MUST support object/array/member-expression code generation. +- Scenario: `macro :: [SPEC-MACRO-EXEC-005] ...` +- Fixture: `/tests/fixtures/macro/object_array_member_macro.ts` + +13. `SPEC-MACRO-EXEC-006` +- Requirement: Macro output MUST support sequence-expression style emitted code. +- Scenario: `macro :: [SPEC-MACRO-EXEC-006] ...` +- Fixture: `/tests/fixtures/macro/debug_sequence_macro.ts` + +## CLI Conformance Backstops + +Integration-level conformance remains covered in `/tests/cli.test.ts`: + +- Macro detection and expansion flow +- Macro-added references +- Recursive expansion +- Infinite recursion guard +- Emitted output does not retain macro definitions + +Self-hosted scenarios are normative; CLI tests are regression backstops. diff --git a/examples/demo.ts b/examples/demo.ts index a780d88..59c42fe 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -2,16 +2,9 @@ * 🎸 Funee Capabilities Demo */ -import { - log, - writeFile, - readFile, - isFile, - assertThat, - is, - cryptoRandomString, - tmpdir, -} from "funee"; +import { log } from "host://console"; +import { writeFile, readFile, isFile, tmpdir } from "host://fs"; +import { assertThat, is, cryptoRandomString } from "funee"; export default async () => { // ═══════════════════════════════════════════════════════════════ diff --git a/examples/github-stats.ts b/examples/github-stats.ts index d70cdb0..2bd8e32 100644 --- a/examples/github-stats.ts +++ b/examples/github-stats.ts @@ -5,14 +5,10 @@ * async iterables, and generates a formatted report. */ -import { - httpGetJSON, - log, - map, - toArray, - fromArray, - writeFile, -} from "funee"; +import { log } from "host://console"; +import { httpGetJSON } from "host://http"; +import { writeFile } from "host://fs"; +import { map, toArray, fromArray } from "funee"; // Types type GithubRepo = { diff --git a/examples/validator-demo.ts b/examples/validator-demo.ts index dc48743..8a2f000 100644 --- a/examples/validator-demo.ts +++ b/examples/validator-demo.ts @@ -5,14 +5,8 @@ * assertion library. */ -import { - scenario, - runScenarios, - assertThat, - is, - log, - Closure, -} from "funee"; +import { log } from "host://console"; +import { scenario, runScenarios, assertThat, is, Closure } from "funee"; // Helper to create inline closures const verify = (fn: T): Closure => ({ diff --git a/examples/watch-demo/debug.ts b/examples/watch-demo/debug.ts index e17fcef..7605ed0 100644 --- a/examples/watch-demo/debug.ts +++ b/examples/watch-demo/debug.ts @@ -2,7 +2,8 @@ * Debug: Check if closure macro captures references correctly */ -import { closure, log } from "funee"; +import { log } from "host://console"; +import { closure } from "funee"; import { add } from "./math.ts"; // Just capture, don't execute diff --git a/examples/watch-demo/math.test.ts b/examples/watch-demo/math.test.ts index 927bbc3..946c8ed 100644 --- a/examples/watch-demo/math.test.ts +++ b/examples/watch-demo/math.test.ts @@ -8,14 +8,8 @@ * The references Map still tells the watcher which files matter. */ -import { - scenario, - runScenarios, - assertThat, - is, - log, - Closure, -} from "funee"; +import { log } from "host://console"; +import { scenario, runScenarios, assertThat, is, Closure } from "funee"; import { add, multiply } from "./math.ts"; diff --git a/funee-lib/filesystem/isFile.ts b/funee-lib/filesystem/isFile.ts index 65cd762..0fed15d 100644 --- a/funee-lib/filesystem/isFile.ts +++ b/funee-lib/filesystem/isFile.ts @@ -2,7 +2,7 @@ * isFile - Check if a path is a file */ -import { fsIsFile } from "funee"; +import { isFile as hostIsFile } from "host://fs"; import { PathString } from "./PathString.ts"; /** @@ -21,5 +21,5 @@ import { PathString } from "./PathString.ts"; * ``` */ export const isFile = (path: PathString): boolean => { - return fsIsFile(path); + return hostIsFile(path); }; diff --git a/funee-lib/filesystem/lstat.ts b/funee-lib/filesystem/lstat.ts index e90a9e8..bf6bb78 100644 --- a/funee-lib/filesystem/lstat.ts +++ b/funee-lib/filesystem/lstat.ts @@ -2,7 +2,7 @@ * lstat - Get file stats without following symlinks */ -import { fsLstat } from "funee"; +import { lstat as hostLstat } from "host://fs"; import { PathString } from "./PathString.ts"; import { parseResult, unwrap, FsResult, FileStats } from "./FsResult.ts"; @@ -15,7 +15,7 @@ import { parseResult, unwrap, FsResult, FileStats } from "./FsResult.ts"; * @returns Result object with stats or error */ export const lstatRaw = (path: PathString): FsResult => { - const json = fsLstat(path); + const json = hostLstat(path); return parseResult(json) as FsResult; }; diff --git a/funee-lib/filesystem/readFile.ts b/funee-lib/filesystem/readFile.ts index a3c819f..a785d5d 100644 --- a/funee-lib/filesystem/readFile.ts +++ b/funee-lib/filesystem/readFile.ts @@ -2,7 +2,7 @@ * readFile - Read file contents as a string */ -import { fsReadFile } from "funee"; +import { readFile as hostReadFile } from "host://fs"; import { FilePathString, PathString } from "./PathString.ts"; import { parseResult, unwrap, FsResult } from "./FsResult.ts"; @@ -15,7 +15,7 @@ import { parseResult, unwrap, FsResult } from "./FsResult.ts"; * @returns Result object with value or error */ export const readFileRaw = (path: PathString): FsResult => { - const json = fsReadFile(path); + const json = hostReadFile(path); return parseResult(json) as FsResult; }; diff --git a/funee-lib/filesystem/readFileBinary.ts b/funee-lib/filesystem/readFileBinary.ts index 225547f..9522024 100644 --- a/funee-lib/filesystem/readFileBinary.ts +++ b/funee-lib/filesystem/readFileBinary.ts @@ -2,7 +2,7 @@ * Read a file as binary data (Uint8Array) */ -import { fsReadFileBinary } from "../host.ts"; +import { readFileBinary as hostReadFileBinary } from "host://fs"; import type { FilePathString, FsResult } from "./index.ts"; import { parseResult, unwrap } from "./FsResult.ts"; @@ -73,7 +73,7 @@ export const base64Decode = (base64: string): Uint8Array => { * ``` */ export const readFileBinaryRaw = (path: FilePathString): FsResult => { - const json = fsReadFileBinary(path); + const json = hostReadFileBinary(path); const result = parseResult(json); if (result.type === "ok") { return { type: "ok", value: base64Decode(result.value) }; diff --git a/funee-lib/filesystem/readdir.ts b/funee-lib/filesystem/readdir.ts index 3b6a7b3..9742fc2 100644 --- a/funee-lib/filesystem/readdir.ts +++ b/funee-lib/filesystem/readdir.ts @@ -2,7 +2,7 @@ * readdir - List directory contents */ -import { fsReaddir } from "funee"; +import { readdir as hostReaddir } from "host://fs"; import { FolderPathString, PathString, RelativePathString } from "./PathString.ts"; import { parseResult, unwrap, FsResult } from "./FsResult.ts"; @@ -15,7 +15,7 @@ import { parseResult, unwrap, FsResult } from "./FsResult.ts"; * @returns Result object with array of filenames or error */ export const readdirRaw = (path: PathString): FsResult => { - const json = fsReaddir(path); + const json = hostReaddir(path); return parseResult(json) as FsResult; }; diff --git a/funee-lib/filesystem/tempDir.ts b/funee-lib/filesystem/tempDir.ts index 879eb61..cccac58 100644 --- a/funee-lib/filesystem/tempDir.ts +++ b/funee-lib/filesystem/tempDir.ts @@ -17,7 +17,9 @@ * ``` */ -import { someDirectory, fsMkdir, spawn } from "funee"; +import { mkdir } from "host://fs"; +import { spawn } from "host://process"; +import { someDirectory } from "../abstracts/index.ts"; import type { FolderPathString } from "./PathString.ts"; /** @@ -58,10 +60,7 @@ export const tempDir = (): TempDir => { const path = someDirectory(); // Create the directory - const result = JSON.parse(fsMkdir(path)); - if (result.error) { - throw new Error(`Failed to create temp directory: ${result.error}`); - } + mkdir(path); return { get path() { diff --git a/funee-lib/filesystem/writeFile.ts b/funee-lib/filesystem/writeFile.ts index 32a8c75..2be1989 100644 --- a/funee-lib/filesystem/writeFile.ts +++ b/funee-lib/filesystem/writeFile.ts @@ -2,7 +2,7 @@ * writeFile - Write content to a file */ -import { fsWriteFile } from "funee"; +import { writeFile as hostWriteFile } from "host://fs"; import { FilePathString, PathString } from "./PathString.ts"; import { parseResult, unwrap, FsResult } from "./FsResult.ts"; @@ -16,7 +16,7 @@ import { parseResult, unwrap, FsResult } from "./FsResult.ts"; * @returns Result object with null value or error */ export const writeFileRaw = (path: PathString, content: string): FsResult => { - const json = fsWriteFile(path, content); + const json = hostWriteFile(path, content); return parseResult(json) as FsResult; }; diff --git a/funee-lib/filesystem/writeFileBinary.ts b/funee-lib/filesystem/writeFileBinary.ts index d5d70ff..01b6037 100644 --- a/funee-lib/filesystem/writeFileBinary.ts +++ b/funee-lib/filesystem/writeFileBinary.ts @@ -2,7 +2,7 @@ * Write binary data (Uint8Array) to a file */ -import { fsWriteFileBinary } from "../host.ts"; +import { writeFileBinary as hostWriteFileBinary } from "host://fs"; import type { FilePathString, FsResult } from "./index.ts"; import { parseResult, unwrap } from "./FsResult.ts"; import { base64Encode } from "./readFileBinary.ts"; @@ -21,7 +21,7 @@ import { base64Encode } from "./readFileBinary.ts"; */ export const writeFileBinaryRaw = (path: FilePathString, data: Uint8Array): FsResult => { const base64 = base64Encode(data); - const json = fsWriteFileBinary(path, base64); + const json = hostWriteFileBinary(path, base64); return parseResult(json); }; diff --git a/funee-lib/host/console.d.ts b/funee-lib/host/console.d.ts new file mode 100644 index 0000000..644e3bb --- /dev/null +++ b/funee-lib/host/console.d.ts @@ -0,0 +1,30 @@ +/** + * Host Console Module + * + * Provides console output functions. + * Import from "host://console" + */ + +/** + * Log a message to stdout + * + * @param args - Values to log (will be joined with spaces) + * + * @example + * ```typescript + * import { log } from "host://console"; + * + * log("Hello", "world", 42); // "Hello world 42" + * ``` + */ +export declare function log(...args: unknown[]): void; + +/** + * Log a debug message + * + * Debug messages may be filtered or formatted differently + * depending on the runtime configuration. + * + * @param args - Values to log (will be joined with spaces) + */ +export declare function debug(...args: unknown[]): void; diff --git a/funee-lib/host/crypto.d.ts b/funee-lib/host/crypto.d.ts new file mode 100644 index 0000000..6637746 --- /dev/null +++ b/funee-lib/host/crypto.d.ts @@ -0,0 +1,22 @@ +/** + * Host Crypto Module + * + * Provides cryptographic utilities. + * Import from "host://crypto" + */ + +/** + * Generate cryptographically secure random bytes + * + * @param length - Number of random bytes to generate + * @returns Uint8Array of random bytes + * + * @example + * ```typescript + * import { randomBytes } from "host://crypto"; + * + * const bytes = randomBytes(32); + * const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + * ``` + */ +export declare function randomBytes(length: number): Uint8Array; diff --git a/funee-lib/host/fs.d.ts b/funee-lib/host/fs.d.ts new file mode 100644 index 0000000..d8903da --- /dev/null +++ b/funee-lib/host/fs.d.ts @@ -0,0 +1,79 @@ +/** + * Host Filesystem Module + * + * Provides file system operations. + * Import from "host://fs" + * + * Note: Functions return JSON strings with result format: + * { type: "ok", value: ... } or { type: "error", error: "..." } + */ + +/** + * File/directory stats returned by lstat() + */ +export interface FileStats { + /** Size in bytes */ + size: number; + /** True if this is a regular file */ + is_file: boolean; + /** True if this is a directory */ + is_directory: boolean; + /** Last modification time as milliseconds since epoch */ + modified_ms: number; +} + +/** + * Read a file as UTF-8 text + * @returns JSON string with result format + */ +export declare function readFile(path: string): string; + +/** + * Read a file as binary data (base64 encoded) + * @returns JSON string with result format (value is base64 string) + */ +export declare function readFileBinary(path: string): string; + +/** + * Write text content to a file + * @returns JSON string with result format + */ +export declare function writeFile(path: string, content: string): string; + +/** + * Write binary data to a file (base64 encoded) + * @returns JSON string with result format + */ +export declare function writeFileBinary(path: string, contentBase64: string): string; + +/** + * Check if path is a regular file + */ +export declare function isFile(path: string): boolean; + +/** + * Check if path exists (file or directory) + */ +export declare function exists(path: string): boolean; + +/** + * Get file/directory stats (does not follow symlinks) + * @returns FileStats object (automatically parsed from JSON) + */ +export declare function lstat(path: string): string; + +/** + * Create a directory + */ +export declare function mkdir(path: string, recursive?: boolean): void; + +/** + * Read directory contents + * @returns JSON string with array of entry names + */ +export declare function readdir(path: string): string; + +/** + * Get the system temporary directory path + */ +export declare function tmpdir(): string; diff --git a/funee-lib/host/http.d.ts b/funee-lib/host/http.d.ts new file mode 100644 index 0000000..762007b --- /dev/null +++ b/funee-lib/host/http.d.ts @@ -0,0 +1,28 @@ +/** + * Host HTTP Module + * + * Provides web-standard fetch API. + * Import from "host://http" + */ + +/** + * Fetch a resource from the network + * + * This is the web-standard fetch() function. + * + * @param input - URL string, URL object, or Request object + * @param init - Optional request configuration + * @returns Promise resolving to the Response + * + * @example + * ```typescript + * import { fetch } from "host://http"; + * + * const response = await fetch("https://api.example.com/data"); + * const data = await response.json(); + * ``` + */ +export declare function fetch( + input: string | URL | Request, + init?: RequestInit +): Promise; diff --git a/funee-lib/host/index.d.ts b/funee-lib/host/index.d.ts new file mode 100644 index 0000000..82b082e --- /dev/null +++ b/funee-lib/host/index.d.ts @@ -0,0 +1,38 @@ +/** + * Host Module Type Declarations + * + * This file references all host module declarations for TypeScript. + * These types describe the interfaces provided by funee's host runtime + * via the host:// import scheme. + * + * @example + * ```typescript + * import { readFile, writeFile } from "host://fs"; + * import { fetch } from "host://http"; + * import { serve } from "host://server"; + * import { spawn } from "host://process"; + * import { setTimeout } from "host://time"; + * import { watchFile } from "host://watch"; + * import { randomBytes } from "host://crypto"; + * import { log } from "host://console"; + * ``` + */ + +/// +/// +/// +/// +/// +/// +/// +/// + +// Re-export all types for convenience +export * from "./fs.d.ts"; +export * from "./http.d.ts"; +export * from "./server.d.ts"; +export * from "./process.d.ts"; +export * from "./time.d.ts"; +export * from "./watch.d.ts"; +export * from "./crypto.d.ts"; +export * from "./console.d.ts"; diff --git a/funee-lib/host/process.d.ts b/funee-lib/host/process.d.ts new file mode 100644 index 0000000..7f5ee34 --- /dev/null +++ b/funee-lib/host/process.d.ts @@ -0,0 +1,133 @@ +/** + * Host Process Module + * + * Provides subprocess spawning functionality. + * Import from "host://process" + */ + +/** + * Signal types supported for process killing + */ +export type Signal = + | "SIGTERM" + | "SIGKILL" + | "SIGINT" + | "SIGHUP" + | "SIGQUIT"; + +/** + * Options for spawning a subprocess + */ +export interface SpawnOptions { + /** Command and arguments as array */ + cmd: string[]; + + /** Working directory for the process */ + cwd?: string; + + /** Environment variables (replaces or merges with process env) */ + env?: Record; + + /** Inherit environment and merge with env option (default: true) */ + inheritEnv?: boolean; + + /** How to handle stdin: "piped" | "inherit" | "null" (default: "null") */ + stdin?: "piped" | "inherit" | "null"; + + /** How to handle stdout: "piped" | "inherit" | "null" (default: "piped") */ + stdout?: "piped" | "inherit" | "null"; + + /** How to handle stderr: "piped" | "inherit" | "null" (default: "piped") */ + stderr?: "piped" | "inherit" | "null"; +} + +/** + * Process exit status + */ +export interface ProcessStatus { + /** True if process exited with code 0 */ + success: boolean; + + /** Exit code (null if terminated by signal) */ + code: number | null; + + /** Signal that terminated the process (null if normal exit) */ + signal: Signal | null; +} + +/** + * Output from a completed process + */ +export interface CommandOutput { + /** Process exit status */ + status: ProcessStatus; + + /** Stdout as Uint8Array (empty if stdout not piped) */ + stdout: Uint8Array; + + /** Stderr as Uint8Array (empty if stderr not piped) */ + stderr: Uint8Array; + + /** Convenience: stdout decoded as UTF-8 string */ + stdoutText(): string; + + /** Convenience: stderr decoded as UTF-8 string */ + stderrText(): string; +} + +/** + * Handle to a running subprocess + */ +export interface Process { + /** Process ID (OS-level PID) */ + readonly pid: number; + + /** Promise that resolves with ProcessStatus when process exits */ + readonly status: Promise; + + /** Send a signal to the process */ + kill(signal?: Signal): void; + + /** Wait for process and collect all output */ + output(): Promise; + + /** Write data to stdin and close it */ + writeInput(data: string | Uint8Array): Promise; +} + +/** + * Spawn a subprocess and wait for completion (simple form) + * + * @param command - Command to execute + * @param args - Optional command arguments + * @returns Promise resolving to CommandOutput when process exits + * + * @example + * ```typescript + * import { spawn } from "host://process"; + * + * const result = await spawn("echo", ["hello"]); + * console.log(result.stdoutText()); // "hello\n" + * ``` + */ +export declare function spawn(command: string, args?: string[]): Promise; + +/** + * Spawn a subprocess with full control (options form) + * + * @param options - Spawn configuration + * @returns Process handle for interacting with the running process + * + * @example + * ```typescript + * import { spawn } from "host://process"; + * + * const proc = spawn({ + * cmd: ["cat"], + * stdin: "piped", + * }); + * await proc.writeInput("hello"); + * const output = await proc.output(); + * ``` + */ +export declare function spawn(options: SpawnOptions): Process; diff --git a/funee-lib/host/server.d.ts b/funee-lib/host/server.d.ts new file mode 100644 index 0000000..af7596d --- /dev/null +++ b/funee-lib/host/server.d.ts @@ -0,0 +1,74 @@ +/** + * Host HTTP Server Module + * + * Provides HTTP server functionality. + * Import from "host://http/server" + */ + +/** + * Request handler function type + */ +export type RequestHandler = (request: Request) => Response | Promise; + +/** + * Options for serve() + */ +export interface ServeOptions { + /** Port to listen on. Use 0 for random available port. */ + port: number; + /** Hostname to bind to. Default: "127.0.0.1" */ + hostname?: string; + /** Called when server starts listening */ + onListen?: (info: { port: number; hostname: string }) => void; + /** Called when handler throws an error */ + onError?: (error: Error) => Response | Promise; +} + +/** + * Server handle returned by serve() + */ +export interface Server { + /** Port the server is listening on */ + readonly port: number; + /** Hostname the server is bound to */ + readonly hostname: string; + /** Gracefully shutdown the server */ + shutdown(): Promise; + /** Async disposable - calls shutdown() when disposed */ + [Symbol.asyncDispose](): Promise; +} + +/** + * Create an HTTP server + * + * @param options - Server options including port and callbacks + * @param handler - Request handler function + * @returns Server handle with shutdown method + * + * @example + * ```typescript + * import { serve } from "host://http/server"; + * + * const server = serve({ port: 3000 }, (req) => new Response("Hello, World!")); + * await server.shutdown(); + * ``` + */ +export declare function serve(options: ServeOptions, handler: RequestHandler): Server; + +/** + * Create a Response with optional body and init + * + * @param body - Response body (string or null) + * @param init - Optional ResponseInit configuration + * @returns Response object + */ +export declare function createResponse(body?: string | null, init?: ResponseInit): Response; + +/** + * Create a JSON Response + * + * @param data - Data to serialize as JSON + * @param init - Optional ResponseInit configuration (Content-Type is set automatically) + * @returns Response with JSON body and appropriate headers + */ +export declare function createJsonResponse(data: unknown, init?: ResponseInit): Response; diff --git a/funee-lib/host/time.d.ts b/funee-lib/host/time.d.ts new file mode 100644 index 0000000..124ee01 --- /dev/null +++ b/funee-lib/host/time.d.ts @@ -0,0 +1,38 @@ +/** + * Host Time Module + * + * Provides timer functions. + * Import from "host://time" + */ + +/** + * Schedule a callback to run after a delay + * + * @param callback - Function to call after delay + * @param ms - Delay in milliseconds + * @returns Timer ID that can be passed to clearTimeout + */ +export declare function setTimeout(callback: () => void, ms: number): number; + +/** + * Cancel a timeout scheduled with setTimeout + * + * @param id - Timer ID returned by setTimeout + */ +export declare function clearTimeout(id: number): void; + +/** + * Schedule a callback to run repeatedly at an interval + * + * @param callback - Function to call at each interval + * @param ms - Interval in milliseconds + * @returns Timer ID that can be passed to clearInterval + */ +export declare function setInterval(callback: () => void, ms: number): number; + +/** + * Cancel an interval scheduled with setInterval + * + * @param id - Timer ID returned by setInterval + */ +export declare function clearInterval(id: number): void; diff --git a/funee-lib/host/watch.d.ts b/funee-lib/host/watch.d.ts new file mode 100644 index 0000000..9938e6b --- /dev/null +++ b/funee-lib/host/watch.d.ts @@ -0,0 +1,44 @@ +/** + * Host Watch Module + * + * Provides file system watching functionality. + * Import from "host://watch" + */ + +/** + * Start watching a path for changes + * + * @param path - Path to watch + * @param recursive - Whether to watch subdirectories + * @returns JSON string with watcher ID or error + * + * @example + * ```typescript + * import { watchStart, watchPoll, watchStop } from "host://watch"; + * + * const result = JSON.parse(watchStart("./src", true)); + * const watcherId = result.value; + * + * // Poll for events + * const events = JSON.parse(watchPoll(watcherId)); + * + * // Stop watching + * watchStop(watcherId); + * ``` + */ +export declare function watchStart(path: string, recursive: boolean): string; + +/** + * Poll for watch events from a watcher + * + * @param watcherId - The watcher ID from watchStart + * @returns JSON string with array of events, or "null" if no events + */ +export declare function watchPoll(watcherId: number): string; + +/** + * Stop a watcher + * + * @param watcherId - The watcher ID from watchStart + */ +export declare function watchStop(watcherId: number): void; diff --git a/funee-lib/http/fetch.ts b/funee-lib/http/fetch.ts index 8feb29e..181bcba 100644 --- a/funee-lib/http/fetch.ts +++ b/funee-lib/http/fetch.ts @@ -155,8 +155,9 @@ declare global { function fetch(input: string | URL | Request, init?: RequestInit): Promise; } -// Re-export the global fetch for explicit imports -export const fetch = globalThis.fetch; +// Import fetch from host://http for explicit imports +import { fetch as hostFetch } from "host://http"; +export const fetch = hostFetch; // ============================================================================ // Factory function aliases (for funee-style API) diff --git a/funee-lib/http/httpFetch.ts b/funee-lib/http/httpFetch.ts index 8894edd..91dd9c1 100644 --- a/funee-lib/http/httpFetch.ts +++ b/funee-lib/http/httpFetch.ts @@ -1,19 +1,12 @@ /** - * httpFetch - Low-level host function for HTTP requests + * httpFetch - Low-level HTTP response types * - * This is the raw host function provided by the funee runtime. * For most use cases, prefer the higher-level httpRequest, httpGetJSON, - * or httpPostJSON functions. - * - * The httpFetch function is exported from "funee" (via host.ts). - * This module re-exports the type and provides helper functions. + * or httpPostJSON functions, or use the standard fetch API from "host://http". */ -// Re-export httpFetch from the main funee module (it's a host function) -export { httpFetch } from "../host.ts"; - /** - * Parsed HTTP response from httpFetch. + * Parsed HTTP response from httpRequest. */ export interface HttpResponse { /** HTTP status code */ @@ -25,9 +18,11 @@ export interface HttpResponse { } /** - * Parse the JSON response from httpFetch. + * Parse a JSON string to HttpResponse. + * + * @deprecated This is for backwards compatibility. Use fetch from "host://http" instead. * - * @param jsonResponse - The JSON string from httpFetch + * @param jsonResponse - The JSON string to parse * @returns Parsed HttpResponse object */ export const parseHttpResponse = (json: string): HttpResponse => { diff --git a/funee-lib/http/httpRequest.ts b/funee-lib/http/httpRequest.ts index e3dd95e..2b90c70 100644 --- a/funee-lib/http/httpRequest.ts +++ b/funee-lib/http/httpRequest.ts @@ -17,9 +17,8 @@ */ import { HttpTarget, targetToURL } from "./HttpTarget.ts"; -import { HttpResponse, parseHttpResponse } from "./httpFetch.ts"; -// Import httpFetch from "funee" so it's recognized as a host function -import { httpFetch } from "funee"; +import { HttpResponse } from "./httpFetch.ts"; +import { fetch } from "host://http"; /** * HTTP methods supported by httpRequest. @@ -69,14 +68,23 @@ export const httpRequest = async ( const { method, target, headers: customHeaders, body: requestBody } = options; const url = targetToURL(target); const headers = customHeaders ?? {}; - const body = requestBody ?? ""; + const body = requestBody ?? null; - const responseJson = await httpFetch( + const response = await fetch(url, { method, - url, - JSON.stringify(headers), - body - ); + headers, + body: body || undefined, + }); - return parseHttpResponse(responseJson); + // Convert Response to HttpResponse format for backwards compatibility + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + status: response.status, + headers: responseHeaders, + body: await response.text(), + }; }; diff --git a/funee-lib/index.ts b/funee-lib/index.ts index 4bbc485..ef3fe1e 100644 --- a/funee-lib/index.ts +++ b/funee-lib/index.ts @@ -36,29 +36,62 @@ export type { MacroResultWithDefinitions } from "./core.ts"; -// Host functions provided by the runtime -export { - log, - debug, - randomBytes, - httpFetch, - // Filesystem host functions (low-level - use the wrappers in filesystem/ instead) - fsReadFile, - fsReadFileBinary, - fsWriteFile, - fsWriteFileBinary, - fsIsFile, - fsLstat, - fsReaddir, - fsExists, - fsMkdir, - // OS host functions (low-level - use the wrappers in os/ instead) - tmpdir as tmpdirHost, - // Watcher host functions (low-level - use the wrappers in watcher/ instead) - watchStart, - watchPoll, - watchStop, -} from "./host.ts"; +// ============================================================================ +// Host Functions (deprecated - import from host:// instead) +// ============================================================================ + +/** @deprecated Import from "host://console" instead */ +export { log } from "host://console"; + +/** @deprecated Import from "host://console" instead */ +export { debug } from "host://console"; + +/** @deprecated Import from "host://crypto" instead */ +export { randomBytes } from "host://crypto"; + +/** @deprecated Import from "host://http" instead (use fetch) */ +export { fetch as httpFetch } from "host://http"; + +// Filesystem host functions - deprecated, import from "host://fs" instead +/** @deprecated Import from "host://fs" instead */ +export { readFile as fsReadFile } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { readFileBinary as fsReadFileBinary } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { writeFile as fsWriteFile } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { writeFileBinary as fsWriteFileBinary } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { isFile as fsIsFile } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { lstat as fsLstat } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { readdir as fsReaddir } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { exists as fsExists } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { mkdir as fsMkdir } from "host://fs"; + +/** @deprecated Import from "host://fs" instead */ +export { tmpdir as tmpdirHost } from "host://fs"; + +// Watcher host functions - deprecated, import from "host://watch" instead +/** @deprecated Import from "host://watch" instead */ +export { watchStart } from "host://watch"; + +/** @deprecated Import from "host://watch" instead */ +export { watchPoll } from "host://watch"; + +/** @deprecated Import from "host://watch" instead */ +export { watchStop } from "host://watch"; // ============================================================================ // AST Types diff --git a/funee-lib/memoize/memoizeInFS.ts b/funee-lib/memoize/memoizeInFS.ts index 89521cf..bcc39a1 100644 --- a/funee-lib/memoize/memoizeInFS.ts +++ b/funee-lib/memoize/memoizeInFS.ts @@ -1,4 +1,4 @@ -import { fsReadFile, fsWriteFile, fsExists, fsMkdir } from "funee"; +import { readFile as hostReadFile, writeFile as hostWriteFile, exists as hostExists, mkdir as hostMkdir } from "host://fs"; import { parseResult, unwrap, FsResult } from "../filesystem/FsResult.ts"; /** @@ -43,16 +43,13 @@ export const memoizeInFS = any>( if (!inProgress[hash]) { inProgress[hash] = (async () => { // Ensure cache directory exists - if (!fsExists("./cache")) { - const mkdirResult = parseResult(fsMkdir("./cache")); - if (mkdirResult.type === "error") { - throw new Error(`Failed to create cache directory: ${mkdirResult.error}`); - } + if (!hostExists("./cache")) { + hostMkdir("./cache"); } // Check for cached result - if (fsExists(cachePath)) { - const readResult = parseResult(fsReadFile(cachePath)) as FsResult; + if (hostExists(cachePath)) { + const readResult = parseResult(hostReadFile(cachePath)) as FsResult; if (readResult.type === "ok") { const cachedValue = readResult.value; if (cachedValue === "undefined") { @@ -68,7 +65,7 @@ export const memoizeInFS = any>( // Write to cache const toCache = result === undefined ? "undefined" : JSON.stringify(result); - const writeResult = parseResult(fsWriteFile(cachePath, toCache)); + const writeResult = parseResult(hostWriteFile(cachePath, toCache)); if (writeResult.type === "error") { throw new Error(`Failed to write cache: ${writeResult.error}`); } diff --git a/funee-lib/os/tmpdir.ts b/funee-lib/os/tmpdir.ts index a2b79bf..f1d5eac 100644 --- a/funee-lib/os/tmpdir.ts +++ b/funee-lib/os/tmpdir.ts @@ -1,4 +1,4 @@ -import { tmpdir as tmpdirHost } from "funee"; +import { tmpdir as hostTmpdir } from "host://fs"; import type { AbsolutePathString } from "../filesystem/index.ts"; /** @@ -15,5 +15,5 @@ import type { AbsolutePathString } from "../filesystem/index.ts"; * ``` */ export const tmpdir = (): AbsolutePathString => { - return tmpdirHost() as AbsolutePathString; + return hostTmpdir() as AbsolutePathString; }; diff --git a/funee-lib/process/index.ts b/funee-lib/process/index.ts index 162d55f..3d56d81 100644 --- a/funee-lib/process/index.ts +++ b/funee-lib/process/index.ts @@ -25,17 +25,12 @@ * ``` */ +import { spawn as hostSpawn } from "host://process"; import type { SpawnOptions, ProcessStatus, CommandOutput, Process, Signal } from "./types.ts"; // Re-export types export type { SpawnOptions, ProcessStatus, CommandOutput, Process, Signal } from "./types.ts"; -// Declare the global spawn function (provided by runtime bootstrap) -declare global { - function spawn(command: string, args?: string[]): Promise; - function spawn(options: SpawnOptions): Process; -} - /** * Spawn a subprocess. * @@ -67,4 +62,4 @@ declare global { * console.log(filtered.stdoutText()); // "ERROR: something bad\n" * ``` */ -export const spawn = globalThis.spawn; +export const spawn = hostSpawn; diff --git a/funee-lib/random/cryptoRandomString.ts b/funee-lib/random/cryptoRandomString.ts index 29dadb3..cbd9ef9 100644 --- a/funee-lib/random/cryptoRandomString.ts +++ b/funee-lib/random/cryptoRandomString.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "funee"; +import { randomBytes } from "host://crypto"; /** * Generate a cryptographically secure random string of the specified length. @@ -18,9 +18,9 @@ import { randomBytes } from "funee"; * ``` */ export const cryptoRandomString = (desiredLength: number): string => { - // randomBytes returns hex string of 2*n chars for n bytes - // So for a string of desiredLength hex chars, we need ceil(desiredLength/2) bytes + // randomBytes returns Uint8Array, convert to hex string const bytesNeeded = Math.ceil(desiredLength / 2); - const hex = randomBytes(bytesNeeded); + const bytes = randomBytes(bytesNeeded); + const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); return hex.slice(0, desiredLength); }; diff --git a/funee-lib/server/serve.ts b/funee-lib/server/serve.ts index 10c3e7a..d3876fd 100644 --- a/funee-lib/server/serve.ts +++ b/funee-lib/server/serve.ts @@ -18,6 +18,8 @@ * ``` */ +import { serve as hostServe } from "host://http/server"; + /** * Request handler function */ @@ -51,14 +53,6 @@ export type Server = { [Symbol.asyncDispose]: () => Promise; }; -// Declare the global serve function (provided by runtime bootstrap) -declare global { - function serve( - options: ServeOptions, - handler: RequestHandler, - ): Server; -} - /** * Create an HTTP server * @@ -77,7 +71,7 @@ declare global { * }); * ``` */ -export const serve: ( +export const serve = hostServe as ( options: ServeOptions, handler: RequestHandler, -) => Server = globalThis.serve; +) => Server; diff --git a/funee-lib/watcher/index.ts b/funee-lib/watcher/index.ts index 51ea179..27fe6a7 100644 --- a/funee-lib/watcher/index.ts +++ b/funee-lib/watcher/index.ts @@ -5,8 +5,7 @@ * Uses the notify crate on the Rust side for cross-platform support. */ -// Import from "funee" to ensure host functions are properly resolved -import { watchStart, watchPoll, watchStop } from "funee"; +import { watchStart, watchPoll, watchStop } from "host://watch"; /** * Event kinds emitted by watchers diff --git a/scripts/prepare-sut.sh b/scripts/prepare-sut.sh new file mode 100755 index 0000000..458b0f6 --- /dev/null +++ b/scripts/prepare-sut.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SUT_DIR="${REPO_ROOT}/target/sut" +SUT_BIN="${SUT_DIR}/funee" + +echo "Building SUT from current source..." +cargo build --release --manifest-path "${REPO_ROOT}/Cargo.toml" + +mkdir -p "${SUT_DIR}" +cp "${REPO_ROOT}/target/release/funee" "${SUT_BIN}" +chmod +x "${SUT_BIN}" + +echo "SUT prepared at ${SUT_BIN}" +"${SUT_BIN}" --version || true diff --git a/scripts/run-self-hosted.sh b/scripts/run-self-hosted.sh new file mode 100755 index 0000000..7979024 --- /dev/null +++ b/scripts/run-self-hosted.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNNER_BIN="${FUNEE_RUNNER_BIN:-funee}" +SUT_BIN="${FUNEE_SUT_BIN:-${REPO_ROOT}/target/sut/funee}" + +if [[ ! -x "${SUT_BIN}" ]]; then + echo "SUT binary not found at ${SUT_BIN}" + echo "Run ./scripts/prepare-sut.sh first or set FUNEE_SUT_BIN." + exit 1 +fi + +echo "Runner binary: ${RUNNER_BIN}" +echo "SUT binary: ${SUT_BIN}" + +# Self-hosted tests refer to ./target/sut/funee from repo root. +cd "${REPO_ROOT}" + +"${RUNNER_BIN}" --version || true +"${SUT_BIN}" --version || true + +"${RUNNER_BIN}" tests/self-hosted/basic.ts +"${RUNNER_BIN}" tests/self-hosted/stdlib.ts +"${RUNNER_BIN}" tests/self-hosted/http.ts +"${RUNNER_BIN}" tests/self-hosted/misc.ts diff --git a/src/execution_request/declaration.rs b/src/execution_request/declaration.rs index 9dcb1d0..6ac01e8 100644 --- a/src/execution_request/declaration.rs +++ b/src/execution_request/declaration.rs @@ -21,6 +21,9 @@ pub enum Declaration { ClosureValue(Closure), FuneeIdentifier(FuneeIdentifier), HostFn(String), + /// Host module export (namespace, export_name) + /// e.g., ("fs", "readFile") for `import { readFile } from "host://fs"` + HostModule(String, String), } fn ident(name: &str) -> Ident { @@ -155,6 +158,27 @@ impl Declaration { }), })) } + Declaration::HostModule(namespace, export_name) => { + // Generate: var name = __host_namespace.export_name; + // The __host_* objects are defined in the bundle preamble + let host_obj_name = format!("__host_{}", namespace.replace('/', "_")); + Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: Default::default(), + ctxt: SyntaxContext::empty(), + kind: VarDeclKind::Var, + declare: false, + decls: vec![VarDeclarator { + span: Default::default(), + name: Pat::Ident(ident(&name).into()), + init: Some(Box::new(Expr::Member(MemberExpr { + span: Default::default(), + obj: Box::new(Expr::Ident(ident(&host_obj_name))), + prop: MemberProp::Ident(ident_name(&export_name)), + }))), + definite: false, + }], + }))) + } }) } } diff --git a/src/execution_request/get_references_from_declaration.rs b/src/execution_request/get_references_from_declaration.rs index 5f1aa5a..c934530 100644 --- a/src/execution_request/get_references_from_declaration.rs +++ b/src/execution_request/get_references_from_declaration.rs @@ -24,6 +24,7 @@ pub fn get_references_from_declaration( } Declaration::FuneeIdentifier(_) => HashSet::new(), Declaration::HostFn(_) => HashSet::new(), + Declaration::HostModule(_, _) => HashSet::new(), } } @@ -82,6 +83,7 @@ pub fn rename_references_in_declaration( } Declaration::FuneeIdentifier(_) => {} Declaration::HostFn(_) => {} + Declaration::HostModule(_, _) => {} }; } diff --git a/src/execution_request/source_graph.rs b/src/execution_request/source_graph.rs index 8988adc..cd34fd9 100644 --- a/src/execution_request/source_graph.rs +++ b/src/execution_request/source_graph.rs @@ -68,10 +68,16 @@ fn is_http_uri(uri: &str) -> bool { uri.starts_with("http://") || uri.starts_with("https://") } +/// Check if a URI is a host:// URL (built-in host modules) +fn is_host_uri(uri: &str) -> bool { + uri.starts_with("host://") +} + /// Resolve an import URI against the current module's URI /// /// Handles: /// - "funee" -> funee-lib path +/// - "host://*" -> host module URIs (returned as-is) /// - HTTP URLs (absolute) -> used as-is /// - Relative paths from HTTP URLs -> resolved against base URL /// - Absolute paths (/) from HTTP URLs -> resolved against HTTP server root @@ -86,6 +92,11 @@ fn resolve_import_uri(import_uri: &str, base_uri: &str, funee_lib_path: &Option< }); } + // Handle host:// URIs - return as-is + if is_host_uri(import_uri) { + return import_uri.to_string(); + } + // If import is already an absolute HTTP URL, use it directly if is_http_uri(import_uri) { return import_uri.to_string(); @@ -243,6 +254,18 @@ impl SourceGraph { } else { let mut current_identifier = reference.1.clone(); loop { + // Check for host:// URIs - these are built-in host modules + if is_host_uri(¤t_identifier.uri) { + let namespace = current_identifier.uri + .strip_prefix("host://") + .unwrap() + .to_string(); + break ( + Declaration::HostModule(namespace, current_identifier.name.clone()), + current_identifier.uri.clone(), + ); + } + let err_source = source_uri.clone(); let err_name = current_identifier.name.clone(); let err_module = current_identifier.uri.clone(); @@ -504,4 +527,37 @@ mod tests { ); assert_eq!(result, "/home/user/project/src/utils.ts"); } + + #[test] + fn test_resolve_import_uri_host_scheme() { + // host:// URIs should be returned as-is + let result = resolve_import_uri( + "host://fs", + "/home/user/project/main.ts", + &None, + ); + assert_eq!(result, "host://fs"); + } + + #[test] + fn test_resolve_import_uri_host_scheme_with_path() { + // host:// URIs with paths should be returned as-is + let result = resolve_import_uri( + "host://http/server", + "/home/user/project/main.ts", + &None, + ); + assert_eq!(result, "host://http/server"); + } + + #[test] + fn test_is_host_uri() { + assert!(is_host_uri("host://fs")); + assert!(is_host_uri("host://http/server")); + assert!(is_host_uri("host://console")); + assert!(!is_host_uri("http://example.com")); + assert!(!is_host_uri("https://example.com")); + assert!(!is_host_uri("/local/path")); + assert!(!is_host_uri("./relative")); + } } diff --git a/src/execution_request/source_graph_to_js_execution_code.rs b/src/execution_request/source_graph_to_js_execution_code.rs index 25c3bd7..3f8b44b 100644 --- a/src/execution_request/source_graph_to_js_execution_code.rs +++ b/src/execution_request/source_graph_to_js_execution_code.rs @@ -24,6 +24,14 @@ impl SourceGraph { // First, expand all macro calls in the graph self.expand_macros(); + // Collect all host modules used in the graph + let mut host_namespaces: std::collections::HashSet = std::collections::HashSet::new(); + for (_, declaration) in self.graph.node_weights() { + if let Declaration::HostModule(namespace, _) = declaration { + host_namespaces.insert(namespace.clone()); + } + } + let mut module_items: Vec = vec![]; let mut dfs = DfsPostOrder::new(&self.graph, self.root); while let Some(nx) = dfs.next(&self.graph) { @@ -67,7 +75,11 @@ impl SourceGraph { let (mut srcmap, buf) = emit_module(self.source_map.clone(), module); let code = String::from_utf8(buf).expect("failed to convert to utf8"); let srcmap_str = get_inline_source_map(&self.source_map, &mut srcmap); - format!("{}{}", code, srcmap_str) + + // Generate host module preamble if any host modules are used + let preamble = generate_host_module_preamble(&host_namespaces); + + format!("{}{}{}", preamble, code, srcmap_str) } /// Expand all macro calls in the graph before emitting @@ -355,3 +367,84 @@ impl SourceGraph { } } } + +/// Generate JavaScript code that defines host module objects +/// These are inlined in the bundle preamble for modules like host://fs, host://http, etc. +fn generate_host_module_preamble(namespaces: &std::collections::HashSet) -> String { + if namespaces.is_empty() { + return String::new(); + } + + let mut preamble = String::new(); + + for namespace in namespaces { + let var_name = format!("__host_{}", namespace.replace('/', "_")); + let module_code = get_host_module_code(namespace); + preamble.push_str(&format!("var {} = {};\n", var_name, module_code)); + } + + preamble +} + +/// Get the JavaScript object implementation for a host module namespace +fn get_host_module_code(namespace: &str) -> &'static str { + match namespace { + "fs" => r#"({ + readFile: (path) => Deno.core.ops.op_fsReadFile(path), + readFileBinary: (path) => Deno.core.ops.op_fsReadFileBinary(path), + writeFile: (path, content) => Deno.core.ops.op_fsWriteFile(path, content), + writeFileBinary: (path, contentBase64) => Deno.core.ops.op_fsWriteFileBinary(path, contentBase64), + isFile: (path) => Deno.core.ops.op_fsIsFile(path), + exists: (path) => Deno.core.ops.op_fsExists(path), + lstat: (path) => Deno.core.ops.op_fsLstat(path), + mkdir: (path, recursive) => Deno.core.ops.op_fsMkdir(path, recursive ?? false), + readdir: (path) => Deno.core.ops.op_fsReaddir(path), + tmpdir: () => Deno.core.ops.op_tmpdir() +})"#, + + "http" => r#"({ + fetch: globalThis.fetch +})"#, + + "http/server" => r#"({ + serve: globalThis.serve, + createResponse: (body, init) => new Response(body, init), + createJsonResponse: (data, init) => Response.json(data, init) +})"#, + + "process" => r#"({ + spawn: globalThis.spawn +})"#, + + "time" => r#"({ + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval +})"#, + + "watch" => r#"({ + watchStart: (path, recursive) => Deno.core.ops.op_watchStart(path, recursive), + watchPoll: (watcherId) => Deno.core.ops.op_watchPoll(watcherId), + watchStop: (watcherId) => Deno.core.ops.op_watchStop(watcherId) +})"#, + + "crypto" => r#"({ + randomBytes: (length) => { + const hex = Deno.core.ops.op_randomBytes(length); + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return bytes; + } +})"#, + + "console" => r#"({ + log: (...args) => console.log(...args), + debug: (...args) => console.debug(...args) +})"#, + + _ => r#"({})"#, + } +} diff --git a/src/execution_request/tests.rs b/src/execution_request/tests.rs index f3219fc..3c98bc6 100644 --- a/src/execution_request/tests.rs +++ b/src/execution_request/tests.rs @@ -321,3 +321,101 @@ export const closure = createMacro((input: T) => { println!("\n✅ Step 2 implementation working: macro call arguments are captured as Closures!"); } + +#[test] +fn test_host_module_imports() { + use crate::execution_request::declaration::Declaration; + use crate::execution_request::source_graph::{LoadParams, SourceGraph}; + use std::collections::HashSet; + use swc_common::SyntaxContext; + + // Create file content that imports from host:// + // Note: Only used imports will be included in the source graph (demand-driven) + // Note: fetch and console are JS globals, so they work without explicit import + let file_loader = Box::new(MockFileLoader { + files: HashMap::from([( + "/test/entry.ts".to_string(), + r#" +import { readFile, writeFile } from "host://fs"; +import { log } from "host://console"; +import { serve } from "host://http/server"; + +export default async function() { + log("Starting..."); + const content = await readFile("/tmp/test.txt"); + await writeFile("/tmp/output.txt", content); + serve({ port: 8080, handler: () => new Response("ok") }); +} + "# + .to_string(), + )]), + }); + + // Build the source graph + let source_graph = SourceGraph::load(LoadParams { + scope: "/test/entry.ts".to_string(), + expression: ast::Expr::Call(CallExpr { + span: Default::default(), + ctxt: SyntaxContext::empty(), + callee: Callee::Expr(Box::new(ast::Expr::Ident(ident("default")))), + type_args: None, + args: vec![], + }), + host_functions: HashSet::new(), + funee_lib_path: None, + file_loader, + }); + + // Find all HostModule declarations + let mut host_modules: Vec<(String, String)> = vec![]; + for node_idx in source_graph.graph.node_indices() { + let (_uri, declaration) = &source_graph.graph[node_idx]; + if let Declaration::HostModule(namespace, export_name) = declaration { + host_modules.push((namespace.clone(), export_name.clone())); + } + } + + // Verify expected host modules (only used ones are included) + // Note: fetch is in the JS globals list, so it's available without import + assert!( + host_modules.iter().any(|(ns, name)| ns == "fs" && name == "readFile"), + "Expected HostModule(fs, readFile). Found: {:?}", host_modules + ); + assert!( + host_modules.iter().any(|(ns, name)| ns == "fs" && name == "writeFile"), + "Expected HostModule(fs, writeFile). Found: {:?}", host_modules + ); + assert!( + host_modules.iter().any(|(ns, name)| ns == "console" && name == "log"), + "Expected HostModule(console, log). Found: {:?}", host_modules + ); + // fetch is a JS global, so importing from host://http doesn't create a HostModule + // (the global takes precedence). Users can use fetch() directly. + assert!( + host_modules.iter().any(|(ns, name)| ns == "http/server" && name == "serve"), + "Expected HostModule(http/server, serve). Found: {:?}", host_modules + ); + + println!("✅ Host module imports are resolved correctly!"); + println!(" Found modules: {:?}", host_modules); + + // Generate the execution code and verify the preamble is included + let code = source_graph.into_js_execution_code(); + + // Check that host module preambles are present + assert!( + code.contains("__host_fs"), + "Expected __host_fs in generated code. Code: {}", &code[..1000.min(code.len())] + ); + assert!( + code.contains("__host_console"), + "Expected __host_console in generated code. Code: {}", &code[..1000.min(code.len())] + ); + assert!( + code.contains("__host_http_server"), + "Expected __host_http_server in generated code (http/server -> http_server). Code: {}", &code[..1000.min(code.len())] + ); + + println!("✅ Host module preambles are generated correctly!"); + println!("\nGenerated code preview:\n{}", &code[..500.min(code.len())]); +} diff --git a/src/main.rs b/src/main.rs index 3cd743a..578215e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1220,13 +1220,19 @@ async fn op_serverStop(server_id: u32) -> Result<(), JsErrorBox> { fn main() -> Result<(), AnyError> { let args: Vec = env::args().collect(); + let show_version = args.contains(&"--version".to_string()); + if show_version { + println!("funee {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } if args.len() < 2 { - eprintln!("Usage: funee [--emit] [--reload] "); + eprintln!("Usage: funee [--emit] [--reload] [--version] "); eprintln!(""); eprintln!("Options:"); eprintln!(" --emit Print bundled JavaScript instead of executing"); eprintln!(" --reload Bypass HTTP cache and fetch fresh from network"); + eprintln!(" --version Print funee version and exit"); eprintln!(""); eprintln!("Runs the default export function from the given TypeScript file."); std::process::exit(1); @@ -1529,6 +1535,16 @@ fn main() -> Result<(), AnyError> { if path.exists() { return Some(path.to_string_lossy().to_string()); } + // Also check one directory above executable (e.g. /bin/funee and /funee-lib) + let mut path = path; + path.pop(); // index.ts + path.pop(); // funee-lib + path.pop(); // exe dir (bin) + path.push("funee-lib"); + path.push("index.ts"); + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } None }) }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index e2a0ebf..840166c 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -348,8 +348,11 @@ describe('funee CLI', () => { * * The Closure should capture both the mult expression AND the multiplier reference. */ - // TODO: Create fixture and implement this test - expect(true).toBe(true); // Placeholder + const { stdout, exitCode } = await runFunee(['macro/cross-file-ref/entry.ts']); + + expect(exitCode).toBe(0); + expect(stdout).toContain("captured.references is Map: true"); + expect(stdout).toContain("has 'add' reference: true"); }); it('expands closure macro at bundle time', async () => { @@ -375,8 +378,10 @@ describe('funee CLI', () => { * When capturing an expression that references external declarations, * the Closure should include those in its references map */ - // TODO: Test with expression that has external refs - expect(true).toBe(true); + const { stdout, exitCode } = await runFunee(['macro/references_introspection.ts']); + + expect(exitCode).toBe(0); + expect(stdout).toContain("references:has_someFunc=1"); }); // ===== STEP 3: MACRO EXECUTION TESTS ===== diff --git a/tests/fixtures/macro/array_macro.ts b/tests/fixtures/macro/array_macro.ts new file mode 100644 index 0000000..72deae2 --- /dev/null +++ b/tests/fixtures/macro/array_macro.ts @@ -0,0 +1,17 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const makeArray = createMacro((...args: any[]) => { + return { + expression: `[${args.map((arg) => arg.expression).join(", ")}]`, + references: new Map(), + }; +}); + +const arr = makeArray(1, 2, 3); + +export default () => { + log(`array:first=${arr[0]}`); + log(`array:third=${arr[2]}`); +}; diff --git a/tests/fixtures/macro/conditional_macro.ts b/tests/fixtures/macro/conditional_macro.ts new file mode 100644 index 0000000..146685c --- /dev/null +++ b/tests/fixtures/macro/conditional_macro.ts @@ -0,0 +1,25 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +// If expression is already a multiplication, leave it unchanged. +const smartDouble = createMacro((arg: any) => { + const expr = String(arg.expression).trim(); + if (expr.includes("*")) { + return { + expression: expr, + references: arg.references, + }; + } + + return { + expression: `(${expr}) * 2`, + references: arg.references, + }; +}); + +const result = smartDouble(5); + +export default () => { + log(`conditional:result=${result}`); +}; diff --git a/tests/fixtures/macro/conditional_macro_already_multiplied.ts b/tests/fixtures/macro/conditional_macro_already_multiplied.ts new file mode 100644 index 0000000..5564919 --- /dev/null +++ b/tests/fixtures/macro/conditional_macro_already_multiplied.ts @@ -0,0 +1,24 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const smartDouble = createMacro((arg: any) => { + const expr = String(arg.expression).trim(); + if (expr.includes("*")) { + return { + expression: expr, + references: arg.references, + }; + } + + return { + expression: `(${expr}) * 2`, + references: arg.references, + }; +}); + +const result = smartDouble(5 * 2); + +export default () => { + log(`conditional_already_multiplied:result=${result}`); +}; diff --git a/tests/fixtures/macro/debug_sequence_macro.ts b/tests/fixtures/macro/debug_sequence_macro.ts new file mode 100644 index 0000000..0963044 --- /dev/null +++ b/tests/fixtures/macro/debug_sequence_macro.ts @@ -0,0 +1,22 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const debug = createMacro((arg: any) => { + const exprType = String(arg.expression).includes("+") + ? "BinaryExpression" + : "Expression"; + + return { + expression: `(log("[DEBUG] Expression type: ${exprType}"), (${arg.expression}))`, + references: arg.references, + }; +}); + +const x = 5; +const y = 10; +const result = debug(x + y); + +export default () => { + log(`debug:result=${result}`); +}; diff --git a/tests/fixtures/macro/introspection_macro.ts b/tests/fixtures/macro/introspection_macro.ts new file mode 100644 index 0000000..21f9e9e --- /dev/null +++ b/tests/fixtures/macro/introspection_macro.ts @@ -0,0 +1,25 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const getExprType = createMacro((arg: any) => { + const expr = String(arg.expression).trim(); + + let exprType = "Unknown"; + if (/^-?\d+(\.\d+)?$/.test(expr)) { + exprType = "NumericLiteral"; + } else if (/[+\-*/]/.test(expr)) { + exprType = "BinaryExpression"; + } + + return { + expression: `"${exprType}"`, + references: new Map(), + }; +}); + +const type = getExprType(5 + 3); + +export default () => { + log(`introspection:type=${type}`); +}; diff --git a/tests/fixtures/macro/member_macro.ts b/tests/fixtures/macro/member_macro.ts new file mode 100644 index 0000000..2c156d9 --- /dev/null +++ b/tests/fixtures/macro/member_macro.ts @@ -0,0 +1,17 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const getProperty = createMacro((objArg: any, propArg: any) => { + return { + expression: `(${objArg.expression})[${propArg.expression}]`, + references: objArg.references, + }; +}); + +const obj = { value: 42, name: "test" }; +const value = getProperty(obj, "value"); + +export default () => { + log(`member:value=${value}`); +}; diff --git a/tests/fixtures/macro/multi_arg_compare.ts b/tests/fixtures/macro/multi_arg_compare.ts new file mode 100644 index 0000000..2025812 --- /dev/null +++ b/tests/fixtures/macro/multi_arg_compare.ts @@ -0,0 +1,19 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const assertEqual = createMacro((expected: any, actual: any) => { + const left = String(expected.expression).trim(); + const right = String(actual.expression).trim(); + + return { + expression: left === right ? "1" : "0", + references: new Map(), + }; +}); + +const result = assertEqual(5, 5); + +export default () => { + log(`multiarg:result=${result}`); +}; diff --git a/tests/fixtures/macro/object_macro.ts b/tests/fixtures/macro/object_macro.ts new file mode 100644 index 0000000..00d4c92 --- /dev/null +++ b/tests/fixtures/macro/object_macro.ts @@ -0,0 +1,17 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const makeConfig = createMacro((nameArg: any, valueArg: any) => { + return { + expression: `({ name: ${nameArg.expression}, value: ${valueArg.expression} })`, + references: new Map(), + }; +}); + +const config = makeConfig("test", 42); + +export default () => { + log(`object:name=${config.name}`); + log(`object:value=${config.value}`); +}; diff --git a/tests/fixtures/macro/references_introspection.ts b/tests/fixtures/macro/references_introspection.ts new file mode 100644 index 0000000..056fae8 --- /dev/null +++ b/tests/fixtures/macro/references_introspection.ts @@ -0,0 +1,18 @@ +import { log } from "funee"; +import { someFunc } from "./references_values.ts"; + +const createMacro = (fn: any) => fn; + +const checkHasReference = createMacro((arg: any) => { + const hasRef = arg.references.has("someFunc") ? 1 : 0; + return { + expression: `${hasRef}`, + references: new Map(), + }; +}); + +const result = checkHasReference(someFunc()); + +export default () => { + log(`references:has_someFunc=${result}`); +}; diff --git a/tests/fixtures/macro/references_values.ts b/tests/fixtures/macro/references_values.ts new file mode 100644 index 0000000..39d21c9 --- /dev/null +++ b/tests/fixtures/macro/references_values.ts @@ -0,0 +1,3 @@ +export function someFunc() { + return 42; +} diff --git a/tests/fixtures/macro/variadic_numeric_count.ts b/tests/fixtures/macro/variadic_numeric_count.ts new file mode 100644 index 0000000..4340a59 --- /dev/null +++ b/tests/fixtures/macro/variadic_numeric_count.ts @@ -0,0 +1,24 @@ +import { log } from "funee"; + +const createMacro = (fn: any) => fn; + +const countNumericArgs = createMacro((...args: any[]) => { + let count = 0; + for (const arg of args) { + const expr = String(arg.expression).trim(); + if (/^-?\d+(\.\d+)?$/.test(expr)) { + count++; + } + } + + return { + expression: `${count}`, + references: new Map(), + }; +}); + +const count = countNumericArgs(5, "hello", 10); + +export default () => { + log(`variadic:count=${count}`); +}; diff --git a/tests/self-hosted/_sut.ts b/tests/self-hosted/_sut.ts new file mode 100644 index 0000000..cf75f35 --- /dev/null +++ b/tests/self-hosted/_sut.ts @@ -0,0 +1 @@ +export const FUNEE_SUT_BIN = "./target/sut/funee"; diff --git a/tests/self-hosted/basic.ts b/tests/self-hosted/basic.ts index af015e6..f43d512 100644 --- a/tests/self-hosted/basic.ts +++ b/tests/self-hosted/basic.ts @@ -2,79 +2,67 @@ import { log, scenario, runScenarios, - Closure, + closure, assertThat, is, contains, spawn, greaterThan, } from "funee"; +import { FUNEE_SUT_BIN } from "./_sut.ts"; -const FUNEE = "./target/release/funee"; +const FUNEE = FUNEE_SUT_BIN; const scenarios = [ // ==================== BASIC EXECUTION ==================== scenario({ description: "basic :: runs hello.ts", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["tests/fixtures/hello.ts"]); await assertThat(result.status.code, is(0)); await assertThat(result.stdoutText(), contains("hello from funee")); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), scenario({ description: "basic :: runs default export expressions", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["tests/fixtures/default-expr.ts"]); await assertThat(result.status.code, is(0)); await assertThat( result.stdoutText(), contains("default export expression works") ); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), scenario({ description: "basic :: supports multiple host functions", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["tests/fixtures/multi-host.ts"]); await assertThat(result.status.code, is(0)); await assertThat(result.stdoutText(), contains("log works")); await assertThat(result.stdoutText(), contains("[DEBUG] debug works")); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), scenario({ description: "basic :: supports async functions", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["tests/fixtures/async.ts"]); await assertThat(result.status.code, is(0)); await assertThat(result.stdoutText(), contains("async start")); await assertThat(result.stdoutText(), contains("async helper called")); await assertThat(result.stdoutText(), contains("async end")); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), // ==================== TREE SHAKING ==================== scenario({ description: "tree shaking :: only includes referenced declarations", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["tests/fixtures/treeshake/entry.ts"]); await assertThat(result.status.code, is(0)); await assertThat(result.stdoutText(), contains("used function")); @@ -89,15 +77,12 @@ const scenarios = [ stdout.includes("also unused - should NOT appear"), is(false) ); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), scenario({ description: "tree shaking :: emitted code does not contain unused declarations", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, [ "--emit", "tests/fixtures/treeshake/entry.ts", @@ -117,17 +102,14 @@ const scenarios = [ stdout.includes("also unused - should NOT appear"), is(false) ); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), // ==================== GLOBALS ==================== scenario({ description: "globals :: supports JavaScript built-in globals", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["tests/fixtures/globals.ts"]); await assertThat(result.status.code, is(0)); await assertThat(result.stdoutText(), contains("Promise.resolve: 42")); @@ -140,32 +122,26 @@ const scenarios = [ ); await assertThat(result.stdoutText(), contains("Math.max: 5")); await assertThat(result.stdoutText(), contains("globals test complete")); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), scenario({ description: "globals :: tree-shakes but preserves global references in emitted code", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, ["--emit", "tests/fixtures/globals.ts"]); await assertThat(result.status.code, is(0)); // Globals should be referenced directly, not as imports await assertThat(result.stdoutText(), contains("Promise")); await assertThat(result.stdoutText(), contains("Object")); await assertThat(result.stdoutText(), contains("JSON")); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), // ==================== ERROR HANDLING ==================== scenario({ description: "error handling :: reports missing import errors", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, [ "tests/fixtures/errors/missing-import.ts", ]); @@ -173,15 +149,12 @@ const scenarios = [ await assertThat(result.status.code, greaterThan(0)); // Should mention what couldn't be found await assertThat(result.stderrText(), contains("doesNotExist")); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), scenario({ description: "error handling :: reports parse errors", - verify: { - expression: async () => { + verify: closure(async () => { const result = await spawn(FUNEE, [ "tests/fixtures/errors/syntax-error.ts", ]); @@ -194,9 +167,7 @@ const scenarios = [ stderr.includes("error") || stderr.includes("expected"); await assertThat(hasErrorInfo, is(true)); - }, - references: new Map(), - } as Closure<() => Promise>, + }), }), ]; diff --git a/tests/self-hosted/misc.ts b/tests/self-hosted/misc.ts index 9d06f04..8e4c351 100644 --- a/tests/self-hosted/misc.ts +++ b/tests/self-hosted/misc.ts @@ -27,6 +27,9 @@ import { watchFile, watchDirectory, } from "funee"; +import { FUNEE_SUT_BIN } from "./_sut.ts"; + +const FUNEE = FUNEE_SUT_BIN; // ============================================================================ // SUBPROCESS SCENARIOS @@ -292,7 +295,8 @@ const timerScenarios = [ const macroScenarios = [ // Closure constructor accepts plain objects scenario({ - description: "macro :: Closure constructor converts plain objects to Map", + description: + "macro :: [SPEC-MACRO-RUNTIME-001] Closure constructor converts plain objects to Map", verify: { expression: async () => { const c = Closure({ @@ -310,7 +314,8 @@ const macroScenarios = [ // Closure constructor accepts Map scenario({ - description: "macro :: Closure constructor accepts Map references", + description: + "macro :: [SPEC-MACRO-RUNTIME-002] Closure constructor accepts Map references", verify: { expression: async () => { const refsMap = new Map([ @@ -332,7 +337,8 @@ const macroScenarios = [ // CanonicalName type structure scenario({ - description: "macro :: CanonicalName has uri and name properties", + description: + "macro :: [SPEC-MACRO-RUNTIME-003] CanonicalName has uri and name properties", verify: { expression: async () => { // CanonicalName is a structural type { uri, name } @@ -351,7 +357,8 @@ const macroScenarios = [ // Definition type structure scenario({ - description: "macro :: Definition has declaration and references", + description: + "macro :: [SPEC-MACRO-RUNTIME-004] Definition has declaration and references", verify: { expression: async () => { const def = Definition({ @@ -368,10 +375,13 @@ const macroScenarios = [ // Test actual closure macro expansion via fixture scenario({ - description: "macro :: closure macro expands to AST at compile time", + description: + "macro :: [SPEC-MACRO-EXPANSION-001] closure macro expands to AST at compile time", verify: { expression: async () => { - const result = await spawn("./target/release/funee", ["tests/fixtures/macro/closure-macro.ts"]); + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/closure-macro.ts", + ]); await assertThat(result.status.code, is(0)); await assertThat(result.stdoutText(), contains("type: object")); await assertThat(result.stdoutText(), contains("AST type: ArrowFunctionExpression")); @@ -382,11 +392,160 @@ const macroScenarios = [ // Test macro with cross-file references scenario({ - description: "macro :: closure captures cross-file references", + description: + "macro :: [SPEC-MACRO-REFERENCES-001] closure captures cross-file references", verify: { expression: async () => { - const result = await spawn("./target/release/funee", ["tests/fixtures/macro/cross-file-ref/entry.ts"]); + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/cross-file-ref/entry.ts", + ]); await assertThat(result.status.code, is(0)); + await assertThat(result.stdoutText(), contains("has 'add' reference: true")); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // Conditional macro expansion + scenario({ + description: + "macro :: [SPEC-MACRO-EXEC-001] conditional macro branches on expression shape", + verify: { + expression: async () => { + const first = await spawn(FUNEE, [ + "tests/fixtures/macro/conditional_macro.ts", + ]); + await assertThat(first.status.code, is(0)); + await assertThat(first.stdoutText(), contains("conditional:result=10")); + + const second = await spawn(FUNEE, [ + "tests/fixtures/macro/conditional_macro_already_multiplied.ts", + ]); + await assertThat(second.status.code, is(0)); + await assertThat( + second.stdoutText(), + contains("conditional_already_multiplied:result=10") + ); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // Introspection over captured expression + scenario({ + description: + "macro :: [SPEC-MACRO-EXEC-002] macro can inspect arg.expression", + verify: { + expression: async () => { + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/introspection_macro.ts", + ]); + await assertThat(result.status.code, is(0)); + await assertThat( + result.stdoutText(), + contains("introspection:type=BinaryExpression") + ); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // Two-argument macro behavior + scenario({ + description: + "macro :: [SPEC-MACRO-EXEC-003] two-argument macro receives both Closure arguments", + verify: { + expression: async () => { + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/multi_arg_compare.ts", + ]); + await assertThat(result.status.code, is(0)); + await assertThat(result.stdoutText(), contains("multiarg:result=1")); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // Variadic macro behavior + scenario({ + description: + "macro :: [SPEC-MACRO-EXEC-004] variadic macros receive all arguments", + verify: { + expression: async () => { + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/variadic_numeric_count.ts", + ]); + await assertThat(result.status.code, is(0)); + await assertThat(result.stdoutText(), contains("variadic:count=2")); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // References-map introspection + scenario({ + description: + "macro :: [SPEC-MACRO-REFERENCES-002] macro can inspect arg.references", + verify: { + expression: async () => { + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/references_introspection.ts", + ]); + await assertThat(result.status.code, is(0)); + await assertThat( + result.stdoutText(), + contains("references:has_someFunc=1") + ); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // Object/Array/Member transformations from macro output + scenario({ + description: + "macro :: [SPEC-MACRO-EXEC-005] macro output can construct object/array/member expressions", + verify: { + expression: async () => { + const objectResult = await spawn(FUNEE, [ + "tests/fixtures/macro/object_macro.ts", + ]); + await assertThat(objectResult.status.code, is(0)); + await assertThat(objectResult.stdoutText(), contains("object:name=test")); + await assertThat(objectResult.stdoutText(), contains("object:value=42")); + + const arrayResult = await spawn(FUNEE, [ + "tests/fixtures/macro/array_macro.ts", + ]); + await assertThat(arrayResult.status.code, is(0)); + await assertThat(arrayResult.stdoutText(), contains("array:first=1")); + await assertThat(arrayResult.stdoutText(), contains("array:third=3")); + + const memberResult = await spawn(FUNEE, [ + "tests/fixtures/macro/member_macro.ts", + ]); + await assertThat(memberResult.status.code, is(0)); + await assertThat(memberResult.stdoutText(), contains("member:value=42")); + }, + references: new Map(), + } as Closure<() => Promise>, + }), + + // Sequence-expression style debug macro + scenario({ + description: + "macro :: [SPEC-MACRO-EXEC-006] macro output supports sequence-expression evaluation", + verify: { + expression: async () => { + const result = await spawn(FUNEE, [ + "tests/fixtures/macro/debug_sequence_macro.ts", + ]); + await assertThat(result.status.code, is(0)); + await assertThat( + result.stdoutText(), + contains("[DEBUG] Expression type: BinaryExpression") + ); + await assertThat(result.stdoutText(), contains("debug:result=15")); }, references: new Map(), } as Closure<() => Promise>, From 5f024684ed44c12f0b32ea72f48e34931ffce796 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 4 May 2026 06:27:25 +0000 Subject: [PATCH 2/2] chore: drop committed vitest cache artifact --- .../vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json deleted file mode 100644 index 5b9cc92..0000000 --- a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"4.0.18","results":[[":tests/cli.test.ts",{"duration":1645.81725,"failed":false}]]} \ No newline at end of file