Skip to content

Rust Port Plan (by GLM-5.2) #78

Description

@AuthorOfTheSurf

Goal

Port KobraScript correctly and minimally to Rust, preserving the existing transpiler behavior (.ks → JavaScript source). The intent is instructional: the author should be able to read the Rust side-by-side with the original JavaScript and learn idiomatic Rust by comparison. All Rust best practices apply — lean on the borrow checker, algebraic data types, Result-based error handling, and zero-cost abstractions.


What KobraScript is today

KobraScript is a source-to-source compiler (transpiler). It does not interpret or execute code — it reads a .ks file and emits JavaScript, which kobra then pipes to node. The pipeline is:

.ks file ──► scanner ──► tokens ──► parser ──► AST ──► analyzer ──► codegen ──► JavaScript
JS module Responsibility
scanner.js Lexical analysis (streaming, line-by-line) → Token[]
token.js Token struct (kind, lexeme, line, col)
parser.js Recursive-descent parser → AST of entities/* nodes
analyzer.js AnalysisContext with parent-chained symbol tables; semantic checks
entities/*.js ~30 AST node types, each with toString / analyze / generateJavaScript
compiler.js Orchestrates the pipeline; CLI flags -t -a -o -i
code-gen/js-beautifier.js Post-processes emitted JS with js-beautify
kobra-cli.js / kobra CLI entry points (kobrac = transpile & print, kobra = transpile & run)

Semantic checks performed today

  • return / leave only inside a function (isSubroutine context flag)
  • break / continue only inside a loop (looped context flag)

Notable codegen operator mappings

  • =====, !=!==, ~=== (coerced), #|| (alt-OR)
  • **Math.pow(a,b), -**(1.0)/Math.pow(a,b)
  • is(typeof a === b), ~?typeof
  • : =: (swap) → b = [a, a = b][0] trick
  • sayconsole.log(...), logeconsole.log(...)
  • close{args} (closure literal) → (function(args){...}(args))

Proposed Rust project structure

kobrascript-rs/
├── Cargo.toml
├── src/
│   ├── main.rs          # CLI entry (kobrac equivalent): arg parsing, orchestration
│   ├── token.rs         # TokenKind enum + Token struct
│   ├── scanner.rs       # scan(source: &str) -> Result<Vec<Token>, CompileError>
│   ├── error.rs         # CompileError enum, error reporting, error count
│   ├── ast.rs           # Stmt / Expr enums + structs (the AST)
│   ├── parser.rs        # Parser struct (recursive descent) -> Result<Program, CompileError>
│   ├── analyzer.rs      # AnalysisContext, semantic checks
│   ├── codegen.rs       # generate_js(&Program) -> String
│   └── beautify.rs      # minimal JS pretty-printer (replaces js-beautify)
└── tests/              # integration tests mirroring test/kobra-code/*

Component-by-component plan

1. token.rs — Tokens as an enum

The JS Token is a plain object with a string kind. In Rust, model token kinds as an enum — this is the first big instructional win: the compiler proves the token set is exhaustive.

#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
    Dollar, DotDot, ColonColonSwap, // $  ..  :=:
    Plus, Minus, Star, Slash, Percent,
    Pow, NegPow,                     // **  -**
    Assign, PlusAssign, /* ... */
    Eq, Neq, ApproxEq, Is,           // ==  !=  ~=  is
    AndAnd, OrOr, Hash,              // &&  ||  #
    TildeQ, TildeBang,               // ~?  ~!
    LParen, RParen, LBrace, RBrace, LBracket, RBracket,
    Colon, Comma, Dot, Arrow, Semicolon, Bang,
    Inc, Dec, New,
    // Keywords
    Fn, Close, Return, Leave, If, Else, Only, While, For,
    Break, Continue, End, Say, Loge, New_,
    // Literals (carry their lexeme)
    NumLit(String), StrLit(String), BoolLit(bool),
    NullLit, UndefinedLit, Id(String),
    Eof,
}

#[derive(Debug, Clone)]
pub struct Token {
    pub kind: TokenKind,
    pub lexeme: String,
    pub line: usize,
    pub col: usize,
}

Instructional note: contrast this with JS where kind is a free-form string and match() does string compare. The Rust enum makes impossible token states unrepresentable.

2. scanner.rs — Lexer

