diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..da9024c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: Build and Deploy to GitHub Pages + +on: + push: + branches: [ main, master, claude/* ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install SwiftWasm + run: | + set -ex + curl -L -o swiftwasm.tar.gz "https://github.com/swiftwasm/swift/releases/download/swift-wasm-5.9.2-RELEASE/swift-wasm-5.9.2-RELEASE-ubuntu22.04_x86_64.tar.gz" + mkdir -p $HOME/swiftwasm + tar -xzf swiftwasm.tar.gz -C $HOME/swiftwasm --strip-components=1 + ls -la $HOME/swiftwasm/ + echo "$HOME/swiftwasm/usr/bin" >> $GITHUB_PATH + $HOME/swiftwasm/usr/bin/swift --version + + - name: Build WASM + run: | + set -ex + swift build --triple wasm32-unknown-wasi -c release --product slox-wasm \ + -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor \ + -Xlinker --export=slox_init + ls -la .build/release/ + cp .build/release/slox-wasm.wasm web/ + # Inject build timestamp into JS + BUILD_TIME=$(date -u +"%Y-%m-%d %H:%M UTC") + sed -i "s/__BUILD_TIME__/$BUILD_TIME/" web/app.js + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './web' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0bcacbd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# CLAUDE.md - Project Guide for AI Assistants + +This document provides context for AI assistants working on the slox project. + +## Project Overview + +**slox** is a Swift implementation of the Lox programming language from "Crafting Interpreters" by Robert Nystrom. It compiles to WebAssembly for browser-based execution via a terminal-style REPL. + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ web/app.js │────▶│ slox-wasm.wasm │────▶│ SloxCore │ +│ (xterm.js) │◀────│ (JavaScriptKit) │◀────│ (Swift Lox) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +### Key Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| SloxCore | `Sources/SloxCore/` | Core interpreter library | +| slox-wasm | `Sources/slox-wasm/main.swift` | WASM entry point & JS API | +| Web REPL | `web/app.js` | Terminal UI (xterm.js) | +| CLI | `Sources/slox/` | Native command-line tool | + +## Build Commands + +```bash +# Native build (for local testing) +swift build + +# Run tests +swift test + +# WASM build (requires SwiftWasm toolchain) +swift build --triple wasm32-unknown-wasi -c release --product slox-wasm \ + -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor \ + -Xlinker --export=slox_init + +# Copy WASM to web folder +cp .build/release/slox-wasm.wasm web/ +``` + +## WASM/JavaScript Integration + +### Exported API (`window.slox`) + +| Function | Signature | Description | +|----------|-----------|-------------| +| `initInterpreter` | `(callback: Function) -> bool` | Initialize with output callback | +| `execute` | `(source: string) -> void` | Execute Lox code (REPL mode) | +| `getEnvironment` | `() -> string` | Get local scope as string | +| `getGlobals` | `() -> string` | Get global definitions | +| `reset` | `() -> void` | Reset interpreter state | + +### JavaScriptKit Notes + +- `JSClosure` instances must be stored globally to prevent GC +- Use `JSFunction` (not `JSObject`) for JavaScript function callbacks +- Use `.object()` (not `.function()`) when assigning closures to properties +- `@_cdecl("name")` exports functions with predictable C names + +## Interpreter Pipeline + +``` +Source → Scanner → Tokens → Parser → AST → Resolver → Interpreter → Result +``` + +### Key Classes + +| Class | File | Responsibility | +|-------|------|----------------| +| `Driver` | `Driver.swift` | Orchestrates the pipeline | +| `Scanner` | `Scanner.swift` | Lexical analysis | +| `Parser` | `Parser.swift` | Syntax analysis (recursive descent) | +| `Resolver` | `Resolver.swift` | Variable binding resolution | +| `Interpreter` | `Interpreter.swift` | AST evaluation | + +### Driver Methods + +- `run(source:)` - Batch execution (print statements only) +- `runRepl(source:)` - REPL mode (returns evaluation result) +- `getGlobals()` - Introspection for `%globals` command +- `getEnvironment()` - Introspection for `%env` command +- `reset()` - Clear user-defined state + +## Web REPL Features + +### Terminal Commands +- `help` - Display language reference +- `clear` - Clear screen + +### Magic Commands +- `%env` - Show current local scope +- `%globals` - Show global definitions +- `%reset` - Reset interpreter state + +### Input Features +- Multiline input (unclosed braces/parens/strings continue) +- Command history (Up/Down arrows) +- Cursor navigation (Left/Right, Ctrl+Left/Right, Home/End) +- Ctrl+C to cancel, Ctrl+L to clear screen + +## Testing + +```bash +# Run all tests +swift test + +# Run specific test file +swift test --filter OutputTests +``` + +### Test Categories (OutputTests.swift) +- Print output tests +- REPL evaluation tests +- Magic command support tests + +## CI/CD + +GitHub Actions workflow (`.github/workflows/deploy.yml`): +1. Install SwiftWasm toolchain +2. Build WASM binary +3. Inject build timestamp into `web/app.js` +4. Deploy to GitHub Pages + +### Build Time Injection +The placeholder `__BUILD_TIME__` in `web/app.js` is replaced with the actual build timestamp during CI via `sed`. + +## File Structure + +``` +slox/ +├── Sources/ +│ ├── SloxCore/ # Core interpreter library +│ │ ├── Driver.swift # Main entry point +│ │ ├── Scanner.swift # Lexer +│ │ ├── Parser.swift # Parser +│ │ ├── Resolver.swift # Variable resolution +│ │ ├── Interpreter.swift # Execution +│ │ └── ... # AST, Environment, etc. +│ ├── slox/ # Native CLI +│ │ └── main.swift +│ └── slox-wasm/ # WASM target +│ └── main.swift # JS API exports +├── Tests/ +│ └── SloxCoreTests/ # Unit tests +├── web/ # Static web files +│ ├── index.html +│ ├── app.js # REPL implementation +│ └── *.js/*.mjs # Runtime dependencies +├── Package.swift # Swift package manifest +└── .github/workflows/ # CI configuration +``` + +## Common Tasks + +### Adding a New Built-in Function +1. Add to `Interpreter.defineGlobals()` in `Interpreter.swift` +2. Add tests in `OutputTests.swift` + +### Modifying WASM API +1. Update `Sources/slox-wasm/main.swift` +2. Add corresponding handler in `web/app.js` +3. Store any new `JSClosure` in a global variable + +### Adding a New Magic Command +1. Add handler in `SloxRepl.handleMagicCommand()` in `app.js` +2. Add Swift support in `Driver.swift` if needed +3. Update help text in `MANPAGE` constant +4. Add tests in `OutputTests.swift` + +## Known Limitations + +- WASM binary is ~1.5MB (JavaScriptKit overhead) +- No file I/O in browser environment +- `clock()` uses JavaScript `Date.now()` via configured provider diff --git a/Package.swift b/Package.swift index 1af4670..25b99d5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,44 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "slox", + products: [ + .executable(name: "slox", targets: ["slox"]), + .executable(name: "slox-wasm", targets: ["slox-wasm"]), + ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", from: "0.3.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"), + .package(url: "https://github.com/swiftwasm/JavaScriptKit", from: "0.19.0"), ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( + name: "SloxCore", + dependencies: [], + path: "Sources/SloxCore" + ), + .executableTarget( name: "slox", dependencies: [ + "SloxCore", .product(name: "ArgumentParser", package: "swift-argument-parser"), - ]), + ], + path: "Sources/slox" + ), + .executableTarget( + name: "slox-wasm", + dependencies: [ + "SloxCore", + .product(name: "JavaScriptKit", package: "JavaScriptKit"), + ], + path: "Sources/slox-wasm" + ), + .testTarget( + name: "SloxCoreTests", + dependencies: ["SloxCore"], + path: "Tests/SloxCoreTests" + ), ] ) diff --git a/Sources/slox/AstPrinter.swift b/Sources/SloxCore/AstPrinter.swift similarity index 100% rename from Sources/slox/AstPrinter.swift rename to Sources/SloxCore/AstPrinter.swift diff --git a/Sources/slox/Class.swift b/Sources/SloxCore/Class.swift similarity index 100% rename from Sources/slox/Class.swift rename to Sources/SloxCore/Class.swift diff --git a/Sources/SloxCore/Driver.swift b/Sources/SloxCore/Driver.swift new file mode 100644 index 0000000..900030c --- /dev/null +++ b/Sources/SloxCore/Driver.swift @@ -0,0 +1,128 @@ +// +// Driver.swift +// SloxCore +// +// Created by Alexander Balaban. +// +// The Driver is the main entry point for executing Lox source code. +// It orchestrates scanning, parsing, resolving, and interpreting. +// + +/// Callback type for handling interpreter output (print statements, results) +public typealias OutputHandler = (String) -> Void + +/// Main driver class that coordinates the Lox interpreter pipeline. +/// Supports both batch execution and REPL-style interactive execution. +public final class Driver { + + // MARK: - Error State (static for cross-component access) + + private static var hadError = false + private static var hadRuntimeError = false + + // MARK: - Instance Properties + + private var outputHandler: OutputHandler + + // Lazy-initialized components (allows reset by setting to nil) + private var _errorConsumer: ErrorConsumer? + private var _interpreter: Interpreter? + + /// Error consumer that tracks parse/runtime errors + private var errorConsumer: ErrorConsumer { + if _errorConsumer == nil { + _errorConsumer = ErrorConsumer( + onError: { Driver.hadError = true }, + onRuntimeError: { Driver.hadRuntimeError = true }, + outputHandler: outputHandler + ) + } + return _errorConsumer! + } + + /// The Lox interpreter instance + private var interpreter: Interpreter { + if _interpreter == nil { + _interpreter = Interpreter(errorConsumer: errorConsumer, outputHandler: outputHandler) + } + return _interpreter! + } + + // MARK: - Initialization + + /// Creates a new Driver with the specified output handler. + /// - Parameter outputHandler: Callback for print output. Defaults to stdout. + public init(outputHandler: @escaping OutputHandler = { print($0) }) { + self.outputHandler = outputHandler + } + + // MARK: - Execution Methods + + /// Executes Lox source code (batch mode, no return value). + /// Errors are reported via the output handler. + /// - Parameter source: The Lox source code to execute + public func run(source: String) { + Self.hadError = false + Self.hadRuntimeError = false + + // Scanning: source -> tokens + let scanner = Scanner(source: source, errorConsumer: errorConsumer) + let tokens = scanner.scan() + + // Parsing: tokens -> AST + let parser = Parser(tokens: tokens, errorConsumer: errorConsumer) + let statements = parser.parse() + + // Resolution: resolve variable bindings + let resolver = Resolver(interpreter: interpreter, errorConsumer: errorConsumer) + resolver.resolve(statements) + + // Interpretation: execute the AST + interpreter.interpret(statements) + } + + /// Executes Lox source code in REPL mode, returning the result. + /// Always returns the value of the last expression (or nil on error). + /// - Parameter source: The Lox source code to execute + /// - Returns: String representation of the result, or nil if error occurred + public func runRepl(source: String) -> String? { + Self.hadError = false + Self.hadRuntimeError = false + + let scanner = Scanner(source: source, errorConsumer: errorConsumer) + let tokens = scanner.scan() + + let parser = Parser(tokens: tokens, errorConsumer: errorConsumer) + let statements = parser.parse() + + // Abort if parse errors occurred + guard !Self.hadError else { return nil } + + let resolver = Resolver(interpreter: interpreter, errorConsumer: errorConsumer) + resolver.resolve(statements) + + // Abort if resolution errors occurred + guard !Self.hadError else { return nil } + + return interpreter.interpretRepl(statements) + } + + // MARK: - Introspection (for magic commands) + + /// Returns string representation of current local environment. + public func getEnvironment() -> String { + return interpreter.environment.description + } + + /// Returns string representation of global scope (built-ins + user definitions). + public func getGlobals() -> String { + return interpreter.globals.description + } + + /// Resets interpreter state, clearing all user-defined variables and functions. + /// Built-in functions (clock, print) are recreated on next use. + public func reset() { + _interpreter = nil + _errorConsumer = nil + } +} diff --git a/Sources/slox/Environment.swift b/Sources/SloxCore/Environment.swift similarity index 100% rename from Sources/slox/Environment.swift rename to Sources/SloxCore/Environment.swift diff --git a/Sources/slox/Error.swift b/Sources/SloxCore/Error.swift similarity index 84% rename from Sources/slox/Error.swift rename to Sources/SloxCore/Error.swift index 2c7bbed..cdb9430 100644 --- a/Sources/slox/Error.swift +++ b/Sources/SloxCore/Error.swift @@ -5,20 +5,23 @@ final class ErrorConsumer { private let onError: () -> Void private let onRuntimeError: () -> Void - + private let outputHandler: OutputHandler + init(onError: @escaping () -> Void, - onRuntimeError: @escaping () -> Void) { + onRuntimeError: @escaping () -> Void, + outputHandler: @escaping OutputHandler = { print($0) }) { self.onError = onError self.onRuntimeError = onRuntimeError + self.outputHandler = outputHandler } - + private func report(line: UInt, `where`: String, message: String) { - print("[line \(line)] Error\(`where`.count > 0 ? " \(`where`)" : ""): \(message)") + outputHandler("[line \(line)] Error\(`where`.count > 0 ? " \(`where`)" : ""): \(message)") onError() } - + private func runtimeReport(line: UInt, `where`: String, message: String) { - print("[line \(line)] Runtime error\(`where`.count > 0 ? " \(`where`)" : ""): \(message)") + outputHandler("[line \(line)] Runtime error\(`where`.count > 0 ? " \(`where`)" : ""): \(message)") onRuntimeError() } } diff --git a/Sources/slox/Expr.swift b/Sources/SloxCore/Expr.swift similarity index 100% rename from Sources/slox/Expr.swift rename to Sources/SloxCore/Expr.swift diff --git a/Sources/slox/Function.swift b/Sources/SloxCore/Function.swift similarity index 82% rename from Sources/slox/Function.swift rename to Sources/SloxCore/Function.swift index 0bed1fc..1c43eff 100644 --- a/Sources/slox/Function.swift +++ b/Sources/SloxCore/Function.swift @@ -2,11 +2,13 @@ // Created by Alexander Balaban. // +#if canImport(Foundation) import Foundation +#endif protocol Callable { var arity: Int { get } - + func call(_ interpreter: Interpreter, _ args: [LoxObject]) throws -> LoxObject } @@ -16,7 +18,7 @@ final class LoxFunction: Callable, CustomStringConvertible { private let declaration: FuncStmt private let closure: Environment private let isInitializer: Bool - + init(declaration: FuncStmt, closure: Environment, isInitializer: Bool) { @@ -24,7 +26,7 @@ final class LoxFunction: Callable, CustomStringConvertible { self.declaration = declaration self.isInitializer = isInitializer } - + func call(_ interpreter: Interpreter, _ args: [LoxObject]) throws -> LoxObject { let env = Environment(enclosing: closure) for i in 0.. LoxFunction { let env = Environment(enclosing: closure) env.define(name: "this", value: .instance(instance)) return LoxFunction(declaration: declaration, closure: env, isInitializer: isInitializer) } - + func returnInit() throws -> LoxObject { guard let initializer = closure.get("init") else { fatalError() } return initializer @@ -63,19 +65,33 @@ extension NativeFunction { var description: String { "" } } +// Global timestamp provider - can be overridden for WASM +public var clockProvider: () -> Double = { + #if canImport(Foundation) + return Date().timeIntervalSince1970 + #else + return 0 + #endif +} + final class ClockNativeFunction: NativeFunction { let arity: Int = 0 - + func call(_ interpreter: Interpreter, _ args: [LoxObject]) throws -> LoxObject { - return .double(Date().timeIntervalSince1970) + return .double(clockProvider()) } } final class PrintNativeFunction: NativeFunction { let arity: Int = 1 - + private let outputHandler: OutputHandler + + init(outputHandler: @escaping OutputHandler = { print($0) }) { + self.outputHandler = outputHandler + } + func call(_ interpreter: Interpreter, _ args: [LoxObject]) throws -> LoxObject { - print(args[0]) + outputHandler("\(args[0])") return .null } } diff --git a/Sources/slox/Instance.swift b/Sources/SloxCore/Instance.swift similarity index 100% rename from Sources/slox/Instance.swift rename to Sources/SloxCore/Instance.swift diff --git a/Sources/slox/Interpreter.swift b/Sources/SloxCore/Interpreter.swift similarity index 89% rename from Sources/slox/Interpreter.swift rename to Sources/SloxCore/Interpreter.swift index 0efb36d..65c8406 100644 --- a/Sources/slox/Interpreter.swift +++ b/Sources/SloxCore/Interpreter.swift @@ -6,24 +6,26 @@ final class Interpreter { struct Return: Error { let obj: LoxObject } - + let globals = Environment() lazy var environment: Environment = { globals }() private let errorConsumer: RuntimeErrorConsumer + private let outputHandler: OutputHandler private var locals = [String: Int]() - - init(errorConsumer: RuntimeErrorConsumer) { + + init(errorConsumer: RuntimeErrorConsumer, outputHandler: @escaping OutputHandler = { print($0) }) { self.errorConsumer = errorConsumer + self.outputHandler = outputHandler globals.define( name: "clock", value: .callable(ClockNativeFunction()) ) globals.define( name: "print", - value: .callable(PrintNativeFunction()) + value: .callable(PrintNativeFunction(outputHandler: outputHandler)) ) } - + func execute(_ stmts: [Stmt], _ env: Environment) throws { let previous = environment defer { environment = previous } @@ -32,7 +34,7 @@ final class Interpreter { try execute(stmt) } } - + func interpret(_ stmts: [Stmt]) { do { for stmt in stmts { @@ -44,7 +46,35 @@ final class Interpreter { fatalError(error.localizedDescription) } } - + + /// REPL-style interpret that returns the result of the last expression + func interpretRepl(_ stmts: [Stmt]) -> String? { + do { + var lastResult: LoxObject = .null + for stmt in stmts { + if let exprStmt = stmt as? ExpressionStmt { + lastResult = try evaluate(exprStmt.expression) + } else if let varStmt = stmt as? VarStmt { + try execute(stmt) + if varStmt.initializer != nil { + lastResult = try environment.get(name: varStmt.name) + } else { + lastResult = .null + } + } else { + try execute(stmt) + lastResult = .null + } + } + return lastResult.description + } catch let e as RuntimeError { + errorConsumer.runtimeError(token: e.token, message: e.message) + return nil + } catch { + return nil + } + } + func resolve(_ expr: Expr, _ depth: Int) { locals[expr.description] = depth } @@ -53,7 +83,7 @@ final class Interpreter { // MARK: - Expressions Visitor extension Interpreter: ExprVisitor { typealias ER = LoxObject - + func visit(_ expr: AssignExpr) throws -> LoxObject { let value = try evaluate(expr.value) if let distance = locals[expr.description] { @@ -63,11 +93,11 @@ extension Interpreter: ExprVisitor { } return value } - + func visit(_ expr: BinaryExpr) throws -> LoxObject { let left = try evaluate(expr.left) let right = try evaluate(expr.right) - + switch (left, right) { case (.double(let lhs), .double(let rhs)): switch expr.op.type { @@ -81,7 +111,7 @@ extension Interpreter: ExprVisitor { return .double(lhs + rhs) case .slash: guard rhs != 0 else { - throw RuntimeError(token: expr.op, message: "Не надо так...") + throw RuntimeError(token: expr.op, message: "Division by zero.") } return .double(lhs / rhs) case .star: @@ -128,7 +158,7 @@ extension Interpreter: ExprVisitor { } } } - + func visit(_ expr: CallExpr) throws -> LoxObject { let obj = try evaluate(expr.calee) var args = [LoxObject]() @@ -148,7 +178,7 @@ extension Interpreter: ExprVisitor { } return try callable.call(self, args) } - + func visit(_ expr: GetExpr) throws -> LoxObject { let obj = try evaluate(expr.object) guard case let .instance(inst) = obj else { @@ -156,18 +186,18 @@ extension Interpreter: ExprVisitor { } return try inst.get(expr.name) } - + func visit(_ expr: GroupingExpr) throws -> LoxObject { return try evaluate(expr.expr) } - + func visit(_ expr: LiteralExpr) throws -> LoxObject { return expr.object } - + func visit(_ expr: LogicalExpr) throws -> LoxObject { let left = try evaluate(expr.left) - + switch expr.op.type { case .and where !isTruthy(left): return .bool(false) @@ -177,7 +207,7 @@ extension Interpreter: ExprVisitor { return try evaluate(expr.right) } } - + func visit(_ expr: SetExpr) throws -> LoxObject { let obj = try evaluate(expr.object) guard case let .instance(inst) = obj else { @@ -187,7 +217,7 @@ extension Interpreter: ExprVisitor { inst.set(expr.name, val) return val } - + func visit(_ expr: SuperExpr) throws -> LoxObject { guard let distance = locals[expr.description] else { return .null } guard case let .klass(superclass) = try environment.get(at: distance, "super") else { return .null } @@ -197,11 +227,11 @@ extension Interpreter: ExprVisitor { } return .callable(method.bind(object)) } - + func visit(_ expr: ThisExpr) throws -> LoxObject { return .null } - + func visit(_ expr: UnaryExpr) throws -> LoxObject { let right = try evaluate(expr.right) switch expr.op.type { @@ -218,7 +248,7 @@ extension Interpreter: ExprVisitor { throw RuntimeError(token: expr.op, message: "Invalid unary operation for given operand.") } } - + func visit(_ expr: VariableExpr) throws -> LoxObject { return try lookup(expr.name, expr) } @@ -227,11 +257,11 @@ extension Interpreter: ExprVisitor { // MARK: - Statements Visitor extension Interpreter: StmtVisitor { typealias SR = Void - + func visit(_ stmt: BlockStmt) throws -> Void { try execute(stmt.statements, Environment(enclosing: environment)) } - + func visit(_ stmt: ClassStmt) throws -> Void { var superklass: LoxClass? if let superclass = stmt.superclass { @@ -241,13 +271,13 @@ extension Interpreter: StmtVisitor { } superklass = superk } - + environment.define(name: stmt.name.lexeme, value: .null) if let superklass = superklass { environment = Environment(enclosing: environment) environment.define(name: "super", value: .klass(superklass)) } - + let methods = stmt.methods.reduce(into: [String: LoxFunction](), { res, method in res[method.name.lexeme] = LoxFunction(declaration: method, closure: environment, @@ -259,11 +289,11 @@ extension Interpreter: StmtVisitor { } environment.assign(name: stmt.name, value: .klass(klass)) } - + func visit(_ stmt: ExpressionStmt) throws -> Void { try evaluate(stmt.expression) } - + func visit(_ stmt: FuncStmt) throws -> Void { let function = LoxFunction(declaration: stmt, closure: environment, isInitializer: false) environment.define( @@ -271,7 +301,7 @@ extension Interpreter: StmtVisitor { value: .callable(function) ) } - + func visit(_ stmt: IfStmt) throws -> Void { if isTruthy(try evaluate(stmt.condition)) { try execute(stmt.then) @@ -279,7 +309,7 @@ extension Interpreter: StmtVisitor { try execute(`else`) } } - + func visit(_ stmt: ReturnStmt) throws -> Void { var value: LoxObject = .null if let v = stmt.value { @@ -287,7 +317,7 @@ extension Interpreter: StmtVisitor { } throw Return(obj: value) } - + func visit(_ stmt: VarStmt) throws -> Void { var res: LoxObject = .null if let initializer = stmt.initializer { @@ -295,7 +325,7 @@ extension Interpreter: StmtVisitor { } environment.define(name: stmt.name.lexeme, value: res) } - + func visit(_ stmt: WhileStmt) throws -> Void { while isTruthy(try evaluate(stmt.condition)) { try execute(stmt.body) @@ -309,11 +339,11 @@ private extension Interpreter { func evaluate(_ expr: Expr) throws -> LoxObject { return try expr.accept(visitor: self) } - + func execute(_ stmt: Stmt) throws { try stmt.accept(visitor: self) } - + func isTruthy(_ val: LoxObject) -> Bool { switch val { case .null: @@ -324,13 +354,13 @@ private extension Interpreter { return true } } - + func isTruthy(_ val: Any?) -> Bool { guard val != nil else { return false } guard let b = val as? Bool else { return true } return b } - + func isEqual(_ lhs: LoxObject, _ rhs: LoxObject) -> Bool { switch (lhs, rhs) { case (let .double(l), let .double(r)): @@ -343,7 +373,7 @@ private extension Interpreter { return false } } - + func lookup(_ name: Token, _ expr: VariableExpr) throws -> LoxObject { if let distance = locals[expr.description] { return try environment.get(at: distance, name) diff --git a/Sources/slox/Object.swift b/Sources/SloxCore/Object.swift similarity index 100% rename from Sources/slox/Object.swift rename to Sources/SloxCore/Object.swift diff --git a/Sources/slox/Parser.swift b/Sources/SloxCore/Parser.swift similarity index 100% rename from Sources/slox/Parser.swift rename to Sources/SloxCore/Parser.swift diff --git a/Sources/slox/Resolver.swift b/Sources/SloxCore/Resolver.swift similarity index 100% rename from Sources/slox/Resolver.swift rename to Sources/SloxCore/Resolver.swift diff --git a/Sources/slox/Scanner.swift b/Sources/SloxCore/Scanner.swift similarity index 99% rename from Sources/slox/Scanner.swift rename to Sources/SloxCore/Scanner.swift index ad5b0c9..2484480 100644 --- a/Sources/slox/Scanner.swift +++ b/Sources/SloxCore/Scanner.swift @@ -112,7 +112,6 @@ private extension Scanner { var depth = 1 while depth != 0 && current != source.endIndex { let char = advance() - print(char) switch char { case "/": if match("*") { diff --git a/Sources/slox/Stmt.swift b/Sources/SloxCore/Stmt.swift similarity index 100% rename from Sources/slox/Stmt.swift rename to Sources/SloxCore/Stmt.swift diff --git a/Sources/slox/Token.swift b/Sources/SloxCore/Token.swift similarity index 100% rename from Sources/slox/Token.swift rename to Sources/SloxCore/Token.swift diff --git a/Sources/slox/TokenType.swift b/Sources/SloxCore/TokenType.swift similarity index 100% rename from Sources/slox/TokenType.swift rename to Sources/SloxCore/TokenType.swift diff --git a/Sources/slox-wasm/main.swift b/Sources/slox-wasm/main.swift new file mode 100644 index 0000000..ec19b4b --- /dev/null +++ b/Sources/slox-wasm/main.swift @@ -0,0 +1,115 @@ +// +// WASM Entry Point for Slox Interpreter +// Created by Alexander Balaban. +// +// This module provides the WebAssembly interface for the Slox interpreter. +// It exports a `slox_init` function that sets up the JavaScript API. +// + +import JavaScriptKit +import SloxCore + +// MARK: - Global State +// These must be kept alive at module scope to prevent garbage collection. +// JavaScriptKit closures are prevent deallocation only while referenced. + +/// The Lox interpreter driver instance +var driver: Driver? + +/// Callback function to send output to the JavaScript terminal +var outputCallback: JSFunction? + +// MARK: - JSClosure References +// JSClosures must be stored globally to prevent deallocation + +var initInterpreterClosure: JSClosure? +var executeClosure: JSClosure? +var getEnvironmentClosure: JSClosure? +var getGlobalsClosure: JSClosure? +var resetClosure: JSClosure? + +// MARK: - Exported Functions + +/// Main initialization function called from JavaScript after WASM loads. +/// Sets up the `window.slox` namespace with interpreter functions. +/// +/// Exported as C symbol `slox_init` for direct WASM export. +@_cdecl("slox_init") +func sloxInit() { + // Configure the clock() built-in to use JavaScript's Date API + clockProvider = { + let date = JSObject.global.Date.function!.new() + return date.getTime!().number! / 1000.0 + } + + // Create or reuse the window.slox namespace object + let sloxNamespace: JSObject = { + if let existing = JSObject.global.slox.object { + return existing + } + let obj = JSObject.global.Object.function!.new() + JSObject.global.slox = .object(obj) + return obj + }() + + // slox.initInterpreter(callback) -> bool + // Initializes the interpreter with an output callback function. + // Returns true on success, false if callback is invalid. + initInterpreterClosure = JSClosure { args -> JSValue in + guard args.count >= 1, let cb = args[0].function else { + return .boolean(false) + } + outputCallback = cb + driver = Driver { output in + if let cb = outputCallback { + _ = cb(JSValue.string(output)) + } + } + return .boolean(true) + } + + // slox.execute(source) -> void + // Executes Lox source code and sends result to output callback. + // Uses REPL mode which always outputs the evaluation result. + executeClosure = JSClosure { args -> JSValue in + guard args.count >= 1 else { return .undefined } + let source = args[0].string ?? "" + if let result = driver?.runRepl(source: source) { + if let cb = outputCallback { + _ = cb(JSValue.string(result)) + } + } + return .undefined + } + + // slox.getEnvironment() -> string + // Returns JSON-like representation of current local scope. + getEnvironmentClosure = JSClosure { _ -> JSValue in + return .string(driver?.getEnvironment() ?? "{}") + } + + // slox.getGlobals() -> string + // Returns JSON-like representation of global definitions. + getGlobalsClosure = JSClosure { _ -> JSValue in + return .string(driver?.getGlobals() ?? "{}") + } + + // slox.reset() -> void + // Resets interpreter state, clearing all user-defined variables/functions. + resetClosure = JSClosure { _ -> JSValue in + driver?.reset() + return .undefined + } + + // Export all functions to window.slox namespace + sloxNamespace.initInterpreter = .object(initInterpreterClosure!) + sloxNamespace.execute = .object(executeClosure!) + sloxNamespace.getEnvironment = .object(getEnvironmentClosure!) + sloxNamespace.getGlobals = .object(getGlobalsClosure!) + sloxNamespace.reset = .object(resetClosure!) + + // Signal to JavaScript that WASM initialization is complete + if let readyFn = JSObject.global.sloxReady.function { + _ = readyFn() + } +} diff --git a/Sources/slox/Driver.swift b/Sources/slox/Driver.swift deleted file mode 100644 index 53ba0d2..0000000 --- a/Sources/slox/Driver.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Created by Alexander Balaban. -// - -import Foundation - -final class Driver { - private static var hadError = false - private static var hadRuntimeError = false - private var skipErrors = false - private lazy var errorConsumer: ErrorConsumer = { - ErrorConsumer( - onError: { Driver.hadError = true }, - onRuntimeError: { Driver.hadRuntimeError = true } - ) - }() - private lazy var interpreter: Interpreter = { - Interpreter(errorConsumer: errorConsumer) - }() - - func run(filepath: String) throws { - run(try String(contentsOfFile: filepath)) - - if Self.hadError { - exit(65) - } - if Self.hadRuntimeError { - exit(70) - } - } - - func repl() { - self.skipErrors = true - - print("s(wift)lox repl – lox language interpreter written in Swift.") - print("Ctrl-C to exit.") - print(">>> ", terminator: "") - - while let input = readLine() { - if input == "env" { - print(interpreter.environment) - } else { - run(input) - } - Self.hadError = false - Self.hadRuntimeError = false - print(">>> ", terminator: "") - } - } - - private func run(_ source: String) { - let scanner = Scanner(source: source, - errorConsumer: errorConsumer) - let tokens = scanner.scan() - let parser = Parser(tokens: tokens, - errorConsumer: errorConsumer) - let statements = parser.parse() - - let resolver = Resolver(interpreter: interpreter, - errorConsumer: errorConsumer) - resolver.resolve(statements) - - if !skipErrors { - if Self.hadError { - exit(65) - } - if Self.hadRuntimeError { - exit(70) - } - } - interpreter.interpret(statements) - } -} diff --git a/Sources/slox/main.swift b/Sources/slox/main.swift index e75a5fe..be1c986 100644 --- a/Sources/slox/main.swift +++ b/Sources/slox/main.swift @@ -2,18 +2,41 @@ // Created by Alexander Balaban. // +import Foundation import ArgumentParser +import SloxCore struct Slox: ParsableCommand { - static let driver = Driver() - @Argument(help: "Path to the Lox file") + static var configuration = CommandConfiguration( + abstract: "s(wift)lox – Lox language interpreter written in Swift" + ) + + @Argument(help: "Path to the Lox file to execute") var filepath: String? mutating func run() throws { + let driver = Driver() + if let filepath = filepath { - try Self.driver.run(filepath: filepath) + let source = try String(contentsOfFile: filepath, encoding: .utf8) + driver.run(source: source) } else { - Self.driver.repl() + repl(driver: driver) + } + } + + private func repl(driver: Driver) { + print("s(wift)lox repl – lox language interpreter written in Swift.") + print("Ctrl-C to exit.") + print(">>> ", terminator: "") + + while let input = readLine() { + if input == "env" { + print(driver.getEnvironment()) + } else { + driver.run(source: input) + } + print(">>> ", terminator: "") } } } diff --git a/Tests/SloxCoreTests/OutputTests.swift b/Tests/SloxCoreTests/OutputTests.swift new file mode 100644 index 0000000..7707616 --- /dev/null +++ b/Tests/SloxCoreTests/OutputTests.swift @@ -0,0 +1,206 @@ +import XCTest +@testable import SloxCore + +final class OutputTests: XCTestCase { + + func testPrintOutputsToHandler() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: "print(\"Hello, World!\");") + + XCTAssertEqual(output, ["Hello, World!"]) + } + + func testPrintNumber() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: "print(42);") + + XCTAssertEqual(output, ["42"]) + } + + func testPrintExpression() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: "print(2 + 3 * 4);") + + XCTAssertEqual(output, ["14"]) + } + + func testMultiplePrints() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: """ + print("one"); + print("two"); + print("three"); + """) + + XCTAssertEqual(output, ["one", "two", "three"]) + } + + func testVariableAndPrint() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: "var x = 10;") + driver.run(source: "print(x);") + + XCTAssertEqual(output, ["10"]) + } + + func testFunctionCallPrintsOutput() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: """ + fun greet(name) { + print("Hello, " + name + "!"); + } + """) + driver.run(source: "greet(\"Swift\");") + + XCTAssertEqual(output, ["Hello, Swift!"]) + } + + func testPrintNil() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: "print(nil);") + + XCTAssertEqual(output, ["nil"]) + } + + func testPrintBoolean() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: "print(true);") + driver.run(source: "print(false);") + + XCTAssertEqual(output, ["true", "false"]) + } + + func testClassMethodPrintsOutput() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: """ + class Greeter { + init(name) { this.name = name; } + greet() { print("Hi, " + this.name); } + } + var g = Greeter("WASM"); + g.greet(); + """) + + XCTAssertEqual(output, ["Hi, WASM"]) + } + + func testFibonacci() { + var output: [String] = [] + let driver = Driver { output.append($0) } + + driver.run(source: """ + fun fib(n) { + if (n < 2) return n; + return fib(n - 1) + fib(n - 2); + } + print(fib(10)); + """) + + XCTAssertEqual(output, ["55"]) + } + + // MARK: - REPL-style tests (runRepl returns evaluation result) + + func testReplExpression() { + let driver = Driver { _ in } + let result = driver.runRepl(source: "2 + 3;") + XCTAssertEqual(result, "5.0") + } + + func testReplString() { + let driver = Driver { _ in } + let result = driver.runRepl(source: "\"hello\";") + XCTAssertEqual(result, "hello") + } + + func testReplNil() { + let driver = Driver { _ in } + let result = driver.runRepl(source: "nil;") + XCTAssertEqual(result, "nil") + } + + func testReplBoolean() { + let driver = Driver { _ in } + XCTAssertEqual(driver.runRepl(source: "true;"), "true") + XCTAssertEqual(driver.runRepl(source: "false;"), "false") + } + + func testReplVariable() { + let driver = Driver { _ in } + let result = driver.runRepl(source: "var x = 42;") + XCTAssertEqual(result, "42.0") + } + + func testReplFunctionCall() { + let driver = Driver { _ in } + driver.runRepl(source: "fun add(a, b) { return a + b; }") + let result = driver.runRepl(source: "add(10, 20);") + XCTAssertEqual(result, "30.0") + } + + func testReplFunctionDefinition() { + let driver = Driver { _ in } + let result = driver.runRepl(source: "fun greet() { return \"hi\"; }") + XCTAssertEqual(result, "nil") + } + + // MARK: - Magic command support tests + + func testGetGlobalsContainsBuiltins() { + let driver = Driver { _ in } + let globals = driver.getGlobals() + XCTAssertTrue(globals.contains("clock")) + XCTAssertTrue(globals.contains("print")) + } + + func testGetGlobalsContainsUserDefinedFunction() { + let driver = Driver { _ in } + _ = driver.runRepl(source: "fun myFunc() { return 42; }") + let globals = driver.getGlobals() + XCTAssertTrue(globals.contains("myFunc")) + } + + func testGetEnvironmentContainsVariable() { + let driver = Driver { _ in } + _ = driver.runRepl(source: "var x = 10;") + let env = driver.getEnvironment() + XCTAssertTrue(env.contains("x")) + } + + func testResetClearsState() { + let driver = Driver { _ in } + _ = driver.runRepl(source: "var x = 10;") + driver.reset() + // After reset, x should not exist - running x should cause error + let result = driver.runRepl(source: "x;") + XCTAssertNil(result) // Error should return nil + } + + func testResetPreservesBuiltins() { + let driver = Driver { _ in } + _ = driver.runRepl(source: "var x = 10;") + driver.reset() + // Built-ins should still work + let globals = driver.getGlobals() + XCTAssertTrue(globals.contains("clock")) + XCTAssertTrue(globals.contains("print")) + } +} diff --git a/web/.nojekyll b/web/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..45b83aa --- /dev/null +++ b/web/app.js @@ -0,0 +1,597 @@ +/** + * slox Web REPL + * + * A terminal-based REPL for the Slox (Swift Lox) interpreter running in WebAssembly. + * Uses xterm.js for terminal emulation and JavaScriptKit for Swift/JS interop. + */ + +// ============================================================================= +// Constants +// ============================================================================= + +// Build timestamp injected by CI (remains placeholder in dev) +const BUILD_TIME = '__BUILD_TIME__'; + +// Terminal prompts (ANSI colored) +const PROMPT = '\x1b[32m>>>\x1b[0m '; +const CONTINUATION_PROMPT = '\x1b[32m...\x1b[0m '; + +// Man-page style help text with ANSI formatting +const MANPAGE = `\x1b[1mSLOX(1) User Commands SLOX(1)\x1b[0m + +\x1b[1mNAME\x1b[0m + slox - Lox language interpreter compiled to WebAssembly + +\x1b[1mDESCRIPTION\x1b[0m + Lox is a dynamically-typed scripting language from the book + "Crafting Interpreters" by Robert Nystrom. This implementation is + written in Swift and compiled to WebAssembly. + +\x1b[1mDATA TYPES\x1b[0m + \x1b[33mnil\x1b[0m The absence of a value + \x1b[33mtrue\x1b[0m / \x1b[33mfalse\x1b[0m Boolean values + \x1b[33m123\x1b[0m, \x1b[33m3.14\x1b[0m Numbers (double-precision floats) + \x1b[33m"hello"\x1b[0m Strings (double quotes only) + +\x1b[1mVARIABLES\x1b[0m + var name = "value"; + var count = 42; + +\x1b[1mCONTROL FLOW\x1b[0m + if (cond) { ... } else { ... } + while (cond) { ... } + for (var i = 0; i < 10; i = i + 1) { ... } + +\x1b[1mFUNCTIONS\x1b[0m + fun greet(name) { + print("Hello, " + name + "!"); + } + greet("World"); + +\x1b[1mCLASSES\x1b[0m + class Animal { + init(name) { this.name = name; } + speak() { print(this.name + " speaks"); } + } + class Dog < Animal { + speak() { print(this.name + " barks!"); } + } + +\x1b[1mBUILT-INS\x1b[0m + \x1b[32mprint\x1b[0m(value) Output a value + \x1b[32mclock\x1b[0m() Seconds since epoch + +\x1b[1mREPL COMMANDS\x1b[0m + \x1b[32mhelp\x1b[0m Show this manual + \x1b[32mclear\x1b[0m Clear the screen + \x1b[32mCtrl+C\x1b[0m Cancel input + \x1b[32mCtrl+L\x1b[0m Clear screen + \x1b[32mUp/Down\x1b[0m Command history + \x1b[32mLeft/Right\x1b[0m Move cursor + \x1b[32mCtrl+Left/Right\x1b[0m Move by word + \x1b[32mHome/End\x1b[0m Start/end of line + +\x1b[1mMULTILINE INPUT\x1b[0m + Incomplete statements (unclosed braces/strings) continue + on the next line with a "..." prompt. + +\x1b[1mMAGIC COMMANDS\x1b[0m + \x1b[36m%env\x1b[0m Show current environment (local scope) + \x1b[36m%globals\x1b[0m Show global definitions + \x1b[36m%reset\x1b[0m Reset interpreter state + +\x1b[1mEXAMPLES\x1b[0m + \x1b[90m>>> print("Hello!");\x1b[0m + Hello! + + \x1b[90m>>> fun fib(n) { if (n<2) return n; return fib(n-1)+fib(n-2); }\x1b[0m + \x1b[90m>>> print(fib(20));\x1b[0m + 6765 + +\x1b[1mSEE ALSO\x1b[0m + https://craftinginterpreters.com + https://github.com/yabalaban/slox + +`; + +// ============================================================================= +// SloxRepl Class +// ============================================================================= + +/** + * Main REPL controller class. + * Manages terminal I/O, command history, multiline input, and WASM communication. + */ +class SloxRepl { + constructor() { + this.terminal = null; // xterm.js Terminal instance + this.line = ''; // Current input line + this.cursor = 0; // Cursor position within line + this.history = []; // Command history + this.historyPos = -1; // Current position in history (-1 = new input) + this.ready = false; // Terminal ready for input + this.wasmLoaded = false; // WASM interpreter loaded successfully + this.multilineBuffer = []; // Accumulated lines for multiline input + this.init(); + } + + // ========================================================================= + // Initialization + // ========================================================================= + + async init() { + // Configure xterm.js terminal with dark theme + this.terminal = new Terminal({ + cols: 80, + rows: 24, + theme: { + background: '#0a0a0a', + foreground: '#b0b0b0', + cursor: '#5a5', + cursorAccent: '#0a0a0a', + selectionBackground: 'rgba(90, 170, 90, 0.3)', + black: '#1a1a1a', + red: '#c66', + green: '#6a6', + yellow: '#aa6', + blue: '#68a', + magenta: '#a6a', + cyan: '#6aa', + white: '#aaa', + brightBlack: '#444', + brightRed: '#e88', + brightGreen: '#8c8', + brightYellow: '#cc8', + brightBlue: '#8ac', + brightMagenta: '#c8c', + brightCyan: '#8cc', + brightWhite: '#ccc' + }, + fontFamily: 'ui-monospace, "SF Mono", "Cascadia Code", "Consolas", monospace', + fontSize: 14, + lineHeight: 1.4, + cursorBlink: true, + cursorStyle: 'bar', + scrollback: 5000 + }); + + // Enable clickable links + this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon()); + this.terminal.open(document.getElementById('terminal')); + + // Resize terminal to fit container (fixed 80 cols, dynamic rows) + const resize = () => { + const container = document.getElementById('terminal'); + const charHeight = 14 * 1.4; + const rows = Math.floor((container.clientHeight - 32) / charHeight); + this.terminal.resize(80, Math.max(rows, 10)); + }; + window.addEventListener('resize', resize); + resize(); + + // Handle all terminal input + this.terminal.onData(data => this.handleInput(data)); + + // Display welcome message + this.terminal.writeln('\x1b[1;32mslox\x1b[0m \x1b[38;5;242m- Lox interpreter in Swift/WASM\x1b[0m'); + this.terminal.writeln('\x1b[38;5;242mType "help" for language reference.\x1b[0m'); + this.terminal.writeln(''); + this.terminal.writeln('\x1b[38;5;242mInitializing WASM...\x1b[0m'); + + await this.loadWasm(); + } + + /** + * Load and initialize the WASM interpreter. + * Sets up WASI environment and JavaScriptKit runtime. + */ + async loadWasm() { + const startTime = Date.now(); + + try { + // Prepare global namespace for WASM exports + window.slox = {}; + window.sloxReady = () => {}; + + // Fetch WASM binary + const response = await fetch('slox-wasm.wasm'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const wasmBytes = await response.arrayBuffer(); + const wasmSize = (wasmBytes.byteLength / 1024).toFixed(1); + + // Load WASI and JavaScriptKit runtime + const { WASI, File, OpenFile, ConsoleStdout } = await import('./wasi-loader.js'); + const { SwiftRuntime } = await import('./javascriptkit-runtime.mjs'); + + // Configure WASI with stdio + const terminal = this.terminal; + const wasi = new WASI([], [], [ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered(msg => terminal.writeln(msg)), // stdout + ConsoleStdout.lineBuffered(msg => console.error(msg)), // stderr + ]); + + // Instantiate WASM module + const swift = new SwiftRuntime(); + const { instance } = await WebAssembly.instantiate(wasmBytes, { + wasi_snapshot_preview1: wasi.wasiImport, + javascript_kit: swift.wasmImports + }); + + // Initialize runtimes + swift.setInstance(instance); + wasi.initialize(instance); + + // Call WASM initialization functions + if (instance.exports._initialize) instance.exports._initialize(); + if (instance.exports.slox_init) instance.exports.slox_init(); + + // Brief delay for async initialization + await new Promise(r => setTimeout(r, 50)); + + // Initialize interpreter with output callback + if (window.slox?.initInterpreter) { + const initOk = window.slox.initInterpreter(out => { + this.terminal.writeln(out); + }); + if (!initOk) throw new Error('initInterpreter returned false'); + + this.wasmLoaded = true; + this.ready = true; + + // Display success message with timing info + const elapsed = Date.now() - startTime; + const buildInfo = BUILD_TIME !== '__BUILD_TIME__' ? `, built: ${BUILD_TIME}` : ''; + this.terminal.writeln(`\x1b[32m✓\x1b[0m \x1b[38;5;242mReady (${wasmSize}KB, ${elapsed}ms${buildInfo})\x1b[0m`); + } else { + throw new Error('API initialization failed'); + } + } catch (e) { + console.error('WASM load error:', e); + this.terminal.writeln(`\x1b[31m✗\x1b[0m \x1b[38;5;242mWASM error: ${e.message}\x1b[0m`); + this.ready = true; // Allow basic terminal use even if WASM fails + } + + this.terminal.writeln(''); + document.getElementById('loading').classList.add('hidden'); + this.terminal.write(PROMPT); + this.terminal.focus(); + } + + // ========================================================================= + // Input Handling + // ========================================================================= + + /** Returns the appropriate prompt based on multiline state */ + getCurrentPrompt() { + return this.multilineBuffer.length > 0 ? CONTINUATION_PROMPT : PROMPT; + } + + /** + * Main input handler - processes all terminal input data. + * Handles escape sequences, control characters, and printable input. + */ + handleInput(data) { + if (!this.ready) return; + + // Handle escape sequences as a unit + if (data.startsWith('\x1b[')) { + this.handleEscapeSequence(data); + return; + } + if (data.startsWith('\x1b')) { + return; // Ignore other escape sequences + } + + // Process each character + for (const char of data) { + const code = char.charCodeAt(0); + + if (char === '\r' || char === '\n') { + this.terminal.write('\r\n'); + this.handleEnter(); + } else if (code === 127 || code === 8) { + this.handleBackspace(); + } else if (char === '\x03') { + // Ctrl+C: cancel current input + this.line = ''; + this.cursor = 0; + this.multilineBuffer = []; + this.terminal.write('^C\r\n' + PROMPT); + } else if (char === '\x0c') { + // Ctrl+L: clear screen + this.terminal.clear(); + this.terminal.write(this.getCurrentPrompt() + this.line); + this.moveCursorToPosition(); + } else if (char === '\x01') { + // Ctrl+A: beginning of line + this.cursor = 0; + this.refreshLine(); + } else if (char === '\x05') { + // Ctrl+E: end of line + this.cursor = this.line.length; + this.refreshLine(); + } else if (char === '\x17') { + // Ctrl+W: delete word backward + this.deleteWordBackward(); + } else if (code >= 32) { + // Printable character + this.insertChar(char); + } + } + } + + /** Handle escape sequences (arrow keys, home/end, etc.) */ + handleEscapeSequence(seq) { + switch (seq) { + case '\x1b[A': this.historyUp(); break; // Up arrow + case '\x1b[B': this.historyDown(); break; // Down arrow + case '\x1b[C': this.moveCursorRight(); break; // Right arrow + case '\x1b[D': this.moveCursorLeft(); break; // Left arrow + case '\x1b[H': // Home + case '\x1b[1~': + this.cursor = 0; + this.refreshLine(); + break; + case '\x1b[F': // End + case '\x1b[4~': + this.cursor = this.line.length; + this.refreshLine(); + break; + case '\x1b[3~': this.handleDelete(); break; // Delete + case '\x1b[1;5C': this.moveWordRight(); break; // Ctrl+Right + case '\x1b[1;5D': this.moveWordLeft(); break; // Ctrl+Left + } + } + + // ========================================================================= + // Line Editing + // ========================================================================= + + /** Insert character at cursor position */ + insertChar(char) { + this.line = this.line.slice(0, this.cursor) + char + this.line.slice(this.cursor); + this.cursor++; + this.refreshLine(); + } + + /** Delete character before cursor */ + handleBackspace() { + if (this.cursor > 0) { + this.line = this.line.slice(0, this.cursor - 1) + this.line.slice(this.cursor); + this.cursor--; + this.refreshLine(); + } + } + + /** Delete character at cursor */ + handleDelete() { + if (this.cursor < this.line.length) { + this.line = this.line.slice(0, this.cursor) + this.line.slice(this.cursor + 1); + this.refreshLine(); + } + } + + /** Move cursor one character left */ + moveCursorLeft() { + if (this.cursor > 0) { + this.cursor--; + this.terminal.write('\x1b[D'); + } + } + + /** Move cursor one character right */ + moveCursorRight() { + if (this.cursor < this.line.length) { + this.cursor++; + this.terminal.write('\x1b[C'); + } + } + + /** Move cursor one word left */ + moveWordLeft() { + if (this.cursor === 0) return; + while (this.cursor > 0 && this.line[this.cursor - 1] === ' ') this.cursor--; + while (this.cursor > 0 && this.line[this.cursor - 1] !== ' ') this.cursor--; + this.refreshLine(); + } + + /** Move cursor one word right */ + moveWordRight() { + if (this.cursor >= this.line.length) return; + while (this.cursor < this.line.length && this.line[this.cursor] !== ' ') this.cursor++; + while (this.cursor < this.line.length && this.line[this.cursor] === ' ') this.cursor++; + this.refreshLine(); + } + + /** Delete word before cursor (Ctrl+W) */ + deleteWordBackward() { + if (this.cursor === 0) return; + const oldCursor = this.cursor; + while (this.cursor > 0 && this.line[this.cursor - 1] === ' ') this.cursor--; + while (this.cursor > 0 && this.line[this.cursor - 1] !== ' ') this.cursor--; + this.line = this.line.slice(0, this.cursor) + this.line.slice(oldCursor); + this.refreshLine(); + } + + /** Redraw current line with cursor at correct position */ + refreshLine() { + const prompt = this.getCurrentPrompt(); + this.terminal.write('\r\x1b[K' + prompt + this.line); + this.moveCursorToPosition(); + } + + /** Move terminal cursor to match this.cursor position */ + moveCursorToPosition() { + const promptLen = 4; // ">>> " or "... " (without ANSI codes) + const targetCol = promptLen + this.cursor; + this.terminal.write(`\r\x1b[${targetCol + 1}G`); + } + + // ========================================================================= + // History Navigation + // ========================================================================= + + /** Navigate to previous command in history */ + historyUp() { + if (this.history.length === 0) return; + if (this.historyPos === -1) { + this.historyPos = this.history.length - 1; + } else if (this.historyPos > 0) { + this.historyPos--; + } + this.setLine(this.history[this.historyPos]); + } + + /** Navigate to next command in history */ + historyDown() { + if (this.historyPos === -1) return; + if (this.historyPos < this.history.length - 1) { + this.historyPos++; + this.setLine(this.history[this.historyPos]); + } else { + this.historyPos = -1; + this.setLine(''); + } + } + + /** Set current line content and move cursor to end */ + setLine(text) { + this.line = text; + this.cursor = text.length; + this.refreshLine(); + } + + // ========================================================================= + // Multiline Input Detection + // ========================================================================= + + /** + * Check if code is incomplete (needs continuation). + * Detects unclosed braces, parentheses, or strings. + */ + isIncomplete(code) { + let braces = 0, parens = 0, inString = false; + + for (let i = 0; i < code.length; i++) { + const char = code[i]; + const prev = i > 0 ? code[i - 1] : ''; + + if (char === '"' && prev !== '\\') { + inString = !inString; + } else if (!inString) { + if (char === '{') braces++; + else if (char === '}') braces--; + else if (char === '(') parens++; + else if (char === ')') parens--; + } + } + + return inString || braces > 0 || parens > 0; + } + + // ========================================================================= + // Command Execution + // ========================================================================= + + /** Handle Enter key - check for continuation or execute */ + handleEnter() { + const currentLine = this.line; + this.line = ''; + this.cursor = 0; + this.historyPos = -1; + + // Accumulate multiline input + this.multilineBuffer.push(currentLine); + const fullCode = this.multilineBuffer.join('\n'); + + // Check if input is incomplete + if (this.isIncomplete(fullCode)) { + this.terminal.write(CONTINUATION_PROMPT); + return; + } + + // Execute complete input + const code = fullCode.trim(); + this.multilineBuffer = []; + + // Add to history (avoid duplicates) + if (code && this.history[this.history.length - 1] !== code) { + this.history.push(code); + } + + if (!code) { + this.terminal.write(PROMPT); + return; + } + + // Handle built-in commands + if (code === 'clear') { + this.terminal.clear(); + } else if (code === 'help') { + this.terminal.write(MANPAGE.replace(/\n/g, '\r\n')); + } else if (code.startsWith('%')) { + this.handleMagicCommand(code); + } else if (this.wasmLoaded && window.slox?.execute) { + // Execute Lox code via WASM + try { + window.slox.execute(code); + } catch (e) { + this.terminal.writeln(`\x1b[31mError: ${e.message}\x1b[0m`); + } + } else { + this.terminal.writeln('\x1b[38;5;242mWASM not available\x1b[0m'); + } + + this.terminal.write(PROMPT); + } + + /** Handle magic commands (%env, %globals, %reset) */ + handleMagicCommand(cmd) { + if (!this.wasmLoaded) { + this.terminal.writeln('\x1b[38;5;242mWASM not available\x1b[0m'); + return; + } + + const command = cmd.toLowerCase().trim(); + + switch (command) { + case '%env': + try { + const env = window.slox.getEnvironment(); + this.terminal.writeln(`\x1b[36mEnvironment:\x1b[0m ${env}`); + } catch (e) { + this.terminal.writeln(`\x1b[31mError: ${e.message}\x1b[0m`); + } + break; + + case '%globals': + try { + const globals = window.slox.getGlobals(); + this.terminal.writeln(`\x1b[36mGlobals:\x1b[0m ${globals}`); + } catch (e) { + this.terminal.writeln(`\x1b[31mError: ${e.message}\x1b[0m`); + } + break; + + case '%reset': + try { + window.slox.reset(); + this.terminal.writeln('\x1b[36mInterpreter state reset.\x1b[0m'); + } catch (e) { + this.terminal.writeln(`\x1b[31mError: ${e.message}\x1b[0m`); + } + break; + + default: + this.terminal.writeln(`\x1b[31mUnknown magic command: ${cmd}\x1b[0m`); + this.terminal.writeln('\x1b[38;5;242mAvailable: %env, %globals, %reset\x1b[0m'); + } + } +} + +// ============================================================================= +// Entry Point +// ============================================================================= + +document.addEventListener('DOMContentLoaded', () => new SloxRepl()); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..dd1a655 --- /dev/null +++ b/web/index.html @@ -0,0 +1,24 @@ + + + + + + slox REPL + + + + +
+
+
+ +
+
+
+ + + + + + + diff --git a/web/javascriptkit-runtime.mjs b/web/javascriptkit-runtime.mjs new file mode 100644 index 0000000..823ffca --- /dev/null +++ b/web/javascriptkit-runtime.mjs @@ -0,0 +1,428 @@ +/// Memory lifetime of closures in Swift are managed by Swift side +class SwiftClosureDeallocator { + constructor(exports) { + if (typeof FinalizationRegistry === "undefined") { + throw new Error("The Swift part of JavaScriptKit was configured to require " + + "the availability of JavaScript WeakRefs. Please build " + + "with `-Xswiftc -DJAVASCRIPTKIT_WITHOUT_WEAKREFS` to " + + "disable features that use WeakRefs."); + } + this.functionRegistry = new FinalizationRegistry((id) => { + exports.swjs_free_host_function(id); + }); + } + track(func, func_ref) { + this.functionRegistry.register(func, func_ref); + } +} + +function assertNever(x, message) { + throw new Error(message); +} + +const decode = (kind, payload1, payload2, memory) => { + switch (kind) { + case 0 /* Boolean */: + switch (payload1) { + case 0: + return false; + case 1: + return true; + } + case 2 /* Number */: + return payload2; + case 1 /* String */: + case 3 /* Object */: + case 6 /* Function */: + case 7 /* Symbol */: + case 8 /* BigInt */: + return memory.getObject(payload1); + case 4 /* Null */: + return null; + case 5 /* Undefined */: + return undefined; + default: + assertNever(kind, `JSValue Type kind "${kind}" is not supported`); + } +}; +// Note: +// `decodeValues` assumes that the size of RawJSValue is 16. +const decodeArray = (ptr, length, memory) => { + // fast path for empty array + if (length === 0) { + return []; + } + let result = []; + // It's safe to hold DataView here because WebAssembly.Memory.buffer won't + // change within this function. + const view = memory.dataView(); + for (let index = 0; index < length; index++) { + const base = ptr + 16 * index; + const kind = view.getUint32(base, true); + const payload1 = view.getUint32(base + 4, true); + const payload2 = view.getFloat64(base + 8, true); + result.push(decode(kind, payload1, payload2, memory)); + } + return result; +}; +// A helper function to encode a RawJSValue into a pointers. +// Please prefer to use `writeAndReturnKindBits` to avoid unnecessary +// memory stores. +// This function should be used only when kind flag is stored in memory. +const write = (value, kind_ptr, payload1_ptr, payload2_ptr, is_exception, memory) => { + const kind = writeAndReturnKindBits(value, payload1_ptr, payload2_ptr, is_exception, memory); + memory.writeUint32(kind_ptr, kind); +}; +const writeAndReturnKindBits = (value, payload1_ptr, payload2_ptr, is_exception, memory) => { + const exceptionBit = (is_exception ? 1 : 0) << 31; + if (value === null) { + return exceptionBit | 4 /* Null */; + } + const writeRef = (kind) => { + memory.writeUint32(payload1_ptr, memory.retain(value)); + return exceptionBit | kind; + }; + const type = typeof value; + switch (type) { + case "boolean": { + memory.writeUint32(payload1_ptr, value ? 1 : 0); + return exceptionBit | 0 /* Boolean */; + } + case "number": { + memory.writeFloat64(payload2_ptr, value); + return exceptionBit | 2 /* Number */; + } + case "string": { + return writeRef(1 /* String */); + } + case "undefined": { + return exceptionBit | 5 /* Undefined */; + } + case "object": { + return writeRef(3 /* Object */); + } + case "function": { + return writeRef(6 /* Function */); + } + case "symbol": { + return writeRef(7 /* Symbol */); + } + case "bigint": { + return writeRef(8 /* BigInt */); + } + default: + assertNever(type, `Type "${type}" is not supported yet`); + } + throw new Error("Unreachable"); +}; + +let globalVariable; +if (typeof globalThis !== "undefined") { + globalVariable = globalThis; +} +else if (typeof window !== "undefined") { + globalVariable = window; +} +else if (typeof global !== "undefined") { + globalVariable = global; +} +else if (typeof self !== "undefined") { + globalVariable = self; +} + +class SwiftRuntimeHeap { + constructor() { + this._heapValueById = new Map(); + this._heapValueById.set(0, globalVariable); + this._heapEntryByValue = new Map(); + this._heapEntryByValue.set(globalVariable, { id: 0, rc: 1 }); + // Note: 0 is preserved for global + this._heapNextKey = 1; + } + retain(value) { + const entry = this._heapEntryByValue.get(value); + if (entry) { + entry.rc++; + return entry.id; + } + const id = this._heapNextKey++; + this._heapValueById.set(id, value); + this._heapEntryByValue.set(value, { id: id, rc: 1 }); + return id; + } + release(ref) { + const value = this._heapValueById.get(ref); + const entry = this._heapEntryByValue.get(value); + entry.rc--; + if (entry.rc != 0) + return; + this._heapEntryByValue.delete(value); + this._heapValueById.delete(ref); + } + referenceHeap(ref) { + const value = this._heapValueById.get(ref); + if (value === undefined) { + throw new ReferenceError("Attempted to read invalid reference " + ref); + } + return value; + } +} + +class Memory { + constructor(exports) { + this.heap = new SwiftRuntimeHeap(); + this.retain = (value) => this.heap.retain(value); + this.getObject = (ref) => this.heap.referenceHeap(ref); + this.release = (ref) => this.heap.release(ref); + this.bytes = () => new Uint8Array(this.rawMemory.buffer); + this.dataView = () => new DataView(this.rawMemory.buffer); + this.writeBytes = (ptr, bytes) => this.bytes().set(bytes, ptr); + this.readUint32 = (ptr) => this.dataView().getUint32(ptr, true); + this.readUint64 = (ptr) => this.dataView().getBigUint64(ptr, true); + this.readInt64 = (ptr) => this.dataView().getBigInt64(ptr, true); + this.readFloat64 = (ptr) => this.dataView().getFloat64(ptr, true); + this.writeUint32 = (ptr, value) => this.dataView().setUint32(ptr, value, true); + this.writeUint64 = (ptr, value) => this.dataView().setBigUint64(ptr, value, true); + this.writeInt64 = (ptr, value) => this.dataView().setBigInt64(ptr, value, true); + this.writeFloat64 = (ptr, value) => this.dataView().setFloat64(ptr, value, true); + this.rawMemory = exports.memory; + } +} + +class SwiftRuntime { + constructor() { + this.version = 708; + this.textDecoder = new TextDecoder("utf-8"); + this.textEncoder = new TextEncoder(); // Only support utf-8 + /** @deprecated Use `wasmImports` instead */ + this.importObjects = () => this.wasmImports; + this.wasmImports = { + swjs_set_prop: (ref, name, kind, payload1, payload2) => { + const memory = this.memory; + const obj = memory.getObject(ref); + const key = memory.getObject(name); + const value = decode(kind, payload1, payload2, memory); + obj[key] = value; + }, + swjs_get_prop: (ref, name, payload1_ptr, payload2_ptr) => { + const memory = this.memory; + const obj = memory.getObject(ref); + const key = memory.getObject(name); + const result = obj[key]; + return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, memory); + }, + swjs_set_subscript: (ref, index, kind, payload1, payload2) => { + const memory = this.memory; + const obj = memory.getObject(ref); + const value = decode(kind, payload1, payload2, memory); + obj[index] = value; + }, + swjs_get_subscript: (ref, index, payload1_ptr, payload2_ptr) => { + const obj = this.memory.getObject(ref); + const result = obj[index]; + return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, this.memory); + }, + swjs_encode_string: (ref, bytes_ptr_result) => { + const memory = this.memory; + const bytes = this.textEncoder.encode(memory.getObject(ref)); + const bytes_ptr = memory.retain(bytes); + memory.writeUint32(bytes_ptr_result, bytes_ptr); + return bytes.length; + }, + swjs_decode_string: (bytes_ptr, length) => { + const memory = this.memory; + const bytes = memory + .bytes() + .subarray(bytes_ptr, bytes_ptr + length); + const string = this.textDecoder.decode(bytes); + return memory.retain(string); + }, + swjs_load_string: (ref, buffer) => { + const memory = this.memory; + const bytes = memory.getObject(ref); + memory.writeBytes(buffer, bytes); + }, + swjs_call_function: (ref, argv, argc, payload1_ptr, payload2_ptr) => { + const memory = this.memory; + const func = memory.getObject(ref); + let result = undefined; + try { + const args = decodeArray(argv, argc, memory); + result = func(...args); + } + catch (error) { + return writeAndReturnKindBits(error, payload1_ptr, payload2_ptr, true, this.memory); + } + return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, this.memory); + }, + swjs_call_function_no_catch: (ref, argv, argc, payload1_ptr, payload2_ptr) => { + const memory = this.memory; + const func = memory.getObject(ref); + const args = decodeArray(argv, argc, memory); + const result = func(...args); + return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, this.memory); + }, + swjs_call_function_with_this: (obj_ref, func_ref, argv, argc, payload1_ptr, payload2_ptr) => { + const memory = this.memory; + const obj = memory.getObject(obj_ref); + const func = memory.getObject(func_ref); + let result; + try { + const args = decodeArray(argv, argc, memory); + result = func.apply(obj, args); + } + catch (error) { + return writeAndReturnKindBits(error, payload1_ptr, payload2_ptr, true, this.memory); + } + return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, this.memory); + }, + swjs_call_function_with_this_no_catch: (obj_ref, func_ref, argv, argc, payload1_ptr, payload2_ptr) => { + const memory = this.memory; + const obj = memory.getObject(obj_ref); + const func = memory.getObject(func_ref); + let result = undefined; + const args = decodeArray(argv, argc, memory); + result = func.apply(obj, args); + return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, this.memory); + }, + swjs_call_new: (ref, argv, argc) => { + const memory = this.memory; + const constructor = memory.getObject(ref); + const args = decodeArray(argv, argc, memory); + const instance = new constructor(...args); + return this.memory.retain(instance); + }, + swjs_call_throwing_new: (ref, argv, argc, exception_kind_ptr, exception_payload1_ptr, exception_payload2_ptr) => { + let memory = this.memory; + const constructor = memory.getObject(ref); + let result; + try { + const args = decodeArray(argv, argc, memory); + result = new constructor(...args); + } + catch (error) { + write(error, exception_kind_ptr, exception_payload1_ptr, exception_payload2_ptr, true, this.memory); + return -1; + } + memory = this.memory; + write(null, exception_kind_ptr, exception_payload1_ptr, exception_payload2_ptr, false, memory); + return memory.retain(result); + }, + swjs_instanceof: (obj_ref, constructor_ref) => { + const memory = this.memory; + const obj = memory.getObject(obj_ref); + const constructor = memory.getObject(constructor_ref); + return obj instanceof constructor; + }, + swjs_create_function: (host_func_id, line, file) => { + var _a; + const fileString = this.memory.getObject(file); + const func = (...args) => this.callHostFunction(host_func_id, line, fileString, args); + const func_ref = this.memory.retain(func); + (_a = this.closureDeallocator) === null || _a === void 0 ? void 0 : _a.track(func, func_ref); + return func_ref; + }, + swjs_create_typed_array: (constructor_ref, elementsPtr, length) => { + const ArrayType = this.memory.getObject(constructor_ref); + const array = new ArrayType(this.memory.rawMemory.buffer, elementsPtr, length); + // Call `.slice()` to copy the memory + return this.memory.retain(array.slice()); + }, + swjs_load_typed_array: (ref, buffer) => { + const memory = this.memory; + const typedArray = memory.getObject(ref); + const bytes = new Uint8Array(typedArray.buffer); + memory.writeBytes(buffer, bytes); + }, + swjs_release: (ref) => { + this.memory.release(ref); + }, + swjs_i64_to_bigint: (value, signed) => { + return this.memory.retain(signed ? value : BigInt.asUintN(64, value)); + }, + swjs_bigint_to_i64: (ref, signed) => { + const object = this.memory.getObject(ref); + if (typeof object !== "bigint") { + throw new Error(`Expected a BigInt, but got ${typeof object}`); + } + if (signed) { + return object; + } + else { + if (object < BigInt(0)) { + return BigInt(0); + } + return BigInt.asIntN(64, object); + } + }, + swjs_i64_to_bigint_slow: (lower, upper, signed) => { + const value = BigInt.asUintN(32, BigInt(lower)) + + (BigInt.asUintN(32, BigInt(upper)) << BigInt(32)); + return this.memory.retain(signed ? BigInt.asIntN(64, value) : BigInt.asUintN(64, value)); + }, + }; + this._instance = null; + this._memory = null; + this._closureDeallocator = null; + } + setInstance(instance) { + this._instance = instance; + if (typeof this.exports._start === "function") { + throw new Error(`JavaScriptKit supports only WASI reactor ABI. + Please make sure you are building with: + -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor + `); + } + if (this.exports.swjs_library_version() != this.version) { + throw new Error(`The versions of JavaScriptKit are incompatible. + WebAssembly runtime ${this.exports.swjs_library_version()} != JS runtime ${this.version}`); + } + } + get instance() { + if (!this._instance) + throw new Error("WebAssembly instance is not set yet"); + return this._instance; + } + get exports() { + return this.instance.exports; + } + get memory() { + if (!this._memory) { + this._memory = new Memory(this.instance.exports); + } + return this._memory; + } + get closureDeallocator() { + if (this._closureDeallocator) + return this._closureDeallocator; + const features = this.exports.swjs_library_features(); + const librarySupportsWeakRef = (features & 1 /* WeakRefs */) != 0; + if (librarySupportsWeakRef) { + this._closureDeallocator = new SwiftClosureDeallocator(this.exports); + } + return this._closureDeallocator; + } + callHostFunction(host_func_id, line, file, args) { + const argc = args.length; + const argv = this.exports.swjs_prepare_host_function_call(argc); + const memory = this.memory; + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + const base = argv + 16 * index; + write(argument, base, base + 4, base + 8, false, memory); + } + let output; + // This ref is released by the swjs_call_host_function implementation + const callback_func_ref = memory.retain((result) => { + output = result; + }); + const alreadyReleased = this.exports.swjs_call_host_function(host_func_id, argv, argc, callback_func_ref); + if (alreadyReleased) { + throw new Error(`The JSClosure has been already released by Swift side. The closure is created at ${file}:${line}`); + } + this.exports.swjs_cleanup_host_function_call(argv); + return output; + } +} + +export { SwiftRuntime }; diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..7dee0ea --- /dev/null +++ b/web/style.css @@ -0,0 +1,88 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + width: 100%; + overflow: hidden; + background: #0a0a0a; +} + +#app { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +#terminal { + padding: 24px; + max-width: 100%; +} + +/* Make xterm fill the container */ +#terminal .xterm { + height: 100%; +} + +#terminal .xterm-viewport { + overflow-y: auto; +} + +/* Loading overlay */ +#loading { + position: fixed; + inset: 0; + background: #0a0a0a; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + transition: opacity 0.3s ease; +} + +#loading.hidden { + opacity: 0; + pointer-events: none; +} + +.loader { + width: 20px; + height: 20px; + border: 2px solid #222; + border-top-color: #4a4; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Selection styling */ +::selection { + background: rgba(74, 170, 74, 0.3); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #333; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #444; +} diff --git a/web/wasi-loader.js b/web/wasi-loader.js new file mode 100644 index 0000000..d22e9a3 --- /dev/null +++ b/web/wasi-loader.js @@ -0,0 +1,221 @@ +// WASI Loader - Browser WASI shim for SwiftWasm +// This file provides WASI implementation for running Swift WASM in browsers + +const WASI_ESUCCESS = 0; +const WASI_EBADF = 8; +const WASI_EINVAL = 28; +const WASI_ENOSYS = 52; + +// Simple in-memory file descriptor +class FileDescriptor { + constructor(data = new Uint8Array(0)) { + this.data = data; + this.offset = 0; + } + + read(len) { + const result = this.data.slice(this.offset, this.offset + len); + this.offset += result.length; + return result; + } + + write(data) { + this.data = new Uint8Array([...this.data, ...data]); + return data.length; + } +} + +// Console output wrapper +class ConsoleOutput { + constructor(writeFn) { + this.writeFn = writeFn; + this.buffer = ''; + } + + write(data) { + const text = new TextDecoder().decode(data); + this.buffer += text; + const lines = this.buffer.split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + this.writeFn(lines[i]); + } + this.buffer = lines[lines.length - 1]; + return data.length; + } + + flush() { + if (this.buffer) { + this.writeFn(this.buffer); + this.buffer = ''; + } + } +} + +export class WASI { + constructor(args = [], env = [], fds = []) { + this.args = args; + this.env = env; + this.fds = fds.length > 0 ? fds : [ + new FileDescriptor(), // stdin + new ConsoleOutput(console.log), // stdout + new ConsoleOutput(console.error) // stderr + ]; + this.memory = null; + this.view = null; + } + + get wasiImport() { + return { + args_get: (argv, argv_buf) => { + return WASI_ESUCCESS; + }, + args_sizes_get: (argc, argv_buf_size) => { + this.view.setUint32(argc, this.args.length, true); + this.view.setUint32(argv_buf_size, 0, true); + return WASI_ESUCCESS; + }, + environ_get: (environ, environ_buf) => { + return WASI_ESUCCESS; + }, + environ_sizes_get: (environc, environ_buf_size) => { + this.view.setUint32(environc, 0, true); + this.view.setUint32(environ_buf_size, 0, true); + return WASI_ESUCCESS; + }, + clock_res_get: (id, resolution) => { + this.view.setBigUint64(resolution, BigInt(1000000), true); + return WASI_ESUCCESS; + }, + clock_time_get: (id, precision, time) => { + const now = BigInt(Date.now()) * BigInt(1000000); + this.view.setBigUint64(time, now, true); + return WASI_ESUCCESS; + }, + fd_advise: () => WASI_ENOSYS, + fd_allocate: () => WASI_ENOSYS, + fd_close: (fd) => { + if (this.fds[fd]) { + this.fds[fd] = null; + return WASI_ESUCCESS; + } + return WASI_EBADF; + }, + fd_datasync: () => WASI_ENOSYS, + fd_fdstat_get: (fd, stat) => { + if (!this.fds[fd]) return WASI_EBADF; + // filetype: character_device = 2 + this.view.setUint8(stat, 2); + this.view.setUint16(stat + 2, 0, true); // flags + this.view.setBigUint64(stat + 8, BigInt(0), true); // rights_base + this.view.setBigUint64(stat + 16, BigInt(0), true); // rights_inheriting + return WASI_ESUCCESS; + }, + fd_fdstat_set_flags: () => WASI_ENOSYS, + fd_fdstat_set_rights: () => WASI_ENOSYS, + fd_filestat_get: () => WASI_ENOSYS, + fd_filestat_set_size: () => WASI_ENOSYS, + fd_filestat_set_times: () => WASI_ENOSYS, + fd_pread: () => WASI_ENOSYS, + fd_prestat_get: (fd, buf) => { + return WASI_EBADF; + }, + fd_prestat_dir_name: () => WASI_EBADF, + fd_pwrite: () => WASI_ENOSYS, + fd_read: (fd, iovs, iovs_len, nread) => { + if (!this.fds[fd]) return WASI_EBADF; + let totalRead = 0; + for (let i = 0; i < iovs_len; i++) { + const ptr = this.view.getUint32(iovs + i * 8, true); + const len = this.view.getUint32(iovs + i * 8 + 4, true); + const data = this.fds[fd].read ? this.fds[fd].read(len) : new Uint8Array(0); + new Uint8Array(this.memory.buffer, ptr, data.length).set(data); + totalRead += data.length; + } + this.view.setUint32(nread, totalRead, true); + return WASI_ESUCCESS; + }, + fd_readdir: () => WASI_ENOSYS, + fd_renumber: () => WASI_ENOSYS, + fd_seek: (fd, offset, whence, newoffset) => { + return WASI_ENOSYS; + }, + fd_sync: () => WASI_ENOSYS, + fd_tell: () => WASI_ENOSYS, + fd_write: (fd, iovs, iovs_len, nwritten) => { + if (!this.fds[fd]) return WASI_EBADF; + let totalWritten = 0; + for (let i = 0; i < iovs_len; i++) { + const ptr = this.view.getUint32(iovs + i * 8, true); + const len = this.view.getUint32(iovs + i * 8 + 4, true); + const data = new Uint8Array(this.memory.buffer, ptr, len); + if (this.fds[fd].write) { + totalWritten += this.fds[fd].write(data); + } else { + totalWritten += len; + } + } + this.view.setUint32(nwritten, totalWritten, true); + return WASI_ESUCCESS; + }, + path_create_directory: () => WASI_ENOSYS, + path_filestat_get: () => WASI_ENOSYS, + path_filestat_set_times: () => WASI_ENOSYS, + path_link: () => WASI_ENOSYS, + path_open: () => WASI_ENOSYS, + path_readlink: () => WASI_ENOSYS, + path_remove_directory: () => WASI_ENOSYS, + path_rename: () => WASI_ENOSYS, + path_symlink: () => WASI_ENOSYS, + path_unlink_file: () => WASI_ENOSYS, + poll_oneoff: () => WASI_ENOSYS, + proc_exit: (code) => { + throw new Error('Process exited with code ' + code); + }, + proc_raise: () => WASI_ENOSYS, + sched_yield: () => WASI_ESUCCESS, + random_get: (buf, buf_len) => { + const buffer = new Uint8Array(this.memory.buffer, buf, buf_len); + crypto.getRandomValues(buffer); + return WASI_ESUCCESS; + }, + sock_accept: () => WASI_ENOSYS, + sock_recv: () => WASI_ENOSYS, + sock_send: () => WASI_ENOSYS, + sock_shutdown: () => WASI_ENOSYS, + }; + } + + initialize(instance) { + this.memory = instance.exports.memory; + this.view = new DataView(this.memory.buffer); + } +} + +export class File { + constructor(data = []) { + this.data = new Uint8Array(data); + this.offset = 0; + } + + read(len) { + const result = this.data.slice(this.offset, this.offset + len); + this.offset += result.length; + return result; + } +} + +export class OpenFile { + constructor(file) { + this.file = file; + } + + read(len) { + return this.file.read(len); + } +} + +export const ConsoleStdout = { + lineBuffered(writeFn) { + return new ConsoleOutput(writeFn); + } +};