diff --git a/.gitignore b/.gitignore index ea8c4bf..8054d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +**/node_modules diff --git a/CLAUDE.md b/CLAUDE.md index 4705108..824fb63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,20 +5,57 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Run ```bash -# Build +# Build entire workspace cargo build # Run REPL (interactive) -cargo run +cargo run --bin aster # Execute a script file -cargo run -- examples/script.ast +cargo run --bin aster -- aster-core/examples/script.ast -# Run tests (none yet; the project has no test/ directory) +# Run tests cargo test ``` -This is a pure-Rust project with no external dependencies (`edition = "2024"`). +This is a Rust workspace. `aster-core` is a pure-Rust library with no external dependencies (`edition = "2024"`). + +## Project structure + +``` +aster/ +├── Cargo.toml # workspace root (aster-core, aster, aster-lsp) +├── aster-core/ # core library — lexer, parser, AST, interpreter +├── aster/ # REPL binary (thin wrapper around aster-core) +├── aster-lsp/ # LSP server (diagnostics, etc.) +└── vscode-ext/ # VS Code extension (syntax highlighting + LSP client) +``` + +## VS Code Extension + +The `vscode-ext/` directory contains a VS Code extension that provides syntax highlighting and language server support for `.ast` files. + +### Install (development) + +```bash +# 1. Build the LSP server +cargo build --bin aster-lsp + +# 2. Install extension npm dependencies +npm --prefix vscode-ext install + +# 3. Register the extension in VS Code + +# Windows: create junction +powershell -Command "New-Item -ItemType Junction -Path '$env:USERPROFILE\.vscode\extensions\aster-lang' -Target '$PWD\vscode-ext' -Force" + +# macOS / Linux: symlink +ln -s "$PWD/vscode-ext" "$HOME/.vscode/extensions/aster-lang" +``` + +After installation, reload VS Code (`Ctrl+Shift+P` → "Developer: Reload Window"). Open any `.ast` file — syntax errors will appear as red squiggly underlines. + +**Note:** The LSP server binary must be built (`cargo build --bin aster-lsp`) before the extension will work. After making changes to `aster-lsp`, rebuild and reload the VS Code window. ## Architecture @@ -28,20 +65,20 @@ This is a pure-Rust project with no external dependencies (`edition = "2024"`). Source text → Lexer → Tokens → Parser → AST → Interpreter → Output ``` -### Module map +### Module map (`aster-core/src/`) | Module | Responsibility | |---|---| -| `lexer/` | Tokenizer. `Lexer::tokenize()` takes `&str` and returns `(Vec, Vec)`. Tokens carry `line`/`column` for error reporting. Supports `//` single-line comments. | +| `lexer/` | Tokenizer. `Lexer::tokenize()` takes `&str` and returns `(Vec, Vec)`. Tokens carry `line`/`column` for error reporting. Supports `//` single-line and `/* */` block comments. | | `parser/` | Recursive-descent parser (Pratt-style for expressions). `Parser::parse()` returns `(Vec, Vec)`. Uses `synchronize()` for error recovery — skips to the next statement boundary on parse failure. | | `ast/` | AST node definitions. `Expr` (in `expr.rs`) covers literals, variables, assignment, property access/indexing, unary/binary/logical ops, calls, lambdas, and object/array literals. `Stmt` (in `stmt.rs`) covers let, expression stmts, blocks, if/while/for, functions, return/break/continue. | | `interpreter/` | Tree-walking evaluator. `Interpreter` holds an `env: Rc>`. `Env` is a linked-list of scopes for lexical nesting. The `Signal` enum propagates `Return`/`Break`/`Continue` through the call stack. `builtins/` registers native functions (`print`, `input`, `clock`) organized as `io` and `os` modules (also available as global functions). | | `error/` | Three error variants: `LexError` (line+column), `ParseError` (token), `RuntimeError` (optional token). All implement `Display` and `std::error::Error`. | -| `main.rs` | Two modes: `run_file` (parse-then-execute, exits with code 65 on lex/parse errors, 70 on runtime errors) and `run_repl` (per-line evaluation with `:exit`/`:reset` commands). | +| `lib.rs` | Crate root — declares public modules, re-exports key types, provides `run_file()` and `run_repl()` convenience functions. | ### Key design decisions -- **No external crate dependencies.** Everything is built on Rust's stdlib. +- **`aster-core` has no external crate dependencies.** Everything is built on Rust's stdlib. `aster-lsp` depends on `lsp-server` + `lsp-types`. - **Rc> for shared ownership.** `Env` chains, `Value::Object`, `Value::Array`, and `Value::Function` all use `Rc>` for interior mutability — matching the dynamic, garbage-collected feel without a GC. - **Error-tolerant parsing.** Both lexer and parser collect errors into a `Vec` rather than failing on the first error, so multiple diagnostics can be reported per run. - **Closures capture by environment.** `Function` structs store the `Env` at definition time; lambda expressions (`fn(params) { body }`) work the same way. diff --git a/Cargo.lock b/Cargo.lock index 06c95fd..0326b12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,3 +5,190 @@ version = 4 [[package]] name = "aster" version = "0.1.0" +dependencies = [ + "aster-core", +] + +[[package]] +name = "aster-core" +version = "0.1.0" + +[[package]] +name = "aster-lsp" +version = "0.1.0" +dependencies = [ + "aster-core", + "lsp-server", + "lsp-types", + "serde", + "serde_json", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lsp-server" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6ada348dbc2703cbe7637b2dda05cff84d3da2819c24abcb305dd613e0ba2e" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index fadaf74..ac04756 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,3 @@ -[package] -name = "aster" -version = "0.1.0" -edition = "2024" - -[dependencies] +[workspace] +members = ["aster-core", "aster", "aster-lsp"] +resolver = "2" diff --git a/aster-core/Cargo.toml b/aster-core/Cargo.toml new file mode 100644 index 0000000..300cb5e --- /dev/null +++ b/aster-core/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "aster-core" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/examples/array.ast b/aster-core/examples/array.ast similarity index 100% rename from examples/array.ast rename to aster-core/examples/array.ast diff --git a/examples/assign_op.ast b/aster-core/examples/assign_op.ast similarity index 100% rename from examples/assign_op.ast rename to aster-core/examples/assign_op.ast diff --git a/examples/break_test.ast b/aster-core/examples/break_test.ast similarity index 100% rename from examples/break_test.ast rename to aster-core/examples/break_test.ast diff --git a/examples/builtins.ast b/aster-core/examples/builtins.ast similarity index 100% rename from examples/builtins.ast rename to aster-core/examples/builtins.ast diff --git a/examples/forin.ast b/aster-core/examples/forin.ast similarity index 100% rename from examples/forin.ast rename to aster-core/examples/forin.ast diff --git a/examples/input.ast b/aster-core/examples/input.ast similarity index 100% rename from examples/input.ast rename to aster-core/examples/input.ast diff --git a/examples/loop.ast b/aster-core/examples/loop.ast similarity index 100% rename from examples/loop.ast rename to aster-core/examples/loop.ast diff --git a/examples/math_mod.ast b/aster-core/examples/math_mod.ast similarity index 100% rename from examples/math_mod.ast rename to aster-core/examples/math_mod.ast diff --git a/examples/object.ast b/aster-core/examples/object.ast similarity index 100% rename from examples/object.ast rename to aster-core/examples/object.ast diff --git a/examples/percent.ast b/aster-core/examples/percent.ast similarity index 100% rename from examples/percent.ast rename to aster-core/examples/percent.ast diff --git a/examples/practical.ast b/aster-core/examples/practical.ast similarity index 100% rename from examples/practical.ast rename to aster-core/examples/practical.ast diff --git a/examples/script.ast b/aster-core/examples/script.ast similarity index 100% rename from examples/script.ast rename to aster-core/examples/script.ast diff --git a/examples/use_math.ast b/aster-core/examples/use_math.ast similarity index 100% rename from examples/use_math.ast rename to aster-core/examples/use_math.ast diff --git a/aster-core/src/analysis.rs b/aster-core/src/analysis.rs new file mode 100644 index 0000000..35dc398 --- /dev/null +++ b/aster-core/src/analysis.rs @@ -0,0 +1,244 @@ +use crate::ast::{Expr, Stmt}; +use crate::lexer::{Token, TokenKind}; + +/// A symbol collected from walking the AST. +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub kind: SymbolKind, +} + +#[derive(Debug, Clone)] +pub enum SymbolKind { + Variable, + Function { params: Vec }, +} + +/// Collect all declared symbols from the given statements. +/// Walks into nested scopes but does NOT perform scope-aware filtering — +/// that's the caller's job (e.g. the LSP completion handler). +pub fn collect_symbols(stmts: &[Stmt]) -> Vec { + let mut symbols = Vec::new(); + collect_stmts(stmts, &mut symbols); + symbols +} + +fn collect_stmts(stmts: &[Stmt], symbols: &mut Vec) { + for stmt in stmts { + collect_stmt(stmt, symbols); + } +} + +fn collect_stmt(stmt: &Stmt, symbols: &mut Vec) { + match stmt { + Stmt::Let { name, initializer, .. } => { + symbols.push(Symbol { + name: name.clone(), + kind: SymbolKind::Variable, + }); + collect_expr(initializer, symbols); + } + Stmt::Function { name, params, body } => { + symbols.push(Symbol { + name: name.clone(), + kind: SymbolKind::Function { + params: params.clone(), + }, + }); + for p in params { + symbols.push(Symbol { + name: p.clone(), + kind: SymbolKind::Variable, + }); + } + collect_stmts(body, symbols); + } + Stmt::Block(body) => collect_stmts(body, symbols), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + collect_expr(condition, symbols); + collect_stmt(then_branch, symbols); + if let Some(else_b) = else_branch { + collect_stmt(else_b, symbols); + } + } + Stmt::While { condition, body } => { + collect_expr(condition, symbols); + collect_stmt(body, symbols); + } + Stmt::For { + initializer, + condition, + step, + body, + } => { + if let Some(init) = initializer { + collect_stmt(init, symbols); + } + if let Some(cond) = condition { + collect_expr(cond, symbols); + } + if let Some(step_expr) = step { + collect_expr(step_expr, symbols); + } + collect_stmt(body, symbols); + } + Stmt::ForIn { + var_name, + iterable, + body, + } => { + symbols.push(Symbol { + name: var_name.clone(), + kind: SymbolKind::Variable, + }); + collect_expr(iterable, symbols); + collect_stmt(body, symbols); + } + Stmt::ExprStmt(expr) | Stmt::Return(Some(expr)) => { + collect_expr(expr, symbols); + } + Stmt::Return(None) | Stmt::Break | Stmt::Continue => {} + } +} + +fn collect_expr(expr: &Expr, symbols: &mut Vec) { + match expr { + Expr::Literal(_) | Expr::Variable(_) => {} + Expr::Assign { value, .. } => collect_expr(value, symbols), + Expr::Get { object, .. } => collect_expr(object, symbols), + Expr::Set { object, value, .. } => { + collect_expr(object, symbols); + collect_expr(value, symbols); + } + Expr::ObjectLiteral { properties } => { + for (_, val) in properties { + collect_expr(val, symbols); + } + } + Expr::ArrayLiteral { elements } => { + for elem in elements { + collect_expr(elem, symbols); + } + } + Expr::IndexGet { array, index } => { + collect_expr(array, symbols); + collect_expr(index, symbols); + } + Expr::IndexSet { + array, + index, + value, + .. + } => { + collect_expr(array, symbols); + collect_expr(index, symbols); + collect_expr(value, symbols); + } + Expr::Unary { right, .. } => collect_expr(right, symbols), + Expr::Binary { left, right, .. } | Expr::Logical { left, right, .. } => { + collect_expr(left, symbols); + collect_expr(right, symbols); + } + Expr::Ternary { + condition, + then_branch, + else_branch, + } => { + collect_expr(condition, symbols); + collect_expr(then_branch, symbols); + collect_expr(else_branch, symbols); + } + Expr::Call { + callee, + arguments, + } => { + collect_expr(callee, symbols); + for arg in arguments { + collect_expr(arg, symbols); + } + } + Expr::Lambda { params, body } => { + for p in params { + symbols.push(Symbol { + name: p.clone(), + kind: SymbolKind::Variable, + }); + } + collect_stmts(body, symbols); + } + } +} + +// ── Token-based position lookups ────────────────────────────────── + +/// Scan tokens for the declaration site of `name`. +/// Recognises `let name`, `const name`, `fn name`, and `for (name in …)`. +/// Returns 1-based (line, column). +pub fn find_declaration(tokens: &[Token], name: &str) -> Option<(usize, usize)> { + let len = tokens.len(); + let mut i = 0; + while i < len { + match &tokens[i].kind { + TokenKind::Let | TokenKind::Const => { + if let Some(tok) = tokens.get(i + 1) { + if let TokenKind::Identifier(n) = &tok.kind { + if n == name { + return Some((tok.line, tok.column)); + } + } + } + } + TokenKind::Fn => { + // fn name( ... or fn name { ... + if let Some(tok) = tokens.get(i + 1) { + if let TokenKind::Identifier(n) = &tok.kind { + if n == name { + return Some((tok.line, tok.column)); + } + } + } + } + TokenKind::For => { + // for ( name in ... ) + // Skip past LeftParen + let mut j = i + 1; + if j < len && tokens[j].kind == TokenKind::LeftParen { + j += 1; + } + if j < len { + if let TokenKind::Identifier(n) = &tokens[j].kind { + if n == name { + return Some((tokens[j].line, tokens[j].column)); + } + } + } + } + _ => {} + } + i += 1; + } + None +} + +/// Scan tokens for every occurrence of `Identifier(name)`. +/// Returns 1-based (line, column) for each match. +pub fn find_all_references(tokens: &[Token], name: &str) -> Vec<(usize, usize)> { + tokens + .iter() + .filter_map(|tok| { + if let TokenKind::Identifier(n) = &tok.kind { + if n == name { + Some((tok.line, tok.column)) + } else { + None + } + } else { + None + } + }) + .collect() +} diff --git a/src/ast/expr.rs b/aster-core/src/ast/expr.rs similarity index 100% rename from src/ast/expr.rs rename to aster-core/src/ast/expr.rs diff --git a/src/ast/mod.rs b/aster-core/src/ast/mod.rs similarity index 100% rename from src/ast/mod.rs rename to aster-core/src/ast/mod.rs diff --git a/src/ast/stmt.rs b/aster-core/src/ast/stmt.rs similarity index 100% rename from src/ast/stmt.rs rename to aster-core/src/ast/stmt.rs diff --git a/src/error/error.rs b/aster-core/src/error/error.rs similarity index 100% rename from src/error/error.rs rename to aster-core/src/error/error.rs diff --git a/src/error/mod.rs b/aster-core/src/error/mod.rs similarity index 100% rename from src/error/mod.rs rename to aster-core/src/error/mod.rs diff --git a/src/interpreter/builtins/core.rs b/aster-core/src/interpreter/builtins/core.rs similarity index 100% rename from src/interpreter/builtins/core.rs rename to aster-core/src/interpreter/builtins/core.rs diff --git a/src/interpreter/builtins/io.rs b/aster-core/src/interpreter/builtins/io.rs similarity index 100% rename from src/interpreter/builtins/io.rs rename to aster-core/src/interpreter/builtins/io.rs diff --git a/src/interpreter/builtins/mod.rs b/aster-core/src/interpreter/builtins/mod.rs similarity index 100% rename from src/interpreter/builtins/mod.rs rename to aster-core/src/interpreter/builtins/mod.rs diff --git a/src/interpreter/builtins/os.rs b/aster-core/src/interpreter/builtins/os.rs similarity index 100% rename from src/interpreter/builtins/os.rs rename to aster-core/src/interpreter/builtins/os.rs diff --git a/src/interpreter/builtins/string.rs b/aster-core/src/interpreter/builtins/string.rs similarity index 100% rename from src/interpreter/builtins/string.rs rename to aster-core/src/interpreter/builtins/string.rs diff --git a/src/interpreter/env.rs b/aster-core/src/interpreter/env.rs similarity index 100% rename from src/interpreter/env.rs rename to aster-core/src/interpreter/env.rs diff --git a/src/interpreter/eval.rs b/aster-core/src/interpreter/eval.rs similarity index 100% rename from src/interpreter/eval.rs rename to aster-core/src/interpreter/eval.rs diff --git a/src/interpreter/exec.rs b/aster-core/src/interpreter/exec.rs similarity index 100% rename from src/interpreter/exec.rs rename to aster-core/src/interpreter/exec.rs diff --git a/src/interpreter/interpreter.rs b/aster-core/src/interpreter/interpreter.rs similarity index 100% rename from src/interpreter/interpreter.rs rename to aster-core/src/interpreter/interpreter.rs diff --git a/src/interpreter/mod.rs b/aster-core/src/interpreter/mod.rs similarity index 100% rename from src/interpreter/mod.rs rename to aster-core/src/interpreter/mod.rs diff --git a/src/interpreter/module.rs b/aster-core/src/interpreter/module.rs similarity index 100% rename from src/interpreter/module.rs rename to aster-core/src/interpreter/module.rs diff --git a/src/interpreter/tests.rs b/aster-core/src/interpreter/tests.rs similarity index 100% rename from src/interpreter/tests.rs rename to aster-core/src/interpreter/tests.rs diff --git a/src/lexer/lexer.rs b/aster-core/src/lexer/lexer.rs similarity index 100% rename from src/lexer/lexer.rs rename to aster-core/src/lexer/lexer.rs diff --git a/src/lexer/mod.rs b/aster-core/src/lexer/mod.rs similarity index 100% rename from src/lexer/mod.rs rename to aster-core/src/lexer/mod.rs diff --git a/src/lexer/tests.rs b/aster-core/src/lexer/tests.rs similarity index 100% rename from src/lexer/tests.rs rename to aster-core/src/lexer/tests.rs diff --git a/src/lexer/token.rs b/aster-core/src/lexer/token.rs similarity index 100% rename from src/lexer/token.rs rename to aster-core/src/lexer/token.rs diff --git a/src/main.rs b/aster-core/src/lib.rs similarity index 75% rename from src/main.rs rename to aster-core/src/lib.rs index 0bf0322..04e3edb 100644 --- a/src/main.rs +++ b/aster-core/src/lib.rs @@ -1,26 +1,26 @@ -mod lexer; -mod ast; -mod parser; -mod interpreter; -mod error; +pub mod lexer; +pub mod ast; +pub mod parser; +pub mod interpreter; +pub mod error; +pub mod analysis; use lexer::Lexer; use parser::Parser; use interpreter::Interpreter; use error::RuntimeError; -use std::env; -use std::fs; + use std::io::Write; use std::io::stdin; use std::io::stdout; -fn print_errors(errors: &[RuntimeError]) { +pub fn print_errors(errors: &[RuntimeError]) { for err in errors { eprintln!("{}", err); } } -fn run_file(filename: &str, src: String) { +pub fn run_file(filename: &str, src: String) { let (tokens, lex_errors) = Lexer::new(&src).tokenize(); if !lex_errors.is_empty() { print_errors(&lex_errors); @@ -46,7 +46,7 @@ fn run_file(filename: &str, src: String) { } } -fn run_repl() { +pub fn run_repl() { println!("Welcome to Aster REPL!"); println!("Type ':exit' to quit."); println!("Type ':reset' to reset the interpreter state."); @@ -100,25 +100,3 @@ fn run_repl() { } } } - -fn main() { - let args: Vec = env::args().collect(); - - match args.len() { - 1 => run_repl(), - 2 => { - let filename = &args[1]; - match fs::read_to_string(filename) { - Ok(src) => run_file(filename, src), - Err(e) => { - eprintln!("Error reading file '{}': {}", filename, e); - std::process::exit(1); - } - } - } - _ => { - eprintln!("Usage: aster [script]"); - std::process::exit(1); - } - } -} diff --git a/src/parser/mod.rs b/aster-core/src/parser/mod.rs similarity index 100% rename from src/parser/mod.rs rename to aster-core/src/parser/mod.rs diff --git a/src/parser/parser.rs b/aster-core/src/parser/parser.rs similarity index 100% rename from src/parser/parser.rs rename to aster-core/src/parser/parser.rs diff --git a/src/parser/tests.rs b/aster-core/src/parser/tests.rs similarity index 100% rename from src/parser/tests.rs rename to aster-core/src/parser/tests.rs diff --git a/tests/fixtures/broken_syntax.ast b/aster-core/tests/fixtures/broken_syntax.ast similarity index 100% rename from tests/fixtures/broken_syntax.ast rename to aster-core/tests/fixtures/broken_syntax.ast diff --git a/tests/fixtures/greet.ast b/aster-core/tests/fixtures/greet.ast similarity index 100% rename from tests/fixtures/greet.ast rename to aster-core/tests/fixtures/greet.ast diff --git a/tests/fixtures/math.ast b/aster-core/tests/fixtures/math.ast similarity index 100% rename from tests/fixtures/math.ast rename to aster-core/tests/fixtures/math.ast diff --git a/tests/fixtures/sub/mod.ast b/aster-core/tests/fixtures/sub/mod.ast similarity index 100% rename from tests/fixtures/sub/mod.ast rename to aster-core/tests/fixtures/sub/mod.ast diff --git a/aster-lsp/Cargo.toml b/aster-lsp/Cargo.toml new file mode 100644 index 0000000..01928e6 --- /dev/null +++ b/aster-lsp/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "aster-lsp" +version = "0.1.0" +edition = "2024" + +[dependencies] +aster-core = { path = "../aster-core" } +lsp-server = "0.7" +lsp-types = "0.97" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/aster-lsp/src/completion.rs b/aster-lsp/src/completion.rs new file mode 100644 index 0000000..85fd7e1 --- /dev/null +++ b/aster-lsp/src/completion.rs @@ -0,0 +1,164 @@ +use aster_core::analysis::{self, SymbolKind}; +use aster_core::lexer::Lexer; +use aster_core::parser::Parser; +use lsp_server::{Connection, Message, Request, Response}; +use lsp_types::{ + CompletionItem, CompletionItemKind, CompletionList, CompletionParams, Uri, +}; + +const KEYWORDS: &[&str] = &[ + "let", "const", "fn", "if", "else", "while", "for", "in", + "break", "continue", "return", "true", "false", "nil", +]; + +const BUILTINS: &[&str] = &[ + "print", "input", "clock", "len", "typeof", "push", "pop", + "split", "trim", "substring", "replace", "contains", + "upper", "lower", "starts_with", "ends_with", "require", +]; + +pub fn handle_completion( + documents: &std::collections::HashMap, + connection: &Connection, + req: Request, +) { + let id = req.id; + let params: CompletionParams = match serde_json::from_value(req.params) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid completion params: {}", e); + return; + } + }; + + let uri = params.text_document_position.text_document.uri; + let position = params.text_document_position.position; + + let text = documents.get(&uri).map(|s| s.as_str()).unwrap_or(""); + let prefix = get_word_prefix(text, position); + + let mut items: Vec = Vec::new(); + + // Keywords + for kw in KEYWORDS { + if kw.starts_with(&prefix) { + items.push(CompletionItem { + label: kw.to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("keyword".into()), + ..Default::default() + }); + } + } + + // Builtins + for b in BUILTINS { + if b.starts_with(&prefix) { + items.push(CompletionItem { + label: b.to_string(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some("builtin".into()), + ..Default::default() + }); + } + } + + // User-defined symbols (from AST) + let (tokens, _) = Lexer::new(text).tokenize(); + let mut parser = Parser::new(tokens); + let (stmts, _) = parser.parse(); + let symbols = analysis::collect_symbols(&stmts); + + for sym in &symbols { + if sym.name.starts_with(&prefix) { + let (kind, detail) = match &sym.kind { + SymbolKind::Variable => (CompletionItemKind::VARIABLE, None), + SymbolKind::Function { params } => ( + CompletionItemKind::FUNCTION, + Some(format!("fn {}({})", sym.name, params.join(", "))), + ), + }; + // Deduplicate: skip if already in the list + if items.iter().any(|i| i.label == sym.name) { + continue; + } + items.push(CompletionItem { + label: sym.name.clone(), + kind: Some(kind), + detail, + ..Default::default() + }); + } + } + + let result = CompletionList { + is_incomplete: false, + items, + }; + + let resp = Response::new_ok(id, result); + let _ = connection.sender.send(Message::Response(resp)); +} + +/// Extract the identifier prefix immediately before the cursor position. +fn get_word_prefix(text: &str, position: lsp_types::Position) -> String { + let line = match get_line(text, position.line as usize) { + Some(l) => l, + None => return String::new(), + }; + + let col = position.character as usize; + let prefix_bytes = if col > line.len() { line } else { &line[..col] }; + + // Walk backwards through the prefix to the start of an identifier + let mut end = prefix_bytes.len(); + while end > 0 { + let ch = prefix_bytes.as_bytes()[end - 1] as char; + if ch.is_alphanumeric() || ch == '_' { + end -= 1; + } else { + break; + } + } + + prefix_bytes[end..].to_string() +} + +/// Get the full word at a given position (for hover). +pub fn get_word_at_position(text: &str, position: lsp_types::Position) -> String { + let line = match get_line(text, position.line as usize) { + Some(l) => l, + None => return String::new(), + }; + + let col = position.character as usize; + let bytes = line.as_bytes(); + + // Find start of word + let mut start = col.min(bytes.len()); + while start > 0 { + let ch = bytes[start - 1] as char; + if ch.is_alphanumeric() || ch == '_' { + start -= 1; + } else { + break; + } + } + + // Find end of word + let mut end = col.min(bytes.len()); + while end < bytes.len() { + let ch = bytes[end] as char; + if ch.is_alphanumeric() || ch == '_' { + end += 1; + } else { + break; + } + } + + line[start..end].to_string() +} + +fn get_line(text: &str, line_idx: usize) -> Option<&str> { + text.lines().nth(line_idx) +} diff --git a/aster-lsp/src/goto_def.rs b/aster-lsp/src/goto_def.rs new file mode 100644 index 0000000..f17a0e9 --- /dev/null +++ b/aster-lsp/src/goto_def.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; + +use aster_core::analysis; +use aster_core::lexer::Lexer; +use lsp_server::{Connection, Message, Request}; +use lsp_types::{GotoDefinitionParams, GotoDefinitionResponse, Location, Position, Range, Uri}; + +use crate::completion; + +pub fn handle_goto_definition( + documents: &HashMap, + connection: &Connection, + req: Request, +) { + let id = req.id; + let params: GotoDefinitionParams = match serde_json::from_value(req.params) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid goto def params: {}", e); + return; + } + }; + + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let text = documents.get(&uri).map(|s| s.as_str()).unwrap_or(""); + let word = completion::get_word_at_position(text, position); + + if word.is_empty() { + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + let (tokens, _) = Lexer::new(text).tokenize(); + + if let Some((line, col)) = analysis::find_declaration(&tokens, &word) { + let l = (line as u32).saturating_sub(1); + let c = (col as u32).saturating_sub(1); + let location = Location { + uri: uri.clone(), + range: Range { + start: Position { + line: l, + character: c, + }, + end: Position { + line: l, + character: c + word.len() as u32, + }, + }, + }; + let response: GotoDefinitionResponse = GotoDefinitionResponse::Scalar(location); + let resp = lsp_server::Response::new_ok(id, response); + let _ = connection.sender.send(Message::Response(resp)); + } else { + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); + } +} diff --git a/aster-lsp/src/hover.rs b/aster-lsp/src/hover.rs new file mode 100644 index 0000000..1619953 --- /dev/null +++ b/aster-lsp/src/hover.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; + +use aster_core::analysis::{self, SymbolKind}; +use aster_core::lexer::Lexer; +use aster_core::parser::Parser; +use lsp_server::{Connection, Message, Request}; +use lsp_types::{HoverContents, MarkupContent, MarkupKind, TextDocumentPositionParams, Uri}; + +use crate::completion; + +const BUILTINS: &[&str] = &[ + "print", "input", "clock", "len", "typeof", "push", "pop", + "split", "trim", "substring", "replace", "contains", + "upper", "lower", "starts_with", "ends_with", "require", +]; + +pub fn handle_hover( + documents: &HashMap, + connection: &Connection, + req: Request, +) { + let id = req.id; + let params: TextDocumentPositionParams = match serde_json::from_value(req.params) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid hover params: {}", e); + return; + } + }; + + let uri = params.text_document.uri; + let position = params.position; + + let text = documents.get(&uri).map(|s| s.as_str()).unwrap_or(""); + let word = completion::get_word_at_position(text, position); + + if word.is_empty() { + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + // Check builtins + if BUILTINS.contains(&word.as_str()) { + let contents = HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: format!("**builtin** `{}`", word), + }); + let hover = lsp_types::Hover { + contents, + range: None, + }; + let resp = lsp_server::Response::new_ok(id, hover); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + // Check keywords + const KEYWORDS: &[&str] = &[ + "let", "const", "fn", "if", "else", "while", "for", "in", + "break", "continue", "return", "true", "false", "nil", + ]; + if KEYWORDS.contains(&word.as_str()) { + let contents = HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: format!("**keyword** `{}`", word), + }); + let hover = lsp_types::Hover { contents, range: None }; + let resp = lsp_server::Response::new_ok(id, hover); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + // Check user-defined symbols + let (tokens, _) = Lexer::new(text).tokenize(); + let mut parser = Parser::new(tokens); + let (stmts, _) = parser.parse(); + let symbols = analysis::collect_symbols(&stmts); + + for sym in &symbols { + if sym.name == word { + let markdown = match &sym.kind { + SymbolKind::Variable => { + format!("**variable** `{}`", sym.name) + } + SymbolKind::Function { params } => { + format!("**fn** `{}({})`", sym.name, params.join(", ")) + } + }; + let contents = HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: markdown, + }); + let hover = lsp_types::Hover { contents, range: None }; + let resp = lsp_server::Response::new_ok(id, hover); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + } + + // Not found — return null + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); +} diff --git a/aster-lsp/src/main.rs b/aster-lsp/src/main.rs new file mode 100644 index 0000000..d953914 --- /dev/null +++ b/aster-lsp/src/main.rs @@ -0,0 +1,11 @@ +mod completion; +mod goto_def; +mod hover; +mod references; +mod rename; +mod server; +mod signature; + +fn main() { + server::run(); +} diff --git a/aster-lsp/src/references.rs b/aster-lsp/src/references.rs new file mode 100644 index 0000000..871b041 --- /dev/null +++ b/aster-lsp/src/references.rs @@ -0,0 +1,62 @@ +use std::collections::HashMap; + +use aster_core::analysis; +use aster_core::lexer::Lexer; +use lsp_server::{Connection, Message, Request}; +use lsp_types::{Location, Position, Range, ReferenceParams, Uri}; + +use crate::completion; + +pub fn handle_references( + documents: &HashMap, + connection: &Connection, + req: Request, +) { + let id = req.id; + let params: ReferenceParams = match serde_json::from_value(req.params) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid references params: {}", e); + return; + } + }; + + let uri = params.text_document_position.text_document.uri; + let position = params.text_document_position.position; + + let text = documents.get(&uri).map(|s| s.as_str()).unwrap_or(""); + let word = completion::get_word_at_position(text, position); + + if word.is_empty() { + let resp = lsp_server::Response::new_ok::>>(id, None); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + let (tokens, _) = Lexer::new(text).tokenize(); + let positions = analysis::find_all_references(&tokens, &word); + + let locations: Vec = positions + .into_iter() + .map(|(line, col)| { + let l = (line as u32).saturating_sub(1); + let c = (col as u32).saturating_sub(1); + Location { + uri: uri.clone(), + range: Range { + start: Position { + line: l, + character: c, + }, + end: Position { + line: l, + character: c + word.len() as u32, + }, + }, + } + }) + .collect(); + + let resp = lsp_server::Response::new_ok(id, locations); + let _ = connection.sender.send(Message::Response(resp)); +} diff --git a/aster-lsp/src/rename.rs b/aster-lsp/src/rename.rs new file mode 100644 index 0000000..a151c27 --- /dev/null +++ b/aster-lsp/src/rename.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +use aster_core::analysis; +use aster_core::lexer::Lexer; +use lsp_server::{Connection, Message, Request}; +use lsp_types::{Position, Range, RenameParams, TextEdit, Uri, WorkspaceEdit}; + +use crate::completion; + +pub fn handle_rename( + documents: &HashMap, + connection: &Connection, + req: Request, +) { + let id = req.id; + let params: RenameParams = match serde_json::from_value(req.params) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid rename params: {}", e); + return; + } + }; + + let uri = params.text_document_position.text_document.uri; + let position = params.text_document_position.position; + let new_name = params.new_name; + + let text = documents.get(&uri).map(|s| s.as_str()).unwrap_or(""); + let word = completion::get_word_at_position(text, position); + + if word.is_empty() || word == new_name { + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + let (tokens, _) = Lexer::new(text).tokenize(); + let positions = analysis::find_all_references(&tokens, &word); + + let edits: Vec = positions + .into_iter() + .map(|(line, col)| { + let l = (line as u32).saturating_sub(1); + let c = (col as u32).saturating_sub(1); + TextEdit { + range: Range { + start: Position { + line: l, + character: c, + }, + end: Position { + line: l, + character: c + word.len() as u32, + }, + }, + new_text: new_name.clone(), + } + }) + .collect(); + + let mut changes = HashMap::new(); + changes.insert(uri, edits); + + let edit = WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }; + + let resp = lsp_server::Response::new_ok(id, edit); + let _ = connection.sender.send(Message::Response(resp)); +} diff --git a/aster-lsp/src/server.rs b/aster-lsp/src/server.rs new file mode 100644 index 0000000..b58b89a --- /dev/null +++ b/aster-lsp/src/server.rs @@ -0,0 +1,193 @@ +use std::collections::HashMap; + +use aster_core::lexer::Lexer; +use aster_core::parser::Parser; +use aster_core::error::RuntimeError; + +use lsp_server::{Connection, Message, Notification}; +use lsp_types::{ + Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, + DidCloseTextDocumentParams, DidOpenTextDocumentParams, InitializeParams, + Position, PublishDiagnosticsParams, Range, ServerCapabilities, TextDocumentSyncCapability, + TextDocumentSyncKind, Uri, +}; + +use crate::completion; +use crate::goto_def; +use crate::hover; +use crate::references; +use crate::rename; +use crate::signature; + +pub fn run() { + eprintln!("Aster LSP server starting..."); + + let (connection, io_threads) = Connection::stdio(); + + let capabilities = ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + completion_provider: Some(lsp_types::CompletionOptions::default()), + hover_provider: Some(lsp_types::HoverProviderCapability::Simple(true)), + signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()), + definition_provider: Some(lsp_types::OneOf::Left(true)), + references_provider: Some(lsp_types::OneOf::Left(true)), + rename_provider: Some(lsp_types::OneOf::Left(true)), + ..ServerCapabilities::default() + }; + + let init_result = connection + .initialize(serde_json::to_value(&capabilities).unwrap()) + .unwrap(); + let _init_params: InitializeParams = serde_json::from_value(init_result).unwrap(); + + eprintln!("Aster LSP initialized successfully"); + + let mut documents: HashMap = HashMap::new(); + + for msg in &connection.receiver { + match msg { + Message::Request(req) => { + if connection.handle_shutdown(&req).unwrap() { + break; + } + handle_request(&documents, &connection, req); + } + Message::Notification(notif) => { + handle_notification(&mut documents, &connection, notif); + } + Message::Response(_resp) => {} + } + } + + io_threads.join().unwrap(); + eprintln!("Aster LSP server stopped."); +} + +fn handle_request( + documents: &HashMap, + connection: &Connection, + req: lsp_server::Request, +) { + match req.method.as_str() { + "textDocument/completion" => { + completion::handle_completion(documents, connection, req); + } + "textDocument/hover" => { + hover::handle_hover(documents, connection, req); + } + "textDocument/signatureHelp" => { + signature::handle_signature_help(documents, connection, req); + } + "textDocument/definition" => { + goto_def::handle_goto_definition(documents, connection, req); + } + "textDocument/references" => { + references::handle_references(documents, connection, req); + } + "textDocument/rename" => { + rename::handle_rename(documents, connection, req); + } + _ => { + eprintln!("Unhandled request: {}", req.method); + } + } +} + +fn handle_notification( + documents: &mut HashMap, + connection: &Connection, + notif: Notification, +) { + match notif.method.as_str() { + "textDocument/didOpen" => { + let params: DidOpenTextDocumentParams = + serde_json::from_value(notif.params).unwrap(); + let uri = params.text_document.uri.clone(); + let text = params.text_document.text.clone(); + documents.insert(uri.clone(), text.clone()); + publish_diagnostics(connection, &uri, &text); + } + "textDocument/didChange" => { + let params: DidChangeTextDocumentParams = + serde_json::from_value(notif.params).unwrap(); + let uri = params.text_document.uri.clone(); + if let Some(change) = params.content_changes.into_iter().last() { + documents.insert(uri.clone(), change.text.clone()); + publish_diagnostics(connection, &uri, &change.text); + } + } + "textDocument/didClose" => { + let params: DidCloseTextDocumentParams = + serde_json::from_value(notif.params).unwrap(); + documents.remove(¶ms.text_document.uri); + let clear = PublishDiagnosticsParams { + uri: params.text_document.uri.clone(), + diagnostics: vec![], + version: None, + }; + send_notification(connection, "textDocument/publishDiagnostics", clear); + } + _ => {} + } +} + +fn publish_diagnostics(connection: &Connection, uri: &Uri, text: &str) { + let (tokens, lex_errors) = Lexer::new(text).tokenize(); + let mut errors = lex_errors; + + let mut parser = Parser::new(tokens); + let (_stmts, parse_errors) = parser.parse(); + errors.extend(parse_errors); + + let diagnostics: Vec = errors + .iter() + .filter_map(error_to_diagnostic) + .collect(); + + let params = PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics, + version: None, + }; + + send_notification(connection, "textDocument/publishDiagnostics", params); +} + +fn error_to_diagnostic(error: &RuntimeError) -> Option { + match error { + RuntimeError::LexError { message, line, column } => { + let l = (*line as u32).saturating_sub(1); + let c = (*column as u32).saturating_sub(1); + Some(Diagnostic { + range: Range { + start: Position { line: l, character: c }, + end: Position { line: l, character: c.saturating_add(1) }, + }, + severity: Some(DiagnosticSeverity::ERROR), + message: format!("[Lex] {}", message), + ..Diagnostic::default() + }) + } + RuntimeError::ParseError { message, token } => { + let l = (token.line as u32).saturating_sub(1); + let c = (token.column as u32).saturating_sub(1); + Some(Diagnostic { + range: Range { + start: Position { line: l, character: c }, + end: Position { line: l, character: c.saturating_add(1) }, + }, + severity: Some(DiagnosticSeverity::ERROR), + message: format!("[Parse] {}", message), + ..Diagnostic::default() + }) + } + RuntimeError::RuntimeError { .. } => None, + } +} + +fn send_notification(connection: &Connection, method: &str, params: impl serde::Serialize) { + let notification = Notification::new(method.to_string(), serde_json::to_value(params).unwrap()); + let _ = connection.sender.send(Message::Notification(notification)); +} diff --git a/aster-lsp/src/signature.rs b/aster-lsp/src/signature.rs new file mode 100644 index 0000000..cf6ea01 --- /dev/null +++ b/aster-lsp/src/signature.rs @@ -0,0 +1,141 @@ +use std::collections::HashMap; + +use aster_core::analysis::{self, SymbolKind}; +use aster_core::lexer::Lexer; +use aster_core::parser::Parser; +use lsp_server::{Connection, Message, Request}; +use lsp_types::{ + ParameterInformation, SignatureHelp, SignatureHelpParams, + SignatureInformation, Uri, +}; + +pub fn handle_signature_help( + documents: &HashMap, + connection: &Connection, + req: Request, +) { + let id = req.id; + let params: SignatureHelpParams = match serde_json::from_value(req.params) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid signature help params: {}", e); + return; + } + }; + + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let text = documents.get(&uri).map(|s| s.as_str()).unwrap_or(""); + + // Find the function being called at cursor + let (func_name, arg_index) = find_call_at_position(text, position); + + if func_name.is_empty() { + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + + // Look up the function in user symbols + let (tokens, _) = Lexer::new(text).tokenize(); + let mut parser = Parser::new(tokens); + let (stmts, _) = parser.parse(); + let symbols = analysis::collect_symbols(&stmts); + + for sym in &symbols { + if sym.name == func_name { + if let SymbolKind::Function { params } = &sym.kind { + let param_labels: Vec = params.iter().map(|p| p.clone()).collect(); + let sig = SignatureInformation { + label: format!("{}({})", func_name, param_labels.join(", ")), + documentation: None, + parameters: Some( + param_labels + .iter() + .map(|p| ParameterInformation { + label: lsp_types::ParameterLabel::Simple(p.clone()), + documentation: None, + }) + .collect(), + ), + active_parameter: None, + }; + let help = SignatureHelp { + signatures: vec![sig], + active_signature: Some(0), + active_parameter: Some(arg_index.min(param_labels.len().saturating_sub(1)) as u32), + }; + let resp = lsp_server::Response::new_ok(id, help); + let _ = connection.sender.send(Message::Response(resp)); + return; + } + } + } + + let resp = lsp_server::Response::new_ok::>(id, None); + let _ = connection.sender.send(Message::Response(resp)); +} + +/// Naive text-based detection of a function call at cursor position. +/// Returns (function_name, argument_index). +fn find_call_at_position(text: &str, position: lsp_types::Position) -> (String, usize) { + let line = match text.lines().nth(position.line as usize) { + Some(l) => l, + None => return (String::new(), 0), + }; + + let col = position.character as usize; + let before_cursor = if col > line.len() { line } else { &line[..col] }; + + // Walk backwards from cursor counting parentheses depth + let mut depth = 0; + + let bytes = before_cursor.as_bytes(); + let mut i = bytes.len(); + + while i > 0 { + i -= 1; + let ch = bytes[i] as char; + match ch { + ')' => depth += 1, + '(' => { + if depth > 0 { + depth -= 1; + } else { + // Found the opening paren of our call + // Count commas between here and cursor to get arg index + let args_slice = &before_cursor[i + 1..]; + let arg_index = args_slice.bytes().filter(|&b| b == b',').count(); + + // Find the function name before '(' + let before_paren = before_cursor[..i].trim_end(); + return (extract_last_identifier(before_paren), arg_index); + } + } + _ => {} + } + } + + (String::new(), 0) +} + +fn extract_last_identifier(s: &str) -> String { + let bytes = s.as_bytes(); + let mut end = bytes.len(); + while end > 0 { + let ch = bytes[end - 1] as char; + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + end -= 1; + } else { + break; + } + } + // If there's a dot, take what's after it (method call) + let ident = &s[end..]; + if let Some(dot_pos) = ident.rfind('.') { + ident[dot_pos + 1..].to_string() + } else { + ident.to_string() + } +} diff --git a/aster/Cargo.toml b/aster/Cargo.toml new file mode 100644 index 0000000..e222c1a --- /dev/null +++ b/aster/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "aster" +version = "0.1.0" +edition = "2024" + +[dependencies] +aster-core = { path = "../aster-core" } diff --git a/aster/src/main.rs b/aster/src/main.rs new file mode 100644 index 0000000..17c5021 --- /dev/null +++ b/aster/src/main.rs @@ -0,0 +1,24 @@ +use std::env; +use std::fs; + +fn main() { + let args: Vec = env::args().collect(); + + match args.len() { + 1 => aster_core::run_repl(), + 2 => { + let filename = &args[1]; + match fs::read_to_string(filename) { + Ok(src) => aster_core::run_file(filename, src), + Err(e) => { + eprintln!("Error reading file '{}': {}", filename, e); + std::process::exit(1); + } + } + } + _ => { + eprintln!("Usage: aster [script]"); + std::process::exit(1); + } + } +} diff --git a/vscode-ext/.vscodeignore b/vscode-ext/.vscodeignore new file mode 100644 index 0000000..feaceea --- /dev/null +++ b/vscode-ext/.vscodeignore @@ -0,0 +1,3 @@ +.vscode/** +node_modules/** +.gitignore diff --git a/vscode-ext/extension.js b/vscode-ext/extension.js new file mode 100644 index 0000000..f5fa601 --- /dev/null +++ b/vscode-ext/extension.js @@ -0,0 +1,43 @@ +const path = require('path'); +const { LanguageClient, TransportKind } = require('vscode-languageclient/node'); + +/** @type {LanguageClient} */ +let client; + +/** + * @param {import('vscode').ExtensionContext} context + */ +function activate(context) { + const isWindows = process.platform === 'win32'; + const serverExe = isWindows ? 'aster-lsp.exe' : 'aster-lsp'; + + // __dirname resolves to the real filesystem path (following the junction), + // so .. goes to the workspace root where target/ lives + const serverPath = path.join(__dirname, '..', 'target', 'debug', serverExe); + + const serverOptions = { + command: serverPath, + args: [], + }; + + const clientOptions = { + documentSelector: [{ scheme: 'file', language: 'aster' }], + }; + + client = new LanguageClient( + 'aster-lsp', + 'Aster Language Server', + serverOptions, + clientOptions + ); + + client.start(); +} + +function deactivate() { + if (client) { + return client.stop(); + } +} + +module.exports = { activate, deactivate }; diff --git a/vscode-ext/language-configuration.json b/vscode-ext/language-configuration.json new file mode 100644 index 0000000..c77ee6a --- /dev/null +++ b/vscode-ext/language-configuration.json @@ -0,0 +1,26 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"] + ], + "wordPattern": "[a-zA-Z_][a-zA-Z0-9_]*" +} diff --git a/vscode-ext/package-lock.json b/vscode-ext/package-lock.json new file mode 100644 index 0000000..09933dc --- /dev/null +++ b/vscode-ext/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "aster-lang", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aster-lang", + "version": "0.1.0", + "dependencies": { + "vscode-languageclient": "^9.0.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + } + } +} diff --git a/vscode-ext/package.json b/vscode-ext/package.json new file mode 100644 index 0000000..378ea23 --- /dev/null +++ b/vscode-ext/package.json @@ -0,0 +1,39 @@ +{ + "name": "aster-lang", + "displayName": "Aster Language Support", + "description": "Syntax highlighting and language support for the Aster scripting language", + "version": "0.1.0", + "publisher": "aster", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Programming Languages" + ], + "main": "./extension.js", + "contributes": { + "languages": [ + { + "id": "aster", + "aliases": [ + "Aster", + "aster" + ], + "extensions": [ + ".ast" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "aster", + "scopeName": "source.aster", + "path": "./syntaxes/aster.tmLanguage.json" + } + ] + }, + "dependencies": { + "vscode-languageclient": "^9.0.0" + } +} diff --git a/vscode-ext/syntaxes/aster.tmLanguage.json b/vscode-ext/syntaxes/aster.tmLanguage.json new file mode 100644 index 0000000..9d14ea4 --- /dev/null +++ b/vscode-ext/syntaxes/aster.tmLanguage.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Aster", + "scopeName": "source.aster", + "fileTypes": ["ast"], + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#function-declaration" }, + { "include": "#keywords" }, + { "include": "#constants" }, + { "include": "#builtins" }, + { "include": "#operators" }, + { "include": "#punctuation" }, + { "include": "#identifiers" } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.block.aster", + "begin": "/\\*", + "end": "\\*/", + "patterns": [ + { + "name": "comment.block.aster", + "match": "." + } + ] + }, + { + "name": "comment.line.double-slash.aster", + "match": "//.*$" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.aster", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.aster", + "match": "\\\\[ntr\"\\\\]" + } + ] + }, + { + "name": "string.quoted.single.aster", + "begin": "'", + "end": "'", + "patterns": [ + { + "name": "constant.character.escape.aster", + "match": "\\\\[ntr'\\\\]" + } + ] + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.float.aster", + "match": "\\b\\d+\\.\\d+\\b" + }, + { + "name": "constant.numeric.integer.aster", + "match": "\\b\\d+\\b" + } + ] + }, + "function-declaration": { + "match": "\\b(fn)\\s+([a-zA-Z_][a-zA-Z0-9_]*)", + "captures": { + "1": { "name": "keyword.control.aster" }, + "2": { "name": "entity.name.function.aster" } + } + }, + "keywords": { + "patterns": [ + { + "name": "keyword.control.aster", + "match": "\\b(let|const|fn|if|else|while|for|in|break|continue|return)\\b" + } + ] + }, + "constants": { + "match": "\\b(true|false|nil)\\b", + "name": "constant.language.aster" + }, + "builtins": { + "match": "\\b(print|input|clock|len|typeof|push|pop|split|trim|substring|replace|contains|upper|lower|starts_with|ends_with|require)\\b", + "name": "support.function.builtin.aster" + }, + "operators": { + "patterns": [ + { + "name": "keyword.operator.assignment.compound.aster", + "match": "\\+=|-=|\\*=|/=|%=" + }, + { + "name": "keyword.operator.logical.aster", + "match": "&&|\\|\\||!" + }, + { + "name": "keyword.operator.comparison.aster", + "match": "==|!=|<=|>=" + }, + { + "name": "keyword.operator.assignment.aster", + "match": "=" + }, + { + "name": "keyword.operator.arithmetic.aster", + "match": "[+\\-*/%]" + }, + { + "name": "keyword.operator.comparison.aster", + "match": "<|>" + } + ] + }, + "punctuation": { + "patterns": [ + { + "name": "punctuation.section.scope.aster", + "match": "[{}]" + }, + { + "name": "punctuation.section.brackets.aster", + "match": "[\\[\\]]" + }, + { + "name": "punctuation.section.parens.aster", + "match": "[\\(\\)]" + }, + { + "name": "punctuation.separator.aster", + "match": "[,;:\\.]" + }, + { + "name": "keyword.operator.ternary.question.aster", + "match": "\\?" + } + ] + }, + "identifiers": { + "match": "[a-zA-Z_][a-zA-Z0-9_]*", + "name": "variable.other.aster" + } + } +}