diff --git a/Cargo.lock b/Cargo.lock index 51dd158..d433238 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,6 +853,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_derive" version = "1.0.219" @@ -1033,6 +1044,8 @@ name = "val-wasm" version = "0.0.0" dependencies = [ "console_error_panic_hook", + "serde", + "serde-wasm-bindgen", "val", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 7bb2dce..32dc050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,6 @@ ariadne = "0.5.1" astro-float = { version = "0.9.5", default-features = false, features = ["std"] } chumsky = "0.10.0" clap = { version = "4.5.35", features = ["derive"] } -criterion = { version = "0.5.1", features = ["html_reports"] } [target.'cfg(not(target_family = "wasm"))'.dependencies] dirs = "6.0.0" @@ -32,6 +31,7 @@ regex = "1.11.1" rustyline = "15.0.0" [dev-dependencies] +criterion = { version = "0.5.1", features = ["html_reports"] } executable-path = "1.0.0" indoc = "2.0.6" pretty_assertions = "1.4.1" diff --git a/crates/val-wasm/Cargo.toml b/crates/val-wasm/Cargo.toml index a6f10ca..1341f08 100644 --- a/crates/val-wasm/Cargo.toml +++ b/crates/val-wasm/Cargo.toml @@ -9,5 +9,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] console_error_panic_hook = "0.1.7" +serde = { version = "1.0.219", features = ["derive"] } +serde-wasm-bindgen = "0.6.5" val = { path = "../.." } wasm-bindgen = "0.2.100" diff --git a/crates/val-wasm/src/ast_node.rs b/crates/val-wasm/src/ast_node.rs index d3e35c9..369b70d 100644 --- a/crates/val-wasm/src/ast_node.rs +++ b/crates/val-wasm/src/ast_node.rs @@ -1,7 +1,6 @@ use super::*; -#[derive(Clone)] -#[wasm_bindgen(getter_with_clone)] +#[derive(Clone, Serialize)] pub struct AstNode { pub kind: String, pub range: Range, diff --git a/crates/val-wasm/src/error.rs b/crates/val-wasm/src/error.rs index 863a939..200cf99 100644 --- a/crates/val-wasm/src/error.rs +++ b/crates/val-wasm/src/error.rs @@ -1,14 +1,12 @@ use super::*; -#[derive(Debug, Clone)] -#[wasm_bindgen] +#[derive(Clone, Debug, Serialize)] pub enum ErrorKind { Evaluator, Parser, } -#[derive(Debug, Clone)] -#[wasm_bindgen(getter_with_clone)] +#[derive(Clone, Debug, Serialize)] pub struct ValError { pub kind: ErrorKind, pub message: String, diff --git a/crates/val-wasm/src/lib.rs b/crates/val-wasm/src/lib.rs index 7529b85..3f7754c 100644 --- a/crates/val-wasm/src/lib.rs +++ b/crates/val-wasm/src/lib.rs @@ -4,7 +4,11 @@ use { error::{ErrorKind, ValError}, range::Range, }, - val::{Environment, Evaluator, Expression, Program, Span, Statement}, + serde::Serialize, + serde_wasm_bindgen::to_value, + val::{ + Environment, Evaluator, Expression, Program, RoundingMode, Span, Statement, + }, wasm_bindgen::prelude::*, }; @@ -18,47 +22,58 @@ fn start() { } #[wasm_bindgen] -pub fn parse(input: &str) -> Result> { +pub fn parse(input: &str) -> Result { match val::parse(input) { - Ok((ast, span)) => Ok(AstNode::from((&ast, &span))), + Ok((ast, span)) => Ok(to_value(&AstNode::from((&ast, &span))).unwrap()), Err(errors) => Err( - errors - .into_iter() - .map(|error| ValError { - kind: ErrorKind::Parser, - message: error.message, - range: Range::from(error.span), - }) - .collect(), + to_value( + &errors + .into_iter() + .map(|error| ValError { + kind: ErrorKind::Parser, + message: error.message, + range: Range::from(error.span), + }) + .collect::>(), + ) + .unwrap(), ), } } #[wasm_bindgen] -pub fn eval(input: &str) -> Result> { +pub fn evaluate(input: &str) -> Result { match val::parse(input) { Ok(ast) => { - let mut evaluator = - Evaluator::from(Environment::new(val::Config::default())); + let mut evaluator = Evaluator::from(Environment::new(val::Config { + precision: 53, + rounding_mode: RoundingMode::FromZero.into(), + })); match evaluator.eval(&ast) { - Ok(value) => Ok(value.to_string()), - Err(error) => Err(vec![ValError { - kind: ErrorKind::Evaluator, - message: error.message, - range: Range::from(error.span), - }]), + Ok(value) => Ok(to_value(&value.to_string()).unwrap()), + Err(error) => Err( + to_value(&[ValError { + kind: ErrorKind::Evaluator, + message: error.message, + range: Range::from(error.span), + }]) + .unwrap(), + ), } } Err(errors) => Err( - errors - .into_iter() - .map(|error| ValError { - kind: ErrorKind::Parser, - message: error.message, - range: Range::from(error.span), - }) - .collect(), + to_value( + &errors + .into_iter() + .map(|error| ValError { + kind: ErrorKind::Parser, + message: error.message, + range: Range::from(error.span), + }) + .collect::>(), + ) + .unwrap(), ), } } diff --git a/crates/val-wasm/src/range.rs b/crates/val-wasm/src/range.rs index b391167..6300d52 100644 --- a/crates/val-wasm/src/range.rs +++ b/crates/val-wasm/src/range.rs @@ -1,7 +1,6 @@ use super::*; -#[derive(Debug, Clone)] -#[wasm_bindgen] +#[derive(Clone, Debug, Serialize)] pub struct Range { pub start: u32, pub end: u32, diff --git a/examples/newton_sqrt.val b/examples/newton.val similarity index 100% rename from examples/newton_sqrt.val rename to examples/newton.val diff --git a/justfile b/justfile index 6aa9f8d..2647eaf 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,8 @@ build-wasm: --out-name val \ --out-dir ../../www/packages/val-wasm + uv run --with arrg tools/example-generator/main.py examples www/src/lib/examples.ts + [group: 'check'] check: cargo check diff --git a/mise.toml b/mise.toml index 95d23b8..efc53e2 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,3 @@ [tools] +python = "3.12" rust = "latest" diff --git a/src/parser.rs b/src/parser.rs index 1a412bd..c51cfab 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -50,14 +50,17 @@ fn statement_parser<'a>() let indexed_ident = simple_ident.foldl( expression .clone() - .delimited_by(just('[').padded(), just(']').padded()) + .delimited_by(just('['), just(']')) + .padded() + .map_with(|expression, e| (expression, e.span())) .repeated(), - |base, index| { - let span = (base.1.start..index.1.end).into(); - ( - Expression::ListAccess(Box::new(base), Box::new(index)), - span, - ) + |base, (index, span)| { + let span = (base.1.start..span.end).into(); + + let expression = + Expression::ListAccess(Box::new(base), Box::new(index)); + + (expression, span) }, ); @@ -215,10 +218,9 @@ fn expression_parser<'a>() .collect::>(); let list = items - .clone() + .delimited_by(just('['), just(']')) .map(Expression::List) - .map_with(|ast, e| (ast, e.span())) - .delimited_by(just('['), just(']')); + .map_with(|ast, e| (ast, e.span())); let atom = number .or(boolean) @@ -233,10 +235,13 @@ fn expression_parser<'a>() let list_access = atom.clone().foldl( expression .clone() - .delimited_by(just('[').padded(), just(']').padded()) + .delimited_by(just('['), just(']')) + .padded() + .map_with(|expression, e| (expression, e.span())) .repeated(), - |list, index| { - let span = (list.1.start..index.1.end).into(); + |list: Spanned>, + (index, span): (Spanned>, SimpleSpan)| { + let span = (list.1.start..span.end).into(); let expression = Expression::ListAccess(Box::new(list), Box::new(index)); diff --git a/tools/example-generator/.python-version b/tools/example-generator/.python-version new file mode 100644 index 0000000..871f80a --- /dev/null +++ b/tools/example-generator/.python-version @@ -0,0 +1 @@ +3.12.3 diff --git a/tools/example-generator/justfile b/tools/example-generator/justfile new file mode 100644 index 0000000..d474b1a --- /dev/null +++ b/tools/example-generator/justfile @@ -0,0 +1,12 @@ +set dotenv-load + +export EDITOR := 'nvim' + +alias f := fmt + +default: + just --list + +[group: 'format'] +fmt: + uv run ruff check --select I --fix && uv run ruff format diff --git a/tools/example-generator/main.py b/tools/example-generator/main.py new file mode 100644 index 0000000..d8c1bcd --- /dev/null +++ b/tools/example-generator/main.py @@ -0,0 +1,105 @@ +import argparse +import os +import shutil +from textwrap import dedent + +from arrg import app, argument + + +def snake_to_camel(snake_str): + components = snake_str.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +@app( + description='Generate a JavaScript dictionary from val source files using imports.', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=dedent( + """\ + Examples: + %(prog)s ./src/tests ./frontend/src/lib/examples.js + %(prog)s /path/to/val/files /path/to/output.js + """ + ), +) +class App: + source_dir: str = argument(help='Directory containing the val source files') + output_file: str = argument(help='Path to the output JavaScript file') + examples_dir: str = argument( + help='Path to the assets/examples directory', default=None, nargs='?' + ) + + def run(self) -> int: + try: + self.generate() + return 0 + except Exception as e: + print(f'error: {e}') + return 1 + + def generate(self): + if not os.path.exists(self.source_dir): + raise FileNotFoundError(f"Source directory '{self.source_dir}' does not exist") + + if self.examples_dir is None: + output_dir = os.path.dirname(self.output_file) + base_dir = os.path.dirname(output_dir) + self.examples_dir = os.path.join(base_dir, 'assets', 'examples') + + os.makedirs(self.examples_dir, exist_ok=True) + os.makedirs(os.path.dirname(self.output_file), exist_ok=True) + + val_files = sorted([f for f in os.listdir(self.source_dir) if f.endswith('.val')]) + + if not val_files: + print(f'Warning: No .val files found in {self.source_dir}') + return + + for file in val_files: + source_file = os.path.join(self.source_dir, file) + target_file = os.path.join(self.examples_dir, file) + shutil.copy2(source_file, target_file) + print(f'Copied {file} to {self.examples_dir}') + + imports = [] + dictionary_entries = [] + + for file in val_files: + base_name = file.replace('.val', '') + var_name = snake_to_camel(base_name) + rel_path = os.path.relpath(self.examples_dir, os.path.dirname(self.output_file)) + imports.append(f"import {var_name} from '{rel_path}/{file}?raw';") + dictionary_entries.append(f' {var_name}: {var_name}') + + content = self._generate_js_dictionary(imports, dictionary_entries) + + with open(self.output_file, 'w') as f: + f.write(content) + + print(f'Successfully generated JS dictionary at {self.output_file}') + print(f'Processed {len(val_files)} val files') + + def _generate_js_dictionary(self, imports, dictionary_entries): + lines = [ + '// This file is generated by `example-generator`. Do not edit manually.', + '', + ] + + for import_line in imports: + lines.append(import_line) + + lines.append('') + lines.append('const EXAMPLES = {') + + dictionary_text = ',\n'.join(dictionary_entries) + lines.append(dictionary_text) + + lines.append('};') + lines.append('') + lines.append('export default EXAMPLES;') + + return '\n'.join(lines) + + +if __name__ == '__main__': + exit(App.from_args().run()) diff --git a/tools/example-generator/pyproject.toml b/tools/example-generator/pyproject.toml new file mode 100644 index 0000000..a0b1e95 --- /dev/null +++ b/tools/example-generator/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "example-generator" +version = "0.0.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12.3" +dependencies = [ + "arrg>=0.1.0", + "ruff>=0.11.6", +] + +[tool.ruff] +src = ["src"] +indent-width = 2 +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I"] + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 20 +indent-style = "space" +quote-style = "single" diff --git a/tools/example-generator/uv.lock b/tools/example-generator/uv.lock new file mode 100644 index 0000000..8a03bae --- /dev/null +++ b/tools/example-generator/uv.lock @@ -0,0 +1,52 @@ +version = 1 +revision = 1 +requires-python = ">=3.12.3" + +[[package]] +name = "arrg" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/53/862db83f09ac980a4af0c3d40d423f07e5cad300730a0fea117040e3e62f/arrg-0.1.0.tar.gz", hash = "sha256:58746665a54c361ee1a7b39f414b0bf373f5255d37cc938b22bbbe49daeda155", size = 17318 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a3/54cfbbae8928e812fefac1cb1272a5819b26863ae4873728ca809af3b9b5/arrg-0.1.0-py3-none-any.whl", hash = "sha256:0b4315b6f5339d6b264808614b20f6a123359532d47b9a0eeecdf10f56cfd8fb", size = 14175 }, +] + +[[package]] +name = "example-generator" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "arrg" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "arrg", specifier = ">=0.1.0" }, + { name = "ruff", specifier = ">=0.11.6" }, +] + +[[package]] +name = "ruff" +version = "0.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105 }, + { url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494 }, + { url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151 }, + { url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951 }, + { url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918 }, + { url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426 }, + { url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012 }, + { url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947 }, + { url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753 }, + { url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121 }, + { url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829 }, + { url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108 }, + { url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366 }, + { url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900 }, + { url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592 }, + { url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766 }, +] diff --git a/www/bun.lock b/www/bun.lock index ec53260..c881a3b 100644 --- a/www/bun.lock +++ b/www/bun.lock @@ -10,6 +10,7 @@ "@radix-ui/react-select": "^2.1.7", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.1.4", + "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.1.3", "@uiw/react-codemirror": "^4.23.10", "class-variance-authority": "^0.7.1", @@ -256,6 +257,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@replit/codemirror-vim": ["@replit/codemirror-vim@6.3.0", "", { "peerDependencies": { "@codemirror/commands": "6.x.x", "@codemirror/language": "6.x.x", "@codemirror/search": "6.x.x", "@codemirror/state": "6.x.x", "@codemirror/view": "6.x.x" } }, "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.3", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.3" } }, "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.3", "@tailwindcss/oxide-darwin-arm64": "4.1.3", "@tailwindcss/oxide-darwin-x64": "4.1.3", "@tailwindcss/oxide-freebsd-x64": "4.1.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.3", "@tailwindcss/oxide-linux-arm64-musl": "4.1.3", "@tailwindcss/oxide-linux-x64-gnu": "4.1.3", "@tailwindcss/oxide-linux-x64-musl": "4.1.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.3", "@tailwindcss/oxide-win32-x64-msvc": "4.1.3" } }, "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ=="], diff --git a/www/index.html b/www/index.html index ead139f..6f41983 100644 --- a/www/index.html +++ b/www/index.html @@ -2,7 +2,7 @@ - + val diff --git a/www/package.json b/www/package.json index 5685ac2..d89c23d 100644 --- a/www/package.json +++ b/www/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-select": "^2.1.7", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.1.4", + "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.1.3", "@uiw/react-codemirror": "^4.23.10", "class-variance-authority": "^0.7.1", diff --git a/www/public/icon.svg b/www/public/icon.svg new file mode 100644 index 0000000..2604117 --- /dev/null +++ b/www/public/icon.svg @@ -0,0 +1 @@ + diff --git a/www/public/vite.svg b/www/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/www/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/www/src/App.tsx b/www/src/App.tsx index a5fff49..9fd4cec 100644 --- a/www/src/App.tsx +++ b/www/src/App.tsx @@ -6,42 +6,52 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useEffect, useState } from 'react'; +import type { AstNode as AstNodeType, ValError } from '@/lib/types'; +import { EditorView } from '@codemirror/view'; +import { Radius, SquareSigma } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; -import init, { AstNode as AstNodeType, ValError, _eval, parse } from 'val-wasm'; +import init, { parse } from 'val-wasm'; -import { Editor } from './components/editor'; +import { Editor, EditorRef } from './components/editor'; import { EditorSettingsDialog } from './components/editor-settings-dialog'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from './components/ui/resizable'; +import EXAMPLES from './lib/examples'; -const EXAMPLES = { - factorial: `fn factorial(n) { - if (n <= 1) { - return 1 - } else { - return n * factorial(n - 1) - } -} - -println(factorial(5))`, -}; +const STORAGE_KEY_CODE = 'val-editor-code'; +const STORAGE_KEY_EXAMPLE = 'val-editor-example'; function App() { const [ast, setAst] = useState(null); - const [code, setCode] = useState(EXAMPLES.factorial); - const [currentExample, setCurrentExample] = useState('factorial'); + + const [code, setCode] = useState(() => { + const savedCode = localStorage.getItem(STORAGE_KEY_CODE); + return savedCode || EXAMPLES.factorial; + }); + + const [currentExample, setCurrentExample] = useState(() => { + const savedExample = localStorage.getItem(STORAGE_KEY_EXAMPLE); + return savedExample || 'factorial'; + }); + + const [editorView, setEditorView] = useState(null); const [errors, setErrors] = useState([]); const [wasmLoaded, setWasmLoaded] = useState(false); + const editorRef = useRef(null); + + const handleEditorReady = (view: EditorView) => { + setEditorView(view); + }; + useEffect(() => { init() .then(() => { setWasmLoaded(true); - setAst(parse(code)); }) .catch((error) => { toast.error(error); @@ -56,8 +66,22 @@ function App() { } catch (error) { setErrors(error as ValError[]); } + }, [code, wasmLoaded]); + + useEffect(() => { + if (editorRef.current?.view && !editorView) { + setEditorView(editorRef.current.view); + } + }, [editorRef.current?.view, editorView]); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY_CODE, code); }, [code]); + useEffect(() => { + localStorage.setItem(STORAGE_KEY_EXAMPLE, currentExample); + }, [currentExample]); + const handleExampleChange = (value: string) => { setCurrentExample(value); setCode(EXAMPLES[value as keyof typeof EXAMPLES]); @@ -69,7 +93,10 @@ function App() {
- +
@@ -117,7 +150,7 @@ function App() { >
{ast ? ( - + ) : (
No AST available
)} diff --git a/www/src/assets/examples/factorial.val b/www/src/assets/examples/factorial.val new file mode 100644 index 0000000..486edf4 --- /dev/null +++ b/www/src/assets/examples/factorial.val @@ -0,0 +1,9 @@ +fn factorial(n) { + if (n <= 1) { + return 1 + } else { + return n * factorial(n - 1) + } +} + +println(factorial(5)); diff --git a/www/src/assets/examples/fibonacci.val b/www/src/assets/examples/fibonacci.val new file mode 100644 index 0000000..a4155a6 --- /dev/null +++ b/www/src/assets/examples/fibonacci.val @@ -0,0 +1,33 @@ +fn fibonacci(n) { + if (n <= 0) { + println("Please enter a positive number") + return + } + + a = 0 + b = 1 + + println("Fibonacci Sequence (first " + n + " numbers):") + + if (n >= 1) { + println(a) + } + + if (n >= 2) { + println(b) + } + + count = 2 + + while (count < n) { + next = a + b + a = b + b = next + count = count + 1 + println(b) + } +} + +num = int(input("How many Fibonacci numbers would you like to see? ")) + +fibonacci(num) diff --git a/www/src/assets/examples/hoc.val b/www/src/assets/examples/hoc.val new file mode 100644 index 0000000..af1daa7 --- /dev/null +++ b/www/src/assets/examples/hoc.val @@ -0,0 +1,68 @@ +fn map(l, f) { + i = 0 + + result = [] + + while (i < len(l)) { + result = append(result, f(l[i])) + i = i + 1 + } + + return result +} + +fn double(x) { + return x * 2 +} + +fn even(x) { + return x % 2 == 0 +} + +fn filter(l, f) { + i = 0 + + result = [] + + while (i < len(l)) { + if (f(l[i])) { + result = append(result, l[i]) + } + + i = i + 1 + } + + return result +} + +fn reduce(l, f, initial) { + i = 0 + + result = initial + + while (i < len(l)) { + result = f(result, l[i]) + i = i + 1 + } + + return result +} + +fn sum(a, b) { + return a + b +} + +fn max(a, b) { + if (a > b) { + return a + } else { + return b + } +} + +l = [1, 2, 3, 4, 5] + +println(map(l, double)) +println(filter(l, even)) +println(reduce(l, sum, 0)) +println(reduce(l, max, l[0])) diff --git a/www/src/assets/examples/loop.val b/www/src/assets/examples/loop.val new file mode 100644 index 0000000..7818ddd --- /dev/null +++ b/www/src/assets/examples/loop.val @@ -0,0 +1,11 @@ +sum = 0; i = 0 + +loop { + if (i >= 5) { + break + } + + sum = sum + i; i = i + 1 +} + +println('sum: ' + sum, 'i: ' + i) diff --git a/www/src/assets/examples/newton.val b/www/src/assets/examples/newton.val new file mode 100644 index 0000000..4438d2b --- /dev/null +++ b/www/src/assets/examples/newton.val @@ -0,0 +1,22 @@ +fn newton_sqrt(x) { + if (x < 0) { + println('Cannot compute square root of negative number') + return 0 + } + + if (x == 0) { + return 0 + } + + guess = x / 2 + epsilon = 0.0001 + + while (abs(guess * guess - x) > epsilon) { + guess = (guess + x / guess) / 2 + } + + return guess +} + +println('Square root of 16 is approximately ' + newton_sqrt(16)) +println('Square root of 2 is approximately ' + newton_sqrt(2)) diff --git a/www/src/assets/examples/newton_sqrt.val b/www/src/assets/examples/newton_sqrt.val new file mode 100644 index 0000000..4438d2b --- /dev/null +++ b/www/src/assets/examples/newton_sqrt.val @@ -0,0 +1,22 @@ +fn newton_sqrt(x) { + if (x < 0) { + println('Cannot compute square root of negative number') + return 0 + } + + if (x == 0) { + return 0 + } + + guess = x / 2 + epsilon = 0.0001 + + while (abs(guess * guess - x) > epsilon) { + guess = (guess + x / guess) / 2 + } + + return guess +} + +println('Square root of 16 is approximately ' + newton_sqrt(16)) +println('Square root of 2 is approximately ' + newton_sqrt(2)) diff --git a/www/src/assets/examples/primes.val b/www/src/assets/examples/primes.val new file mode 100644 index 0000000..2b447d8 --- /dev/null +++ b/www/src/assets/examples/primes.val @@ -0,0 +1,58 @@ +fn is_prime(n) { + if (n <= 1) { + return false + } + + if (n <= 3) { + return true + } + + if (n % 2 == 0 || n % 3 == 0) { + return false + } + + i = 5 + + while (i * i <= n) { + if (n % i == 0 || n % (i + 2) == 0) { + return false + } + + i = i + 6 + } + + return true +} + +fn find_primes(start, end) { + println("Prime numbers between " + start + " and " + end + ":") + + count = 0 + + current = start + + while (current <= end) { + if (is_prime(current)) { + print(current + " ") + + count = count + 1 + + if (count % 10 == 0) { + println("") + } + } + + current = current + 1 + } + + if (count % 10 != 0) { + println("") + } + + println("Found " + count + " prime numbers.") +} + +lower = int(input("Enter lower bound: ")) +upper = int(input("Enter upper bound: ")) + +find_primes(lower, upper) diff --git a/www/src/assets/examples/strings.val b/www/src/assets/examples/strings.val new file mode 100644 index 0000000..5083fa3 --- /dev/null +++ b/www/src/assets/examples/strings.val @@ -0,0 +1,92 @@ +fn reverse_string(str) { + n = len(str) + + chars = list(str) + result = list(str) + + i = 0 + + while (i < n) { + result[i] = chars[n - i - 1] + i = i + 1 + } + + return join(result, "") +} + +fn is_palindrome(str) { + n = len(str) + + chars = list(str) + + i = 0 + j = n - 1 + + while (i < j) { + if (chars[i] != chars[j]) { + return false + } + + i = i + 1 + j = j - 1 + } + + return true +} + +fn count_words(text) { + if (len(text) == 0) { + return 0 + } + + words = split(text, ' ') + + return len(list(words)) +} + +fn common_prefix(str1, str2) { + chars1 = list(str1) + chars2 = list(str2) + + len1 = len(str1) + len2 = len(str2) + + max_check = len1 + + if (len2 < len1) { + max_check = len2 + } + + prefix_chars = [] + + i = 0 + + while (i < max_check) { + if (chars1[i] == chars2[i]) { + prefix_chars = prefix_chars + [chars1[i]] + } else { + break + } + + i = i + 1 + } + + return join(prefix_chars, "") +} + +println("Testing string functions:") + +test_str = "racecar" +println("Original: " + test_str) +println("Reversed: " + reverse_string(test_str)) +println("Is palindrome: " + is_palindrome(test_str)) + +test_str2 = "hello world" +println("Original: " + test_str2) +println("Reversed: " + reverse_string(test_str2)) +println("Is palindrome: " + is_palindrome(test_str2)) +println("Word count: " + count_words(test_str2)) + +str1 = "programming" +str2 = "progress" +println("Common prefix of '" + str1 + "' and '" + str2 + "': " + common_prefix(str1, str2)) diff --git a/www/src/components/ast-node.tsx b/www/src/components/ast-node.tsx index bd1b905..920a0b0 100644 --- a/www/src/components/ast-node.tsx +++ b/www/src/components/ast-node.tsx @@ -1,63 +1,103 @@ +import { addHighlightEffect, removeHighlightEffect } from '@/lib/highlight'; +import { AstNode as AstNodeType } from '@/lib/types'; +import { EditorView } from '@codemirror/view'; import { ChevronDown, ChevronRight } from 'lucide-react'; -import React, { useState } from 'react'; -import { AstNode as AstNodeType } from 'val-wasm'; +import React, { memo, useCallback, useState } from 'react'; interface AstNodeProps { node: AstNodeType; depth?: number; + editorView?: EditorView | null; } -export const AstNode: React.FC = ({ node, depth = 0 }) => { - const [isExpanded, setIsExpanded] = useState(true); - const [isHovered, setIsHovered] = useState(false); - - const hasChildren = node.children && node.children.length > 0; - - const style = { - backgroundColor: isHovered ? 'rgba(59, 130, 246, 0.1)' : 'transparent', - borderRadius: '2px', - paddingLeft: `${depth * 16}px`, - }; - - return ( -
-
setIsExpanded(!isExpanded)} - onMouseLeave={() => setIsHovered(false)} - onMouseOver={() => setIsHovered(true)} - style={style} - > - - {hasChildren ? ( - isExpanded ? ( - +export const AstNode: React.FC = memo( + ({ node, depth = 0, editorView }) => { + const [isExpanded, setIsExpanded] = useState(true); + const [isHovered, setIsHovered] = useState(false); + + const hasChildren = node.children && node.children.length > 0; + + const isValidRange = node.range.start < node.range.end; + + const style = { + backgroundColor: isHovered ? 'rgba(59, 130, 246, 0.1)' : 'transparent', + borderRadius: '2px', + paddingLeft: `${depth * 16}px`, + }; + + const handleMouseOver = useCallback(() => { + setIsHovered(true); + + if (editorView && isValidRange) { + editorView.dispatch({ + effects: [ + addHighlightEffect.of({ + from: node.range.start, + to: node.range.end, + }), + EditorView.scrollIntoView(node.range.start, { y: 'center' }), + ], + }); + } + }, [editorView, node.range.start, node.range.end, isValidRange]); + + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + + if (editorView && isValidRange) { + editorView.dispatch({ + effects: removeHighlightEffect.of(null), + }); + } + }, [editorView, isValidRange]); + + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + return ( +
+
+ + {hasChildren ? ( + isExpanded ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} - - - {node.kind} - - - [{node.range.start}: {node.range.end}] - -
+ + )} + + + {node.kind} - {isExpanded && hasChildren && ( -
- {node.children.map((child, index) => ( - - ))} + + [{node.range.start}: {node.range.end}]{!isValidRange && ' (empty)'} +
- )} -
- ); -}; + + {isExpanded && hasChildren && ( +
+ {node.children.map((child, index) => ( + + ))} +
+ )} +
+ ); + } +); + +AstNode.displayName = 'AstNode'; diff --git a/www/src/components/editor.tsx b/www/src/components/editor.tsx index 5bca8e7..0aa3c42 100644 --- a/www/src/components/editor.tsx +++ b/www/src/components/editor.tsx @@ -1,4 +1,5 @@ -import { highlightExtension } from '@/lib/cm-highlight-extension'; +import { highlightExtension } from '@/lib/highlight'; +import { ValError } from '@/lib/types'; import { useEditorSettings } from '@/providers/editor-settings-provider'; import { rust } from '@codemirror/lang-rust'; import { @@ -7,14 +8,21 @@ import { indentOnInput, syntaxHighlighting, } from '@codemirror/language'; -import { Diagnostic, lintGutter, linter } from '@codemirror/lint'; +import { Diagnostic, linter } from '@codemirror/lint'; +import { vim } from '@replit/codemirror-vim'; import CodeMirror, { EditorState, EditorView } from '@uiw/react-codemirror'; -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { ValError } from 'val-wasm'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; interface EditorProps { errors: ValError[]; onChange?: (value: string, viewUpdate: any) => void; + onEditorReady?: (view: EditorView) => void; value: string; } @@ -23,7 +31,7 @@ export interface EditorRef { } export const Editor = forwardRef( - ({ value, errors, onChange }, ref) => { + ({ value, errors, onChange, onEditorReady }, ref) => { const { settings } = useEditorSettings(); const viewRef = useRef(null); @@ -34,7 +42,35 @@ export const Editor = forwardRef( }, })); - const createEditorTheme = useCallback( + useEffect(() => { + if (viewRef.current && onEditorReady) { + onEditorReady(viewRef.current); + } + }, [viewRef.current, onEditorReady]); + + const createExtensions = useCallback(() => { + const extensions = [ + EditorState.tabSize.of(settings.tabSize), + bracketMatching(), + highlightExtension, + indentOnInput(), + linter(diagnostics()), + rust(), + syntaxHighlighting(defaultHighlightStyle), + ]; + + if (settings.lineWrapping) { + extensions.push(EditorView.lineWrapping); + } + + if (settings.keybindings === 'vim') { + extensions.push(vim()); + } + + return extensions; + }, [settings]); + + const createTheme = useCallback( () => EditorView.theme({ '&': { @@ -52,6 +88,9 @@ export const Editor = forwardRef( fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', }, + '.cm-line': { + padding: '0 10px', + }, '.cm-content': { padding: '10px 0', }, @@ -78,25 +117,6 @@ export const Editor = forwardRef( [settings] ); - const createExtensions = useCallback(() => { - const extensions = [ - EditorState.tabSize.of(settings.tabSize), - bracketMatching(), - highlightExtension, - indentOnInput(), - lintGutter(), - linter(diagnostics()), - rust(), - syntaxHighlighting(defaultHighlightStyle), - ]; - - if (settings.lineWrapping) { - extensions.push(EditorView.lineWrapping); - } - - return extensions; - }, [settings]); - const diagnostics = () => useCallback( (_view: EditorView): Diagnostic[] => { @@ -125,10 +145,18 @@ export const Editor = forwardRef( [errors] ); + const handleEditorCreate = (view: EditorView) => { + viewRef.current = view; + + if (onEditorReady) { + onEditorReady(view); + } + }; + return ( ( }} height='100%' extensions={createExtensions()} - onCreateEditor={(view) => { - viewRef.current = view; - }} + onCreateEditor={handleEditorCreate} onChange={onChange} className='h-full' /> ); } ); + +Editor.displayName = 'Editor'; diff --git a/www/src/lib/examples.ts b/www/src/lib/examples.ts new file mode 100644 index 0000000..5f786f8 --- /dev/null +++ b/www/src/lib/examples.ts @@ -0,0 +1,20 @@ +// This file is generated by `example-generator`. Do not edit manually. +import factorial from '../assets/examples/factorial.val?raw'; +import fibonacci from '../assets/examples/fibonacci.val?raw'; +import hoc from '../assets/examples/hoc.val?raw'; +import loop from '../assets/examples/loop.val?raw'; +import newton from '../assets/examples/newton.val?raw'; +import primes from '../assets/examples/primes.val?raw'; +import strings from '../assets/examples/strings.val?raw'; + +const EXAMPLES = { + factorial: factorial, + fibonacci: fibonacci, + hoc: hoc, + loop: loop, + newton: newton, + primes: primes, + strings: strings, +}; + +export default EXAMPLES; diff --git a/www/src/lib/cm-highlight-extension.ts b/www/src/lib/highlight.ts similarity index 100% rename from www/src/lib/cm-highlight-extension.ts rename to www/src/lib/highlight.ts diff --git a/www/src/lib/types.ts b/www/src/lib/types.ts new file mode 100644 index 0000000..924c13d --- /dev/null +++ b/www/src/lib/types.ts @@ -0,0 +1,18 @@ +export type AstNode = { + kind: string; + range: Range; + children: AstNode[]; +}; + +export type Range = { + start: number; + end: number; +}; + +export type ValErrorKind = 'Parser' | 'Evaluator'; + +export type ValError = { + kind: ValErrorKind; + message: string; + range: Range; +};