Port scan() faithfully. Key differences from JS:

  • JS streams the file line-by-line with byline. In Rust, read the whole file into a String (simpler, idiomatic) and iterate lines with .lines().enumerate(). This is a deliberate simplification worth calling out.
  • Replace the pile of regexes with char-pattern matching (char::is_ascii_digit, manual peek/next over a Chars iterator or byte slice). This showcases Rust's explicit character handling vs JS regex.
  • String-literal escape handling (\n, \xNN, \uNNNN, \cX control chars) ports directly.
  • Return Result<Vec<Token>, CompileError>no global mutable error counter. Each illegal char/number becomes an Err. This is the second major instructional contrast: Rust forces errors to be handled explicitly rather than mutating a module-global error.count.
pub fn scan(source: &str) -> Result<Vec<Token>, CompileError> {
    let mut tokens = Vec::new();
    for (i, line) in source.lines().enumerate() {
        scan_line(line, i + 1, &mut tokens)?;   // line is 1-indexed
    }
    tokens.push(Token::eof());
    Ok(tokens)
}

3. ast.rs — The AST as algebraic data types

The JS codebase has ~30 entity classes, each with toString / analyze / generateJavaScript. In idiomatic Rust, model the AST as two enums (Stmt, Expr) with data-carrying variants, then implement behavior with match. This is the centerpiece of the port for instructional value — it shows sum types, Box for recursion, and exhaustive pattern matching replacing dynamic dispatch.

pub enum Stmt {
    Declaration { name: String, init: Expr },
    FnDecl { name: String, params: Vec<String>, body: Block },
    If { conditionals: Vec<Conditional>, else_block: Option<Block> },
    OnlyIf { conditional: Conditional, default: Option<Block> },
    For { assignments: Vec<Stmt>, condition: Expr, after: Vec<Expr>, body: Box<Block> },
    While { condition: Expr, body: Box<Block> },
    Say(Expr),
    Return(Expr),
    Leave,
    Break,
    Continue,
    ExprStmt(Expr),
}

pub enum Expr {
    NumLit(String),
    StrLit(String),
    BoolLit(bool),
    NullLit,
    UndefinedLit,
    Name(String),
    Binary { op: TokenKind, left: Box<Expr>, right: Box<Expr>, wrapped: bool },
    Unary { op: TokenKind, operand: Box<Expr> },
    PostUnary { op: TokenKind, operand: Box<Expr> },
    Call { callee: Box<Expr>, args: Vec<Expr> },
    Index { target: Box<Expr>, index: Box<Expr> },
    Dot { target: Box<Expr>, property: String },
    FnLit { name: Option<String>, params: Vec<String>, body: Box<Block> },
    ClosureLit { args: Vec<String>, body: Box<Block> },
    ArrayLit(Vec<Expr>),
    ObjectLit(Vec<Property>),
}

pub struct Block(pub Vec<Stmt>);
pub struct Conditional { pub condition: Expr, pub body: Block }
pub struct Property { pub key: Expr, pub value: Expr }

Why enums over a Node trait with structs? A trait-object approach mirrors the JS OOP design 1:1, but it's less idiomatic Rust and loses exhaustiveness checking. The enum + match approach is what a Rust practitioner would reach for first, and it makes the JS↔Rust contrast sharpest. (A trait-based variant could be shown in an appendix/alternative if desired.)

4. parser.rs — Recursive descent, encapsulated

Port parser.js directly. The JS parser uses module-global tokens and continuing mutable state. In Rust, encapsulate that in a Parser struct — another instructional contrast (no globals; state lives in the struct).

pub struct Parser {
    tokens: Vec<Token>,
    pos: usize,
    continuing: bool,
}

impl Parser {
    fn at(&self, kind: TokenKind) -> bool { ... }
    fn at_any(&self, kinds: &[TokenKind]) -> bool { ... }
    fn next_is(&self, kind: TokenKind) -> bool { ... }
    fn match_(&mut self, kind: TokenKind) -> Result<Token, CompileError> { ... }
    fn match_any(&mut self, kinds: &[TokenKind]) -> Result<Token, CompileError> { ... }

    pub fn parse(tokens: Vec<Token>) -> Result<Program, CompileError> {
        let mut p = Parser { tokens, pos: 0, continuing: false };
        p.parse_program()
    }
}

Each parseExp0..parseExp9 becomes a method. The precedence-climbing grammar maps over verbatim. Result propagates the first parse error (vs JS's "keep going after error").

5. analyzer.rs — Semantic analysis

Port AnalysisContext with an owned parent chain and a HashMap<String, ()> symbol table. The isSubroutine / looped flags become fields on the context. analyze walks the AST via match.

pub struct AnalysisContext<'a> {
    parent: Option<&'a AnalysisContext<'a>>,
    symbols: HashMap<String, ()>,
    is_subroutine: bool,
    looped: bool,
}

Checks preserved exactly:

  • Return / Leave outside a subroutine → CompileError
  • Break / Continue outside a loop → CompileError

The JS error.count global is replaced by collecting errors into a Vec<CompileError> (or returning on first error). Either is fine; collecting-all is closer to the JS "report everything" behavior.

6. codegen.rs — Emit JavaScript

Port each entity's generateJavaScript(state) as a match arm in fn gen_js(stmt, state) -> String / fn gen_expr(expr, state) -> String. The state struct carries continuing_declaration: bool exactly as in JS.

Operator mappings port verbatim (=====, **Math.pow, : =:→swap trick, etc.). Use format! macros where JS used util.format.

7. beautify.rs — Replace js-beautify

js-beautify is a JS library with no Rust equivalent. Two options:

  • (Recommended, minimal) Emit already-indented JS during codegen (track depth in state), so no post-processing beautifier is needed. This is cleaner than the JS approach and a nice instructional improvement.
  • (Alternative) Write a tiny indentation-based pretty-printer that re-indents brace-delimited output.

Either keeps the port dependency-free (no JS runtime needed for formatting).

8. main.rs — CLI

Port kobra-cli.js. Use clap (or manual env::args parsing for minimal deps) to mirror the flags:

  • kobrac <file.ks> — transpile & print JS
  • -t — scan & print tokens, exit
  • -a — scan & parse & print AST, exit
  • -i — up to semantic analysis & print semantic graph, exit
  • -o — optimize (currently a no-op stub, as in JS)
  • .ks extension validation

The kobra run-mode (pipe to node) can be a separate binary target or a --run flag that shells out to node on the generated JS.


Testing strategy

Mirror the existing Mocha test suite using Rust's built-in #[test] + tests/ directory:

JS test file Rust equivalent
test-scanner.js Unit tests asserting exact token streams
test-good-programs.js Integration test: every good-programs/*.ks compiles without error
test-syntax-errors.js Every syntax-errors/*.ks produces a parse error
test-semantic-errors.js Every semantic-errors/*.ks produces an analyze error

Reuse the same .ks fixture files from test/kobra-code/ — copy them into the Rust crate's test fixtures so behavior is directly comparable. This is the validation gate: the Rust port must pass the same corpus.


Scope

In scope (minimal, correct):

  • Scanner, parser, analyzer, codegen, CLI — full feature parity with the current JS transpiler.
  • All good/syntax/semantic test fixtures passing.

Out of scope (deliberately):

  • The optimize() step (already a no-op stub in JS — keep as a no-op or document as future work).
  • A JS runtime / interpreter (KobraScript transpiles; it does not interpret).
  • The hapi-server.ks / promise.ks examples that rely on JS host APIs — these compile (transpile) fine; "running" them is the host's job.

Key Rust idioms this port teaches (the instructional payoff)

  1. Enums with data replacing ~30 prototype classes — sum types & exhaustive match.
  2. Result<T, E> replacing the global mutable error.count — errors as values.
  3. Box<T> for recursive AST types — ownership & heap allocation where needed.
  4. Encapsulated Parser struct replacing module-global mutable state.
  5. &str / String and char-level scanning replacing regex-heavy JS lexing.
  6. No null/undefinedOption<T> for the optional else block, optional fn name, etc.
  7. #[derive(Debug, Clone, PartialEq)] for cheap, correct trait impls.
  8. Borrow checker enforcing that the AST and context are threaded cleanly (no shared mutable globals).

Suggested implementation order

  1. token.rs + error.rs (foundations)
  2. scanner.rs + scanner tests (validate against test-scanner.js fixtures)
  3. ast.rs (the type definitions)
  4. parser.rs + syntax-error tests
  5. analyzer.rs + semantic-error tests
  6. codegen.rs + good-programs tests (transpile & compare output)
  7. beautify.rs (or inline indentation in codegen)
  8. main.rs CLI wiring

Each step is independently testable against the existing fixture corpus.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions