diff --git a/.github/workflows/editor.yml b/.github/workflows/editor.yml new file mode 100644 index 0000000..f5d267f --- /dev/null +++ b/.github/workflows/editor.yml @@ -0,0 +1,149 @@ +# The editor tools (spec/11 CP-8e): build the crate natively and as WebAssembly, run the language server's LSP tests on +# both the server and the extension's bundle, and package the VS Code extension (one .vsix for every platform) and the +# language server's npm package. +# +# Releases are by version (tooling/RELEASING.md), once the repository variable RELEASES is `on`. On main: +# - a version of tooling/rx-language-server that npm does not have is published there by trusted publishing (OIDC, +# no token), and tagged `language-server-v`; +# - a version of tooling/vscode-rx without its tag `vscode-v` is published to the VS Marketplace (a Microsoft +# Entra identity, or the secret VSCE_PAT) and Open VSX (the secret OVSX_PAT), and tagged. +# So a release is a version bump, merged. +name: editor + +on: + push: + branches: [main] + paths: ["compiler/**", "tooling/**", ".github/workflows/editor.yml"] + pull_request: + paths: ["compiler/**", "tooling/**", ".github/workflows/editor.yml"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + workspaces: compiler + - uses: pnpm/action-setup@v4 + with: + version: 11.1.1 # solid-next's packageManager + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: rxc and its WebAssembly build + run: | + cargo build --release --manifest-path compiler/Cargo.toml + cargo build --lib --release --target wasm32-unknown-unknown --manifest-path compiler/Cargo.toml + - name: Solid next (the types the tests check against) + run: sh ref/scripts/solid-next.sh && npm --prefix ref install + - name: Language server tests + working-directory: tooling/rx-language-server + run: | + npm ci + node test/run.js + RXC=$GITHUB_WORKSPACE/compiler/target/release/rxc node test/run.js + - name: Package the extension + working-directory: tooling/vscode-rx + run: | + npm ci + npm run package + SERVER=dist/server.js node ../rx-language-server/test/run.js + - name: Pack the language server + working-directory: tooling/rx-language-server + run: npm pack + - uses: actions/upload-artifact@v4 + with: + name: vsix + path: tooling/vscode-rx/*.vsix + - uses: actions/upload-artifact@v4 + with: + name: npm + path: tooling/rx-language-server/*.tgz + + # which versions are new: run on main only, after the build passed + versions: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.RELEASES == 'on' + needs: build + runs-on: ubuntu-latest + outputs: + server: ${{ steps.v.outputs.server }} + vscode: ${{ steps.v.outputs.vscode }} + steps: + - uses: actions/checkout@v5 + - id: v + run: | + s=$(node -p 'const p = require("./tooling/rx-language-server/package.json"); p.name + "@" + p.version') + if [ -z "$(npm view "$s" version 2>/dev/null)" ]; then echo "server=$s" >> "$GITHUB_OUTPUT"; fi + v=$(node -p 'require("./tooling/vscode-rx/package.json").version') + if [ -z "$(git ls-remote --tags origin "refs/tags/vscode-v$v")" ]; then echo "vscode=$v" >> "$GITHUB_OUTPUT"; fi + + publish-npm: + needs: versions + if: needs.versions.outputs.server != '' + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # trusted publishing: npm takes the job's OIDC token, and adds provenance + contents: write # the tag + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v4 + with: + name: npm + - uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - run: npm install -g npm@latest # trusted publishing needs npm 11.5.1 or later + - run: npm publish ./*.tgz --access public + - name: Tag + run: | + t="language-server-v${SERVER##*@}" + git tag "$t" && git push origin "$t" + env: + SERVER: ${{ needs.versions.outputs.server }} + + publish-vscode: + needs: versions + if: needs.versions.outputs.vscode != '' + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # the Marketplace by Microsoft Entra, when AZURE_CLIENT_ID is set + contents: write # the tag + env: # job-wide, so that a step's `if` can see whether each is set + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v4 + with: + name: vsix + - uses: actions/setup-node@v5 + with: + node-version: 22 + # the Marketplace takes a Microsoft Entra identity (no token) when the repository has AZURE_CLIENT_ID and + # AZURE_TENANT_ID, else the secret VSCE_PAT (an Azure DevOps token: global ones end 2026-12-01) + - name: Sign in to Microsoft Entra (OIDC) + if: vars.AZURE_CLIENT_ID != '' + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + allow-no-subscriptions: true + - name: VS Marketplace + if: vars.AZURE_CLIENT_ID != '' || env.VSCE_PAT != '' + run: npx --yes @vscode/vsce publish --packagePath ./*.vsix ${{ vars.AZURE_CLIENT_ID != '' && '--azure-credential' || '' }} + - name: Open VSX + if: env.OVSX_PAT != '' + run: npx --yes ovsx publish ./*.vsix -p "$OVSX_PAT" + - name: Tag + if: vars.AZURE_CLIENT_ID != '' || env.VSCE_PAT != '' || env.OVSX_PAT != '' + run: git tag "vscode-v$V" && git push origin "vscode-v$V" + env: + V: ${{ needs.versions.outputs.vscode }} diff --git a/compiler/Cargo.toml b/compiler/Cargo.toml index f2aa7b6..f3a1c56 100644 --- a/compiler/Cargo.toml +++ b/compiler/Cargo.toml @@ -9,7 +9,8 @@ publish = false [lib] name = "reactivescript" path = "src/lib.rs" -# rlib for rxc; cdylib for the Node-API build (`--features node`, src/node.rs) that @reactivescript/compiler ships +# rlib for rxc; cdylib for the Node-API build (`--features node`, src/node.rs) that @reactivescript/compiler ships, and +# for the WebAssembly build the language server loads (src/wasm.rs, CP-8e) crate-type = ["rlib", "cdylib"] [[bin]] diff --git a/compiler/README.md b/compiler/README.md index 3b3f386..66a92ab 100644 --- a/compiler/README.md +++ b/compiler/README.md @@ -18,6 +18,8 @@ CP-1d). Both give the same answers as the reference. | `model.rs` | `model.ts` | CP-6: regions, symbols, computations with their reads, writes and calls | | `emit.rs`, `awaits.rs` | `ref/compile/emit.ts`, `awaits.ts` | the lowering: `__rt` calls around JSX views, and each target through Solid | | `js.rs`, `oxc.rs` | | the lowered module as a tree, then as an Oxc program (spans: spec/11 CP-1c) | +| `spans.rs` | | what the AST's spans promise (spec/11 CP-8b), and a diagnostic's range (CP-8d) | +| `project.rs`, `prelude.ts` | `ref/types/project.ts` | the TSX projection TypeScript checks (spec/13 TY-1), with mappings onto `.rx` (CP-8c) | The Solid pin (`solidjs-compiler` in `Cargo.toml`) must be the commit in `ref/SOLID_NEXT`. @@ -25,10 +27,16 @@ The Solid pin (`solidjs-compiler` in `Cargo.toml`) must be the commit in ```sh cargo build --release target/release/rxc check ../examples/todo.rx # diagnostics +target/release/rxc check --json ../examples/todo.rx # … with ranges, for an editor or an agent (CP-8d) +target/release/rxc project ../examples/todo.rx # the TSX projection, with mappings onto .rx (CP-8c) target/release/rxc model ../examples/todo.rx # the resolved model, as JSON +target/release/rxc serve # a language server's requests, a JSON line each (CP-8e) +cargo build --lib --release --target wasm32-unknown-unknown # … the same requests as WebAssembly (src/wasm.rs), what the server loads target/release/rxc compile ../examples/todo.rx --out out # modules (dom; --target ssr|universal), source maps onto .rx, style.css target/release/rxc app ../examples/todo.rx # the same as JSON, imports by .rx path: what the Vite front end gets npm --prefix ../ref run crate # CP-5: the crate against ref/ on every program +npm --prefix ../ref run crate -- --project # … and the TSX projection, text for text, every mapping checked +RX_CRATE=1 npm --prefix ../ref run types # the types scenarios, on the crate's projection npm --prefix ../ref run crate -- --lower # … and the lowered JavaScript, for every target, and its source maps RX_CRATE=1 npm --prefix ../ref run compiled # the scenarios, run on the crate's output RX_CRATE=1 npm --prefix ../ref run ssr # … served and hydrated from the crate's output diff --git a/compiler/src/ast.rs b/compiler/src/ast.rs index 851b43f..1c2828f 100644 --- a/compiler/src/ast.rs +++ b/compiler/src/ast.rs @@ -25,8 +25,47 @@ pub enum Field { Spread(Expr), } +/// A byte range of the `.rx` source (`start..end`). `Span::NONE` (0..0) marks a node the compiler made, not the parser. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Span { + pub start: u32, + pub end: u32, +} +impl Span { + pub const NONE: Span = Span { start: 0, end: 0 }; + pub fn new(start: usize, end: usize) -> Self { + Span { start: start as u32, end: end as u32 } + } + pub fn is_none(&self) -> bool { + self.start == 0 && self.end == 0 + } + /// The 1-based line `start` is on. + pub fn line(&self, src: &str) -> usize { + src.as_bytes()[..(self.start as usize).min(src.len())].iter().filter(|b| **b == b'\n').count() + 1 + } + pub fn text<'a>(&self, src: &'a str) -> &'a str { + src.get(self.start as usize..self.end as usize).unwrap_or("") + } +} + +/// An expression and where it was written. +#[derive(Debug, Clone)] +pub struct Expr { + pub k: ExprKind, + pub span: Span, +} +impl Expr { + pub fn new(k: ExprKind, span: Span) -> Self { + Expr { k, span } + } + /// An expression the compiler made: no source. + pub fn made(k: ExprKind) -> Self { + Expr { k, span: Span::NONE } + } +} + #[derive(Debug, Clone)] -pub enum Expr { +pub enum ExprKind { Lit(Lit), Id(String), Bin { op: String, l: Box, r: Box }, @@ -80,8 +119,15 @@ impl Target { } } +/// A statement and where it was written. +#[derive(Debug, Clone)] +pub struct Stmt { + pub k: StmtKind, + pub span: Span, +} + #[derive(Debug, Clone)] -pub enum Stmt { +pub enum StmtKind { Write { target: Target, e: Expr }, Let { name: String, e: Expr, ty: Option }, Expr(Expr), @@ -95,19 +141,24 @@ pub enum Stmt { Throw(Expr), } impl Stmt { + pub fn kind(&self) -> &'static str { + self.k.kind() + } +} +impl StmtKind { pub fn kind(&self) -> &'static str { match self { - Stmt::Write { .. } => "write", - Stmt::Let { .. } => "let", - Stmt::Expr(_) => "expr", - Stmt::Optimistic { .. } => "optimistic", - Stmt::Refresh(_) => "refresh", - Stmt::Affects(_) => "affects", - Stmt::Action(_) => "action", - Stmt::Sif { .. } => "sif", - Stmt::Return(_) => "return", - Stmt::Try { .. } => "try", - Stmt::Throw(_) => "throw", + StmtKind::Write { .. } => "write", + StmtKind::Let { .. } => "let", + StmtKind::Expr(_) => "expr", + StmtKind::Optimistic { .. } => "optimistic", + StmtKind::Refresh(_) => "refresh", + StmtKind::Affects(_) => "affects", + StmtKind::Action(_) => "action", + StmtKind::Sif { .. } => "sif", + StmtKind::Return(_) => "return", + StmtKind::Try { .. } => "try", + StmtKind::Throw(_) => "throw", } } } @@ -141,14 +192,15 @@ pub struct ImportName { #[derive(Debug, Clone)] pub enum Decl { - Source { name: String, init: Expr, line: usize, question: bool, ty: Option, exported: bool, key: Option }, - Eq { name: String, e: Expr, line: usize, deferred: bool, key: Option, ty: Option, exported: bool }, + Source { name: String, init: Expr, line: usize, span: Span, question: bool, ty: Option, exported: bool, key: Option }, + Eq { name: String, e: Expr, line: usize, span: Span, deferred: bool, key: Option, ty: Option, exported: bool }, Import { names: Vec, from: String, pure: bool, side: Option, line: usize, + span: Span, fails: Option>, /// an `.rx` import's module key (MO-1, set by the loader) resolved: Option, @@ -158,15 +210,15 @@ pub enum Decl { declared_pure: Option>, declared_side: Option>, }, - Error { name: String, params: Vec, ptypes: Vec>, msg: Option, line: usize, exported: bool }, - TypeDecl { name: String, line: usize, text: String, exported: bool }, - Effect { inputs: Vec, body: Vec, line: usize }, - Fn { name: String, params: Vec, decls: Vec, tail: Expr, line: usize, ret: Option, exported: bool }, - Component { name: String, props: String, decls: Vec, view: Vec, line: usize, props_type: Option, exported: bool, is_default: bool }, - Action { name: String, params: Vec, ptypes: Vec>, body: Vec, line: usize, exported: bool }, - Server { name: String, params: Vec, ptypes: Vec>, ret: Option, body: Vec, line: usize, exported: bool }, - Provide { name: String, e: Expr, line: usize }, - Context { name: String, ty: Option, source: bool, dflt: Option, line: usize, exported: bool }, + Error { name: String, params: Vec, ptypes: Vec>, msg: Option, line: usize, span: Span, exported: bool }, + TypeDecl { name: String, line: usize, span: Span, text: String, exported: bool }, + Effect { inputs: Vec, body: Vec, line: usize, span: Span }, + Fn { name: String, params: Vec, decls: Vec, tail: Expr, line: usize, span: Span, ret: Option, exported: bool }, + Component { name: String, props: String, decls: Vec, view: Vec, line: usize, span: Span, props_type: Option, exported: bool, is_default: bool }, + Action { name: String, params: Vec, ptypes: Vec>, body: Vec, line: usize, span: Span, exported: bool }, + Server { name: String, params: Vec, ptypes: Vec>, ret: Option, body: Vec, line: usize, span: Span, exported: bool }, + Provide { name: String, e: Expr, line: usize, span: Span }, + Context { name: String, ty: Option, source: bool, dflt: Option, line: usize, span: Span, exported: bool }, } impl Decl { pub fn line(&self) -> usize { @@ -185,6 +237,39 @@ impl Decl { | Decl::Context { line, .. } => *line, } } + /// Where the declaration was written, from its first keyword (`export` included) to its end. + pub fn span(&self) -> Span { + match self { + Decl::Source { span, .. } + | Decl::Eq { span, .. } + | Decl::Import { span, .. } + | Decl::Error { span, .. } + | Decl::TypeDecl { span, .. } + | Decl::Effect { span, .. } + | Decl::Fn { span, .. } + | Decl::Component { span, .. } + | Decl::Action { span, .. } + | Decl::Server { span, .. } + | Decl::Provide { span, .. } + | Decl::Context { span, .. } => *span, + } + } + pub fn set_span(&mut self, to: Span) { + match self { + Decl::Source { span, .. } + | Decl::Eq { span, .. } + | Decl::Import { span, .. } + | Decl::Error { span, .. } + | Decl::TypeDecl { span, .. } + | Decl::Effect { span, .. } + | Decl::Fn { span, .. } + | Decl::Component { span, .. } + | Decl::Action { span, .. } + | Decl::Server { span, .. } + | Decl::Provide { span, .. } + | Decl::Context { span, .. } => *span = to, + } + } /// The name a declaration binds (`"name" in d`): every kind but `import` and `effect`. pub fn name(&self) -> Option<&str> { match self { @@ -235,6 +320,8 @@ impl Decl { pub struct Attr { pub name: String, pub e: Expr, + /// the attribute's name + pub span: Span, } #[derive(Debug, Clone)] @@ -258,6 +345,7 @@ pub enum MarkupKind { pub struct Markup { pub kind: MarkupKind, pub line: Option, + pub span: Span, } #[derive(Debug, Clone)] @@ -313,55 +401,55 @@ pub fn num_json(n: f64) -> Value { impl Expr { pub fn kind(&self) -> &'static str { - match self { - Expr::Lit(_) => "lit", - Expr::Id(_) => "id", - Expr::Bin { .. } => "bin", - Expr::Un { .. } => "un", - Expr::Pre { .. } => "pre", - Expr::Peek(_) => "peek", - Expr::Await { .. } => "await", - Expr::Latest(_) => "latest", - Expr::Pending(_) => "pending", - Expr::If { .. } => "if", - Expr::Tpl(_) => "tpl", - Expr::Member { .. } => "member", - Expr::Index { .. } => "index", - Expr::Call { .. } => "call", - Expr::Arr(_) => "arr", - Expr::Rec(_) => "rec", - Expr::Fn { .. } => "fn", - Expr::Draft { .. } => "draft", - Expr::Use(_) => "use", - Expr::Spread(_) => "spread", - Expr::Throw(_) => "throw", - Expr::Catch { .. } => "catch", - Expr::StyleMap { .. } => "stylemap", - Expr::ServerFn { .. } => "serverfn", + match &self.k { + ExprKind::Lit(_) => "lit", + ExprKind::Id(_) => "id", + ExprKind::Bin { .. } => "bin", + ExprKind::Un { .. } => "un", + ExprKind::Pre { .. } => "pre", + ExprKind::Peek(_) => "peek", + ExprKind::Await { .. } => "await", + ExprKind::Latest(_) => "latest", + ExprKind::Pending(_) => "pending", + ExprKind::If { .. } => "if", + ExprKind::Tpl(_) => "tpl", + ExprKind::Member { .. } => "member", + ExprKind::Index { .. } => "index", + ExprKind::Call { .. } => "call", + ExprKind::Arr(_) => "arr", + ExprKind::Rec(_) => "rec", + ExprKind::Fn { .. } => "fn", + ExprKind::Draft { .. } => "draft", + ExprKind::Use(_) => "use", + ExprKind::Spread(_) => "spread", + ExprKind::Throw(_) => "throw", + ExprKind::Catch { .. } => "catch", + ExprKind::StyleMap { .. } => "stylemap", + ExprKind::ServerFn { .. } => "serverfn", } } pub fn to_json(&self) -> Value { let k = s(self.kind()); let b = |e: &Expr| Some(e.to_json()); let ob = |e: &Option>| e.as_ref().map(|e| e.to_json()); - match self { - Expr::Lit(l) => obj(vec![("k", k), ("v", lit_json(l))]), - Expr::Id(n) => obj(vec![("k", k), ("name", s(n))]), - Expr::Bin { op, l, r } => obj(vec![("k", k), ("op", s(op)), ("l", b(l)), ("r", b(r))]), - Expr::Un { op, e } => obj(vec![("k", k), ("op", s(op)), ("e", b(e))]), - Expr::Pre { name, init } => obj(vec![("k", k), ("name", s(name)), ("init", ob(init))]), - Expr::Peek(e) | Expr::Latest(e) | Expr::Pending(e) | Expr::Spread(e) | Expr::Throw(e) => obj(vec![("k", k), ("e", b(e))]), - Expr::Await { e, init, stream } => obj(vec![("k", k), ("e", b(e)), ("init", ob(init)), ("stream", Some(Value::Bool(*stream)))]), - Expr::If { c, t, f } => obj(vec![("k", k), ("c", b(c)), ("t", b(t)), ("f", b(f))]), - Expr::Tpl(parts) => obj(vec![ + match &self.k { + ExprKind::Lit(l) => obj(vec![("k", k), ("v", lit_json(l))]), + ExprKind::Id(n) => obj(vec![("k", k), ("name", s(n))]), + ExprKind::Bin { op, l, r } => obj(vec![("k", k), ("op", s(op)), ("l", b(l)), ("r", b(r))]), + ExprKind::Un { op, e } => obj(vec![("k", k), ("op", s(op)), ("e", b(e))]), + ExprKind::Pre { name, init } => obj(vec![("k", k), ("name", s(name)), ("init", ob(init))]), + ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => obj(vec![("k", k), ("e", b(e))]), + ExprKind::Await { e, init, stream } => obj(vec![("k", k), ("e", b(e)), ("init", ob(init)), ("stream", Some(Value::Bool(*stream)))]), + ExprKind::If { c, t, f } => obj(vec![("k", k), ("c", b(c)), ("t", b(t)), ("f", b(f))]), + ExprKind::Tpl(parts) => obj(vec![ ("k", k), ("parts", Some(Value::Array(parts.iter().map(|p| match p { TplPart::Str(x) => Value::String(x.clone()), TplPart::Expr(e) => e.to_json() }).collect()))), ]), - Expr::Member { o, p, opt } => obj(vec![("k", k), ("o", b(o)), ("p", s(p)), ("opt", flag(*opt))]), - Expr::Index { o, i, opt } => obj(vec![("k", k), ("o", b(o)), ("i", b(i)), ("opt", flag(*opt))]), - Expr::Call { f, args } => obj(vec![("k", k), ("f", b(f)), ("args", Some(Value::Array(args.iter().map(|a| a.to_json()).collect())))]), - Expr::Arr(items) => obj(vec![("k", k), ("items", Some(Value::Array(items.iter().map(|a| a.to_json()).collect())))]), - Expr::Rec(fields) => obj(vec![ + ExprKind::Member { o, p, opt } => obj(vec![("k", k), ("o", b(o)), ("p", s(p)), ("opt", flag(*opt))]), + ExprKind::Index { o, i, opt } => obj(vec![("k", k), ("o", b(o)), ("i", b(i)), ("opt", flag(*opt))]), + ExprKind::Call { f, args } => obj(vec![("k", k), ("f", b(f)), ("args", Some(Value::Array(args.iter().map(|a| a.to_json()).collect())))]), + ExprKind::Arr(items) => obj(vec![("k", k), ("items", Some(Value::Array(items.iter().map(|a| a.to_json()).collect())))]), + ExprKind::Rec(fields) => obj(vec![ ("k", k), ( "fields", @@ -376,12 +464,12 @@ impl Expr { )), ), ]), - Expr::Fn { params, body, stmts } => obj(vec![("k", k), ("params", Some(strs(params))), ("body", b(body)), ("stmts", stmts.as_ref().map(|ss| stmts_json(ss)))]), - Expr::Draft { seed, body } => obj(vec![("k", k), ("seed", b(seed)), ("body", Some(stmts_json(body)))]), - Expr::Use(n) => obj(vec![("k", k), ("name", s(n))]), - Expr::Catch { e, name, classes, alt } => obj(vec![("k", k), ("e", b(e)), ("name", os(name)), ("classes", classes.as_ref().map(|c| strs(c))), ("alt", b(alt))]), - Expr::StyleMap { css, line } => obj(vec![("k", k), ("css", s(css)), ("line", num(*line))]), - Expr::ServerFn { params, ptypes, ret, body } => obj(vec![("k", k), ("params", Some(strs(params))), ("ptypes", Some(ostrs(ptypes))), ("ret", os(ret)), ("body", Some(stmts_json(body)))]), + ExprKind::Fn { params, body, stmts } => obj(vec![("k", k), ("params", Some(strs(params))), ("body", b(body)), ("stmts", stmts.as_ref().map(|ss| stmts_json(ss)))]), + ExprKind::Draft { seed, body } => obj(vec![("k", k), ("seed", b(seed)), ("body", Some(stmts_json(body)))]), + ExprKind::Use(n) => obj(vec![("k", k), ("name", s(n))]), + ExprKind::Catch { e, name, classes, alt } => obj(vec![("k", k), ("e", b(e)), ("name", os(name)), ("classes", classes.as_ref().map(|c| strs(c))), ("alt", b(alt))]), + ExprKind::StyleMap { css, line } => obj(vec![("k", k), ("css", s(css)), ("line", num(*line))]), + ExprKind::ServerFn { params, ptypes, ret, body } => obj(vec![("k", k), ("params", Some(strs(params))), ("ptypes", Some(ostrs(ptypes))), ("ret", os(ret)), ("body", Some(stmts_json(body)))]), } } } @@ -399,15 +487,15 @@ fn target_json(t: &Target) -> Value { impl Stmt { pub fn to_json(&self) -> Value { let k = s(self.kind()); - match self { - Stmt::Write { target, e } | Stmt::Optimistic { target, e } => obj(vec![("k", k), ("target", Some(target_json(target))), ("e", Some(e.to_json()))]), - Stmt::Let { name, e, ty } => obj(vec![("k", k), ("name", s(name)), ("e", Some(e.to_json())), ("type", os(ty))]), - Stmt::Expr(e) | Stmt::Throw(e) => obj(vec![("k", k), ("e", Some(e.to_json()))]), - Stmt::Refresh(n) | Stmt::Affects(n) => obj(vec![("k", k), ("name", s(n))]), - Stmt::Action(body) => obj(vec![("k", k), ("body", Some(stmts_json(body)))]), - Stmt::Sif { c, t, f } => obj(vec![("k", k), ("c", Some(c.to_json())), ("t", Some(stmts_json(t))), ("f", Some(stmts_json(f)))]), - Stmt::Return(e) => obj(vec![("k", k), ("e", e.as_ref().map(|e| e.to_json()))]), - Stmt::Try { body, catch_name, catch_classes, handler, fin } => obj(vec![ + match &self.k { + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => obj(vec![("k", k), ("target", Some(target_json(target))), ("e", Some(e.to_json()))]), + StmtKind::Let { name, e, ty } => obj(vec![("k", k), ("name", s(name)), ("e", Some(e.to_json())), ("type", os(ty))]), + StmtKind::Expr(e) | StmtKind::Throw(e) => obj(vec![("k", k), ("e", Some(e.to_json()))]), + StmtKind::Refresh(n) | StmtKind::Affects(n) => obj(vec![("k", k), ("name", s(n))]), + StmtKind::Action(body) => obj(vec![("k", k), ("body", Some(stmts_json(body)))]), + StmtKind::Sif { c, t, f } => obj(vec![("k", k), ("c", Some(c.to_json())), ("t", Some(stmts_json(t))), ("f", Some(stmts_json(f)))]), + StmtKind::Return(e) => obj(vec![("k", k), ("e", e.as_ref().map(|e| e.to_json()))]), + StmtKind::Try { body, catch_name, catch_classes, handler, fin } => obj(vec![ ("k", k), ("body", Some(stmts_json(body))), ("catchName", os(catch_name)), @@ -428,7 +516,7 @@ pub fn markups_json(ms: &[Markup]) -> Value { impl Decl { pub fn to_json(&self) -> Value { match self { - Decl::Source { name, init, line, question, ty, exported, key } => obj(vec![ + Decl::Source { name, init, line, question, ty, exported, key, .. } => obj(vec![ ("k", s("source")), ("name", s(name)), ("init", Some(init.to_json())), @@ -438,7 +526,7 @@ impl Decl { ("key", os(key)), ("exported", flag(*exported)), ]), - Decl::Eq { name, e, line, deferred, key, ty, exported } => obj(vec![ + Decl::Eq { name, e, line, deferred, key, ty, exported, .. } => obj(vec![ ("k", s("eq")), ("name", s(name)), ("e", Some(e.to_json())), @@ -448,7 +536,7 @@ impl Decl { ("type", os(ty)), ("exported", flag(*exported)), ]), - Decl::Import { names, from, pure, side, line, fails, resolved, declared, declared_errors, declared_pure, declared_side } => obj(vec![ + Decl::Import { names, from, pure, side, line, fails, resolved, declared, declared_errors, declared_pure, declared_side, .. } => obj(vec![ ("k", s("import")), ("names", Some(Value::Array(names.iter().map(|n| obj(vec![("name", s(&n.name)), ("alias", s(&n.alias))])).collect()))), ("from", s(from)), @@ -471,7 +559,7 @@ impl Decl { }), ), ]), - Decl::Error { name, params, ptypes, msg, line, exported } => obj(vec![ + Decl::Error { name, params, ptypes, msg, line, exported, .. } => obj(vec![ ("k", s("error")), ("name", s(name)), ("params", Some(strs(params))), @@ -480,9 +568,9 @@ impl Decl { ("line", num(*line)), ("exported", flag(*exported)), ]), - Decl::TypeDecl { name, line, text, exported } => obj(vec![("k", s("typedecl")), ("name", s(name)), ("line", num(*line)), ("text", s(text)), ("exported", flag(*exported))]), - Decl::Effect { inputs, body, line } => obj(vec![("k", s("effect")), ("inputs", Some(Value::Array(inputs.iter().map(|e| e.to_json()).collect()))), ("body", Some(stmts_json(body))), ("line", num(*line))]), - Decl::Fn { name, params, decls, tail, line, ret, exported } => obj(vec![ + Decl::TypeDecl { name, line, text, exported, .. } => obj(vec![("k", s("typedecl")), ("name", s(name)), ("line", num(*line)), ("text", s(text)), ("exported", flag(*exported))]), + Decl::Effect { inputs, body, line, .. } => obj(vec![("k", s("effect")), ("inputs", Some(Value::Array(inputs.iter().map(|e| e.to_json()).collect()))), ("body", Some(stmts_json(body))), ("line", num(*line))]), + Decl::Fn { name, params, decls, tail, line, ret, exported, .. } => obj(vec![ ("k", s("fn")), ("name", s(name)), ("params", Some(Value::Array(params.iter().map(|p| obj(vec![("name", s(&p.name)), ("ref", Some(Value::Bool(p.r#ref))), ("type", os(&p.ty))])).collect()))), @@ -492,7 +580,7 @@ impl Decl { ("ret", os(ret)), ("exported", flag(*exported)), ]), - Decl::Component { name, props, decls, view, line, props_type, exported, is_default } => obj(vec![ + Decl::Component { name, props, decls, view, line, props_type, exported, is_default, .. } => obj(vec![ ("k", s("component")), ("name", s(name)), ("props", s(props)), @@ -503,7 +591,7 @@ impl Decl { ("exported", flag(*exported)), ("isDefault", flag(*is_default)), ]), - Decl::Action { name, params, ptypes, body, line, exported } => obj(vec![ + Decl::Action { name, params, ptypes, body, line, exported, .. } => obj(vec![ ("k", s("action")), ("name", s(name)), ("params", Some(strs(params))), @@ -512,7 +600,7 @@ impl Decl { ("line", num(*line)), ("exported", flag(*exported)), ]), - Decl::Server { name, params, ptypes, ret, body, line, exported } => obj(vec![ + Decl::Server { name, params, ptypes, ret, body, line, exported, .. } => obj(vec![ ("k", s("server")), ("name", s(name)), ("params", Some(strs(params))), @@ -522,8 +610,8 @@ impl Decl { ("line", num(*line)), ("exported", flag(*exported)), ]), - Decl::Provide { name, e, line } => obj(vec![("k", s("provide")), ("name", s(name)), ("e", Some(e.to_json())), ("line", num(*line))]), - Decl::Context { name, ty, source, dflt, line, exported } => obj(vec![ + Decl::Provide { name, e, line, .. } => obj(vec![("k", s("provide")), ("name", s(name)), ("e", Some(e.to_json())), ("line", num(*line))]), + Decl::Context { name, ty, source, dflt, line, exported, .. } => obj(vec![ ("k", s("context")), ("name", s(name)), ("type", os(ty)), diff --git a/compiler/src/awaits.rs b/compiler/src/awaits.rs index a04ea47..a414d3e 100644 --- a/compiler/src/awaits.rs +++ b/compiler/src/awaits.rs @@ -2,7 +2,7 @@ //! which the equation reaches it (E-11: an arm not selected is not demanded, so its flight is not asked). The compiler //! hoists each into its own async node, asked only while its guards hold; the equation reads it where the `await` was. -use crate::ast::{Expr, Markup, MarkupKind}; +use crate::ast::{Expr, ExprKind, Markup, MarkupKind}; use crate::check::{children, Node}; #[derive(Clone, Copy)] @@ -12,7 +12,7 @@ pub struct Guard<'a> { pub nullish: bool, } pub struct Hoisted<'a> { - /// an `Expr::Await` + /// an `ExprKind::Await` pub node: &'a Expr, pub guards: Vec>, } @@ -34,8 +34,8 @@ pub fn non_tail_awaits(root: &Expr, tail: bool) -> Vec> { fn walk<'a>(n: Node<'a>, tail: bool, g: &[Guard<'a>], out: &mut Vec>) { let g0 = |e| Guard { e, neg: false, nullish: false }; if let Node::E(x) = n { - match x { - Expr::Await { e, init, .. } => { + match &x.k { + ExprKind::Await { e, init, .. } => { if !tail { out.push(Hoisted { node: x, guards: g.to_vec() }); } @@ -45,13 +45,13 @@ fn walk<'a>(n: Node<'a>, tail: bool, g: &[Guard<'a>], out: &mut Vec> } return; } - Expr::If { c, t, f } => { + ExprKind::If { c, t, f } => { walk(Node::E(c), false, g, out); walk(Node::E(t), tail, &with(g, g0(c)), out); walk(Node::E(f), tail, &with(g, Guard { e: c, neg: true, nullish: false }), out); return; } - Expr::Bin { op, l, r } => { + ExprKind::Bin { op, l, r } => { walk(Node::E(l), false, g, out); match op.as_str() { "&&" => walk(Node::E(r), false, &with(g, g0(l)), out), @@ -61,13 +61,13 @@ fn walk<'a>(n: Node<'a>, tail: bool, g: &[Guard<'a>], out: &mut Vec> } return; } - Expr::Catch { e, alt, .. } => { + ExprKind::Catch { e, alt, .. } => { walk(Node::E(e), tail, g, out); walk(Node::E(alt), false, g, out); return; } // a closure's body runs later; a draft's statements are its own - Expr::Fn { .. } | Expr::Draft { .. } => return, + ExprKind::Fn { .. } | ExprKind::Draft { .. } => return, _ => {} } } diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 2ad4482..2568428 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -43,16 +43,16 @@ const BUILTINS: &[&str] = &["len", "str", "map", "filter", "sum", "fail", "fetch /// EF-5's cleanup shapes: a `return` of a closure anywhere in the body, a closure as its last expression, or an expression body that is one. fn returns_closure(f: &Expr) -> bool { fn in_stmts(ss: &[Stmt]) -> bool { - ss.iter().any(|s| match s { - Stmt::Return(Some(Expr::Fn { .. })) => true, - Stmt::Sif { t, f, .. } => in_stmts(t) || in_stmts(f), - Stmt::Try { body, handler, fin, .. } => in_stmts(body) || handler.as_deref().is_some_and(in_stmts) || fin.as_deref().is_some_and(in_stmts), + ss.iter().any(|s| match &s.k { + StmtKind::Return(Some(Expr { k: ExprKind::Fn { .. }, .. })) => true, + StmtKind::Sif { t, f, .. } => in_stmts(t) || in_stmts(f), + StmtKind::Try { body, handler, fin, .. } => in_stmts(body) || handler.as_deref().is_some_and(in_stmts) || fin.as_deref().is_some_and(in_stmts), _ => false, }) } - match f { - Expr::Fn { stmts: None, body, .. } => matches!(**body, Expr::Fn { .. }), - Expr::Fn { stmts: Some(ss), .. } => in_stmts(ss) || matches!(ss.last(), Some(Stmt::Expr(Expr::Fn { .. }))), + match &f.k { + ExprKind::Fn { stmts: None, body, .. } => matches!(body.k, ExprKind::Fn { .. }), + ExprKind::Fn { stmts: Some(ss), .. } => in_stmts(ss) || matches!(ss.last().map(|s| &s.k), Some(StmtKind::Expr(Expr { k: ExprKind::Fn { .. }, .. }))), _ => false, } } @@ -296,58 +296,58 @@ pub(crate) fn children<'a>(n: Node<'a>, paths: bool) -> Vec> { let mut out = vec![]; let ss = |out: &mut Vec>, ss: &'a [Stmt]| out.extend(ss.iter().map(Node::S)); match n { - Node::E(e) => match e { - Expr::Lit(_) | Expr::Id(_) | Expr::Use(_) | Expr::StyleMap { .. } => {} - Expr::Bin { l, r, .. } => out.extend([Node::E(l), Node::E(r)]), - Expr::Un { e, .. } | Expr::Peek(e) | Expr::Latest(e) | Expr::Pending(e) | Expr::Spread(e) | Expr::Throw(e) => out.push(Node::E(e)), - Expr::Pre { init, .. } => out.extend(init.iter().map(|x| Node::E(x))), - Expr::Await { e, init, .. } => { + Node::E(e) => match &e.k { + ExprKind::Lit(_) | ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::StyleMap { .. } => {} + ExprKind::Bin { l, r, .. } => out.extend([Node::E(l), Node::E(r)]), + ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => out.push(Node::E(e)), + ExprKind::Pre { init, .. } => out.extend(init.iter().map(|x| Node::E(x))), + ExprKind::Await { e, init, .. } => { out.push(Node::E(e)); out.extend(init.iter().map(|x| Node::E(x))); } - Expr::If { c, t, f } => out.extend([Node::E(c), Node::E(t), Node::E(f)]), - Expr::Tpl(parts) => out.extend(parts.iter().filter_map(|p| if let TplPart::Expr(e) = p { Some(Node::E(e)) } else { None })), - Expr::Member { o, .. } => out.push(Node::E(o)), - Expr::Index { o, i, .. } => out.extend([Node::E(o), Node::E(i)]), - Expr::Call { f, args } => { + ExprKind::If { c, t, f } => out.extend([Node::E(c), Node::E(t), Node::E(f)]), + ExprKind::Tpl(parts) => out.extend(parts.iter().filter_map(|p| if let TplPart::Expr(e) = p { Some(Node::E(e)) } else { None })), + ExprKind::Member { o, .. } => out.push(Node::E(o)), + ExprKind::Index { o, i, .. } => out.extend([Node::E(o), Node::E(i)]), + ExprKind::Call { f, args } => { out.push(Node::E(f)); out.extend(args.iter().map(Node::E)); } - Expr::Arr(items) => out.extend(items.iter().map(Node::E)), - Expr::Rec(fields) => out.extend(fields.iter().map(|f| match f { + ExprKind::Arr(items) => out.extend(items.iter().map(Node::E)), + ExprKind::Rec(fields) => out.extend(fields.iter().map(|f| match f { Field::Kv { v, .. } => Node::E(v), Field::Spread(e) => Node::E(e), })), - Expr::Fn { body, stmts, .. } => { + ExprKind::Fn { body, stmts, .. } => { out.push(Node::E(body)); if let Some(s) = stmts { ss(&mut out, s); } } - Expr::Draft { seed, body } => { + ExprKind::Draft { seed, body } => { out.push(Node::E(seed)); ss(&mut out, body); } - Expr::Catch { e, alt, .. } => out.extend([Node::E(e), Node::E(alt)]), - Expr::ServerFn { body, .. } => ss(&mut out, body), + ExprKind::Catch { e, alt, .. } => out.extend([Node::E(e), Node::E(alt)]), + ExprKind::ServerFn { body, .. } => ss(&mut out, body), }, - Node::S(s) => match s { - Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { + Node::S(s) => match &s.k { + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { if paths { out.extend(target.path.iter().filter_map(|p| if let PathSeg::Expr(x) = p { Some(Node::E(x)) } else { None })); } out.push(Node::E(e)); } - Stmt::Let { e, .. } | Stmt::Expr(e) | Stmt::Throw(e) => out.push(Node::E(e)), - Stmt::Refresh(_) | Stmt::Affects(_) => {} - Stmt::Action(body) => ss(&mut out, body), - Stmt::Sif { c, t, f } => { + StmtKind::Let { e, .. } | StmtKind::Expr(e) | StmtKind::Throw(e) => out.push(Node::E(e)), + StmtKind::Refresh(_) | StmtKind::Affects(_) => {} + StmtKind::Action(body) => ss(&mut out, body), + StmtKind::Sif { c, t, f } => { out.push(Node::E(c)); ss(&mut out, t); ss(&mut out, f); } - Stmt::Return(e) => out.extend(e.iter().map(Node::E)), - Stmt::Try { body, handler, fin, .. } => { + StmtKind::Return(e) => out.extend(e.iter().map(Node::E)), + StmtKind::Try { body, handler, fin, .. } => { ss(&mut out, body); if let Some(h) = handler { ss(&mut out, h); @@ -365,59 +365,59 @@ pub(crate) fn children<'a>(n: Node<'a>, paths: bool) -> Vec> { /// Free identifiers of an expression (`pre:x` for `pre x`), minus names bound inside it. fn free_names(e: &Expr, bound: &HashSet, out: &mut IndexSet) { let go = |x: &Expr, out: &mut IndexSet| free_names(x, bound, out); - match e { - Expr::Lit(_) | Expr::Use(_) => {} - Expr::Id(n) => { + match &e.k { + ExprKind::Lit(_) | ExprKind::Use(_) => {} + ExprKind::Id(n) => { if !bound.contains(n) { out.insert(n.clone()); } } - Expr::Pre { name, .. } => { + ExprKind::Pre { name, .. } => { if !bound.contains(name) { out.insert(format!("pre:{}", name)); } } - Expr::Peek(x) => go(x, out), - Expr::Await { e, init, .. } => { + ExprKind::Peek(x) => go(x, out), + ExprKind::Await { e, init, .. } => { go(e, out); if let Some(i) = init { go(i, out); } } - Expr::Latest(x) | Expr::Pending(x) | Expr::Un { e: x, .. } | Expr::Spread(x) => go(x, out), - Expr::Bin { l, r, .. } => { + ExprKind::Latest(x) | ExprKind::Pending(x) | ExprKind::Un { e: x, .. } | ExprKind::Spread(x) => go(x, out), + ExprKind::Bin { l, r, .. } => { go(l, out); go(r, out); } - Expr::If { c, t, f } => { + ExprKind::If { c, t, f } => { go(c, out); go(t, out); go(f, out); } - Expr::Tpl(parts) => { + ExprKind::Tpl(parts) => { for p in parts { if let TplPart::Expr(x) = p { go(x, out); } } } - Expr::Member { o, .. } => go(o, out), - Expr::Index { o, i, .. } => { + ExprKind::Member { o, .. } => go(o, out), + ExprKind::Index { o, i, .. } => { go(o, out); go(i, out); } - Expr::Call { f, args } => { + ExprKind::Call { f, args } => { go(f, out); for a in args { go(a, out); } } - Expr::Arr(items) => { + ExprKind::Arr(items) => { for a in items { go(a, out); } } - Expr::Rec(fields) => { + ExprKind::Rec(fields) => { for f in fields { match f { Field::Kv { v, .. } => go(v, out), @@ -425,7 +425,7 @@ fn free_names(e: &Expr, bound: &HashSet, out: &mut IndexSet) { } } } - Expr::Fn { params, body, stmts } => { + ExprKind::Fn { params, body, stmts } => { let mut b = bound.clone(); b.extend(params.iter().cloned()); free_names(body, &b, out); @@ -433,14 +433,14 @@ fn free_names(e: &Expr, bound: &HashSet, out: &mut IndexSet) { free_names_stmts(ss, &b, out); } } - Expr::Draft { seed, body } => { + ExprKind::Draft { seed, body } => { go(seed, out); let mut b = bound.clone(); b.insert("d".into()); free_names_stmts(body, &b, out); } - Expr::Throw(x) => go(x, out), - Expr::Catch { e, name, alt, .. } => { + ExprKind::Throw(x) => go(x, out), + ExprKind::Catch { e, name, alt, .. } => { go(e, out); match name { Some(n) => { @@ -451,18 +451,18 @@ fn free_names(e: &Expr, bound: &HashSet, out: &mut IndexSet) { None => free_names(alt, bound, out), } } - Expr::StyleMap { .. } | Expr::ServerFn { .. } => {} + ExprKind::StyleMap { .. } | ExprKind::ServerFn { .. } => {} } } fn free_names_stmts(ss: &[Stmt], bound0: &HashSet, out: &mut IndexSet) { let mut bound = bound0.clone(); for s in ss { - match s { - Stmt::Let { name, e, .. } => { + match &s.k { + StmtKind::Let { name, e, .. } => { free_names(e, &bound, out); bound.insert(name.clone()); } - Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { if !target.is_ctx() && !bound.contains(&target.name) { out.insert(target.name.clone()); } @@ -473,24 +473,24 @@ fn free_names_stmts(ss: &[Stmt], bound0: &HashSet, out: &mut IndexSet free_names(e, &bound, out), - Stmt::Return(e) => { + StmtKind::Expr(e) | StmtKind::Throw(e) => free_names(e, &bound, out), + StmtKind::Return(e) => { if let Some(e) = e { free_names(e, &bound, out); } } - Stmt::Refresh(n) | Stmt::Affects(n) => { + StmtKind::Refresh(n) | StmtKind::Affects(n) => { if !bound.contains(n) { out.insert(n.clone()); } } - Stmt::Action(body) => free_names_stmts(body, &bound, out), - Stmt::Sif { c, t, f } => { + StmtKind::Action(body) => free_names_stmts(body, &bound, out), + StmtKind::Sif { c, t, f } => { free_names(c, &bound, out); free_names_stmts(t, &bound, out); free_names_stmts(f, &bound, out); } - Stmt::Try { body, catch_name, handler, fin, .. } => { + StmtKind::Try { body, catch_name, handler, fin, .. } => { free_names_stmts(body, &bound, out); if let Some(h) = handler { let mut b = bound.clone(); @@ -517,11 +517,11 @@ fn strip_pre(n: &str) -> &str { /// Free names outside `peek` — the reads that make an initializer a snapshot (E-4). fn reads_outside_peek(n: Node, out: &mut IndexSet) { match n { - Node::E(Expr::Peek(_)) | Node::E(Expr::Fn { .. }) => {} - Node::E(Expr::Id(name)) => { + Node::E(Expr { k: ExprKind::Peek(_), .. }) | Node::E(Expr { k: ExprKind::Fn { .. }, .. }) => {} + Node::E(Expr { k: ExprKind::Id(name), .. }) => { out.insert(name.clone()); } - Node::E(Expr::Pre { name, .. }) => { + Node::E(Expr { k: ExprKind::Pre { name, .. }, .. }) => { out.insert(name.clone()); } _ => { @@ -533,19 +533,19 @@ fn reads_outside_peek(n: Node, out: &mut IndexSet) { } /// `map(xs, x => ({…}))` / `xs.map(x => ({…}))` fn maps_into_records(e: &Expr) -> bool { - let Expr::Call { f, args } = e else { return false }; - let fn_arg = match &**f { - Expr::Id(n) if n == "map" => args.get(1), - Expr::Member { p, .. } if p == "map" => args.first(), + let ExprKind::Call { f, args } = &e.k else { return false }; + let fn_arg = match &f.k { + ExprKind::Id(n) if n == "map" => args.get(1), + ExprKind::Member { p, .. } if p == "map" => args.first(), _ => None, }; - matches!(fn_arg, Some(Expr::Fn { stmts: None, body, .. }) if matches!(**body, Expr::Rec(_))) + matches!(fn_arg, Some(Expr { k: ExprKind::Fn { stmts: None, body, .. }, .. }) if matches!(&body.k, ExprKind::Rec(_))) } /// 12 SF-1d: the server fn a declaration's value is — itself, or wrapped by calls. pub fn unwrap_server(e: &Expr) -> Option<&Expr> { - match e { - Expr::ServerFn { .. } => Some(e), - Expr::Call { args, .. } => { + match &e.k { + ExprKind::ServerFn { .. } => Some(e), + ExprKind::Call { args, .. } => { let inner: Vec<&Expr> = args.iter().filter_map(unwrap_server).collect(); if inner.len() == 1 { Some(inner[0]) } else { None } } @@ -557,27 +557,27 @@ fn js_string(s: &str) -> String { } /// A short rendering of an expression, for messages. pub fn show(e: &Expr) -> String { - match e { - Expr::Lit(l) => match l { + match &e.k { + ExprKind::Lit(l) => match l { Lit::Undefined => "undefined".into(), Lit::Null => "null".into(), Lit::Bool(b) => b.to_string(), Lit::Num(n) => js_number(*n), Lit::Str(s) => js_string(s), }, - Expr::Id(n) => n.clone(), - Expr::Member { o, p, .. } => format!("{}.{}", show(o), p), - Expr::Call { f, .. } => format!("{}(…)", show(f)), - Expr::Await { e, .. } => format!("await {}", show(e)), - Expr::Latest(e) => format!("latest {}", show(e)), - Expr::Peek(e) => format!("peek {}", show(e)), - Expr::Throw(e) => format!("throw {}", show(e)), - Expr::Index { o, .. } => format!("{}[…]", show(o)), - Expr::Use(n) => format!("use {}", n), - Expr::Catch { e, .. } => format!("{} catch …", show(e)), - Expr::Tpl(_) => "`…`".into(), - Expr::Bin { op, l, r } => format!("{} {} {}", show(l), op, show(r)), - Expr::If { c, .. } => format!("{} ? … : …", show(c)), + ExprKind::Id(n) => n.clone(), + ExprKind::Member { o, p, .. } => format!("{}.{}", show(o), p), + ExprKind::Call { f, .. } => format!("{}(…)", show(f)), + ExprKind::Await { e, .. } => format!("await {}", show(e)), + ExprKind::Latest(e) => format!("latest {}", show(e)), + ExprKind::Peek(e) => format!("peek {}", show(e)), + ExprKind::Throw(e) => format!("throw {}", show(e)), + ExprKind::Index { o, .. } => format!("{}[…]", show(o)), + ExprKind::Use(n) => format!("use {}", n), + ExprKind::Catch { e, .. } => format!("{} catch …", show(e)), + ExprKind::Tpl(_) => "`…`".into(), + ExprKind::Bin { op, l, r } => format!("{} {} {}", show(l), op, show(r)), + ExprKind::If { c, .. } => format!("{} ? … : …", show(c)), _ => "…".into(), } } @@ -1002,7 +1002,7 @@ impl<'a> Checker<'a> { continue; } Decl::Effect { .. } | Decl::Provide { .. } | Decl::TypeDecl { .. } => continue, - Decl::Context { name, ty, source, dflt, line, exported } => { + Decl::Context { name, ty, source, dflt, line, exported, .. } => { if self.envs[env].vars.contains_key(name) { self.err("DUPLICATE_DECLARATION", "E-7", format!("{} is declared twice in one block", name)); } @@ -1062,7 +1062,7 @@ impl<'a> Checker<'a> { } Decl::Eq { e, key, .. } => { sym.maps_records = key.is_none() && maps_into_records(e); - if let Expr::Id(n) = e { + if let ExprKind::Id(n) = &e.k { if let Some(t) = self.lookup(env, n) { if self.syms[t].io { sym.io = true; @@ -1071,7 +1071,7 @@ impl<'a> Checker<'a> { } if let Some(sf) = unwrap_server(e) { sym.kind = SymKind::Server; - if let Expr::ServerFn { params, .. } = sf { + if let ExprKind::ServerFn { params, .. } = &sf.k { sym.params = Some(params.iter().map(|n| SigParam { name: n.clone(), r#ref: false, ty: None }).collect()); } server_expr = Some(sf as *const Expr); @@ -1126,7 +1126,7 @@ impl<'a> Checker<'a> { match d { Decl::Server { name, params, body, .. } => servers.push((name, params, body)), Decl::Eq { name, e, .. } => { - if let Some(Expr::ServerFn { params, body, .. }) = unwrap_server(e) { + if let Some(Expr { k: ExprKind::ServerFn { params, body, .. }, .. }) = unwrap_server(e) { servers.push((name, params, body)); } } @@ -1450,18 +1450,18 @@ impl<'a> Checker<'a> { } fn rows_walk(&mut self, x: Node<'a>, env: EId, rows: &mut IndexSet) { if let Node::E(e) = x { - match e { - Expr::Await { e: inner, init, .. } => { + match &e.k { + ExprKind::Await { e: inner, init, .. } => { rows.insert("pending".into()); if init.is_none() && self.client_call(inner, env) { rows.insert("client".into()); } } - Expr::Latest(_) | Expr::Pending(_) => { + ExprKind::Latest(_) | ExprKind::Pending(_) => { rows.insert("lane".into()); } - Expr::Fn { .. } | Expr::ServerFn { .. } => return, - Expr::Use(name) => { + ExprKind::Fn { .. } | ExprKind::ServerFn { .. } => return, + ExprKind::Use(name) => { let Some(c) = self.lookup(env, name).and_then(|s| self.syms[s].ctx.clone()) else { return }; if let Some((r, _)) = &c.contract { rows.extend(r.iter().cloned()); @@ -1480,7 +1480,7 @@ impl<'a> Checker<'a> { } return; } - Expr::Id(n) => { + ExprKind::Id(n) => { if let Some(s) = self.lookup(env, n) { if self.syms[s].rows.is_some() { let r = self.sym_rows(s); @@ -1499,10 +1499,10 @@ impl<'a> Checker<'a> { /// SF-5b: a call into `client` JS. fn client_call(&self, e: &Expr, env: EId) -> bool { - let f = if let Expr::Catch { e, .. } = e { &**e } else { e }; - match f { - Expr::Call { f, .. } => match &**f { - Expr::Id(n) => self.lookup(env, n).is_some_and(|s| self.syms[s].side == Some(Side::Client)), + let f = if let ExprKind::Catch { e, .. } = &e.k { &**e } else { e }; + match &f.k { + ExprKind::Call { f, .. } => match &f.k { + ExprKind::Id(n) => self.lookup(env, n).is_some_and(|s| self.syms[s].side == Some(Side::Client)), _ => false, }, _ => false, @@ -1556,8 +1556,8 @@ impl<'a> Checker<'a> { } else { "memo" }; - if let Expr::Call { f, args } = e { - if let Expr::Id(fname) = &**f { + if let ExprKind::Call { f, args } = &e.k { + if let ExprKind::Id(fname) = &f.k { if let Some(fs) = self.lookup(env, fname) { if self.syms[fs].kind == SymKind::Fn && self.syms[fs].instantiating { self.snapshot_args(e, fs, env); @@ -1592,7 +1592,7 @@ impl<'a> Checker<'a> { self.regions[region].provides.insert(info.key.clone()); self.provisions.push(Provision { key: info.key.clone(), e, env }); if info.source { - let t = if let Expr::Id(n) = e { self.lookup(env, n) } else { None }; + let t = if let ExprKind::Id(n) = &e.k { self.lookup(env, n) } else { None }; if t.is_none_or(|t| self.syms[t].kind != SymKind::Source || self.syms[t].imported.is_some()) { self.err("PROVIDE_NOT_A_SOURCE", "RG-7c", format!("context {} carries a source: provide one of this module's sources by name (`provide {} = x`)", name, name)); } @@ -1699,8 +1699,8 @@ impl<'a> Checker<'a> { } fn osw_direct(&mut self, ss: &'a [Stmt], env: EId, read: &IndexMap) { for st in ss { - match st { - Stmt::Write { target, .. } | Stmt::Optimistic { target, .. } if !target.is_ctx() => { + match &st.k { + StmtKind::Write { target, .. } | StmtKind::Optimistic { target, .. } if !target.is_ctx() => { if let Some(t) = self.lookup(env, &target.name) { if let Some(path) = read.get(&t) { let n = self.syms[t].name.clone(); @@ -1715,11 +1715,11 @@ impl<'a> Checker<'a> { } } } - Stmt::Sif { t, f, .. } => { + StmtKind::Sif { t, f, .. } => { self.osw_direct(t, env, read); self.osw_direct(f, env, read); } - Stmt::Try { body, handler, fin, .. } => { + StmtKind::Try { body, handler, fin, .. } => { self.osw_direct(body, env, read); if let Some(h) = handler { self.osw_direct(h, env, read); @@ -1728,7 +1728,7 @@ impl<'a> Checker<'a> { self.osw_direct(f, env, read); } } - Stmt::Action(body) => self.osw_direct(body, env, read), + StmtKind::Action(body) => self.osw_direct(body, env, read), _ => {} } } @@ -1736,7 +1736,7 @@ impl<'a> Checker<'a> { /// E-10: a reactive argument for a value parameter of an instantiating fn is snapshotted. fn snapshot_args(&mut self, call: &'a Expr, f: SId, env: EId) { - let Expr::Call { args, .. } = call else { return }; + let ExprKind::Call { args, .. } = &call.k else { return }; let params = self.syms[f].params.clone().unwrap_or_default(); let fname = self.syms[f].name.clone(); for (i, p) in params.iter().enumerate() { @@ -1888,9 +1888,9 @@ impl<'a> Checker<'a> { /// `field`: the member read of an identifier (`props.title`), for the model (CP-6). fn expr(&mut self, e: &'a Expr, env: EId, ctx: Ctx, field: Option<&str>) { - match e { - Expr::Lit(_) => {} - Expr::Id(name) => { + match &e.k { + ExprKind::Lit(_) => {} + ExprKind::Id(name) => { let s = self.resolve(name, env, ctx); if let Some(s) = s { self.read(s, ctx, false, field); @@ -1901,7 +1901,7 @@ impl<'a> Checker<'a> { } } } - Expr::Pre { name, init } => { + ExprKind::Pre { name, init } => { let s = self.resolve(name, env, ctx); if let Some(s) = s { self.read(s, ctx, true, None); @@ -1913,7 +1913,7 @@ impl<'a> Checker<'a> { self.expr(i, env, ctx, None); } } - Expr::Use(name) => { + ExprKind::Use(name) => { if ctx.server { self.err("SERVER_CAPTURE", "SF-3", format!("a server function's body reads `use {}`: a provision is the client's — pass it as an argument", name)); } @@ -1927,19 +1927,19 @@ impl<'a> Checker<'a> { } } } - Expr::Peek(x) => { + ExprKind::Peek(x) => { self.model.snap += 1; self.expr(x, env, ctx, None); self.model.snap -= 1; } - Expr::Latest(x) | Expr::Pending(x) => { + ExprKind::Latest(x) | ExprKind::Pending(x) => { let save = self.model.probe; - self.model.probe = Some(if matches!(e, Expr::Latest(_)) { "latest" } else { "pending" }); + self.model.probe = Some(if matches!(&e.k, ExprKind::Latest(_)) { "latest" } else { "pending" }); self.expr(x, env, ctx, None); self.model.probe = save; } - Expr::Un { e: x, .. } | Expr::Spread(x) => self.expr(x, env, ctx, None), - Expr::Await { e: inner, init, .. } => { + ExprKind::Un { e: x, .. } | ExprKind::Spread(x) => self.expr(x, env, ctx, None), + ExprKind::Await { e: inner, init, .. } => { if ctx.imperative && !self.stmt_awaits.contains(&(e as *const Expr)) { let sh = show(e); self.err( @@ -1954,7 +1954,7 @@ impl<'a> Checker<'a> { self.expr(i, env, ctx, None); } } - Expr::ServerFn { params, body, .. } => { + ExprKind::ServerFn { params, body, .. } => { let region = self.reg(env); let inner = self.env(Some(env), region); for p in params { @@ -1965,8 +1965,8 @@ impl<'a> Checker<'a> { self.syms[sym].server_rejects = Some(ServerRejects::Body { body, env: inner, cache: None }); } } - Expr::Throw(x) => self.expr(x, env, ctx, None), - Expr::Catch { e: inner, name, classes, alt } => { + ExprKind::Throw(x) => self.expr(x, env, ctx, None), + ExprKind::Catch { e: inner, name, classes, alt } => { self.expr(inner, env, ctx, None); if let Some(cs) = classes { for n in cs { @@ -1980,39 +1980,39 @@ impl<'a> Checker<'a> { } self.expr(alt, ienv, ctx, None); } - Expr::Bin { l, r, .. } => { + ExprKind::Bin { l, r, .. } => { self.expr(l, env, ctx, None); self.expr(r, env, ctx, None); } - Expr::If { c, t, f } => { + ExprKind::If { c, t, f } => { self.expr(c, env, ctx, None); self.expr(t, env, ctx, None); self.expr(f, env, ctx, None); } - Expr::Tpl(parts) => { + ExprKind::Tpl(parts) => { for p in parts { if let TplPart::Expr(x) = p { self.expr(x, env, ctx, None); } } } - Expr::Member { o, p, .. } => { - if let Expr::Id(_) = &**o { + ExprKind::Member { o, p, .. } => { + if let ExprKind::Id(_) = &o.k { self.expr(o, env, ctx, Some(p)); } else { self.expr(o, env, ctx, None); } } - Expr::Index { o, i, .. } => { + ExprKind::Index { o, i, .. } => { self.expr(o, env, ctx, None); self.expr(i, env, ctx, None); } - Expr::Arr(items) => { + ExprKind::Arr(items) => { for x in items { self.expr(x, env, ctx, None); } } - Expr::Rec(fields) => { + ExprKind::Rec(fields) => { for f in fields { match f { Field::Kv { v, .. } => self.expr(v, env, ctx, None), @@ -2020,13 +2020,13 @@ impl<'a> Checker<'a> { } } } - Expr::Call { f, args } => { - if let Expr::Id(fname) = &**f { + ExprKind::Call { f, args } => { + if let ExprKind::Id(fname) = &f.k { if fname == "until" && self.lookup(env, "until").is_none() { if !ctx.imperative { self.err("IO_IN_REACTIVE", "AC-6", "until(…) waits for the world to confirm a condition: it is called from imperative scope (an action or a handler), and awaited".into()); } - let ok = args.len() == 1 && matches!(&args[0], Expr::Fn { params, .. } if params.is_empty()); + let ok = args.len() == 1 && matches!(&args[0].k, ExprKind::Fn { params, .. } if params.is_empty()); if !ok { self.err("UNTIL_PREDICATE", "AC-6", "until takes a predicate, `until(() => e)`: a value would be read once and never change".into()); for a in args { @@ -2044,7 +2044,7 @@ impl<'a> Checker<'a> { if !ctx.imperative { self.err("IO_IN_REACTIVE", rule, if settled { "onSettled(…) schedules work from imperative scope (a handler, an apply block, an action); in a component body the mount hook is `effect () { … }` (EF-4)".into() } else { "onFlush(…) is called from imperative scope (a handler, an apply block, an action): it starts a run of its own after this flush".into() }); } - let ok = args.len() == 1 && matches!(&args[0], Expr::Fn { params, .. } if params.is_empty()); + let ok = args.len() == 1 && matches!(&args[0].k, ExprKind::Fn { params, .. } if params.is_empty()); if !ok { self.err("HOOK_CALLBACK", rule, format!("{} takes a closure, `{}(() => {{ … }})`: its body is the run it schedules", fname, fname)); for a in args { @@ -2092,7 +2092,7 @@ impl<'a> Checker<'a> { self.expr(a, env, ctx, None); } } - Expr::Fn { params, body, stmts } => { + ExprKind::Fn { params, body, stmts } => { let region = self.reg(env); let inner = self.env(Some(env), region); for p in params { @@ -2108,19 +2108,19 @@ impl<'a> Checker<'a> { } self.expr(body, inner, Ctx { imperative: ctx.imperative, in_fn: ctx.in_fn, server: ctx.server, in_action: false }, None); } - Expr::Draft { seed, body } => { + ExprKind::Draft { seed, body } => { self.expr(seed, env, ctx, None); let region = self.reg(env); let inner = self.env(Some(env), region); self.set_value(inner, "d", self.line); for s in body { - match s { - Stmt::Let { name, e, .. } => { + match &s.k { + StmtKind::Let { name, e, .. } => { self.expr(e, inner, ctx, None); self.set_value(inner, name, self.line); } - Stmt::Expr(x) => self.expr(x, inner, ctx, None), - Stmt::Write { target, e } => { + StmtKind::Expr(x) => self.expr(x, inner, ctx, None), + StmtKind::Write { target, e } => { if target.name != "d" || target.is_ctx() { self.err("DRAFT_WRITE_OUTSIDE_DRAFT", "ST-3a", format!("a draft block writes only its draft, not {}{}", if target.is_ctx() { "use " } else { "" }, target.name)); } @@ -2135,7 +2135,7 @@ impl<'a> Checker<'a> { } } } - Expr::StyleMap { .. } => {} + ExprKind::StyleMap { .. } => {} } } @@ -2144,14 +2144,14 @@ impl<'a> Checker<'a> { let region = self.reg(env0); let env = self.env(Some(env0), region); for s in ss { - if let Stmt::Let { e, .. } | Stmt::Expr(e) = s { - let t = if let Expr::Catch { e: inner, .. } = e { &**inner } else { e }; - if let Expr::Await { .. } = t { + if let StmtKind::Let { e, .. } | StmtKind::Expr(e) = &s.k { + let t = if let ExprKind::Catch { e: inner, .. } = &e.k { &**inner } else { e }; + if let ExprKind::Await { .. } = &t.k { self.stmt_awaits.insert(t as *const Expr); } } - match s { - Stmt::Let { name, e, .. } => { + match &s.k { + StmtKind::Let { name, e, .. } => { self.expr(e, env, ctx, None); let captures = self.stored(e, env); let mut sym = Sym::new(SymKind::Value, name, region, self.line); @@ -2159,14 +2159,14 @@ impl<'a> Checker<'a> { let id = self.new_sym(sym); self.envs[env].vars.insert(name.clone(), id); } - Stmt::Expr(e) | Stmt::Throw(e) => self.expr(e, env, ctx, None), - Stmt::Return(e) => { + StmtKind::Expr(e) | StmtKind::Throw(e) => self.expr(e, env, ctx, None), + StmtKind::Return(e) => { if let Some(e) = e { self.expr(e, env, ctx, None); } } - Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { - let op = if matches!(s, Stmt::Optimistic { .. }) { "optimistic" } else { "write" }; + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { + let op = if matches!(&s.k, StmtKind::Optimistic { .. }) { "optimistic" } else { "write" }; if ctx.server { self.err( "SERVER_WRITE", @@ -2243,8 +2243,8 @@ impl<'a> Checker<'a> { self.expr(e, env, ctx, None); self.escape(target, e, tsym, env); } - Stmt::Refresh(name) | Stmt::Affects(name) => { - let is_refresh = matches!(s, Stmt::Refresh(_)); + StmtKind::Refresh(name) | StmtKind::Affects(name) => { + let is_refresh = matches!(&s.k, StmtKind::Refresh(_)); if ctx.server { self.err( "SERVER_CAPTURE", @@ -2271,7 +2271,7 @@ impl<'a> Checker<'a> { } } } - Stmt::Action(body) => { + StmtKind::Action(body) => { if ctx.server { self.err("SERVER_CAPTURE", "SF-3", "`action { … }` in a server function's body: it is the client's (do it in the action that awaited the call)".into()); continue; @@ -2279,12 +2279,12 @@ impl<'a> Checker<'a> { self.comp("action", env, Some("(inline)".into()), None, |c| c.stmts(body, env, Ctx { in_action: true, ..Ctx::imperative() })); self.actions.push(ActionRec { name: "(inline)".into(), body, env, line: self.line }); } - Stmt::Sif { c, t, f } => { + StmtKind::Sif { c, t, f } => { self.expr(c, env, ctx, None); self.stmts(t, env, ctx); self.stmts(f, env, ctx); } - Stmt::Try { body, catch_name, catch_classes, handler, fin } => { + StmtKind::Try { body, catch_name, catch_classes, handler, fin } => { self.stmts(body, env, ctx); if let Some(cs) = catch_classes { for n in cs { @@ -2314,7 +2314,7 @@ impl<'a> Checker<'a> { } fn stored_walk(&self, x: Node<'a>, env: EId, out: &mut Vec) { match x { - Node::E(f @ Expr::Fn { .. }) => { + Node::E(f @ Expr { k: ExprKind::Fn { .. }, .. }) => { for n in names_of(f) { if let Some(s) = self.lookup(env, strip_pre(&n)) { if self.syms[s].kind.reactive() { @@ -2324,7 +2324,7 @@ impl<'a> Checker<'a> { } } } - Node::E(Expr::Id(n)) => { + Node::E(Expr { k: ExprKind::Id(n), .. }) => { if let Some(s) = self.lookup(env, n) { out.extend(self.syms[s].captures.iter().copied()); } @@ -2337,8 +2337,8 @@ impl<'a> Checker<'a> { } } fn escape(&mut self, target: &Target, e: &'a Expr, tsym: Option, env: EId) { - let value = match e { - Expr::Fn { body, stmts: None, .. } => &**body, + let value = match &e.k { + ExprKind::Fn { body, stmts: None, .. } => &**body, _ => e, }; let captured = self.stored(value, env); @@ -2451,7 +2451,7 @@ impl<'a> Checker<'a> { MarkupKind::Mfor { name, xs, decls, body } => { self.comp("reader", env, None, Some("for".into()), |c| c.expr(xs, env, Ctx::reactive(), None)); self.readers.push(ReaderRec { region, e: xs, env, line: self.line }); - let src = if let Expr::Id(n) = xs { self.lookup(env, n) } else { None }; + let src = if let ExprKind::Id(n) = &xs.k { self.lookup(env, n) } else { None }; if src.is_some_and(|s| self.syms[s].maps_records) || maps_into_records(xs) { self.err("UNSTABLE_LIST_IDENTITY", "ST-4b", format!("for {} in …: the rows are rebuilt as fresh records on every change — declare a key (`x key id = …`)", name)); } @@ -2600,9 +2600,9 @@ impl<'a> Checker<'a> { } } fn reads_children(&self, e: &Expr, env: EId) -> bool { - match e { - Expr::Member { o, p, .. } if p == "children" => match &**o { - Expr::Id(n) => self.lookup(env, n).is_some_and(|s| self.syms[s].kind == SymKind::Props), + match &e.k { + ExprKind::Member { o, p, .. } if p == "children" => match &o.k { + ExprKind::Id(n) => self.lookup(env, n).is_some_and(|s| self.syms[s].kind == SymKind::Props), _ => false, }, _ => false, @@ -2715,7 +2715,7 @@ impl<'a> Checker<'a> { let mut out = Fails::default(); let e = match n { Node::E(e) => e, - Node::S(Stmt::Throw(x)) => { + Node::S(Stmt { k: StmtKind::Throw(x), .. }) => { // a statement walked as an expression shares the expression's `throw` case let f = self.fails_of(x, env, imp, bound); out.add(f); @@ -2730,9 +2730,9 @@ impl<'a> Checker<'a> { return out; } }; - match e { - Expr::Lit(_) | Expr::Pending(_) | Expr::Pre { .. } | Expr::ServerFn { .. } => out, - Expr::Id(name) => { + match &e.k { + ExprKind::Lit(_) | ExprKind::Pending(_) | ExprKind::Pre { .. } | ExprKind::ServerFn { .. } => out, + ExprKind::Id(name) => { if bound.contains_key(name) { return out; } @@ -2750,8 +2750,8 @@ impl<'a> Checker<'a> { _ => out, } } - Expr::Member { o, p, .. } => { - if let Expr::Id(on) = &**o { + ExprKind::Member { o, p, .. } => { + if let ExprKind::Id(on) = &o.k { if !bound.contains_key(on) { if let Some(s) = self.lookup(env, on) { if self.syms[s].kind == SymKind::Props { @@ -2765,7 +2765,7 @@ impl<'a> Checker<'a> { out.add(f); out } - Expr::Use(name) => { + ExprKind::Use(name) => { let Some(c) = self.lookup(env, name).and_then(|s| self.syms[s].ctx.clone()) else { return out }; if let Some((_, fails)) = &c.contract { out.cls.extend(fails.iter().cloned()); @@ -2782,7 +2782,7 @@ impl<'a> Checker<'a> { } out } - Expr::Await { e: inner, init, .. } => { + ExprKind::Await { e: inner, init, .. } => { let f = self.fails_of(inner, env, imp, bound); out.add(f); if let Some(i) = init { @@ -2794,13 +2794,13 @@ impl<'a> Checker<'a> { } out } - Expr::Throw(x) => { + ExprKind::Throw(x) => { let f = self.fails_of(x, env, imp, bound); out.add(f); out.cls.insert(self.class_of(x, env, bound)); out } - Expr::Catch { e: inner, name, classes, alt } => { + ExprKind::Catch { e: inner, name, classes, alt } => { let inn = self.fails_of(inner, env, imp, bound); let c = Catch::of(classes); let got: IndexSet = inn.cls.iter().filter(|x| c.caught(x)).cloned().collect(); @@ -2822,7 +2822,7 @@ impl<'a> Checker<'a> { out.add(f); out } - Expr::Fn { params, body, stmts } => { + ExprKind::Fn { params, body, stmts } => { if imp || stmts.is_some() { return out; } @@ -2839,9 +2839,9 @@ impl<'a> Checker<'a> { out.add(f); out } - Expr::Call { f, args } => { - match &**f { - Expr::Id(fname) if !bound.contains_key(fname) => { + ExprKind::Call { f, args } => { + match &f.k { + ExprKind::Id(fname) if !bound.contains_key(fname) => { if let Some(s) = self.lookup(env, fname) { let k = self.syms[s].kind; if k == SymKind::Fn && self.syms[s].fails_t.is_some() { @@ -2864,11 +2864,11 @@ impl<'a> Checker<'a> { } out } - Expr::Draft { seed, body } => { + ExprKind::Draft { seed, body } => { let x = self.fails_of(seed, env, imp, bound); out.add(x); for st in body { - if let Stmt::Let { e, .. } | Stmt::Expr(e) | Stmt::Write { e, .. } = st { + if let StmtKind::Let { e, .. } | StmtKind::Expr(e) | StmtKind::Write { e, .. } = &st.k { let x = self.fails_of(e, env, imp, bound); out.add(x); } @@ -2887,30 +2887,30 @@ impl<'a> Checker<'a> { /// R-f-8: what an awaited value's promise may reject with. fn rejects(&mut self, e: &'a Expr, env: EId, seen: &mut HashSet) -> IndexSet { fn root(x: &Expr) -> &Expr { - match x { - Expr::Member { o, .. } | Expr::Index { o, .. } => root(o), + match &x.k { + ExprKind::Member { o, .. } | ExprKind::Index { o, .. } => root(o), _ => x, } } let error = || -> IndexSet { ["Error".to_string()].into_iter().collect() }; - match e { - Expr::Lit(_) | Expr::Tpl(_) | Expr::Arr(_) | Expr::Rec(_) | Expr::Bin { .. } | Expr::Un { .. } => IndexSet::new(), - Expr::If { t, f, .. } => { + match &e.k { + ExprKind::Lit(_) | ExprKind::Tpl(_) | ExprKind::Arr(_) | ExprKind::Rec(_) | ExprKind::Bin { .. } | ExprKind::Un { .. } => IndexSet::new(), + ExprKind::If { t, f, .. } => { let mut a = self.rejects(t, env, seen); a.extend(self.rejects(f, env, seen)); a } - Expr::Call { f, args } => { + ExprKind::Call { f, args } => { let r = root(f); - if let Expr::Id(fname) = &**f { + if let ExprKind::Id(fname) = &f.k { if fname == "until" && self.lookup(env, "until").is_none() { return match args.first() { - Some(Expr::Fn { body, stmts: None, .. }) => self.fails_of(body, env, false, &HashMap::new()).cls, + Some(Expr { k: ExprKind::Fn { body, stmts: None, .. }, .. }) => self.fails_of(body, env, false, &HashMap::new()).cls, _ => IndexSet::new(), }; } } - if let Expr::Id(rn) = r { + if let ExprKind::Id(rn) = &r.k { if let Some(s) = self.lookup(env, rn) { let k = self.syms[s].kind; if k == SymKind::Import { @@ -2921,7 +2921,7 @@ impl<'a> Checker<'a> { } if k == SymKind::Fn { if let Some((t, te)) = self.syms[s].fn_tail { - if matches!(&**f, Expr::Id(_)) && !seen.contains(&s) { + if matches!(&f.k, ExprKind::Id(_)) && !seen.contains(&s) { seen.insert(s); return self.rejects(t, te, seen); } @@ -2937,7 +2937,7 @@ impl<'a> Checker<'a> { } error() } - Expr::Id(name) => { + ExprKind::Id(name) => { if let Some(s) = self.lookup(env, name) { if let Some((x, xe)) = self.syms[s].expr { if !seen.contains(&s) { @@ -2953,14 +2953,14 @@ impl<'a> Checker<'a> { } /// R-f-3: the class a `throw` fails with — a declared class's construction, a rethrown catch binding, else Error. fn class_of(&self, e: &Expr, env: EId, bound: &HashMap>) -> String { - if let Expr::Call { f, .. } = e { - if let Expr::Id(n) = &**f { + if let ExprKind::Call { f, .. } = &e.k { + if let ExprKind::Id(n) = &f.k { if self.lookup(env, n).is_some_and(|s| matches!(self.syms[s].kind, SymKind::ErrClass | SymKind::Import)) { return n.clone(); } } } - if let Expr::Id(n) = e { + if let ExprKind::Id(n) = &e.k { if let Some(b) = bound.get(n) { return if b.len() == 1 { b[0].clone() @@ -2979,23 +2979,23 @@ impl<'a> Checker<'a> { let region = self.reg(env0); let env = self.env(Some(env0), region); for s in ss { - match s { - Stmt::Let { name, e, .. } => { + match &s.k { + StmtKind::Let { name, e, .. } => { let f = self.fails_of(e, env, true, bound); out.add(f); self.set_value(env, name, 0); } - Stmt::Expr(e) => { + StmtKind::Expr(e) => { let f = self.fails_of(e, env, true, bound); out.add(f); } - Stmt::Return(e) => { + StmtKind::Return(e) => { if let Some(e) = e { let f = self.fails_of(e, env, true, bound); out.add(f); } } - Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { for p in &target.path { if let PathSeg::Expr(x) = p { let f = self.fails_of(x, env, true, bound); @@ -3005,14 +3005,14 @@ impl<'a> Checker<'a> { let f = self.fails_of(e, env, true, bound); out.add(f); } - Stmt::Throw(e) => { + StmtKind::Throw(e) => { let f = self.fails_of(e, env, true, bound); out.add(f); for c in self.class_of(e, env, bound).split('|') { out.cls.insert(c.to_string()); } } - Stmt::Sif { c, t, f } => { + StmtKind::Sif { c, t, f } => { let x = self.fails_of(c, env, true, bound); out.add(x); let x = self.stmts_fails(t, env, bound); @@ -3020,7 +3020,7 @@ impl<'a> Checker<'a> { let x = self.stmts_fails(f, env, bound); out.add(x); } - Stmt::Try { body, catch_name, catch_classes, handler, fin } => { + StmtKind::Try { body, catch_name, catch_classes, handler, fin } => { let b = self.stmts_fails(body, env, bound); if let Some(h) = handler { let c = Catch::of(catch_classes); @@ -3052,7 +3052,7 @@ impl<'a> Checker<'a> { out.add(x); } } - Stmt::Action(_) | Stmt::Refresh(_) | Stmt::Affects(_) => {} + StmtKind::Action(_) | StmtKind::Refresh(_) | StmtKind::Affects(_) => {} } } out @@ -3420,8 +3420,8 @@ impl<'a> Checker<'a> { /// The calls by name in a server fn's body (`callsIn`: every value, a target's path included). fn calls_in(n: Node, out: &mut Vec) { - if let Node::E(Expr::Call { f, .. }) = n { - if let Expr::Id(name) = &**f { + if let Node::E(Expr { k: ExprKind::Call { f, .. }, .. }) = n { + if let ExprKind::Id(name) = &f.k { out.push(name.clone()); } } diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index 5305a83..ccbefe6 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -4,7 +4,7 @@ //! `const`. Names are mangled (`v$x` the value or accessor, `w$x` a source's writer, `p$x` a companion `pre` memo). The //! module is a tree (`js.rs`), never text (CP-3). -use crate::ast::{Decl, Expr, Lit, Markup, MarkupKind, PathSeg, Program, Stmt}; +use crate::ast::{Decl, Expr, ExprKind, Lit, Markup, MarkupKind, PathSeg, Program, Stmt, StmtKind}; use crate::awaits::{is_handler, non_tail_awaits, view_readers}; use crate::check::{children, unwrap_server, Node}; use crate::js::*; @@ -357,10 +357,10 @@ impl<'p> Gen<'p> { Ok(f) } fn chain(&mut self, name: &str, x: &Expr) -> R { - match x { - Expr::ServerFn { params, body, .. } => self.server_hoist(name, params, body, true), - Expr::Call { f, args } if matches!(&**f, Expr::Id(_)) => { - let Expr::Id(fname) = &**f else { unreachable!() }; + match &x.k { + ExprKind::ServerFn { params, body, .. } => self.server_hoist(name, params, body, true), + ExprKind::Call { f, args } if matches!(&f.k, ExprKind::Id(_)) => { + let ExprKind::Id(fname) = &f.k else { unreachable!() }; let found = self.mod_decls.iter().any(|d| matches!(d, Decl::Import { resolved: None, from, names, .. } if !from.starts_with('.') && names.iter().any(|n| &n.alias == fname))); if !found { return fail(format!("{name}: a server fn's wrapper is a package's function (here, {fname})")); @@ -369,7 +369,7 @@ impl<'p> Gen<'p> { for a in args { out.push(if unwrap_server(a).is_some() { self.chain(name, a)? - } else if let Expr::Lit(l) = a { + } else if let ExprKind::Lit(l) = &a.k { lit(l) } else { return fail(format!("{name}: a wrapper's other arguments are literals")); @@ -516,9 +516,9 @@ impl<'p> Gen<'p> { .filter(|a| !is_handler(&a.name)) .map(|a| Attribute { name: a.name.clone(), - value: match &a.e { - Expr::Lit(Lit::Str(s)) => Some(AttributeValue::Static(s.clone())), - Expr::Lit(Lit::Bool(true)) => None, + value: match &a.e.k { + ExprKind::Lit(Lit::Str(s)) => Some(AttributeValue::Static(s.clone())), + ExprKind::Lit(Lit::Bool(true)) => None, _ => Some(AttributeValue::Dynamic), }, }) @@ -859,7 +859,7 @@ impl<'p> Gen<'p> { } fn value(&mut self, d: &Decl, env: Env) -> R> { if let Decl::Provide { name, e, .. } = d { - let b = if let Expr::Id(n) = e { self.lookup(env, n) } else { None }; + let b = if let ExprKind::Id(n) = &e.k { self.lookup(env, n) } else { None }; let src = b.as_ref().filter(|b| b.kind == Kind::Src); let mut out = if src.is_some() { vec![] } else { self.hoist_all(&[e], env)? }; let entry = match src { @@ -877,7 +877,7 @@ impl<'p> Gen<'p> { let b = self.frames[env].vars.get(name).unwrap().clone(); let mut out = match d { Decl::Source { question: true, init, .. } => self.hoist(Some(name), init, env, true)?, - Decl::Eq { e, .. } if !matches!(e, Expr::Draft { .. }) => self.hoist(Some(name), e, env, true)?, + Decl::Eq { e, .. } if !matches!(&e.k, ExprKind::Draft { .. }) => self.hoist(Some(name), e, env, true)?, _ => vec![], }; out.append(&mut self.value1(d, env, &b)?); @@ -895,7 +895,7 @@ impl<'p> Gen<'p> { let x = self.expr(g.e, env, &reactive())?; guards.push(if g.nullish { bin("==", x, E::Null) } else if g.neg { not(x) } else { x }); } - let Expr::Await { e: inner, init, stream } = h.node else { unreachable!() }; + let ExprKind::Await { e: inner, init, stream } = &h.node.k else { unreachable!() }; let body0 = self.expr(inner, env, &Ctx { tail: true, ..Ctx::default() })?; let body = if *stream { body0 } else { rtc("awaited", vec![body0]) }; self.hoisted.insert(h.node as *const Expr, v.clone()); @@ -959,8 +959,8 @@ impl<'p> Gen<'p> { Ok(out) }; // ST-3 - if let Expr::Draft { seed, body } = e { - let Expr::Pre { name: sn, init: Some(si) } = &**seed else { return fail("a draft's seed must be `pre self -> seed`") }; + if let ExprKind::Draft { seed, body } = &e.k { + let ExprKind::Pre { name: sn, init: Some(si) } = &seed.k else { return fail("a draft's seed must be `pre self -> seed`") }; if sn != name { return fail("a draft's seed must be `pre self -> seed`"); } @@ -971,8 +971,8 @@ impl<'p> Gen<'p> { return fin(vec![konst(b.js.clone(), rtc("draft", vec![rtc("untrack", vec![thunk(s)]), arrow_block(vec![P::Id("v$d".into())], db, false)]))], self); } // RG-6 - if let Expr::Call { f, .. } = e { - if let Expr::Id(fname) = &**f { + if let ExprKind::Call { f, .. } = &e.k { + if let ExprKind::Id(fname) = &f.k { if self.lookup(env, fname).is_some_and(|x| x.kind == Kind::Ifn) { let v = self.instantiate(e, env)?; return fin(vec![konst(b.js.clone(), v)], self); @@ -984,7 +984,7 @@ impl<'p> Gen<'p> { return fin(vec![konst(b.js.clone(), rtc("keyed", vec![str(name.clone()), str(k.clone()), thunk(x)]))], self); } // R-f-4 over a tail await: the flight is an inner memo, read through a boundary - if let (Expr::Catch { e: ce, name: cn, classes, alt }, false) = (e, deferred) { + if let (ExprKind::Catch { e: ce, name: cn, classes, alt }, false) = (&e.k, deferred) { let inner = self.new_env(Some(env)); if let Some(n) = cn { self.set(inner, n, val(mg(n))); @@ -1015,9 +1015,9 @@ impl<'p> Gen<'p> { } /// SV-7b: a call into `client` JS — awaited, a client question: Solid's `ssrSource: "client"`, never asked on the server. fn client_call(&self, e: &Expr, env: Env) -> bool { - let f = if let Expr::Catch { e, .. } = e { &**e } else { e }; - if let Expr::Call { f, .. } = f { - if let Expr::Id(n) = &**f { + let f = if let ExprKind::Catch { e, .. } = &e.k { &**e } else { e }; + if let ExprKind::Call { f, .. } = &f.k { + if let ExprKind::Id(n) = &f.k { return self.lookup(env, n).is_some_and(|b| b.client); } } @@ -1026,9 +1026,9 @@ impl<'p> Gen<'p> { fn tail_client(&self, e: &Expr, env: Env) -> bool { fn walk(g: &Gen, n: Node, env: Env) -> bool { if let Node::E(x) = n { - match x { - Expr::Fn { .. } => return false, - Expr::Await { e, .. } if !g.hoisted.contains_key(&(x as *const Expr)) && g.client_call(e, env) => return true, + match &x.k { + ExprKind::Fn { .. } => return false, + ExprKind::Await { e, .. } if !g.hoisted.contains_key(&(x as *const Expr)) && g.client_call(e, env) => return true, _ => {} } } @@ -1044,9 +1044,9 @@ impl<'p> Gen<'p> { fn loading_value0(&mut self, e: &Expr, env: Env) -> R { fn find<'a>(n: Node<'a>) -> Option<&'a Expr> { if let Node::E(x) = n { - match x { - Expr::Fn { .. } => return None, - Expr::Await { init: Some(i), .. } => return Some(i), + match &x.k { + ExprKind::Fn { .. } => return None, + ExprKind::Await { init: Some(i), .. } => return Some(i), _ => {} } } @@ -1060,13 +1060,13 @@ impl<'p> Gen<'p> { fn draft_body(&mut self, ss: &[Stmt], env: Env) -> R> { let mut out = vec![]; for s in ss { - match s { - Stmt::Let { name, e, .. } => { + match &s.k { + StmtKind::Let { name, e, .. } => { self.set(env, name, val(mg(name))); out.push(konst(mg(name), self.expr(e, env, &reactive())?)); } - Stmt::Expr(e) => out.push(sexpr(self.expr(e, env, &reactive())?)), - Stmt::Write { target, e } => { + StmtKind::Expr(e) => out.push(sexpr(self.expr(e, env, &reactive())?)), + StmtKind::Write { target, e } => { let mut path = vec![]; for p in &target.path { path.push(match p { @@ -1103,7 +1103,7 @@ impl<'p> Gen<'p> { // an instantiating fn (RG-6): called once, returns the accessor of its tail let mut body = self.block(decls, &[], inner, None, false)?; strip_return_null(&mut body); - let tb = if let Expr::Id(n) = tail { self.lookup(inner, n) } else { None }; + let tb = if let ExprKind::Id(n) = &tail.k { self.lookup(inner, n) } else { None }; let t = match tb { Some(tb) if matches!(tb.kind, Kind::Acc | Kind::Src) => id(tb.js), _ => rtc("memo", vec![E::Null, thunk(self.expr(tail, inner, &reactive())?)]), @@ -1112,8 +1112,8 @@ impl<'p> Gen<'p> { Ok(S::Func(Func { name: Some(mg(name)), params: ps, directives: vec![], body, is_async: false, generator: false })) } fn instantiate(&mut self, e: &Expr, env: Env) -> R { - let Expr::Call { f, args } = e else { unreachable!() }; - let Expr::Id(fname) = &**f else { unreachable!() }; + let ExprKind::Call { f, args } = &e.k else { unreachable!() }; + let ExprKind::Id(fname) = &f.k else { unreachable!() }; let b = self.lookup(env, fname).unwrap(); let mut out = vec![]; for (i, (_, r#ref)) in b.f.as_ref().unwrap().params.iter().enumerate() { @@ -1122,7 +1122,7 @@ impl<'p> Gen<'p> { continue; }; if *r#ref { - if let Expr::Id(n) = a { + if let ExprKind::Id(n) = &a.k { if let Some(ab) = self.lookup(env, n).filter(|ab| matches!(ab.kind, Kind::Acc | Kind::Src)) { out.push(id(ab.js)); continue; @@ -1146,10 +1146,10 @@ impl<'p> Gen<'p> { // ---- expressions fn expr(&mut self, e: &Expr, env: Env, ctx: &Ctx) -> R { let sub = Ctx { tail: false, ..ctx.clone() }; - Ok(match e { - Expr::Lit(l) => lit(l), - Expr::Id(n) => self.reference(n, env, ctx)?, - Expr::Pre { name, init } => { + Ok(match &e.k { + ExprKind::Lit(l) => lit(l), + ExprKind::Id(n) => self.reference(n, env, ctx)?, + ExprKind::Pre { name, init } => { let i = match init { Some(i) => self.expr(i, env, &sub)?, None => undef(), @@ -1161,8 +1161,8 @@ impl<'p> Gen<'p> { let Some(p) = self.lookup(env, name).and_then(|b| b.prev) else { return fail(format!("pre {name}: no companion memo")) }; rtc("preOr", vec![call(id(p), vec![]), thunk(i)]) } - Expr::Peek(x) => rtc("untrack", vec![thunk(self.expr(x, env, &sub)?)]), - Expr::Await { e: x, stream, .. } => { + ExprKind::Peek(x) => rtc("untrack", vec![thunk(self.expr(x, env, &sub)?)]), + ExprKind::Await { e: x, stream, .. } => { if let Some(v) = self.hoisted.get(&(e as *const Expr)) { return Ok(call(id(v.clone()), vec![])); } @@ -1188,18 +1188,18 @@ impl<'p> Gen<'p> { } } // R-12: Solid's latest — of a value that never had one it throws, in a handler too (S-solid-28) - Expr::Latest(x) => rtc("latest", vec![thunk(self.expr(x, env, &Ctx { imp: false, ..ctx.clone() })?)]), - Expr::Pending(x) => rtc("isPending", vec![thunk(self.expr(x, env, &sub)?)]), - Expr::If { c, t, f } => { + ExprKind::Latest(x) => rtc("latest", vec![thunk(self.expr(x, env, &Ctx { imp: false, ..ctx.clone() })?)]), + ExprKind::Pending(x) => rtc("isPending", vec![thunk(self.expr(x, env, &sub)?)]), + ExprKind::If { c, t, f } => { let c = self.expr(c, env, &sub)?; let t = self.expr(t, env, ctx)?; let f = self.expr(f, env, ctx)?; E::Cond(Box::new(c), Box::new(t), Box::new(f)) } // R-f-3 - Expr::Throw(x) => rtc("throw", vec![self.expr(x, env, &sub)?]), + ExprKind::Throw(x) => rtc("throw", vec![self.expr(x, env, &sub)?]), // R-f-4: over a sync value, or the tail await's promise/stream - Expr::Catch { e: x, name, classes, alt } => { + ExprKind::Catch { e: x, name, classes, alt } => { if ctx.imp && has_await_json(&x.to_json()) { return fail("`await … catch` inside an expression of imperative scope (AWAIT_IN_EXPRESSION, AC-1)"); } @@ -1213,7 +1213,7 @@ impl<'p> Gen<'p> { let p = name.as_ref().map(|n| mg(n)).unwrap_or("__e".into()); rtc(if ctx.imp { "tryCatch" } else { "catchRead" }, vec![thunk(body), arrow(&["__e"], rtc("isA", vec![id("__e"), cls])), arrow(&[&p], a)]) } - Expr::Tpl(parts) => { + ExprKind::Tpl(parts) => { let mut xs = vec![]; for p in parts { xs.push(match p { @@ -1227,22 +1227,22 @@ impl<'p> Gen<'p> { Some(first) => it.fold(first, |a, b| bin("+", a, b)), } } - Expr::Member { o, p, .. } => E::Member(Box::new(self.expr(o, env, &sub)?), p.clone(), true), - Expr::Index { o, i, .. } => { + ExprKind::Member { o, p, .. } => E::Member(Box::new(self.expr(o, env, &sub)?), p.clone(), true), + ExprKind::Index { o, i, .. } => { let o = self.expr(o, env, &sub)?; E::Index(Box::new(o), Box::new(self.expr(i, env, &sub)?), true) } // 07 CSS-5 - Expr::StyleMap { css, line } => self.class_map(css, *line)?, - Expr::Call { f, args } => { + ExprKind::StyleMap { css, line } => self.class_map(css, *line)?, + ExprKind::Call { f, args } => { // AC-6: Solid's until; the predicate is reactive scope - if let (Expr::Id(n), Some(Expr::Fn { body, .. })) = (&**f, args.first()) { + if let (ExprKind::Id(n), Some(Expr { k: ExprKind::Fn { body, .. }, .. })) = (&f.k, args.first()) { if n == "until" && self.lookup(env, "until").is_none() { return Ok(rtc("until", vec![thunk(self.expr(body, env, &Ctx { dynamic: ctx.dynamic, ..Ctx::default() })?)])); } } // EF-7, EF-8: Solid's own - if let Expr::Id(n) = &**f { + if let ExprKind::Id(n) = &f.k { if (n == "onSettled" || n == "onFlush") && self.lookup(env, n).is_none() { let mut xs = vec![]; for a in args { @@ -1251,24 +1251,24 @@ impl<'p> Gen<'p> { return Ok(rtc(n, xs)); } } - let callee = if let Expr::Member { o, p, .. } = &**f { E::Member(Box::new(self.expr(o, env, &sub)?), p.clone(), true) } else { self.expr(f, env, &sub)? }; + let callee = if let ExprKind::Member { o, p, .. } = &f.k { E::Member(Box::new(self.expr(o, env, &sub)?), p.clone(), true) } else { self.expr(f, env, &sub)? }; let mut xs = vec![]; for a in args { xs.push(self.expr(a, env, &sub)?); } call(callee, xs) } - Expr::Arr(items) => { + ExprKind::Arr(items) => { let mut xs = vec![]; for i in items { - xs.push(match i { - Expr::Spread(x) => E::Spread(Box::new(self.expr(x, env, &sub)?)), + xs.push(match &i.k { + ExprKind::Spread(x) => E::Spread(Box::new(self.expr(x, env, &sub)?)), _ => self.expr(i, env, &sub)?, }); } E::Arr(xs) } - Expr::Rec(fields) => { + ExprKind::Rec(fields) => { let mut ps = vec![]; for f in fields { ps.push(match f { @@ -1278,7 +1278,7 @@ impl<'p> Gen<'p> { } E::Obj(ps) } - Expr::Fn { params, body, stmts } => { + ExprKind::Fn { params, body, stmts } => { let inner = self.params(env, params); let ps: Vec

= params.iter().map(|p| P::Id(mg(p))).collect(); match stmts { @@ -1293,8 +1293,8 @@ impl<'p> Gen<'p> { } } } - Expr::Use(n) => rtc("useGet", vec![self.reference(n, env, &Ctx { dynamic: ctx.dynamic, ..Ctx::default() })?, id("__o")]), - Expr::Bin { op, l, r } => { + ExprKind::Use(n) => rtc("useGet", vec![self.reference(n, env, &Ctx { dynamic: ctx.dynamic, ..Ctx::default() })?, id("__o")]), + ExprKind::Bin { op, l, r } => { let l = self.expr(l, env, &sub)?; let r = self.expr(r, env, &sub)?; match op.as_str() { @@ -1303,10 +1303,10 @@ impl<'p> Gen<'p> { op => bin(static_op(op), l, r), } } - Expr::Un { op, e: x } => E::Un(static_op(op), Box::new(self.expr(x, env, &sub)?)), - Expr::Draft { .. } => return fail("a draft is an equation's whole expression"), - Expr::Spread(_) => return fail("spread outside an array literal"), - Expr::ServerFn { .. } => undef(), + ExprKind::Un { op, e: x } => E::Un(static_op(op), Box::new(self.expr(x, env, &sub)?)), + ExprKind::Draft { .. } => return fail("a draft is an equation's whole expression"), + ExprKind::Spread(_) => return fail("spread outside an array literal"), + ExprKind::ServerFn { .. } => undef(), }) } fn reference(&mut self, name: &str, env: Env, ctx: &Ctx) -> R { @@ -1358,12 +1358,12 @@ impl<'p> Gen<'p> { let mut out = vec![]; for (i, s) in ss.iter().enumerate() { // AC-1 / R-f-4: a statement-level `await … catch` is a try around the await - let c = match s { - Stmt::Let { e: c @ Expr::Catch { e: inner, .. }, .. } | Stmt::Expr(c @ Expr::Catch { e: inner, .. }) if matches!(&**inner, Expr::Await { .. }) => Some(c), + let c = match &s.k { + StmtKind::Let { e: c @ Expr { k: ExprKind::Catch { e: inner, .. }, .. }, .. } | StmtKind::Expr(c @ Expr { k: ExprKind::Catch { e: inner, .. }, .. }) if matches!(&inner.k, ExprKind::Await { .. }) => Some(c), _ => None, }; - if let Some(Expr::Catch { e: ce, name: cn, classes, alt }) = c { - let v = if let Stmt::Let { name, .. } = s { Some(mg(name)) } else { None }; + if let Some(Expr { k: ExprKind::Catch { e: ce, name: cn, classes, alt }, .. }) = c { + let v = if let StmtKind::Let { name, .. } = &s.k { Some(mg(name)) } else { None }; let set = |x: E| match &v { Some(v) => sexpr(E::Assign("=", Box::new(id(v.clone())), Box::new(x))), None => sexpr(x), @@ -1385,31 +1385,31 @@ impl<'p> Gen<'p> { handler: Some(vec![S::IfThen(not(rtc("isA", vec![id(ev.clone()), cls])), Box::new(S::Throw(id(ev)))), set(a)]), fin: None, }); - if let (Some(v), Stmt::Let { name, .. }) = (&v, s) { + if let (Some(v), StmtKind::Let { name, .. }) = (&v, &s.k) { self.set(env, name, val(v.clone())); } continue; } - match s { - Stmt::Let { name, e, .. } => { + match &s.k { + StmtKind::Let { name, e, .. } => { let x = self.expr(e, env, &sub)?; out.push(S::Let(mg(name), Some(x))); self.set(env, name, val(mg(name))); } - Stmt::Expr(e) => { + StmtKind::Expr(e) => { let x = self.expr(e, env, &sub)?; out.push(if return_last && i == ss.len() - 1 { S::Return(Some(x)) } else { sexpr(x) }); } - Stmt::Return(e) => { + StmtKind::Return(e) => { let x = match e { Some(e) => Some(self.expr(e, env, &sub)?), None => None, }; out.push(S::Return(x)); } - Stmt::Throw(e) => out.push(S::Throw(self.expr(e, env, &sub)?)), - Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { - let optimistic = matches!(s, Stmt::Optimistic { .. }); + StmtKind::Throw(e) => out.push(S::Throw(self.expr(e, env, &sub)?)), + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { + let optimistic = matches!(&s.k, StmtKind::Optimistic { .. }); let mut keys = vec![]; for p in &target.path { keys.push(match p { @@ -1441,25 +1441,25 @@ impl<'p> Gen<'p> { call(dot(w, "set"), vec![v]) })); } - Stmt::Refresh(n) | Stmt::Affects(n) => { + StmtKind::Refresh(n) | StmtKind::Affects(n) => { let get = match self.lookup(env, n) { Some(b) => id(b.js), None => dot(rtc("lookup", vec![str(n.clone())]), "get"), }; - out.push(sexpr(if matches!(s, Stmt::Refresh(_)) { rtc("refresh", vec![str(n.clone()), get]) } else { rtc("affects", vec![get]) })); + out.push(sexpr(if matches!(&s.k, StmtKind::Refresh(_)) { rtc("refresh", vec![str(n.clone()), get]) } else { rtc("affects", vec![get]) })); } - Stmt::Action(body) => { + StmtKind::Action(body) => { let b = self.stmts(body, env, &Ctx { imp: true, gen: true, dynamic: ctx.dynamic, ..Ctx::default() }, false)?; let gen = E::Func(Box::new(Func { name: None, params: vec![], directives: vec![], body: b, is_async: false, generator: true })); out.push(sexpr(call(rtc("action", vec![gen]), vec![]))); } - Stmt::Sif { c, t, f } => { + StmtKind::Sif { c, t, f } => { let c = self.expr(c, env, &sub)?; let t = self.stmts(t, env, ctx, false)?; let f = self.stmts(f, env, ctx, false)?; out.push(S::If(c, t, Some(f))); } - Stmt::Try { body, catch_name, catch_classes, handler, fin } => { + StmtKind::Try { body, catch_name, catch_classes, handler, fin } => { let block = self.stmts(body, env, ctx, false)?; let (param, handler) = match handler { Some(hs) => { @@ -1563,11 +1563,11 @@ impl<'p> Gen<'p> { MarkupKind::El { tag, attrs, children } => { let mut xs = vec![]; for a in attrs { - xs.push(match (&self.style_hash, a.name.as_str(), &a.e) { - (Some(h), "class", Expr::Lit(Lit::Str(v))) => ("class".to_string(), AttrValue::Expr(str(format!("{v} {h}")))), - (Some(h), "class", e) => { + xs.push(match (&self.style_hash, a.name.as_str(), &a.e.k) { + (Some(h), "class", ExprKind::Lit(Lit::Str(v))) => ("class".to_string(), AttrValue::Expr(str(format!("{v} {h}")))), + (Some(h), "class", _) => { let h = h.clone(); - ("class".to_string(), AttrValue::Expr(rtc("scopedClass", vec![self.expr(e, env, &re)?, str(h)]))) + ("class".to_string(), AttrValue::Expr(rtc("scopedClass", vec![self.expr(&a.e, env, &re)?, str(h)]))) } _ => self.attr(&a.name, &a.e, env)?, }); @@ -1585,7 +1585,7 @@ impl<'p> Gen<'p> { } } MarkupKind::Mif { c, t, t_decls, f, f_decls } => { - let narrowed = if let Expr::Id(n) = c { self.lookup(env, n).filter(|b| matches!(b.kind, Kind::Acc | Kind::Src)).map(|b| (n.clone(), b)) } else { None }; + let narrowed = if let ExprKind::Id(n) = &c.k { self.lookup(env, n).filter(|b| matches!(b.kind, Kind::Acc | Kind::Src)).map(|b| (n.clone(), b)) } else { None }; let narrow = narrowed.map(|(n, b)| { let mut nb = bind(Kind::Acc, format!("n${n}")); nb.w = b.w; diff --git a/compiler/src/lib.rs b/compiler/src/lib.rs index 6508a02..b0e64ef 100644 --- a/compiler/src/lib.rs +++ b/compiler/src/lib.rs @@ -15,4 +15,9 @@ pub mod modules; pub mod node; pub mod oxc; pub mod parse; +pub mod project; +pub mod serve; pub mod signature; +pub mod spans; +#[cfg(target_arch = "wasm32")] +pub mod wasm; diff --git a/compiler/src/main.rs b/compiler/src/main.rs index d3398d6..833799c 100644 --- a/compiler/src/main.rs +++ b/compiler/src/main.rs @@ -1,6 +1,15 @@ //! `rxc`: the crate's command line. //! //! rxc check file.rx... diagnostics, as `ref/check.ts` prints them +//! rxc check --json file.rx... +//! the same as `{diagnostics: [{file, line, range, code, rule, message}]}`, ranges per CP-8 +//! rxc project app.rx TY-1c: the program's TSX projection, each file with its mappings onto the `.rx` +//! (`[generated offset, source offset, length]`, CP-8), and the prelude it imports; with no +//! file, reads `[{id, entry, files}]` on stdin, as `conform` does (`ref/crate.ts --project`) +//! rxc serve for a language server: one JSON request a line on stdin, `{id, method: "analyze", params: +//! {file, overlays, prelude}}`, answered a line each with the file's projection (mappings in +//! UTF-16 offsets; `ranges`, `[generated, length, source, length]`) and its diagnostics, the parse recovering (CP-8e); `method: "prelude"` too (`serve.rs`; `wasm.rs` answers the +//! same requests as WebAssembly) //! rxc model file.rx... the resolved model (CP-6) of each module, as JSON //! rxc conform reads `[{id, entry, files: {path: source}}]` on stdin and prints, per program, every module's //! AST, the diagnostics, the printed signatures and the models: what `ref/crate.ts` compares. @@ -19,7 +28,9 @@ use reactivescript::app::{self, read_disk}; use reactivescript::check::check_modules; use reactivescript::emit::{compile_modules, lower_view, Target}; use reactivescript::parse::parse_stmts; +use reactivescript::project::{project_modules, unverified, PRELUDE}; use reactivescript::modules::load_modules; +use reactivescript::serve; use reactivescript::signature::print_signature; use serde_json::{json, Map, Value}; use std::collections::HashMap; @@ -51,7 +62,22 @@ fn conform_one(entry: &str, files: &HashMap) -> Value { for (p, m) in &checked.models { models.insert(p.clone(), m.clone()); } + let mut spans = Map::new(); + for p in &set.order { + let mut bad = reactivescript::spans::check_spans(&set.sources[p], &set.programs[p]); + // CP-8e: on a program that parses, the editor's parse is the same parse, with nothing to recover from + let (rp, errs) = reactivescript::parse::parse_program_recovering(&set.sources[p]); + if !errs.is_empty() { + bad.push(format!("the editor's parse recovered from {} error(s) in a program that parses: {}", errs.len(), errs[0].0)); + } else if Some(rp.to_json()) != reactivescript::parse::parse_program(&set.sources[p]).ok().map(|x| x.to_json()) { + bad.push("the editor's parse gives another tree".into()); + } + if !bad.is_empty() { + spans.insert(p.clone(), json!(bad)); + } + } json!({ + "spans": spans, "order": set.order, "asts": asts, "diags": checked.diags.iter().map(|d| d.to_json()).collect::>(), @@ -60,6 +86,26 @@ fn conform_one(entry: &str, files: &HashMap) -> Value { }) } +/// TY-1c: each module's projection with its mappings onto the `.rx` (CP-8), and how many of them fail to hold (none). +fn project_one(entry: &str, files: &HashMap) -> Value { + let read = |p: &str| files.get(p).cloned(); + let set = match load_modules(entry, &read) { + Ok(s) => s, + Err(e) => return json!({ "error": e.0 }), + }; + projection_json(&set) +} +fn projection_json(set: &reactivescript::modules::ModuleSet) -> Value { + let files: Vec = project_modules(set) + .iter() + .map(|p| { + let bad = set.sources.get(&p.rx).map(|src| unverified(p, src).len()).unwrap_or(0); + json!({ "path": p.path, "rx": p.rx, "code": p.code, "lines": p.lines, "mappings": p.mappings.iter().map(|m| [m.gen, m.src, m.len]).collect::>(), "ranges": p.ranges.iter().map(|r| [r.gen, r.glen, r.src, r.len]).collect::>(), "unverified": bad }) + }) + .collect(); + json!({ "files": files }) +} + fn lower_one(r: &Value) -> Value { let files: HashMap = r["files"].as_object().unwrap().iter().map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())).collect(); let entry = r["entry"].as_str().unwrap(); @@ -105,6 +151,12 @@ fn lower_one(r: &Value) -> Value { v } +/// A parse error as `diagnostic_json` writes a diagnostic: from where the parser stopped to the end of that line. +fn parse_error_json(f: &str, e: &reactivescript::parse::ParseError) -> Value { + let file = e.2.clone().unwrap_or_else(|| abs(f)); + let src = read_disk(&file); + serve::parse_error_in(&file, e, src.as_deref()) +} fn main() { let args: Vec = std::env::args().skip(1).collect(); let Some(cmd) = args.first() else { @@ -131,6 +183,47 @@ fn main() { .collect(); println!("{}", serde_json::to_string(&out).unwrap()); } + "project" if args.len() == 1 => { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input).expect("stdin"); + let req: Vec = serde_json::from_str(&input).expect("request: [{id, entry, files}]"); + let out: Vec = req + .iter() + .map(|r| { + let files: HashMap = r["files"].as_object().unwrap().iter().map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())).collect(); + let mut v = std::panic::catch_unwind(|| project_one(r["entry"].as_str().unwrap(), &files)).unwrap_or_else(|_| json!({ "panic": true })); + v["id"] = r["id"].clone(); + v + }) + .collect(); + println!("{}", serde_json::to_string(&json!({ "prelude": PRELUDE, "programs": out })).unwrap()); + } + "project" => { + let set = match load_modules(&abs(&args[1]), &read_disk) { + Ok(s) => s, + Err(e) => { + println!("{}", json!({ "error": e.0 })); + std::process::exit(1); + } + }; + let mut v = projection_json(&set); + v["prelude"] = json!(PRELUDE); + println!("{}", serde_json::to_string_pretty(&v).unwrap()); + } + "serve" => { + // one JSON request a line on stdin, `{id, method, params}`; one response a line, `{id, result}` + use std::io::{BufRead, Write}; + let stdout = std::io::stdout(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Ok(req) = serde_json::from_str::(&line) else { continue }; + let method = req["method"].as_str().unwrap_or_default(); + let result = std::panic::catch_unwind(|| serve::request(method, &req["params"], &read_disk)).unwrap_or_else(|_| json!({ "error": "panic" })); + let mut o = stdout.lock(); + let _ = writeln!(o, "{}", json!({ "id": req["id"], "result": result })); + let _ = o.flush(); + } + } "lower" => { let mut input = String::new(); std::io::stdin().read_to_string(&mut input).expect("stdin"); @@ -211,19 +304,29 @@ fn main() { } "check" | "model" => { let mut bad = 0; - for f in &args[1..] { + let as_json = cmd == "check" && args.iter().any(|a| a == "--json"); + let mut found: Vec = vec![]; + for f in args[1..].iter().filter(|a| !a.starts_with("--")) { let set = match load_modules(&abs(f), &read_disk) { Ok(s) => s, Err(e) => { - eprintln!("{}: parse error: {}", f, e.0); bad += 1; + if as_json { + found.push(parse_error_json(f, &e)); + } else { + eprintln!("{}: parse error: {}", f, e.0); + } continue; } }; let checked = check_modules(&set); for d in &checked.diags { bad += 1; - eprintln!("{}:{}: error {} [{}] {}", d.file.as_deref().unwrap_or(f), d.line, d.code, d.rule, d.msg); + if as_json { + found.push(serve::diagnostic_json(&set, d)); + } else { + eprintln!("{}:{}: error {} [{}] {}", d.file.as_deref().unwrap_or(f), d.line, d.code, d.rule, d.msg); + } } if cmd == "model" { let cwd = std::env::current_dir().map(|p| p.to_string_lossy().into_owned() + "/").unwrap_or_default(); @@ -237,10 +340,13 @@ fn main() { }) .collect(); println!("{}", serde_json::to_string_pretty(&ms).unwrap()); - } else if checked.diags.is_empty() { + } else if checked.diags.is_empty() && !as_json { println!("{}: ok", f); } } + if as_json { + println!("{}", serde_json::to_string_pretty(&json!({ "diagnostics": found })).unwrap()); + } std::process::exit(if bad > 0 { 1 } else { 0 }); } _ => { diff --git a/compiler/src/modules.rs b/compiler/src/modules.rs index 6a6e67d..e411dcc 100644 --- a/compiler/src/modules.rs +++ b/compiler/src/modules.rs @@ -17,6 +17,10 @@ pub struct ModuleSet { pub declarations: IndexMap>, /// MO-9, by `.rx` path pub signatures: IndexMap, + /// each program's text, which its spans index + pub sources: HashMap, + /// an editor's load (CP-8e): what the parse recovered from, each with its file + pub parse_errors: Vec, } pub fn is_rx(from: &str) -> bool { @@ -58,7 +62,10 @@ struct Loader<'r> { diags: Vec, declarations: IndexMap>, signatures: IndexMap, + sources: HashMap, state: HashMap, + recover: bool, + parse_errors: Vec, } fn replace_js_ext(from: &str) -> String { @@ -76,7 +83,13 @@ impl Loader<'_> { return Ok(()); } let Some(src) = (self.read)(path) else { return Ok(()) }; - let mut p = parse_program(&src).map_err(|e| ParseError(format!("{}: {}", path, e.0)))?; + let mut p = if self.recover { + let (p, errs) = crate::parse::parse_program_recovering(&src); + self.parse_errors.extend(errs.into_iter().map(|e| ParseError(e.0, e.1, Some(path.to_string())))); + p + } else { + parse_program(&src).map_err(|e| ParseError(format!("{}: {}", path, e.0), e.1, Some(path.to_string())))? + }; self.state.insert(path.to_string(), State::Busy); for d in p.decls.iter_mut() { let Decl::Import { from, line, resolved, declared, declared_errors, declared_pure, declared_side, .. } = d else { continue }; @@ -85,7 +98,7 @@ impl Loader<'_> { let dts = join_path(path, &(replace_js_ext(from) + ".d.rx")); if !self.declarations.contains_key(&dts) { if let Some(src) = (self.read)(&dts) { - let ds = parse_declarations(&src).map_err(|e| ParseError(format!("{}: {}", dts, e.0)))?; + let ds = parse_declarations(&src).map_err(|e| ParseError(format!("{}: {}", dts, e.0), e.1, Some(dts.clone())))?; self.declarations.insert(dts.clone(), ds); } } @@ -121,7 +134,7 @@ impl Loader<'_> { // MO-9: no body — its signature, read in place let dts = to.strip_suffix(".rx").map(|s| format!("{}.d.rx", s)).unwrap_or_else(|| to.clone()); if let Some(sig) = (self.read)(&dts) { - let s = parse_signature(&sig, &to).map_err(|e| ParseError(e.0))?; + let s = parse_signature(&sig, &to).map_err(|e| ParseError(e.0, None, Some(dts.clone())))?; self.signatures.insert(to.clone(), s); continue; } @@ -138,6 +151,7 @@ impl Loader<'_> { self.visit(&to, &st)?; } self.programs.insert(path.to_string(), p); + self.sources.insert(path.to_string(), src); self.state.insert(path.to_string(), State::Done); self.order.push(path.to_string()); Ok(()) @@ -146,7 +160,15 @@ impl Loader<'_> { /// Load `entry` and everything it imports from `.rx`. `read` returns a module's source, or `None` when there is none. pub fn load_modules(entry: &str, read: &dyn Fn(&str) -> Option) -> Result { - let mut l = Loader { read, programs: HashMap::new(), order: vec![], diags: vec![], declarations: IndexMap::new(), signatures: IndexMap::new(), state: HashMap::new() }; + load(entry, read, false) +} +/// CP-8e: an editor's load. Each module is parsed recovering (`parse_program_recovering`), its errors in `parse_errors`; +/// a declaration file that does not parse is still an error. +pub fn load_modules_recovering(entry: &str, read: &dyn Fn(&str) -> Option) -> Result { + load(entry, read, true) +} +fn load(entry: &str, read: &dyn Fn(&str) -> Option, recover: bool) -> Result { + let mut l = Loader { read, programs: HashMap::new(), order: vec![], diags: vec![], declarations: IndexMap::new(), signatures: IndexMap::new(), sources: HashMap::new(), state: HashMap::new(), recover, parse_errors: vec![] }; l.visit(entry, &[])?; - Ok(ModuleSet { entry: entry.to_string(), programs: l.programs, order: l.order, diags: l.diags, declarations: l.declarations, signatures: l.signatures }) + Ok(ModuleSet { entry: entry.to_string(), programs: l.programs, order: l.order, diags: l.diags, declarations: l.declarations, signatures: l.signatures, sources: l.sources, parse_errors: l.parse_errors }) } diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index 05c3100..e54c10a 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -7,7 +7,8 @@ use std::collections::HashSet; use std::sync::LazyLock; #[derive(Debug, Clone, PartialEq)] -pub struct ParseError(pub String); +/// What failed to parse; where (a byte offset), and in which file, when known. +pub struct ParseError(pub String, pub Option, pub Option); impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) @@ -53,14 +54,41 @@ fn is_ident_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '_' || c == '$' } +/// An expression from `start` to where the cursor is. +fn ex(start: usize, k: ExprKind, c: &Cursor) -> Expr { + Expr::new(k, Span::new(start, c.i)) +} +/// A statement from `start` to where the cursor is. +fn st(start: usize, k: StmtKind, c: &Cursor) -> Stmt { + Stmt { k, span: Span::new(start, c.i) } +} + /// A position in the source, in bytes, always on a char boundary. `peek` at the end is `'\0'` (the reference's `""`). pub struct Cursor<'a> { pub src: &'a str, pub i: usize, + /// an editor's parse (CP-8e): a missing member name or expression is an error recorded in `errors`, and the parse goes + /// on with an empty one, so that what is being typed still projects + pub recover: bool, + pub errors: Vec, } impl<'a> Cursor<'a> { pub fn new(src: &'a str) -> Self { - Cursor { src, i: 0 } + Cursor { src, i: 0, recover: false, errors: vec![] } + } + /// What `fail` would return, recorded instead (`recover`). + fn note(&mut self, msg: &str) { + if let Err(e) = self.fail::<()>(msg) { + self.errors.push(e); + } + } + /// A member's name; in an editor's parse, `x.` with none yet is `x.` with an empty one. + fn member(&mut self) -> R { + if self.recover && self.peek_ident().is_none() { + self.note("member"); + return Ok(String::new()); + } + self.ident_or("member") } pub fn line(&self) -> usize { self.src[..self.i].bytes().filter(|b| *b == b'\n').count() + 1 @@ -123,7 +151,7 @@ impl<'a> Cursor<'a> { } pub fn fail(&self, msg: &str) -> R { let near: String = self.rest().chars().take(20).collect(); - Err(ParseError(format!("line {}: {} near '{}'", self.line(), msg, near))) + Err(ParseError(format!("line {}: {} near '{}'", self.line(), msg, near), Some(self.i), None)) } pub fn ident(&mut self) -> Option { let m = IDENT.find(self.rest())?; @@ -242,13 +270,78 @@ pub fn parse_program(src: &str) -> R { Decl::Component { name, line, .. } => (name.clone(), *line), _ => unreachable!(), }; - let el = Markup { kind: MarkupKind::El { tag: name, attrs: vec![], children: vec![] }, line: Some(line) }; + let el = Markup { kind: MarkupKind::El { tag: name, attrs: vec![], children: vec![] }, line: Some(line), span: Span::NONE }; return Ok(Program { decls, view: vec![el], legacy_view: None }); } let legacy = !view.is_empty(); Ok(Program { decls, view, legacy_view: Some(legacy) }) } +/// CP-8e: an editor's parse. A declaration that does not parse is left out (the error recorded, the parse resumed at the +/// next line that starts at column 0), and a missing member name or expression is an empty one, so the rest of the file +/// still projects. Never used to compile: `parse_program` is the language. +pub fn parse_program_recovering(src: &str) -> (Program, Vec) { + let mut c = Cursor::new(src); + c.recover = true; + let mut decls = vec![]; + c.ws(true); + while !c.eof() { + if !looks_like_decl(&mut c) { + let (at, n) = (c.i, c.errors.len()); + match parse_markup(&mut c, Close::Top) { + Ok(view) if !view.is_empty() => { + let mut errors = c.errors; + errors.push(ParseError("line 1: markup at a module's top level (MO-1c)".into(), Some(at), None)); + return (Program { decls, view, legacy_view: Some(true) }, errors); + } + Ok(_) => break, + Err(e) => { + c.errors.truncate(n); + c.errors.push(e); + c.i = at; + skip_line(&mut c); + } + } + } else { + let (at, n) = (c.i, c.errors.len()); + match parse_decl(&mut c) { + Ok(d) => decls.push(d), + Err(e) => { + // what the failed attempt noted on the way is moot: the declaration is out + c.errors.truncate(n); + c.errors.push(e); + c.i = at; + skip_line(&mut c); + } + } + } + c.ws(true); + c.eat(";"); + c.ws(true); + } + let (view, legacy_view) = match decls.iter().find(|d| matches!(d, Decl::Component { is_default: true, .. })) { + Some(Decl::Component { name, line, .. }) => (vec![Markup { kind: MarkupKind::El { tag: name.clone(), attrs: vec![], children: vec![] }, line: Some(*line), span: Span::NONE }], None), + _ => (vec![], Some(false)), + }; + (Program { decls, view, legacy_view }, c.errors) +} +/// On to the next line that starts at column 0 with something other than a closing brace (where a declaration may). +fn skip_line(c: &mut Cursor) { + loop { + match c.rest().find('\n') { + None => { + c.i = c.src.len(); + return; + } + Some(j) => c.i += j + 1, + } + let ch = c.peek(); + if !ch.is_whitespace() && ch != '}' && ch != '\0' { + return; + } + } +} + /// MO-8: a declaration file (`api.d.rx`) for a JS module. #[derive(Debug, Clone)] pub enum JsDecl { @@ -388,6 +481,12 @@ fn params_typed(c: &mut Cursor) -> R<(Vec, Vec>)> { fn parse_decl(c: &mut Cursor) -> R { c.ws(true); + let start = c.i; + let mut d = parse_decl_at(c)?; + d.set_span(Span::new(start, c.i)); + Ok(d) +} +fn parse_decl_at(c: &mut Cursor) -> R { let line = c.line(); if c.keyword("export") { c.ws(true); @@ -453,13 +552,13 @@ fn parse_decl(c: &mut Cursor) -> R { return c.fail("from"); } c.ws(true); - let from = match parse_primary(c)? { - Expr::Lit(Lit::Str(s)) => s, + let from = match parse_primary(c)?.k { + ExprKind::Lit(Lit::Str(s)) => s, _ => return c.fail("module path"), }; let save = c.i; c.ws(false); - let mk = |fails| Decl::Import { names, from, pure, side, line, fails, resolved: None, declared: None, declared_errors: None, declared_pure: None, declared_side: None }; + let mk = |fails| Decl::Import { names, from, pure, side, line, span: Span::NONE, fails, resolved: None, declared: None, declared_errors: None, declared_pure: None, declared_side: None }; if c.keyword("fails") { c.ws(true); let fails = parse_classes(c)?.unwrap_or_else(|| vec!["Error".to_string()]); @@ -493,10 +592,10 @@ fn parse_decl(c: &mut Cursor) -> R { if c.eat("=>") { c.ws(true); let msg = parse_expr(c)?; - return Ok(Decl::Error { name, params, ptypes, msg: Some(msg), line, exported: false }); + return Ok(Decl::Error { name, params, ptypes, msg: Some(msg), line, span: Span::NONE, exported: false }); } c.i = save; - return Ok(Decl::Error { name, params, ptypes, msg: None, line, exported: false }); + return Ok(Decl::Error { name, params, ptypes, msg: None, line, span: Span::NONE, exported: false }); } if TYPE_DECL.is_match(c.rest()) { let t0 = c.i; @@ -520,7 +619,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.bump(); } } - return Ok(Decl::TypeDecl { name, line, text: c.src[t0..c.i].to_string(), exported: false }); + return Ok(Decl::TypeDecl { name, line, span: Span::NONE, text: c.src[t0..c.i].to_string(), exported: false }); } if c.keyword("source") { c.ws(true); @@ -536,12 +635,12 @@ fn parse_decl(c: &mut Cursor) -> R { if c.eat(":=") { c.ws(true); let init = parse_expr(c)?; - return Ok(Decl::Source { name, init, line, question: false, ty, exported: false, key }); + return Ok(Decl::Source { name, init, line, span: Span::NONE, question: false, ty, exported: false, key }); } c.expect("=")?; c.ws(true); let init = parse_expr(c)?; - return Ok(Decl::Source { name, init, line, question: true, ty, exported: false, key }); + return Ok(Decl::Source { name, init, line, span: Span::NONE, question: true, ty, exported: false, key }); } if c.keyword("effect") { c.ws(true); @@ -559,7 +658,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.expect(")")?; c.ws(true); let body = parse_block(c)?; - return Ok(Decl::Effect { inputs, body, line }); + return Ok(Decl::Effect { inputs, body, line, span: Span::NONE }); } if c.keyword("deferred") { c.ws(true); @@ -569,7 +668,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.expect("=")?; c.ws(true); let e = parse_expr(c)?; - return Ok(Decl::Eq { name, e, line, deferred: true, key: None, ty, exported: false }); + return Ok(Decl::Eq { name, e, line, span: Span::NONE, deferred: true, key: None, ty, exported: false }); } if c.keyword("action") { c.ws(true); @@ -581,7 +680,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.expect(")")?; c.ws(true); let body = parse_block(c)?; - return Ok(Decl::Action { name, params, ptypes, body, line, exported: false }); + return Ok(Decl::Action { name, params, ptypes, body, line, span: Span::NONE, exported: false }); } if SERVER_DECL.is_match(c.rest()) { c.keyword("server"); @@ -600,7 +699,7 @@ fn parse_decl(c: &mut Cursor) -> R { let ret = opt_type(c, true)?; c.ws(true); let body = parse_block(c)?; - return Ok(Decl::Server { name, params, ptypes, ret, body, line, exported: false }); + return Ok(Decl::Server { name, params, ptypes, ret, body, line, span: Span::NONE, exported: false }); } if c.keyword("context") { c.ws(true); @@ -621,10 +720,10 @@ fn parse_decl(c: &mut Cursor) -> R { c.bump(); c.ws(true); let dflt = parse_expr(c)?; - return Ok(Decl::Context { name, ty, source, dflt: Some(dflt), line, exported: false }); + return Ok(Decl::Context { name, ty, source, dflt: Some(dflt), line, span: Span::NONE, exported: false }); } c.i = save; - return Ok(Decl::Context { name, ty, source, dflt: None, line, exported: false }); + return Ok(Decl::Context { name, ty, source, dflt: None, line, span: Span::NONE, exported: false }); } if c.keyword("provide") { c.ws(true); @@ -633,7 +732,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.expect("=")?; c.ws(true); let e = parse_expr(c)?; - return Ok(Decl::Provide { name, e, line }); + return Ok(Decl::Provide { name, e, line, span: Span::NONE }); } if c.keyword("fn") { c.ws(true); @@ -660,7 +759,7 @@ fn parse_decl(c: &mut Cursor) -> R { if c.eat("=>") { c.ws(true); let tail = parse_expr(c)?; - return Ok(Decl::Fn { name, params, decls: vec![], tail, line, ret, exported: false }); + return Ok(Decl::Fn { name, params, decls: vec![], tail, line, span: Span::NONE, ret, exported: false }); } c.expect("{")?; let mut decls = vec![]; @@ -677,7 +776,7 @@ fn parse_decl(c: &mut Cursor) -> R { let tail = parse_expr(c)?; c.ws(true); c.expect("}")?; - return Ok(Decl::Fn { name, params, decls, tail, line, ret, exported: false }); + return Ok(Decl::Fn { name, params, decls, tail, line, span: Span::NONE, ret, exported: false }); } if c.keyword("component") { c.ws(true); @@ -692,7 +791,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.ws(true); c.expect("{")?; let (decls, view) = parse_block_body(c)?; - return Ok(Decl::Component { name, props, decls, view, line, props_type, exported: false, is_default: false }); + return Ok(Decl::Component { name, props, decls, view, line, span: Span::NONE, props_type, exported: false, is_default: false }); } let save = c.i; let name = c.ident(); @@ -716,7 +815,7 @@ fn parse_decl(c: &mut Cursor) -> R { c.bump(); c.ws(true); let e = parse_expr(c)?; - return Ok(Decl::Eq { name: name.unwrap(), e, line, deferred: false, key, ty, exported: false }); + return Ok(Decl::Eq { name: name.unwrap(), e, line, span: Span::NONE, deferred: false, key, ty, exported: false }); } c.i = save; if c.peek_ident().is_some() && WRITE_AHEAD.is_match(c.rest()) { @@ -772,6 +871,7 @@ fn parse_path(c: &mut Cursor) -> R> { fn parse_stmt(c: &mut Cursor) -> R { c.ws(true); + let start = c.i; if c.keyword("optimistic") { c.ws(true); let name = c.ident_or("target")?; @@ -780,19 +880,19 @@ fn parse_stmt(c: &mut Cursor) -> R { c.expect(":=")?; c.ws(true); let e = parse_expr(c)?; - return Ok(Stmt::Optimistic { target: Target { name, path, ctx: None }, e }); + return Ok(st(start, StmtKind::Optimistic { target: Target { name, path, ctx: None }, e }, c)); } if c.keyword("refresh") { c.ws(true); - return Ok(Stmt::Refresh(c.ident_or("name")?)); + return Ok(st(start, StmtKind::Refresh(c.ident_or("name")?), c)); } if c.keyword("affects") { c.ws(true); - return Ok(Stmt::Affects(c.ident_or("name")?)); + return Ok(st(start, StmtKind::Affects(c.ident_or("name")?), c)); } if c.keyword("action") { c.ws(true); - return Ok(Stmt::Action(parse_block(c)?)); + return Ok(st(start, StmtKind::Action(parse_block(c)?), c)); } if c.keyword("let") || c.keyword("const") { c.ws(true); @@ -802,7 +902,7 @@ fn parse_stmt(c: &mut Cursor) -> R { c.expect("=")?; c.ws(true); let e = parse_expr(c)?; - return Ok(Stmt::Let { name, e, ty }); + return Ok(st(start, StmtKind::Let { name, e, ty }, c)); } if c.keyword("try") { c.ws(true); @@ -835,18 +935,18 @@ fn parse_stmt(c: &mut Cursor) -> R { if handler.is_none() && fin.is_none() { return c.fail("catch or finally"); } - return Ok(Stmt::Try { body, catch_name, catch_classes, handler, fin }); + return Ok(st(start, StmtKind::Try { body, catch_name, catch_classes, handler, fin }, c)); } if c.keyword("throw") { c.ws(true); - return Ok(Stmt::Throw(parse_expr(c)?)); + return Ok(st(start, StmtKind::Throw(parse_expr(c)?), c)); } if c.keyword("return") { c.ws(false); if c.peek() == '}' || c.peek() == ';' || c.peek() == '\n' || c.eof() { - return Ok(Stmt::Return(None)); + return Ok(st(start, StmtKind::Return(None), c)); } - return Ok(Stmt::Return(Some(parse_expr(c)?))); + return Ok(st(start, StmtKind::Return(Some(parse_expr(c)?)), c)); } if c.keyword("if") { c.ws(true); @@ -863,7 +963,7 @@ fn parse_stmt(c: &mut Cursor) -> R { c.ws(true); f = if c.peek() == '{' { parse_block(c)? } else { vec![parse_stmt(c)?] }; } - return Ok(Stmt::Sif { c: cond, t, f }); + return Ok(st(start, StmtKind::Sif { c: cond, t, f }, c)); } // write? target := expr let save = c.i; @@ -881,18 +981,19 @@ fn parse_stmt(c: &mut Cursor) -> R { if c.eat(":=") { c.ws(true); let e = parse_expr(c)?; - return Ok(Stmt::Write { target: Target { name: n, path, ctx: Some(ctx) }, e }); + return Ok(st(start, StmtKind::Write { target: Target { name: n, path, ctx: Some(ctx) }, e }, c)); } } } c.i = save; - Ok(Stmt::Expr(parse_expr(c)?)) + Ok(st(start, StmtKind::Expr(parse_expr(c)?), c)) } // ---- expressions ---- /// An expression, with any trailing `catch (x: A | B) => alt` (R-f-4). pub fn parse_expr(c: &mut Cursor) -> R { let mut e = parse_expr_no_catch(c)?; + let start = e.span.start as usize; loop { let save = c.i; c.ws(true); @@ -917,7 +1018,7 @@ pub fn parse_expr(c: &mut Cursor) -> R { c.expect("=>")?; c.ws(true); let alt = parse_expr_no_catch(c)?; - e = Expr::Catch { e: Box::new(e), name, classes, alt: Box::new(alt) }; + e = Expr::new(ExprKind::Catch { e: Box::new(e), name, classes, alt: Box::new(alt) }, Span::new(start, c.i)); } } @@ -946,6 +1047,7 @@ fn draft_ahead(c: &Cursor) -> bool { fn parse_expr_no_catch(c: &mut Cursor) -> R { c.ws(true); + let start = c.i; if draft_ahead(c) { c.keyword("draft"); c.ws(true); @@ -956,7 +1058,7 @@ fn parse_expr_no_catch(c: &mut Cursor) -> R { c.expect(")")?; c.ws(true); let body = parse_block(c)?; - return Ok(Expr::Draft { seed: Box::new(seed), body }); + return Ok(ex(start, ExprKind::Draft { seed: Box::new(seed), body }, c)); } { // 07 §3: TS arrow functions, verbatim — `x => e`, `(a, b) => e` @@ -1000,14 +1102,14 @@ fn parse_expr_no_catch(c: &mut Cursor) -> R { if c.peek() == '{' { let at = c.i; if let Ok(stmts) = parse_block(c) { - if stmts.iter().any(|st| !matches!(st, Stmt::Expr(Expr::Id(_)))) { - return Ok(Expr::Fn { params, body: Box::new(Expr::Lit(Lit::Undefined)), stmts: Some(stmts) }); + if stmts.iter().any(|st| !matches!(&st.k, StmtKind::Expr(Expr { k: ExprKind::Id(_), .. }))) { + return Ok(ex(start, ExprKind::Fn { params, body: Box::new(Expr::made(ExprKind::Lit(Lit::Undefined))), stmts: Some(stmts) }, c)); } } c.i = at; } let body = parse_expr(c)?; - return Ok(Expr::Fn { params, body: Box::new(body), stmts: None }); + return Ok(ex(start, ExprKind::Fn { params, body: Box::new(body), stmts: None }, c)); } c.i = save; } @@ -1029,7 +1131,7 @@ fn parse_expr_no_catch(c: &mut Cursor) -> R { c.expect("=>")?; c.ws(true); let body = parse_expr(c)?; - return Ok(Expr::Fn { params, body: Box::new(body), stmts: None }); + return Ok(ex(start, ExprKind::Fn { params, body: Box::new(body), stmts: None }, c)); } let cond = parse_bin(c, 0)?; c.ws(false); @@ -1044,7 +1146,7 @@ fn parse_expr_no_catch(c: &mut Cursor) -> R { c.expect(":")?; c.ws(true); let f = parse_expr_no_catch(c)?; - return Ok(Expr::If { c: Box::new(cond), t: Box::new(t), f: Box::new(f) }); + return Ok(ex(cond.span.start as usize, ExprKind::If { c: Box::new(cond), t: Box::new(t), f: Box::new(f) }, c)); } Ok(cond) } @@ -1055,6 +1157,7 @@ fn parse_bin(c: &mut Cursor, lvl: usize) -> R { return parse_unary(c); } let mut l = parse_bin(c, lvl + 1)?; + let start = l.span.start as usize; loop { c.ws(false); let op = LEVELS[lvl].iter().find(|o| c.starts_with(o) && !(**o == "-" && c.peek_at(1) == '>')); @@ -1067,17 +1170,18 @@ fn parse_bin(c: &mut Cursor, lvl: usize) -> R { "!==" => "!=", o => o, }; - l = Expr::Bin { op: op.to_string(), l: Box::new(l), r: Box::new(r) }; + l = Expr::new(ExprKind::Bin { op: op.to_string(), l: Box::new(l), r: Box::new(r) }, Span::new(start, c.i)); } } fn parse_unary(c: &mut Cursor) -> R { c.ws(true); + let start = c.i; if c.eat("!") { - return Ok(Expr::Un { op: "!".into(), e: Box::new(parse_unary(c)?) }); + return Ok(ex(start, ExprKind::Un { op: "!".into(), e: Box::new(parse_unary(c)?) }, c)); } if c.peek() == '-' && c.peek_at(1) != '>' { c.bump(); - return Ok(Expr::Un { op: "-".into(), e: Box::new(parse_unary(c)?) }); + return Ok(ex(start, ExprKind::Un { op: "-".into(), e: Box::new(parse_unary(c)?) }, c)); } if c.keyword("pre") { c.ws(true); @@ -1085,13 +1189,13 @@ fn parse_unary(c: &mut Cursor) -> R { c.ws(false); if c.eat("->") { c.ws(true); - return Ok(Expr::Pre { name, init: Some(Box::new(parse_unary(c)?)) }); + return Ok(ex(start, ExprKind::Pre { name, init: Some(Box::new(parse_unary(c)?)) }, c)); } - return Ok(Expr::Pre { name, init: None }); + return Ok(ex(start, ExprKind::Pre { name, init: None }, c)); } if c.keyword("peek") { c.ws(true); - return Ok(Expr::Peek(Box::new(parse_unary(c)?))); + return Ok(ex(start, ExprKind::Peek(Box::new(parse_unary(c)?)), c)); } if c.keyword("await") { let stream = c.eat("*"); @@ -1101,31 +1205,32 @@ fn parse_unary(c: &mut Cursor) -> R { if c.eat("->") { c.ws(true); let init = parse_unary(c)?; - return Ok(Expr::Await { e: Box::new(e), init: Some(Box::new(init)), stream }); + return Ok(ex(start, ExprKind::Await { e: Box::new(e), init: Some(Box::new(init)), stream }, c)); } - return Ok(Expr::Await { e: Box::new(e), init: None, stream }); + return Ok(ex(start, ExprKind::Await { e: Box::new(e), init: None, stream }, c)); } if c.keyword("latest") { c.ws(true); - return Ok(Expr::Latest(Box::new(parse_unary(c)?))); + return Ok(ex(start, ExprKind::Latest(Box::new(parse_unary(c)?)), c)); } if c.keyword("throw") { c.ws(true); - return Ok(Expr::Throw(Box::new(parse_unary(c)?))); + return Ok(ex(start, ExprKind::Throw(Box::new(parse_unary(c)?)), c)); } if c.keyword("use") { c.ws(true); - return Ok(Expr::Use(c.ident_or("context name")?)); + return Ok(ex(start, ExprKind::Use(c.ident_or("context name")?), c)); } if c.starts_with("pending?") { c.i += 8; c.ws(true); - return Ok(Expr::Pending(Box::new(parse_unary(c)?))); + return Ok(ex(start, ExprKind::Pending(Box::new(parse_unary(c)?)), c)); } parse_postfix(c) } fn parse_postfix(c: &mut Cursor) -> R { let mut e = parse_primary(c)?; + let start = e.span.start as usize; loop { if c.eat("?.") { if c.peek() == '[' { @@ -1134,21 +1239,21 @@ fn parse_postfix(c: &mut Cursor) -> R { let i = parse_expr(c)?; c.ws(true); c.expect("]")?; - e = Expr::Index { o: Box::new(e), i: Box::new(i), opt: true }; + e = Expr::new(ExprKind::Index { o: Box::new(e), i: Box::new(i), opt: true }, Span::new(start, c.i)); } else { - let p = c.ident_or("member")?; - e = Expr::Member { o: Box::new(e), p, opt: true }; + let p = c.member()?; + e = Expr::new(ExprKind::Member { o: Box::new(e), p, opt: true }, Span::new(start, c.i)); } } else if c.eat(".") { - let p = c.ident_or("member")?; - e = Expr::Member { o: Box::new(e), p, opt: false }; + let p = c.member()?; + e = Expr::new(ExprKind::Member { o: Box::new(e), p, opt: false }, Span::new(start, c.i)); } else if c.peek() == '[' { c.bump(); c.ws(true); let i = parse_expr(c)?; c.ws(true); c.expect("]")?; - e = Expr::Index { o: Box::new(e), i: Box::new(i), opt: false }; + e = Expr::new(ExprKind::Index { o: Box::new(e), i: Box::new(i), opt: false }, Span::new(start, c.i)); } else if c.peek() == '(' { c.bump(); c.ws(true); @@ -1162,7 +1267,7 @@ fn parse_postfix(c: &mut Cursor) -> R { c.ws(true); } c.expect(")")?; - e = Expr::Call { f: Box::new(e), args }; + e = Expr::new(ExprKind::Call { f: Box::new(e), args }, Span::new(start, c.i)); } else { return Ok(e); } @@ -1170,6 +1275,7 @@ fn parse_postfix(c: &mut Cursor) -> R { } fn parse_primary(c: &mut Cursor) -> R { c.ws(true); + let start = c.i; if STYLE.is_match(c.rest()) { let line = c.line(); c.i += " R { let Some(end) = c.rest().find("") else { return c.fail("expected ") }; let css = c.rest()[..end].to_string(); c.i += end + "".len(); - return Ok(Expr::StyleMap { css, line }); + return Ok(ex(start, ExprKind::StyleMap { css, line }, c)); } if SERVER_FN_EXPR.is_match(c.rest()) { c.keyword("server"); @@ -1194,7 +1300,7 @@ fn parse_primary(c: &mut Cursor) -> R { let ret = opt_type(c, true)?; c.ws(true); let body = parse_block(c)?; - return Ok(Expr::ServerFn { params, ptypes, ret, body }); + return Ok(ex(start, ExprKind::ServerFn { params, ptypes, ret, body }, c)); } let ch = c.peek(); if ch == '`' { @@ -1225,7 +1331,7 @@ fn parse_primary(c: &mut Cursor) -> R { if !s.is_empty() || parts.is_empty() { parts.push(TplPart::Str(s)); } - return Ok(Expr::Tpl(parts)); + return Ok(ex(start, ExprKind::Tpl(parts), c)); } if ch == '"' || ch == '\'' { c.bump(); @@ -1240,12 +1346,12 @@ fn parse_primary(c: &mut Cursor) -> R { c.bump(); } c.expect(&ch.to_string())?; - return Ok(Expr::Lit(Lit::Str(s))); + return Ok(ex(start, ExprKind::Lit(Lit::Str(s)), c)); } if let Some(m) = NUMBER.find(c.rest()) { let text = m.as_str().to_string(); c.i += text.len(); - return Ok(Expr::Lit(Lit::Num(text.replace('_', "").parse::().unwrap_or(f64::NAN)))); + return Ok(ex(start, ExprKind::Lit(Lit::Num(text.replace('_', "").parse::().unwrap_or(f64::NAN))), c)); } if c.eat("(") { c.ws(true); @@ -1258,9 +1364,10 @@ fn parse_primary(c: &mut Cursor) -> R { c.ws(true); let mut items = vec![]; while !c.starts_with("]") { + let s0 = c.i; if c.eat("...") { c.ws(true); - items.push(Expr::Spread(Box::new(parse_expr(c)?))); + items.push(ex(s0, ExprKind::Spread(Box::new(parse_expr(c)?)), c)); } else { items.push(parse_expr(c)?); } @@ -1271,7 +1378,7 @@ fn parse_primary(c: &mut Cursor) -> R { c.ws(true); } c.expect("]")?; - return Ok(Expr::Arr(items)); + return Ok(ex(start, ExprKind::Arr(items), c)); } if c.eat("{") { c.ws(true); @@ -1287,12 +1394,13 @@ fn parse_primary(c: &mut Cursor) -> R { c.ws(true); continue; } + let k0 = c.i; let key = match c.ident() { Some(k) => k, None => { if c.peek() == '"' || c.peek() == '\'' { - match parse_primary(c)? { - Expr::Lit(Lit::Str(s)) => s, + match parse_primary(c)?.k { + ExprKind::Lit(Lit::Str(s)) => s, _ => unreachable!(), } } else { @@ -1306,7 +1414,7 @@ fn parse_primary(c: &mut Cursor) -> R { let v = parse_expr(c)?; fields.push(Field::Kv { key, v }); } else { - fields.push(Field::Kv { v: Expr::Id(key.clone()), key }); + fields.push(Field::Kv { v: Expr::new(ExprKind::Id(key.clone()), Span::new(k0, k0 + key.len())), key }); } c.ws(true); if !c.eat(",") { @@ -1315,22 +1423,31 @@ fn parse_primary(c: &mut Cursor) -> R { c.ws(true); } c.expect("}")?; - return Ok(Expr::Rec(fields)); + return Ok(ex(start, ExprKind::Rec(fields), c)); } if c.keyword("true") { - return Ok(Expr::Lit(Lit::Bool(true))); + return Ok(ex(start, ExprKind::Lit(Lit::Bool(true)), c)); } if c.keyword("false") { - return Ok(Expr::Lit(Lit::Bool(false))); + return Ok(ex(start, ExprKind::Lit(Lit::Bool(false)), c)); } if c.keyword("undefined") { - return Ok(Expr::Lit(Lit::Undefined)); + return Ok(ex(start, ExprKind::Lit(Lit::Undefined), c)); } if c.keyword("null") { - return Ok(Expr::Lit(Lit::Null)); + return Ok(ex(start, ExprKind::Lit(Lit::Null), c)); + } + if c.recover && (c.i == 0 || c.src.as_bytes()[c.i - 1] == b'\n') && looks_like_decl(c) { + c.note("expression"); + return Ok(ex(start, ExprKind::Id(String::new()), c)); } if let Some(id) = c.ident() { - return Ok(Expr::Id(id)); + return Ok(ex(start, ExprKind::Id(id), c)); + } + if c.recover { + // a missing expression; what follows (on its own line) is the next declaration's + c.note("expression"); + return Ok(ex(start, ExprKind::Id(String::new()), c)); } c.fail("expression") } @@ -1366,7 +1483,7 @@ fn parse_markup(c: &mut Cursor, close: Close) -> R> { broke = s.contains('\n'); let tag = close_text.as_deref().unwrap(); if !s.is_empty() && !broke && !c.starts_with(&format!("", tag)) && !out.is_empty() { - out.push(Markup { kind: MarkupKind::Text(" ".into()), line: None }); + out.push(Markup { kind: MarkupKind::Text(" ".into()), line: None, span: Span::NONE }); } } else { c.ws(true); @@ -1388,9 +1505,11 @@ fn parse_markup(c: &mut Cursor, close: Close) -> R> { return Ok(out); } let line = c.line(); + let m0 = c.i; if c.starts_with("<") { let mut m = parse_element(c)?; m.line = Some(line); + m.span = Span::new(m0, c.i); out.push(m); continue; } @@ -1401,18 +1520,20 @@ fn parse_markup(c: &mut Cursor, close: Close) -> R> { c.ws(true); c.expect("}")?; ctl.line = Some(line); + ctl.span = Span::new(m0, c.i); out.push(ctl); continue; } let e = parse_expr(c)?; c.ws(true); c.expect("}")?; - out.push(Markup { kind: MarkupKind::Read(e), line: Some(line) }); + out.push(Markup { kind: MarkupKind::Read(e), line: Some(line), span: Span::new(m0, c.i) }); continue; } if !in_element { if let Some(mut ctl) = parse_control(c)? { ctl.line = Some(line); + ctl.span = Span::new(m0, c.i); out.push(ctl); continue; } @@ -1421,6 +1542,7 @@ fn parse_markup(c: &mut Cursor, close: Close) -> R> { return c.fail("declaration after markup: a block is declarations, then its view"); } let mut s = String::new(); + let s0 = c.i; while !c.eof() && !"<{}\n".contains(c.peek()) { s.push(c.peek()); c.bump(); @@ -1445,10 +1567,11 @@ fn parse_markup(c: &mut Cursor, close: Close) -> R> { return c.fail("declaration or markup"); } if broke && matches!(out.last(), Some(Markup { kind: MarkupKind::Text(_), .. })) { - out.push(Markup { kind: MarkupKind::Text(" ".into()), line: None }); + out.push(Markup { kind: MarkupKind::Text(" ".into()), line: None, span: Span::NONE }); } if !s.is_empty() { - out.push(Markup { kind: MarkupKind::Text(s), line: None }); + let span = Span::new(s0, s0 + s.len()); + out.push(Markup { kind: MarkupKind::Text(s), line: None, span }); } if c.i == start { // the reference loops here forever (a stray `}` in an element); this ends it @@ -1460,7 +1583,7 @@ fn parse_markup(c: &mut Cursor, close: Close) -> R> { /// Control forms of a view: `if c { … } else { … }`, `for x in e { … }`, `loading … { … }`, `errored …`, `portal …`. fn parse_control(c: &mut Cursor) -> R> { let save = c.i; - let mk = |kind| Some(Markup { kind, line: None }); + let mk = |kind| Some(Markup { kind, line: None, span: Span::NONE }); if c.keyword("if") { c.ws(true); let cond = parse_expr(c)?; @@ -1611,7 +1734,7 @@ fn looks_like_decl(c: &mut Cursor) -> bool { } fn parse_element(c: &mut Cursor) -> R { c.expect("<")?; - let mk = |kind| Markup { kind, line: None }; + let mk = |kind| Markup { kind, line: None, span: Span::NONE }; if c.eat(">") { let children = parse_markup(c, Close::Tag(String::new()))?; return Ok(mk(MarkupKind::El { tag: String::new(), attrs: vec![], children })); @@ -1640,24 +1763,55 @@ fn parse_element(c: &mut Cursor) -> R { if c.eat(">") { break; } + let a0 = c.i; let Some(name) = c.tag_name() else { return c.fail("attr") }; + let span = Span::new(a0, c.i); c.ws(false); if !c.eat("=") { - attrs.push(Attr { name, e: Expr::Lit(Lit::Bool(true)) }); + attrs.push(Attr { name, e: Expr::new(ExprKind::Lit(Lit::Bool(true)), span), span }); continue; } c.ws(true); if c.eat("{") { c.ws(true); let e = parse_expr(c)?; - attrs.push(Attr { name, e }); + attrs.push(Attr { name, e, span }); c.ws(true); c.expect("}")?; } else { let e = parse_primary(c)?; - attrs.push(Attr { name, e }); + attrs.push(Attr { name, e, span }); } } let children = parse_markup(c, Close::Tag(tag.clone()))?; Ok(mk(MarkupKind::El { tag, attrs, children })) } + +#[cfg(test)] +mod recovering { + use super::*; + + fn names(p: &Program) -> Vec { + p.decls.iter().filter_map(|d| d.name().map(|n| n.to_string())).collect() + } + + #[test] + fn half_typed_member_and_expression_still_parse() { + let src = "source user := { name: \"a\" }\nshown = user.\nnext = \nexport default component App() {\n

{shown}

\n}\n"; + let (p, errs) = parse_program_recovering(src); + assert_eq!(names(&p), ["user", "shown", "next", "App"]); + assert_eq!(errs.len(), 2); + let Decl::Eq { e, .. } = &p.decls[1] else { panic!() }; + assert!(matches!(&e.k, ExprKind::Member { p, .. } if p.is_empty())); + assert_eq!(e.span.text(src), "user."); + } + + #[test] + fn a_broken_declaration_is_left_out_and_the_rest_parse() { + let src = "source a := 1\nfn f(x => x\nb = a + 1\ncomponent Item(props) {\n
  • {props.x
  • \n}\nexport default component App() {\n \n}\n"; + let (p, errs) = parse_program_recovering(src); + assert_eq!(names(&p), ["a", "b", "App"]); + assert_eq!(errs.len(), 2); + assert!(parse_program(src).is_err()); + } +} diff --git a/compiler/src/prelude.ts b/compiler/src/prelude.ts new file mode 100644 index 0000000..feabdae --- /dev/null +++ b/compiler/src/prelude.ts @@ -0,0 +1,44 @@ +// ReactiveScript's forms, as TypeScript sees them (types/project.ts) +import type { JSX } from "@solidjs/web/jsx-runtime"; +export declare function await_(v: T): Awaited; +export declare function awaitS(v: T): T extends AsyncIterable ? U : Awaited; +export declare function first(v: T, init: I): T | I; +export declare function pre(init?: T): T; +export declare function pending(v: unknown): boolean; +export declare function caught(v: T, alt: (e: any) => R): T | R; +export declare function raise(e: unknown): never; +/** W-1/W-6: a write checks its value (or its function of the slot) against the target — whose type alone decides it. */ +export declare function set(target: T): (v: T | ((p: T) => T)) => void; +/** A source's type when none is written: its initializer's, widened — a source is written later (`""` is a string, + * `null` and `[]` say nothing yet). With a written type, `src` is that type, not what the initializer narrows it to. */ +export type Widen = 0 extends (1 & T) ? any : [T] extends [null | undefined] ? any : [T] extends [never[]] ? any[] : [T] extends [string] ? string : [T] extends [number] ? number : [T] extends [boolean] ? boolean : T; +export declare function src(v: T): Widen; +export declare function typed(v: T): T; +type ElemOf = 0 extends (1 & X) ? any : X extends Iterable ? U : X extends ArrayLike ? U : any; +export declare function each(xs: X, f: (x: ElemOf) => JSX.Element): JSX.Element; +/** 12 SF-6a: what crosses the wire — data. `Bad` is true where a function or an instance of a class is in it (`any`: not + * known here; the run-time check stands). A server fn's parameters and result must not be. */ +type Bad = 0 extends (1 & T) ? false : T extends string | number | boolean | bigint | null | undefined ? false : T extends Function ? true + : T extends readonly (infer U)[] ? Bad : T extends object ? (Object extends T ? false : true extends { [K in keyof T]-?: Bad }[keyof T] ? true : false) : true; +export declare function wire(f: (...a: A) => Promise, ok: true extends Bad ? "WIRE_VALUE: its result is not data (a function, or an instance of a class, does not cross)" : true extends Bad ? "WIRE_VALUE: a parameter is not data" : true): void; +/** 07 §6: a view (`props.children: View`); R-f-10: the class a panic is. */ +export type View = JSX.Element; +export type Panic = Error; +export interface Ctx { readonly __ctx: T } +export declare function ctx(d?: T): Ctx; +export declare function use(c: Ctx): T; +export declare function provide(c: Ctx, v: NoInfer): void; +export declare function effect(f: () => void | (() => void)): void; +export declare function draft(seed: T, f: (d: T) => void): T; +export declare function len(x: unknown): number; +export declare function until(p: () => T): Promise>; // AC-6: Solid's until — the first truthy value +export declare function onSettled(f: () => void): void; // EF-7: imperative scope only, so no cleanup return +export declare function onFlush(f: () => void): void; // EF-8 +export declare function str(x: unknown): string; +export declare function map(xs: X, f: (x: ElemOf, i: number) => U): U[]; +export declare function filter(xs: X, f: (x: ElemOf) => unknown): ElemOf[]; +export declare function every(xs: X, f: (x: ElemOf) => unknown): boolean; +export declare function sum(xs: readonly number[]): number; +export declare function fail(msg: string): never; +export declare function fetch(key: string): Promise; +export declare function log(...a: unknown[]): void; diff --git a/compiler/src/project.rs b/compiler/src/project.rs new file mode 100644 index 0000000..0f322f6 --- /dev/null +++ b/compiler/src/project.rs @@ -0,0 +1,995 @@ +//! TY-1 (spec/13), ported from `ref/types/project.ts`: a checked program projected to TSX that TypeScript checks, and that an +//! editor asks for types (TY-1c). The projection is the reference's, text for text (`ref/crate.ts --project` diffs them); +//! what the crate adds is where each piece came from (CP-8): every name, member, literal and written type the projection +//! copies from the `.rx` is a mapping, generated offset to source offset, of text that is the same on both sides. + +use crate::ast::*; +use crate::modules::ModuleSet; +use crate::parse::JsDecl; +use crate::signature::{ExportSig, ModSig}; +use indexmap::{IndexMap, IndexSet}; +use regex::Regex; +use serde_json::Value; +use std::collections::HashSet; +use std::sync::LazyLock; + +/// The helpers every projection imports (`__`), and the prelude's names (`len`, `map`, …): `ref/types/project.ts`'s PRELUDE. +pub const PRELUDE: &str = include_str!("prelude.ts"); + +static ROW_WORDS: LazyLock = LazyLock::new(|| Regex::new(r"^((?:io|pending|lane|fails\([^)]*\))\s+)+").unwrap()); +static SOURCE_WORD: LazyLock = LazyLock::new(|| Regex::new(r"^source\s+").unwrap()); +const BUILTINS: [&str; 12] = ["len", "str", "map", "filter", "sum", "fail", "fetch", "every", "log", "until", "onSettled", "onFlush"]; +fn builtin(n: &str) -> bool { + BUILTINS.contains(&n) +} + +/// One mapping: `len` bytes at `gen` in the projection are the same text as `len` bytes at `src` in the `.rx`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Mapping { + pub gen: u32, + pub src: u32, + pub len: u32, +} + +/// A coarser correspondence: `glen` bytes at `gen` in the projection were written for the `len` bytes at `src` (an +/// expression, a statement, a declaration's line), not the same text. An editor maps through it what falls between +/// mappings (a type error on `(a + b)` starts at its parenthesis); it is listed innermost first. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Range { + pub gen: u32, + pub glen: u32, + pub src: u32, + pub len: u32, +} + +/// Text with the mappings of the pieces in it that came from the source. +#[derive(Default, Clone)] +struct Code { + s: String, + m: Vec, + r: Vec, +} +impl Code { + fn new() -> Self { + Code::default() + } + fn of(s: &str) -> Self { + Code { s: s.to_string(), m: vec![], r: vec![] } + } + /// All of this text was written for `sp`. + fn from(mut self, sp: Span) -> Self { + if !sp.is_none() { + self.r.push(Range { gen: 0, glen: self.s.len() as u32, src: sp.start, len: sp.end - sp.start }); + } + self + } + fn t(mut self, s: &str) -> Self { + self.s.push_str(s); + self + } + fn c(mut self, o: Code) -> Self { + self.push(o); + self + } + fn push(&mut self, o: Code) { + let at = self.s.len() as u32; + self.m.extend(o.m.into_iter().map(|m| Mapping { gen: m.gen + at, ..m })); + self.r.extend(o.r.into_iter().map(|r| Range { gen: r.gen + at, ..r })); + self.s.push_str(&o.s); + } + /// `text`, mapped to `src` when it was found there. + fn at(mut self, text: &str, src: Option) -> Self { + if let Some(src) = src { + self.m.push(Mapping { gen: self.s.len() as u32, src: src as u32, len: text.len() as u32 }); + } + self.s.push_str(text); + self + } + fn join(xs: Vec, sep: &str) -> Code { + let mut out = Code::new(); + for (i, x) in xs.into_iter().enumerate() { + if i > 0 { + out.s.push_str(sep); + } + out.push(x); + } + out + } +} + +/// The reference's `W`: lines, each with the `.rx` line it came from. +#[derive(Default)] +struct W { + buf: Code, + lines: Vec, + last: usize, +} +impl W { + fn new() -> Self { + W { buf: Code::new(), lines: vec![], last: 1 } + } + /// A statement or a markup item carries no line of its own (0): it is on its declaration's. + fn emit(&mut self, text: Code, line: usize) { + if line != 0 { + self.last = line; + } + let n = text.s.matches('\n').count() + 1; + if !self.lines.is_empty() { + self.buf.s.push('\n'); + } + self.buf.push(text); + for _ in 0..n { + self.lines.push(if line != 0 { line } else { self.last }); + } + } +} + +/// One projected file: its TSX, for each of its lines the `.rx` line it came from, and its mappings onto the `.rx`. +pub struct Projected { + pub path: String, + pub code: String, + pub lines: Vec, + pub rx: String, + pub mappings: Vec, + pub ranges: Vec, +} + +/// A written type, without its row words (`pending T` → `T`, 04 CN-4a) or a context's `source` (RG-7c). +fn type_text(t: &Option) -> Option { + let t = t.as_ref()?; + let x = ROW_WORDS.replace(t, ""); + let x = SOURCE_WORD.replace(&x, ""); + let x = crate::parse::js_trim(&x); + if x.is_empty() { + None + } else { + Some(x.to_string()) + } +} +/// `JSON.stringify` of a literal. +fn j(l: &Lit) -> String { + match crate::ast::lit_json(l) { + None => "undefined".into(), + Some(v) => serde_json::to_string(&v).unwrap(), + } +} +fn js_str(s: &str) -> String { + serde_json::to_string(s).unwrap() +} +fn is_handler(a: &str) -> bool { + crate::awaits::is_handler(a) +} +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} + +/// The file a module projects to: `main.rx` → `main.rx.tsx`, so `import … from "./lib.rx"` resolves to the projection. +pub fn projected_path(rx: &str) -> String { + format!("{rx}.tsx") +} + +pub fn project_modules(set: &ModuleSet) -> Vec { + project_modules_with(set, None) +} +/// `prelude`: where the projections import the prelude from, in place of `__prelude.ts` at the program's root (an editor +/// writes it once, where the project's own `@solidjs/web` resolves). +pub fn project_modules_with(set: &ModuleSet, prelude: Option<&str>) -> Vec { + let mut out = vec![]; + let mut js_stubs: IndexMap> = IndexMap::new(); + for path in &set.order { + let p = &set.programs[path]; + let src = set.sources.get(path).map(|s| s.as_str()).unwrap_or(""); + let mut pr = Proj { w: W::new(), set, path, src, js_stubs: &mut js_stubs, used: IndexSet::new(), declared: HashSet::new(), prelude }; + pr.module(p); + let w = pr.w; + out.push(Projected { path: projected_path(path), code: w.buf.s, lines: w.lines, rx: path.clone(), mappings: w.buf.m, ranges: w.buf.r }); + } + for (rx, sig) in &set.signatures { + out.push(Projected { path: format!("{rx}.d.ts"), code: signature_stub(sig), lines: vec![], rx: rx.strip_suffix(".rx").map(|s| format!("{s}.d.rx")).unwrap_or(rx.clone()), mappings: vec![], ranges: vec![] }); + } + for (dts, decls) in &set.declarations { + let path = dts.strip_suffix(".d.rx").map(|s| format!("{s}.d.ts")).unwrap_or(dts.clone()); + out.push(Projected { path, code: declaration_file(decls), lines: vec![], rx: dts.clone(), mappings: vec![], ranges: vec![] }); + } + for (m, names) in js_stubs { + let code = names.iter().map(|n| if n == "default" { "declare const __d: any; export default __d;".to_string() } else { format!("export declare const {n}: any;") }).collect::>().join("\n"); + out.push(Projected { path: m.clone(), code, lines: vec![], rx: m, mappings: vec![], ranges: vec![] }); + } + out +} + +/// Mappings whose two sides are not the same text: none, when the projection keeps its promise (CP-8). +pub fn unverified(p: &Projected, src: &str) -> Vec { + p.mappings + .iter() + .filter(|m| { + let g = p.code.get(m.gen as usize..(m.gen + m.len) as usize); + let s = src.get(m.src as usize..(m.src + m.len) as usize); + g.is_none() || g != s + }) + .copied() + .collect() +} + +/// TY-4c: an `.rx` module read by its signature (MO-9) is typed by the `.d.ts` TypeScript emits from its projection (TY-1c); +/// none is shipped here, so its exports are `any`. +fn signature_stub(sig: &ModSig) -> String { + sig.exports + .iter() + .map(|(n, x)| match x { + ExportSig::Type => format!("export type {n} = any;"), + ExportSig::Comp { .. } => format!("export declare function {n}(props: any): any;"), + _ => format!("export declare const {n}: any;"), + }) + .collect::>() + .join("\n") +} +/// MO-8: a `.d.rx` as TypeScript's `.d.ts` for the same JS module. +fn declaration_file(decls: &[JsDecl]) -> String { + decls + .iter() + .map(|d| match d { + JsDecl::Type { text, .. } => format!("export {text}"), + JsDecl::Error { name, .. } => format!("export declare class {name}$ extends Error {{}}\nexport declare const {name}: (message?: string) => {name}$;\nexport type {name} = {name}$;"), + JsDecl::Fn { name, params, ptypes, ret, .. } => { + let ps = params.iter().enumerate().map(|(i, n)| match ptypes.get(i).and_then(|t| t.as_ref()) { + Some(t) => format!("{n}: {t}"), + None => n.clone(), + }); + format!("export declare function {name}({}): {};", ps.collect::>().join(", "), ret.as_deref().unwrap_or("any")) + } + }) + .collect::>() + .join("\n") + + "\n" +} + +/// `JSON.stringify(stmts).includes('"k":"await"')`: an `await` anywhere in them, closures included. +fn has_await(ss: &[Stmt]) -> bool { + stmts_json(ss).to_string().contains("\"k\":\"await\"") +} +/// The names an expression reads, in the order the reference's walk meets them; a `pre` reads the previous instant, so +/// nothing under it orders. +fn free_ids(e: &Expr) -> IndexSet { + fn go(v: &Value, out: &mut IndexSet) { + match v { + Value::Array(xs) => xs.iter().for_each(|x| go(x, out)), + Value::Object(o) => { + if o.get("k").and_then(|k| k.as_str()) == Some("id") { + if let Some(n) = o.get("name").and_then(|n| n.as_str()) { + out.insert(n.to_string()); + } + } + if o.get("k").and_then(|k| k.as_str()) == Some("pre") { + return; + } + for (k, x) in o { + if k != "k" { + go(x, out); + } + } + } + _ => {} + } + } + let mut out = IndexSet::new(); + go(&e.to_json(), &mut out); + out +} + +struct Proj<'a> { + w: W, + set: &'a ModuleSet, + path: &'a str, + src: &'a str, + js_stubs: &'a mut IndexMap>, + /// prelude names the module reads + used: IndexSet, + /// names the module declares (they shadow the prelude) + declared: HashSet, + prelude: Option<&'a str>, +} + +impl Proj<'_> { + // ---- where a name or a written type is, in the source ---- + /// The first whole-word `word` in `within`, at or after `from`. + fn find(&self, word: &str, within: Span, from: usize) -> Option { + if within.is_none() || word.is_empty() { + return None; + } + let (a, b) = ((within.start as usize).max(from), within.end as usize); + let hay = self.src.get(a..b)?; + let wordy = word.starts_with(is_word); + hay.match_indices(word).find_map(|(i, _)| { + let before = hay[..i].chars().next_back(); + let after = hay[i + word.len()..].chars().next(); + (!wordy || (!before.is_some_and(is_word) && !after.is_some_and(is_word))).then_some(a + i) + }) + } + /// `word` as found in `within` from `*from` on, mapped; `*from` moves past it. + fn name(&self, word: &str, within: Span, from: &mut usize) -> Code { + let at = self.find(word, within, *from); + if let Some(a) = at { + *from = a + word.len(); + } + Code::new().at(word, at) + } + /// A written type's text (row words dropped), found after `*from`. + fn ty(&self, t: &str, within: Span, from: &mut usize) -> Code { + self.name(t, within, from) + } + /// `: T`, or nothing. + fn ann(&self, t: &Option, within: Span, from: &mut usize) -> Code { + match type_text(t) { + Some(x) => Code::of(": ").c(self.ty(&x, within, from)), + None => Code::new(), + } + } + /// `n` or `n: T` for a parameter list of names and their written types (no row words to drop). + fn params(&self, params: &[String], ptypes: &[Option], within: Span, from: &mut usize) -> Code { + let ps = params + .iter() + .enumerate() + .map(|(i, n)| { + let c = self.name(n, within, from); + match ptypes.get(i).and_then(|t| t.as_ref()) { + Some(t) => c.t(": ").c(self.ty(t, within, from)), + None => c, + } + }) + .collect(); + Code::join(ps, ", ") + } + + fn module(&mut self, p: &Program) { + let saved = std::mem::replace(&mut self.w, W::new()); + self.decls(&p.decls, "", true); + if !p.view.is_empty() { + self.w.emit(Code::of("export const __view = ("), p.view[0].line.unwrap_or(1)); + self.markup(&p.view, " "); + self.w.emit(Code::of(");"), p.view[p.view.len() - 1].line.unwrap_or(1)); + } + let body = std::mem::replace(&mut self.w, saved); + let prelude: Vec = self.used.iter().filter(|n| !self.declared.contains(*n)).cloned().collect(); + let types: Vec<&str> = ["View", "Panic"].into_iter().filter(|n| !self.declared.contains(*n)).collect(); + let rel = self.prelude.map(|p| p.to_string()).unwrap_or_else(|| self.rel("__prelude.ts")); + let mut head = format!("import * as __ from \"{rel}\"; import type {{ {} }} from \"{rel}\";", types.join(", ")); + if !prelude.is_empty() { + head += &format!(" import {{ {} }} from \"{rel}\";", prelude.join(", ")); + } + self.w.emit(Code::of(&head), 0); + if !body.lines.is_empty() { + self.w.buf.s.push('\n'); + self.w.buf.push(body.buf); + self.w.lines.extend(body.lines); + } + } + /// A path relative to this module's directory, for the prelude (at the root of the program). + fn rel(&self, f: &str) -> String { + let depth = self.path.split('/').count() as i64 - 1 - (self.set.entry.split('/').count() as i64 - 1); + if depth > 0 { + "../".repeat(depth as usize) + f + } else { + format!("./{f}") + } + } + + // ---- declarations, in dependency order (functions are hoisted; values are not) ---- + fn decls(&mut self, ds: &[Decl], ind: &str, top: bool) { + let ex = |d: &Decl| if top && d.exported() { "export " } else { "" }; + for d in ds { + if let Some(n) = d.name() { + self.declared.insert(n.to_string()); + } + } + for d in ds { + let sp = d.span(); + let mut at = sp.start as usize; + match d { + Decl::Import { .. } => self.import_decl(d, ind), + Decl::TypeDecl { text, line, .. } => { + let c = Code::of(ind).t(ex(d)).c(self.name(text, sp, &mut at)); + self.w.emit(c, *line); + } + Decl::Error { name, params, ptypes, msg, line, .. } => { + let fields = params.iter().enumerate().map(|(i, n)| format!("declare {n}: {};", ptypes.get(i).and_then(|t| t.as_deref()).unwrap_or("any"))).collect::>().join(" "); + let nm = self.name(name, sp, &mut at); + let ps = self.params(params, ptypes, sp, &mut at); + let body = match msg { + Some(m) => Code::of("{ void (").c(self.expr(m, false, None)).t(&format!("); return new {name}$(); }}")), + None => Code::of(&format!("new {name}$()")), + }; + let c = Code::of(&format!("{ind}{}class {name}$ extends Error {{ {fields} }}\n{ind}{}const ", ex(d), ex(d))) + .c(nm) + .t(" = (") + .c(ps) + .t(&format!("): {name}$ => ")) + .c(body) + .t(&format!(";\n{ind}{}type {name} = {name}$;", ex(d))); + self.w.emit(c, *line); + } + Decl::Context { name, ty, dflt, line, .. } => { + let nm = self.name(name, sp, &mut at); + let t = match type_text(ty) { + Some(x) => self.ty(&x, sp, &mut at), + None => Code::of("any"), + }; + let dv = dflt.as_ref().map(|e| self.expr(e, false, None)).unwrap_or_default(); + let c = Code::of(ind).t(ex(d)).t("const ").c(nm).t(" = __.ctx<").c(t).t(">(").c(dv).t(");"); + self.w.emit(c, *line); + } + _ => {} + } + } + for d in ds { + let sp = d.span(); + let mut at = sp.start as usize; + match d { + Decl::Fn { name, params, decls, tail, line, ret, .. } => { + let nm = self.name(name, sp, &mut at); + let ps = Code::join( + params + .iter() + .map(|p| { + let n = self.name(&p.name, sp, &mut at); + n.c(self.ann(&p.ty, sp, &mut at)) + }) + .collect(), + ", ", + ); + let r = self.ann(ret, sp, &mut at); + let head = Code::of(ind).t(ex(d)).t("function ").c(nm).t("(").c(ps).t(")").c(r); + if decls.is_empty() { + let t = self.expr(tail, false, None); + self.w.emit(head.t(" { return ").c(t).t("; }"), *line); + continue; + } + self.w.emit(head.t(" {"), *line); + self.decls(decls, &format!("{ind} "), false); + let t = self.expr(tail, false, None); + self.w.emit(Code::of(&format!("{ind} return ")).c(t).t(&format!(";\n{ind}}}")), *line); + } + Decl::Component { name, props, decls, view, line, props_type, .. } => { + let nm = self.name(name, sp, &mut at); + let pn = self.name(props, sp, &mut at); + let pt = match props_type { + Some(t) => Code::of(": ").c(self.ty(t, sp, &mut at)), + None => Code::of(": any"), + }; + self.w.emit(Code::of(ind).t(ex(d)).t("function ").c(nm).t("(").c(pn).c(pt).t(") {"), *line); + self.decls(decls, &format!("{ind} "), false); + self.w.emit(Code::of(&format!("{ind} return (")), *line); + self.markup(view, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind} );\n{ind}}}")), *line); + } + Decl::Action { name, params, ptypes, body, line, .. } | Decl::Server { name, params, ptypes, body, line, .. } => { + let nm = self.name(name, sp, &mut at); + let ps = self.params(params, ptypes, sp, &mut at); + let r = match d { + Decl::Server { ret: Some(r), .. } => Code::of(": Promise<").c(self.ty(r, sp, &mut at)).t(">"), + _ => Code::new(), + }; + self.w.emit(Code::of(ind).t(ex(d)).t("async function ").c(nm).t("(").c(ps).t(")").c(r).t(" {"), *line); + self.stmts(body, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind}}}")), *line); + if matches!(d, Decl::Server { .. }) { + // SF-6a, statically where the types are known + self.w.emit(Code::of(&format!("{ind}__.wire({name}, true);")), *line); + } + } + _ => {} + } + } + for d in self.order(ds) { + let sp = d.span(); + let mut at = sp.start as usize; + match d { + Decl::Source { name, init, line, ty, .. } => { + let nm = self.name(name, sp, &mut at); + let a = self.ann(ty, sp, &mut at); + let v = match type_text(ty) { + Some(t) => Code::of(&format!("__.typed<{t}>(")).c(self.expr(init, false, None)).t(")"), + None => Code::of("__.src(").c(self.expr(init, false, None)).t(")"), + }; + self.w.emit(Code::of(ind).t(ex(d)).t("const ").c(nm).c(a).t(" = ").c(v).t(";").from(sp), *line); + } + Decl::Eq { name, e, line, ty, .. } => { + let nm = self.name(name, sp, &mut at); + let a = self.ann(ty, sp, &mut at); + let v = self.expr(e, false, Some(name)); + self.w.emit(Code::of(ind).t(ex(d)).t("const ").c(nm).c(a).t(" = ").c(v).t(";").from(sp), *line); + } + Decl::Provide { name, e, line, .. } => { + let nm = self.name(name, sp, &mut at); + let v = self.expr(e, false, None); + self.w.emit(Code::of(&format!("{ind}__.provide(")).c(nm).t(", ").c(v).t(");"), *line); + } + _ => {} + } + } + for d in ds { + if let Decl::Effect { inputs, body, line, .. } = d { + self.w.emit(Code::of(&format!("{ind}__.effect(() => {{")), *line); + for (i, e) in inputs.iter().enumerate() { + let v = self.expr(e, false, None); + self.w.emit(Code::of(&format!("{ind} const ${i} = ")).c(v).t(";"), *line); + } + self.stmts(body, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind}}});")), *line); + } + } + } + + fn import_decl(&mut self, d: &Decl, ind: &str) { + let Decl::Import { names, from, line, resolved, declared, .. } = d else { return }; + let sp = d.span(); + for n in names { + if builtin(&n.name) { + // the prelude's own (`import { log } from "./prelude"`) + self.declared.remove(&n.alias); + } + } + for n in names.iter().filter(|n| builtin(&n.name)) { + self.used.insert(n.name.clone()); + } + let rest: Vec<&ImportName> = names.iter().filter(|n| !builtin(&n.name)).collect(); + if rest.is_empty() { + return; + } + if resolved.is_none() && declared.is_none() { + // a JS module with no declaration: `any` + let key = self.mod_path(from) + ".ts"; + let s = self.js_stubs.entry(key).or_default(); + for n in &rest { + s.insert(n.name.clone()); + } + } + let mut at = sp.start as usize; + let def = rest.iter().find(|n| n.name == "default").map(|n| self.name(&n.alias, sp, &mut at)); + let parts: Vec = rest + .iter() + .filter(|n| n.name != "default") + .map(|n| { + if n.name == n.alias { + self.name(&n.name, sp, &mut at) + } else { + let a = self.name(&n.name, sp, &mut at); + a.t(" as ").c(self.name(&n.alias, sp, &mut at)) + } + }) + .collect(); + let mut c = Code::of(ind).t("import "); + let has_parts = !parts.is_empty(); + if let Some(def) = def { + c = c.c(def).t(if has_parts { ", " } else { "" }); + } + if has_parts { + c = c.t("{ ").c(Code::join(parts, ", ")).t(" }"); + } + let q = js_str(from); + let fa = self.find(&q[1..q.len() - 1], sp, at).map(|a| a - 1).filter(|a| self.src[*a..].starts_with(&q)); + c = c.t(" from ").at(&q, fa).t(";"); + self.w.emit(c, *line); + } + /// A JS import's module, from the program's root (where the stubs are written). + fn mod_path(&self, from: &str) -> String { + let mut parts: Vec<&str> = self.path.split('/').collect(); + parts.pop(); + for seg in from.split('/') { + if seg == "." || seg.is_empty() { + continue; + } + if seg == ".." { + parts.pop(); + } else { + parts.push(seg); + } + } + parts.join("/") + } + /// E-7: values in dependency order (C-1 makes it a DAG; a `pre` reads the previous instant, so it is no edge). + fn order<'d>(&self, ds: &'d [Decl]) -> Vec<&'d Decl> { + let vals: Vec<&Decl> = ds.iter().filter(|d| matches!(d, Decl::Source { .. } | Decl::Eq { .. } | Decl::Provide { .. })).collect(); + let mut by_name: IndexMap<&str, usize> = IndexMap::new(); + for (i, d) in vals.iter().enumerate() { + if !matches!(d, Decl::Provide { .. }) { + by_name.insert(d.name().unwrap(), i); + } + } + fn visit<'d>(i: usize, vals: &[&'d Decl], by_name: &IndexMap<&str, usize>, done: &mut Vec, busy: &mut Vec, out: &mut Vec<&'d Decl>) { + if done[i] || busy[i] { + return; + } + busy[i] = true; + let e = match vals[i] { + Decl::Eq { e, .. } | Decl::Provide { e, .. } => e, + Decl::Source { init, .. } => init, + _ => unreachable!(), + }; + for n in free_ids(e) { + if let Some(&t) = by_name.get(n.as_str()) { + if t != i { + visit(t, vals, by_name, done, busy, out); + } + } + } + busy[i] = false; + done[i] = true; + out.push(vals[i]); + } + let (mut done, mut busy, mut out) = (vec![false; vals.len()], vec![false; vals.len()], vec![]); + for i in 0..vals.len() { + visit(i, &vals, &by_name, &mut done, &mut busy, &mut out); + } + out + } + + // ---- imperative scope ---- + fn stmts(&mut self, ss: &[Stmt], ind: &str) { + for s in ss { + let sp = s.span; + let mut at = sp.start as usize; + match &s.k { + StmtKind::Let { name, e, ty } => { + let nm = self.name(name, sp, &mut at); + let a = self.ann(ty, sp, &mut at); + let v = self.expr(e, true, None); + self.w.emit(Code::of(&format!("{ind}let ")).c(nm).c(a).t(" = ").c(v).t(";").from(sp), 0); + } + StmtKind::Expr(e) => { + let v = self.expr(e, true, None); + self.w.emit(Code::of(ind).c(v).t(";").from(sp), 0); + } + StmtKind::Return(e) => { + let v = match e { + Some(e) => Code::of(" ").c(self.expr(e, true, None)), + None => Code::new(), + }; + self.w.emit(Code::of(&format!("{ind}return")).c(v).t(";").from(sp), 0); + } + StmtKind::Throw(e) => { + let v = self.expr(e, true, None); + self.w.emit(Code::of(&format!("{ind}throw ")).c(v).t(";").from(sp), 0); + } + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { + let t = self.target(target, sp, &mut at); + let v = self.expr(e, true, None); + self.w.emit(Code::of(&format!("{ind}__.set(")).c(t).t(")(").c(v).t(");").from(sp), 0); + } + StmtKind::Refresh(n) | StmtKind::Affects(n) => { + let nm = self.name(n, sp, &mut at); + self.w.emit(Code::of(&format!("{ind}void ")).c(nm).t(";"), 0); + } + StmtKind::Action(body) => { + self.w.emit(Code::of(&format!("{ind}void (async () => {{")), 0); + self.stmts(body, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind}}})();")), 0); + } + StmtKind::Sif { c, t, f } => { + let v = self.expr(c, true, None); + self.w.emit(Code::of(&format!("{ind}if (")).c(v).t(") {"), 0); + self.stmts(t, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind}}} else {{")), 0); + self.stmts(f, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind}}}")), 0); + } + StmtKind::Try { body, catch_name, catch_classes, handler, fin } => { + self.w.emit(Code::of(&format!("{ind}try {{")), 0); + self.stmts(body, &format!("{ind} ")); + if let Some(h) = handler { + let bind = match catch_name { + Some(n) => format!(" const {n} = __e as {};", catch_classes.as_ref().map(|c| c.join(" | ")).unwrap_or("Error".into())), + None => String::new(), + }; + self.w.emit(Code::of(&format!("{ind}}} catch (__e) {{{bind}")), 0); + self.stmts(h, &format!("{ind} ")); + } + if let Some(f) = fin { + self.w.emit(Code::of(&format!("{ind}}} finally {{")), 0); + self.stmts(f, &format!("{ind} ")); + } + self.w.emit(Code::of(&format!("{ind}}}")), 0); + } + } + } + } + fn target(&mut self, t: &Target, within: Span, at: &mut usize) -> Code { + let nm = self.name(&t.name, within, at); + let mut s = if t.is_ctx() { Code::of("__.use(").c(nm).t(")") } else { nm }; + for p in &t.path { + s = match p { + PathSeg::Name(n) => { + let m = self.name(n, within, at); + s.t(".").c(m) + } + PathSeg::Expr(e) => s.t("[").c(self.expr(e, true, None)).t("]"), + }; + } + s + } + + // ---- expressions: `imp` imperative scope (a real `await`); `me` the equation being defined (its own `pre`) ---- + fn expr(&mut self, e: &Expr, imp: bool, me: Option<&str>) -> Code { + self.expr_text(e, imp, me).from(e.span) + } + fn expr_text(&mut self, e: &Expr, imp: bool, me: Option<&str>) -> Code { + let sp = e.span; + let src_at = |text: &str| -> Option { (!sp.is_none() && sp.text(self.src) == text).then_some(sp.start as usize) }; + macro_rules! go { + ($x:expr) => { + self.expr($x, imp, me) + }; + } + match &e.k { + ExprKind::Lit(l) => { + let t = j(l); + let at = src_at(&t); + Code::new().at(&t, at) + } + ExprKind::Id(n) => { + if builtin(n) && !self.declared.contains(n) { + self.used.insert(n.clone()); + } + let at = src_at(n); + Code::new().at(n, at) + } + ExprKind::Pre { name, init } => { + let mut from = sp.start as usize; + if me == Some(name.as_str()) { + match init { + Some(i) => Code::of("__.pre(").c(go!(i)).t(")"), + None => Code::of("__.pre()"), + } + } else { + let nm = self.name(name, sp, &mut from); + let iv = init.as_ref().map(|i| go!(i)).unwrap_or_default(); + Code::of("__.pre(").c(iv).t(")") + } + } + ExprKind::Peek(x) | ExprKind::Latest(x) => go!(x), + ExprKind::Pending(x) => Code::of("__.pending(").c(go!(x)).t(")"), + ExprKind::Await { e: x, init, stream } => { + if imp { + return Code::of("(await ").c(go!(x)).t(")"); + } + let v = Code::of(if *stream { "__.awaitS(" } else { "__.await_(" }).c(go!(x)).t(")"); + match init { + Some(i) => Code::of("__.first(").c(v).t(", ").c(go!(i)).t(")"), + None => v, + } + } + ExprKind::If { c, t, f } => Code::of("(").c(go!(c)).t(" ? ").c(go!(t)).t(" : ").c(go!(f)).t(")"), + ExprKind::Throw(x) => Code::of("__.raise(").c(go!(x)).t(")"), + ExprKind::Catch { e: x, name, classes, alt } => { + let v = go!(x); + let a = go!(alt); + Code::of("__.caught(").c(v).t(&format!(", ({}: {}) => ", name.as_deref().unwrap_or("__e"), classes.as_ref().map(|c| c.join(" | ")).unwrap_or("Error".into()))).c(a).t(")") + } + ExprKind::Tpl(parts) => { + let mut c = Code::of("`"); + for p in parts { + match p { + TplPart::Str(s) => c = c.t(&escape_tpl(s)), + TplPart::Expr(x) => c = c.t("${").c(go!(x)).t("}"), + } + } + c.t("`") + } + ExprKind::Member { o, p, opt } => { + // `.p` with its dot, so that a position just after the dot (what is being typed) is mapped too + let dot = format!(".{p}"); + let at = (!sp.is_none() && sp.text(self.src).ends_with(dot.as_str())).then(|| sp.end as usize - dot.len()); + go!(o).t(if *opt { "?" } else { "" }).at(&dot, at) + } + ExprKind::Index { o, i, opt } => go!(o).t(if *opt { "?.[" } else { "[" }).c(go!(i)).t("]"), + ExprKind::StyleMap { css, .. } => Code::of(&class_map(css)), + ExprKind::Call { f, args } => { + let fc = go!(f); + let a = args.iter().map(|a| go!(a)).collect(); + fc.t("(").c(Code::join(a, ", ")).t(")") + } + ExprKind::Arr(items) => { + let a = items.iter().map(|a| go!(a)).collect(); + Code::of("[").c(Code::join(a, ", ")).t("]") + } + ExprKind::Rec(fields) => { + let fs = fields + .iter() + .map(|f| match f { + Field::Spread(x) => Code::of("...").c(go!(x)), + Field::Kv { key, v } => Code::of(&js_str(key)).t(": ").c(go!(v)), + }) + .collect(); + Code::of("({ ").c(Code::join(fs, ", ")).t(" })") + } + ExprKind::Spread(x) => Code::of("...").c(go!(x)), + ExprKind::Fn { params, body, stmts } => { + let mut from = sp.start as usize; + let ps = Code::join(params.iter().map(|p| self.name(p, sp, &mut from)).collect(), ", "); + let Some(ss) = stmts else { + return Code::of("((").c(ps).t(") => ").c(self.expr(body, imp, me)).t(")"); + }; + let inner = self.nested(|me| me.stmts(ss, " ")); + Code::of(if has_await(ss) { "(async (" } else { "((" }).c(ps).t(") => {\n").c(inner).t("\n})") + } + ExprKind::ServerFn { params, ptypes, ret, body } => { + let mut from = sp.start as usize; + let ps = self.params(params, ptypes, sp, &mut from); + let r = match ret { + Some(r) => Code::of(": Promise<").c(self.ty(r, sp, &mut from)).t(">"), + None => Code::new(), + }; + let inner = self.nested(|me| me.stmts(body, " ")); + // SF-1d: an async function, its wire checked (TY-5a) + Code::of("((__f) => (__.wire(__f, true), __f))(async (").c(ps).t(")").c(r).t(" => {\n").c(inner).t("\n})") + } + ExprKind::Draft { seed, body } => { + let s = go!(seed); + let inner = self.nested(|me| me.stmts(body, " ")); + Code::of("__.draft(").c(s).t(", (d) => {\n").c(inner).t("\n})") + } + ExprKind::Use(n) => { + let mut from = sp.start as usize + 3; + Code::of("__.use(").c(self.name(n, sp, &mut from)).t(")") + } + ExprKind::Bin { op, l, r } => { + let o = match op.as_str() { + "==" => "===", + "!=" => "!==", + o => o, + }; + Code::of("(").c(go!(l)).t(&format!(" {o} ")).c(go!(r)).t(")") + } + ExprKind::Un { op, e: x } => Code::of("(").t(op).c(go!(x)).t(")"), + } + } + /// What `f` writes, as one piece of text (a closure's body). + fn nested(&mut self, f: impl FnOnce(&mut Self)) -> Code { + let saved = std::mem::replace(&mut self.w, W::new()); + f(self); + std::mem::replace(&mut self.w, saved).buf + } + + // ---- views: JSX over Solid's JSX types ---- + fn markup(&mut self, ms: &[Markup], ind: &str) { + if ms.is_empty() { + self.w.emit(Code::of(&format!("{ind}null")), 0); + return; + } + self.w.emit(Code::of(&format!("{ind}<>")), ms[0].line.unwrap_or(0)); + for m in ms { + self.child(m, &format!("{ind} ")); + } + self.w.emit(Code::of(&format!("{ind}")), ms[ms.len() - 1].line.unwrap_or(0)); + } + fn region(&mut self, decls: &[Decl], view: &[Markup], ind: &str, line: usize) { + if decls.is_empty() { + self.markup(view, ind); + return; + } + self.w.emit(Code::of(&format!("{ind}(() => {{")), line); + self.decls(decls, &format!("{ind} "), false); + self.w.emit(Code::of(&format!("{ind} return (")), line); + self.markup(view, &format!("{ind} ")); + self.w.emit(Code::of(&format!("{ind} );\n{ind}}})()")), line); + } + fn child(&mut self, m: &Markup, ind: &str) { + let line = m.line.unwrap_or(0); + let sp = m.span; + let i2 = format!("{ind} "); + match &m.kind { + MarkupKind::Text(s) => self.w.emit(Code::of(&format!("{ind}{{{}}}", js_str(s))), line), + MarkupKind::Style(_) => {} + MarkupKind::Read(e) => { + let v = self.expr(e, false, None); + self.w.emit(Code::of(&format!("{ind}{{")).c(v).t("}").from(sp), line); + } + MarkupKind::El { tag, attrs, children } => { + if tag.is_empty() { + self.w.emit(Code::of(&format!("{ind}<>")), line); + for c in children { + self.child(c, &i2); + } + self.w.emit(Code::of(&format!("{ind}")), line); + return; + } + let tag_at = (!sp.is_none() && sp.text(self.src)[1..].starts_with(tag.as_str())).then_some(sp.start as usize + 1); + let mut a = Code::new(); + for at in attrs { + let name_at = (!at.span.is_none() && at.span.text(self.src) == at.name).then_some(at.span.start as usize); + a = a.t(" ").at(&at.name, name_at); + if !matches!(at.e.k, ExprKind::Lit(Lit::Bool(true))) { + a = a.t("={").c(self.expr(&at.e, is_handler(&at.name), None)).t("}"); + } + } + let open = Code::of(&format!("{ind}<")).at(tag, tag_at).c(a); + if children.is_empty() { + self.w.emit(open.t(" />"), line); + return; + } + self.w.emit(open.t(">"), line); + for c in children { + self.child(c, &i2); + } + let close_at = self.src.get(..sp.end as usize).filter(|s| !sp.is_none() && s.ends_with(&format!(""))).map(|_| sp.end as usize - tag.len() - 1); + self.w.emit(Code::of(&format!("{ind}"), line); + } + MarkupKind::Mif { c, t, t_decls, f, f_decls } => { + let v = self.expr(c, false, None); + self.w.emit(Code::of(&format!("{ind}{{")).c(v).t(" ? ("), line); + self.region(t_decls, t, &i2, line); + self.w.emit(Code::of(&format!("{ind}) : (")), line); + self.region(f_decls, f, &i2, line); + self.w.emit(Code::of(&format!("{ind})}}")), line); + } + MarkupKind::Mfor { name, xs, decls, body } => { + let mut from = sp.start as usize; + let nm = self.name(name, sp, &mut from); + let v = self.expr(xs, false, None); + self.w.emit(Code::of(&format!("{ind}{{__.each(")).c(v).t(", (").c(nm).t(") => ("), line); + self.region(decls, body, &i2, line); + self.w.emit(Code::of(&format!("{ind}))}}")), line); + } + MarkupKind::Loading { on, fallback, decls, body } => { + if let Some(on) = on { + let v = self.expr(on, false, None); + self.w.emit(Code::of(&format!("{ind}{{void ")).c(v).t("}"), line); + } + self.w.emit(Code::of(&format!("{ind}{{(")), line); + self.markup(fallback, &i2); + self.w.emit(Code::of(&format!("{ind})}}{{(")), line); + self.region(decls, body, &i2, line); + self.w.emit(Code::of(&format!("{ind})}}")), line); + } + MarkupKind::Portal { to, decls, body } => { + if let Some(to) = to { + let v = self.expr(to, false, None); + self.w.emit(Code::of(&format!("{ind}{{void ")).c(v).t("}"), line); + } + self.w.emit(Code::of(&format!("{ind}{{(")), line); + self.region(decls, body, &i2, line); + self.w.emit(Code::of(&format!("{ind})}}")), line); + } + MarkupKind::Errored { fallback, decls, body, err_name, classes } => { + let head = format!("{ind}{{(({}: {}) => (", err_name.as_deref().unwrap_or("__err"), classes.as_ref().map(|c| c.join(" | ")).unwrap_or("Error".into())); + self.w.emit(Code::of(&head), line); + self.markup(fallback, &i2); + self.w.emit(Code::of(&format!("{ind}))(null!)}}{{(")), line); + self.region(decls, body, &i2, line); + self.w.emit(Code::of(&format!("{ind})}}")), line); + } + } + } +} + +/// A template literal's text: `` ` ``, `\` and `${` escaped. +fn escape_tpl(s: &str) -> String { + let mut out = String::new(); + let mut it = s.chars().peekable(); + while let Some(c) = it.next() { + match c { + '`' | '\\' => { + out.push('\\'); + out.push(c); + } + '$' if it.peek() == Some(&'{') => { + out.push_str("\\${"); + it.next(); + } + _ => out.push(c), + } + } + out +} + +/// 07 CSS-5: TSRX's class map, as its typecheck projection writes it — each class a `string`. +fn class_map(css: &str) -> String { + use solidjs_compiler::style::*; + let r = compile_style(StyleInput { css, location: StyleLocation { filename: "class-map.tsrx", line: 1, column: 19 }, elements: &[], kind: StyleKind::Expression, minify: false }); + let Ok(r) = r else { return "({} as const)".into() }; + if r.class_map.is_empty() { + return "({} as const)".into(); + } + let fields: Vec = r.class_map.into_iter().map(|c| format!("{}: \"\" as string", js_str(&c.class_name))).collect(); + format!("({{ {} }} as const)", fields.join(", ")) +} diff --git a/compiler/src/serve.rs b/compiler/src/serve.rs new file mode 100644 index 0000000..2025e27 --- /dev/null +++ b/compiler/src/serve.rs @@ -0,0 +1,88 @@ +//! `rxc serve`'s requests (spec/11 CP-8e), for a language server: the native binary answers them a line at a time, the +//! WebAssembly build (`wasm.rs`) a call at a time, the same way. `disk` reads a file the editor does not have open. +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// One request: `analyze` (`{file, overlays, prelude}`) or `prelude`. +pub fn request(method: &str, params: &Value, disk: &dyn Fn(&str) -> Option) -> Value { + match method { + "analyze" => analyze(params, disk), + "prelude" => json!(crate::project::PRELUDE), + _ => json!({ "error": "unknown method" }), + } +} + +/// `rxc check --json`: a diagnostic with its range (CP-8), for an editor or an agent. Lines are 1-based, columns 0-based in +/// UTF-16 code units, as LSP counts; `start`/`end` are byte offsets; `rule` names the spec rule (spec/99). +pub fn diagnostic_json(set: &crate::modules::ModuleSet, d: &crate::check::Diagnostic) -> Value { + let file = d.file.clone().unwrap_or_else(|| set.entry.clone()); + let mut v = json!({ "file": file, "line": d.line, "code": d.code, "rule": d.rule, "message": d.msg }); + if let (Some(src), Some(p)) = (set.sources.get(&file), set.programs.get(&file)) { + if let Some(r) = crate::spans::line_range(src, p, d.line.max(1) as usize) { + let ((l0, c0), (l1, c1)) = (crate::spans::line_col(src, r.start), crate::spans::line_col(src, r.end)); + v["range"] = json!({ "start": { "line": l0, "column": c0 }, "end": { "line": l1, "column": c1 }, "offsets": [r.start, r.end] }); + } + } + v +} + +pub fn parse_error_in(file: &str, e: &crate::parse::ParseError, src: Option<&str>) -> Value { + let mut v = json!({ "file": file, "code": "PARSE", "rule": "07", "message": e.0 }); + if let (Some(at), Some(src)) = (e.1, src) { + let at = at.min(src.len()); + let end = src[at..].find('\n').map(|j| at + j).unwrap_or(src.len()); + let end = at + src[at..end].trim_end().len(); + let ((l0, c0), (l1, c1)) = (crate::spans::line_col(src, at as u32), crate::spans::line_col(src, end as u32)); + v["line"] = json!(l0); + v["range"] = json!({ "start": { "line": l0, "column": c0 }, "end": { "line": l1, "column": c1 }, "offsets": [at, end] }); + } + v +} + +/// UTF-16 offsets of a text's byte offsets (what JavaScript strings, Volar and LSP index by). +pub fn utf16_index(s: &str) -> Vec { + let mut idx = vec![0u32; s.len() + 1]; + let mut u = 0u32; + for (b, ch) in s.char_indices() { + for k in 0..ch.len_utf8() { + idx[b + k] = u; + } + u += ch.len_utf16() as u32; + } + idx[s.len()] = u; + idx +} + +/// `rxc serve`, one request: `{file, overlays: {path: text}, prelude}` — the file loaded (and its imports) as an editor +/// has them, recovering (CP-8e); its projection with mappings in UTF-16 offsets, and its diagnostics: the parse's, else +/// the checker's. +pub fn analyze(params: &Value, disk: &dyn Fn(&str) -> Option) -> Value { + let file = params["file"].as_str().unwrap_or_default().to_string(); + let overlays: HashMap = params["overlays"].as_object().map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()).unwrap_or_default(); + let read = |p: &str| overlays.get(p).cloned().or_else(|| disk(p)); + let set = match crate::modules::load_modules_recovering(&file, &read) { + Ok(s) => s, + Err(e) => { + let src = e.2.as_deref().and_then(|f| read(f)); + return json!({ "diagnostics": [parse_error_in(e.2.as_deref().unwrap_or(&file), &e, src.as_deref())] }); + } + }; + let src = set.sources.get(&file).cloned().unwrap_or_default(); + let prelude = params["prelude"].as_str(); + let mut out = json!({ "code": null, "mappings": [] }); + if let Some(p) = crate::project::project_modules_with(&set, prelude).into_iter().find(|p| p.rx == file) { + let (gi, si) = (utf16_index(&p.code), utf16_index(&src)); + let maps: Vec<[u32; 3]> = p.mappings.iter().map(|m| [gi[m.gen as usize], si[m.src as usize], si[(m.src + m.len) as usize] - si[m.src as usize]]).collect(); + let u = |i: &[u32], a: u32, n: u32| [i[a as usize], i[(a + n) as usize] - i[a as usize]]; + let ranges: Vec<[u32; 4]> = p.ranges.iter().map(|r| { let (g, s) = (u(&gi, r.gen, r.glen), u(&si, r.src, r.len)); [g[0], g[1], s[0], s[1]] }).collect(); + out = json!({ "code": p.code, "mappings": maps, "ranges": ranges }); + } + let own: Vec = if set.parse_errors.iter().any(|e| e.2.as_deref() == Some(file.as_str())) { + set.parse_errors.iter().filter(|e| e.2.as_deref() == Some(file.as_str())).map(|e| parse_error_in(&file, e, Some(&src))).collect() + } else { + crate::check::check_modules(&set).diags.iter().map(|d| diagnostic_json(&set, d)).filter(|d| d["file"] == json!(file)).collect() + }; + out["diagnostics"] = json!(own); + out +} + diff --git a/compiler/src/spans.rs b/compiler/src/spans.rs new file mode 100644 index 0000000..6c6db4f --- /dev/null +++ b/compiler/src/spans.rs @@ -0,0 +1,434 @@ +//! What the parser's spans promise (spec/11 CP-8), checked on a parsed program: each span is inside the source and inside +//! its parent's, a declaration's and a markup item's span starts on the line the node records, and a leaf's span is the +//! text it was parsed from (a name is its name, a member ends with its property). `ref/crate.ts` runs this on every +//! program; an editor's positions are only as good as these. + +use crate::ast::*; + +struct Spans<'a> { + src: &'a str, + out: Vec, +} + +impl Spans<'_> { + fn bad(&mut self, at: Span, what: String) { + self.out.push(format!("{}..{} (line {}): {}", at.start, at.end, at.line(self.src), what)); + } + /// `at` is a real span, inside the source and inside `parent`. + fn inside(&mut self, at: Span, parent: Span, what: &str) -> bool { + if at.is_none() { + return false; + } + let (s, e) = (at.start as usize, at.end as usize); + if s > e || e > self.src.len() || !self.src.is_char_boundary(s) || !self.src.is_char_boundary(e) { + self.bad(at, format!("{what}: not a range of the source")); + return false; + } + if !parent.is_none() && (at.start < parent.start || at.end > parent.end) { + self.bad(at, format!("{what}: outside its parent {}..{}", parent.start, parent.end)); + } + true + } + fn word_in(&mut self, at: Span, name: &str, what: &str) { + let t = at.text(self.src); + let found = t.match_indices(name).any(|(i, _)| { + let before = t[..i].chars().next_back(); + let after = t[i + name.len()..].chars().next(); + !before.is_some_and(is_word) && !after.is_some_and(is_word) + }); + if !found { + self.bad(at, format!("{what} `{name}` is not in its text")); + } + } + + fn decls(&mut self, ds: &[Decl], parent: Span) { + for d in ds { + self.decl(d, parent); + } + } + fn decl(&mut self, d: &Decl, parent: Span) { + let at = d.span(); + if !self.inside(at, parent, "declaration") { + self.bad(at, format!("a {} declaration has no span", d.to_json()["k"])); + return; + } + if at.line(self.src) != d.line() { + self.bad(at, format!("declaration on line {}, its span starts on line {}", d.line(), at.line(self.src))); + } + if let Some(n) = d.name() { + self.word_in(at, n, "the declared name"); + } + match d { + Decl::Source { init: e, .. } | Decl::Eq { e, .. } | Decl::Provide { e, .. } => self.expr(e, at), + Decl::Error { msg, .. } => msg.iter().for_each(|e| self.expr(e, at)), + Decl::Context { dflt, .. } => dflt.iter().for_each(|e| self.expr(e, at)), + Decl::Effect { inputs, body, .. } => { + inputs.iter().for_each(|e| self.expr(e, at)); + self.stmts(body, at); + } + Decl::Fn { decls, tail, .. } => { + self.decls(decls, at); + self.expr(tail, at); + } + Decl::Component { decls, view, .. } => { + self.decls(decls, at); + self.markups(view, at); + } + Decl::Action { body, .. } | Decl::Server { body, .. } => self.stmts(body, at), + Decl::Import { .. } | Decl::TypeDecl { .. } => {} + } + } + + fn stmts(&mut self, ss: &[Stmt], parent: Span) { + for s in ss { + self.stmt(s, parent); + } + } + fn stmt(&mut self, s: &Stmt, parent: Span) { + let at = s.span; + if !self.inside(at, parent, "statement") { + self.bad(parent, format!("a {} statement has no span", s.kind())); + return; + } + match &s.k { + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { + self.word_in(at, &target.name, "the written name"); + for p in &target.path { + if let PathSeg::Expr(x) = p { + self.expr(x, at); + } + } + self.expr(e, at); + } + StmtKind::Let { name, e, .. } => { + self.word_in(at, name, "the bound name"); + self.expr(e, at); + } + StmtKind::Expr(e) | StmtKind::Throw(e) => self.expr(e, at), + StmtKind::Return(e) => e.iter().for_each(|e| self.expr(e, at)), + StmtKind::Refresh(n) | StmtKind::Affects(n) => self.word_in(at, n, "the name"), + StmtKind::Action(body) => self.stmts(body, at), + StmtKind::Sif { c, t, f } => { + self.expr(c, at); + self.stmts(t, at); + self.stmts(f, at); + } + StmtKind::Try { body, handler, fin, .. } => { + self.stmts(body, at); + handler.iter().for_each(|h| self.stmts(h, at)); + fin.iter().for_each(|h| self.stmts(h, at)); + } + } + } + + fn expr(&mut self, e: &Expr, parent: Span) { + let at = e.span; + if !self.inside(at, parent, e.kind()) { + // the one expression the parser makes: a statement-bodied arrow's `body` + if !matches!(e.k, ExprKind::Lit(Lit::Undefined)) { + self.bad(parent, format!("a {} expression has no span", e.kind())); + } + return; + } + let text = at.text(self.src); + match &e.k { + ExprKind::Id(n) if text != n => self.bad(at, format!("the name `{n}` spans `{text}`")), + ExprKind::Member { p, .. } if !text.ends_with(p.as_str()) => self.bad(at, format!("the member `.{p}` spans `{text}`")), + ExprKind::Use(n) | ExprKind::Pre { name: n, .. } => self.word_in(at, n, "the name"), + ExprKind::Lit(Lit::Str(_)) if !(text.starts_with('"') || text.starts_with('\'')) => self.bad(at, format!("a string spans `{text}`")), + ExprKind::Lit(Lit::Num(_)) if !text.starts_with(|c: char| c.is_ascii_digit()) => self.bad(at, format!("a number spans `{text}`")), + _ => {} + } + let mut sub = |x: &Expr| self.expr(x, at); + match &e.k { + ExprKind::Lit(_) | ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::StyleMap { .. } => {} + ExprKind::Bin { l, r, .. } => { + sub(l); + sub(r); + } + ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => sub(e), + ExprKind::Pre { init, .. } => init.iter().for_each(|x| sub(x)), + ExprKind::Await { e, init, .. } => { + sub(e); + init.iter().for_each(|x| sub(x)); + } + ExprKind::If { c, t, f } => { + sub(c); + sub(t); + sub(f); + } + ExprKind::Tpl(parts) => parts.iter().for_each(|p| { + if let TplPart::Expr(x) = p { + sub(x) + } + }), + ExprKind::Member { o, .. } => sub(o), + ExprKind::Index { o, i, .. } => { + sub(o); + sub(i); + } + ExprKind::Call { f, args } => { + sub(f); + args.iter().for_each(&mut sub); + } + ExprKind::Arr(items) => items.iter().for_each(&mut sub), + ExprKind::Rec(fields) => fields.iter().for_each(|f| match f { + Field::Kv { v, .. } => sub(v), + Field::Spread(x) => sub(x), + }), + ExprKind::Catch { e, alt, .. } => { + sub(e); + sub(alt); + } + ExprKind::Fn { body, stmts, .. } => { + sub(body); + if let Some(ss) = stmts { + self.stmts(ss, at); + } + } + ExprKind::Draft { seed, body } => { + sub(seed); + self.stmts(body, at); + } + ExprKind::ServerFn { body, .. } => self.stmts(body, at), + } + } + + fn markups(&mut self, ms: &[Markup], parent: Span) { + for m in ms { + self.markup(m, parent); + } + } + fn markup(&mut self, m: &Markup, parent: Span) { + let at = m.span; + if !self.inside(at, parent, "markup") { + // made by the parser: the space between two texts, and a module's view of its default component (MO-1c) + if m.line.is_some() && !matches!(m.kind, MarkupKind::El { .. }) { + self.bad(parent, "a markup item has no span".into()); + } + return; + } + if let Some(l) = m.line { + if at.line(self.src) != l { + self.bad(at, format!("markup on line {l}, its span starts on line {}", at.line(self.src))); + } + } + match &m.kind { + MarkupKind::El { tag, attrs, children } => { + if !tag.is_empty() && !at.text(self.src)[1..].starts_with(tag.as_str()) { + self.bad(at, format!("the element <{tag}> spans `{}`", at.text(self.src))); + } + for a in attrs { + if self.inside(a.span, at, "attribute") && a.span.text(self.src) != a.name { + self.bad(a.span, format!("the attribute `{}` spans `{}`", a.name, a.span.text(self.src))); + } + self.expr(&a.e, at); + } + self.markups(children, at); + } + MarkupKind::Read(e) => self.expr(e, at), + MarkupKind::Text(t) if at.text(self.src) != t => self.bad(at, format!("the text {t:?} spans {:?}", at.text(self.src))), + MarkupKind::Text(_) | MarkupKind::Style(_) => {} + MarkupKind::Mif { c, t, t_decls, f, f_decls } => { + self.expr(c, at); + self.decls(t_decls, at); + self.markups(t, at); + self.decls(f_decls, at); + self.markups(f, at); + } + MarkupKind::Mfor { name, xs, decls, body } => { + self.word_in(at, name, "the bound name"); + self.expr(xs, at); + self.decls(decls, at); + self.markups(body, at); + } + MarkupKind::Loading { on, fallback, decls, body } => { + on.iter().for_each(|e| self.expr(e, at)); + self.markups(fallback, at); + self.decls(decls, at); + self.markups(body, at); + } + MarkupKind::Errored { fallback, decls, body, .. } => { + self.markups(fallback, at); + self.decls(decls, at); + self.markups(body, at); + } + MarkupKind::Portal { to, decls, body } => { + to.iter().for_each(|e| self.expr(e, at)); + self.decls(decls, at); + self.markups(body, at); + } + } + } +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} + +/// Every way `p`'s spans break their promise, against `src`, the text it was parsed from; empty when they keep it. +pub fn check_spans(src: &str, p: &Program) -> Vec { + let mut s = Spans { src, out: vec![] }; + s.decls(&p.decls, Span::NONE); + s.markups(&p.view, Span::NONE); + s.out +} + +/// The range a diagnostic on `line` covers (the checker is line-precise, CN-1a): from where the first node that starts on +/// that line starts, to the furthest any such node reaches on it; the line's text when no node starts there. +pub fn line_range(src: &str, p: &Program, line: usize) -> Option { + let (mut ls, mut n) = (0usize, 1usize); + while n < line { + ls = ls + src[ls..].find('\n')? + 1; + n += 1; + } + let le = src[ls..].find('\n').map(|j| ls + j).unwrap_or(src.len()); + let mut r: Option<(u32, u32)> = None; + let mut take = |at: Span| { + if !at.is_none() && (at.start as usize) >= ls && (at.start as usize) <= le { + let end = at.end.min(le as u32); + r = Some(r.map_or((at.start, end), |(a, b)| (a.min(at.start), b.max(end)))); + } + }; + walk(p, &mut take); + if let Some((a, b)) = r { + let t = src[a as usize..b as usize].trim_end(); + return Some(Span::new(a as usize, a as usize + t.len())); + } + let text = &src[ls..le]; + let t = text.trim_start(); + let a = ls + (text.len() - t.len()); + Some(Span::new(a, a + t.trim_end().len())) +} + +/// Every span `p` holds: declarations, statements, expressions, markup and attributes. +pub fn walk(p: &Program, f: &mut dyn FnMut(Span)) { + fn decl(d: &Decl, f: &mut dyn FnMut(Span)) { + f(d.span()); + match d { + Decl::Source { init: e, .. } | Decl::Eq { e, .. } | Decl::Provide { e, .. } => expr(e, f), + Decl::Error { msg, .. } => msg.iter().for_each(|e| expr(e, f)), + Decl::Context { dflt, .. } => dflt.iter().for_each(|e| expr(e, f)), + Decl::Effect { inputs, body, .. } => { + inputs.iter().for_each(|e| expr(e, f)); + body.iter().for_each(|s| stmt(s, f)); + } + Decl::Fn { decls, tail, .. } => { + decls.iter().for_each(|d| decl(d, f)); + expr(tail, f); + } + Decl::Component { decls, view, .. } => { + decls.iter().for_each(|d| decl(d, f)); + view.iter().for_each(|m| markup(m, f)); + } + Decl::Action { body, .. } | Decl::Server { body, .. } => body.iter().for_each(|s| stmt(s, f)), + Decl::Import { .. } | Decl::TypeDecl { .. } => {} + } + } + fn stmt(s: &Stmt, f: &mut dyn FnMut(Span)) { + f(s.span); + match &s.k { + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { + target.path.iter().for_each(|p| if let PathSeg::Expr(x) = p { expr(x, f) }); + expr(e, f); + } + StmtKind::Let { e, .. } | StmtKind::Expr(e) | StmtKind::Throw(e) => expr(e, f), + StmtKind::Return(e) => e.iter().for_each(|e| expr(e, f)), + StmtKind::Refresh(_) | StmtKind::Affects(_) => {} + StmtKind::Action(b) => b.iter().for_each(|s| stmt(s, f)), + StmtKind::Sif { c, t, f: e } => { + expr(c, f); + t.iter().chain(e).for_each(|s| stmt(s, f)); + } + StmtKind::Try { body, handler, fin, .. } => body.iter().chain(handler.iter().flatten()).chain(fin.iter().flatten()).for_each(|s| stmt(s, f)), + } + } + fn expr(e: &Expr, f: &mut dyn FnMut(Span)) { + f(e.span); + let mut sub: Vec<&Expr> = vec![]; + match &e.k { + ExprKind::Lit(_) | ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::StyleMap { .. } => {} + ExprKind::Bin { l, r, .. } => sub.extend([&**l, &**r]), + ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => sub.push(e), + ExprKind::Pre { init, .. } => sub.extend(init.iter().map(|x| &**x)), + ExprKind::Await { e, init, .. } => sub.extend(std::iter::once(&**e).chain(init.iter().map(|x| &**x))), + ExprKind::If { c, t, f } => sub.extend([&**c, &**t, &**f]), + ExprKind::Tpl(parts) => sub.extend(parts.iter().filter_map(|p| if let TplPart::Expr(x) = p { Some(x) } else { None })), + ExprKind::Member { o, .. } => sub.push(o), + ExprKind::Index { o, i, .. } => sub.extend([&**o, &**i]), + ExprKind::Call { f, args } => sub.extend(std::iter::once(&**f).chain(args.iter())), + ExprKind::Arr(items) => sub.extend(items.iter()), + ExprKind::Rec(fields) => sub.extend(fields.iter().map(|x| match x { + Field::Kv { v, .. } => v, + Field::Spread(x) => x, + })), + ExprKind::Catch { e, alt, .. } => sub.extend([&**e, &**alt]), + ExprKind::Fn { body, stmts, .. } => { + sub.push(body); + stmts.iter().flatten().for_each(|s| stmt(s, f)); + } + ExprKind::Draft { seed, body } => { + sub.push(seed); + body.iter().for_each(|s| stmt(s, f)); + } + ExprKind::ServerFn { body, .. } => body.iter().for_each(|s| stmt(s, f)), + } + sub.into_iter().for_each(|x| expr(x, f)); + } + fn markup(m: &Markup, f: &mut dyn FnMut(Span)) { + f(m.span); + let (ds, ms, es): (Vec<&Decl>, Vec<&Markup>, Vec<&Expr>) = match &m.kind { + MarkupKind::El { attrs, children, .. } => { + attrs.iter().for_each(|a| f(a.span)); + (vec![], children.iter().collect(), attrs.iter().map(|a| &a.e).collect()) + } + MarkupKind::Read(e) => (vec![], vec![], vec![e]), + MarkupKind::Text(_) | MarkupKind::Style(_) => (vec![], vec![], vec![]), + MarkupKind::Mif { c, t, t_decls, f: fm, f_decls } => (t_decls.iter().chain(f_decls).collect(), t.iter().chain(fm).collect(), vec![c]), + MarkupKind::Mfor { xs, decls, body, .. } => (decls.iter().collect(), body.iter().collect(), vec![xs]), + MarkupKind::Loading { on, fallback, decls, body } => (decls.iter().collect(), fallback.iter().chain(body).collect(), on.iter().collect()), + MarkupKind::Errored { fallback, decls, body, .. } => (decls.iter().collect(), fallback.iter().chain(body).collect(), vec![]), + MarkupKind::Portal { to, decls, body } => (decls.iter().collect(), body.iter().collect(), to.iter().collect()), + }; + es.into_iter().for_each(|e| expr(e, f)); + ds.into_iter().for_each(|d| decl(d, f)); + ms.into_iter().for_each(|m| markup(m, f)); + } + p.decls.iter().for_each(|d| decl(d, f)); + p.view.iter().for_each(|m| markup(m, f)); +} + +/// A byte offset as a 1-based line and 0-based column in UTF-16 code units (what LSP and TypeScript count). +pub fn line_col(src: &str, at: u32) -> (usize, usize) { + let at = (at as usize).min(src.len()); + let ls = src[..at].rfind('\n').map(|i| i + 1).unwrap_or(0); + (src[..ls].matches('\n').count() + 1, src[ls..at].encode_utf16().count()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::parse_program; + + #[test] + fn spans_hold_and_a_broken_one_is_caught() { + let src = "source count := 0\ndoubled = count * 2\naction inc() {\n count := count + 1\n}\nexport default component App() {\n \n}\n"; + let mut p = parse_program(src).unwrap(); + assert_eq!(check_spans(src, &p), Vec::::new()); + let Decl::Eq { e, span, .. } = &p.decls[1] else { panic!() }; + assert_eq!(span.text(src), "doubled = count * 2"); + assert_eq!(e.span.text(src), "count * 2"); + let Decl::Action { body, .. } = &p.decls[2] else { panic!() }; + assert_eq!(body[0].span.text(src), "count := count + 1"); + if let Decl::Eq { e, .. } = &mut p.decls[1] { + e.span = Span::new(e.span.start as usize + 1, e.span.end as usize); + if let ExprKind::Bin { l, .. } = &mut e.k { + l.span = Span::new(l.span.start as usize, l.span.end as usize + 1); + } + } + assert_eq!(check_spans(src, &p).len(), 2); + let r = line_range(src, &p, 4).unwrap(); + assert_eq!(r.text(src), "count := count + 1"); + assert_eq!(line_col(src, r.start), (4, 2)); + } +} diff --git a/compiler/src/wasm.rs b/compiler/src/wasm.rs new file mode 100644 index 0000000..4d4c333 --- /dev/null +++ b/compiler/src/wasm.rs @@ -0,0 +1,54 @@ +//! The WebAssembly build (spec/11 CP-8e): `serve.rs`'s requests as calls, for a language server that runs the crate in +//! process (`tooling/rx-language-server/src/rxc.js`), one build for every platform. A plain C ABI over linear memory, +//! so that no binding generator is needed: +//! rx_alloc(n) -> ptr / rx_free(ptr, n) memory the host writes a request into, or frees an answer with +//! rx_call(ptr, len) -> ptr a request, `{method, params}` as JSON; the answer is `[len: u32 LE][JSON]` +//! and the host provides `env.rx_read(ptr, len) -> ptr`, a file's text as `[len: u32 LE][UTF-8]` in memory it got from +//! `rx_alloc`, or 0 when there is no such file. +use serde_json::Value; + +#[link(wasm_import_module = "env")] +extern "C" { + fn rx_read(ptr: *const u8, len: usize) -> *mut u8; +} + +#[no_mangle] +pub extern "C" fn rx_alloc(n: usize) -> *mut u8 { + let mut v = Vec::::with_capacity(n.max(1)); + let p = v.as_mut_ptr(); + std::mem::forget(v); + p +} + +/// # Safety +/// `ptr` and `n` are what `rx_alloc(n)` returned and was given. +#[no_mangle] +pub unsafe extern "C" fn rx_free(ptr: *mut u8, n: usize) { + drop(Vec::from_raw_parts(ptr, 0, n.max(1))); +} + +/// Takes a `[len][bytes]` block the host made with `rx_alloc`. +unsafe fn take(p: *mut u8) -> String { + let n = u32::from_le_bytes(*(p as *const [u8; 4])) as usize; + let s = String::from_utf8_lossy(std::slice::from_raw_parts(p.add(4), n)).into_owned(); + rx_free(p, n + 4); + s +} + +fn read(path: &str) -> Option { + let p = unsafe { rx_read(path.as_ptr(), path.len()) }; + (!p.is_null()).then(|| unsafe { take(p) }) +} + +/// # Safety +/// `ptr`/`len` is a request the host wrote into memory from `rx_alloc(len)`, which this frees. +#[no_mangle] +pub unsafe extern "C" fn rx_call(ptr: *mut u8, len: usize) -> *mut u8 { + let req: Value = serde_json::from_slice(std::slice::from_raw_parts(ptr, len)).unwrap_or(Value::Null); + rx_free(ptr, len); + let out = crate::serve::request(req["method"].as_str().unwrap_or_default(), &req["params"], &read).to_string(); + let p = rx_alloc(out.len() + 4); + std::ptr::copy_nonoverlapping((out.len() as u32).to_le_bytes().as_ptr(), p, 4); + std::ptr::copy_nonoverlapping(out.as_ptr(), p.add(4), out.len()); + p +} diff --git a/ref/README.md b/ref/README.md index 1acce90..3028891 100644 --- a/ref/README.md +++ b/ref/README.md @@ -47,7 +47,12 @@ built by both, and the two must agree exactly. Needs cargo; not Solid. With `-- --lower`, both also lower every accepted program through Solid's compiler for each target and must write the same JavaScript (needs cargo and Solid at SOLID_NEXT); with `RX_CRATE=1`, `npm run compiled` and `npm run ssr` run the -scenarios on the crate's lowering (`compile/lowered.ts`). +scenarios on the crate's lowering (`compile/lowered.ts`). The plain run also +checks the crate's spans (CP-8b). With `-- --project`, both project every +accepted program to TSX (`types/project.ts`) and must write the same files, +and each of the crate's mappings onto `.rx` must join the same text (CP-8c); +`RX_CRATE=1 npm run types` checks the types scenarios on the crate's +projection. Phases: 1. core: sources, equations, `pre`, `peek`, effects, instants (02-core §4) diff --git a/ref/crate.ts b/ref/crate.ts index 7fb68bb..d95e8fc 100644 --- a/ref/crate.ts +++ b/ref/crate.ts @@ -4,7 +4,10 @@ // transform(), the crate's emit.rs then compile_program() — for the universal, dom and ssr targets, with a scenario table's // handler rows as compile/run.ts passes them; the modules must agree (comments dropped, both printed by esbuild), and the // crate's source maps onto .rx must hold (checkMap, CP-1c). -// Usage: node --experimental-strip-types ref/crate.ts [--lower] [filter] (builds compiler/ with cargo first; VERBOSE=1 lists each; +// With --project (TY-1c, CP-8): every program the checker accepts is projected to TSX by both — types/project.ts and the +// crate's project.rs — and the files must be the same text, line for line; each of the crate's mappings must join the same +// text on both sides. +// Usage: node --experimental-strip-types ref/crate.ts [--lower|--project] [filter] (builds compiler/ with cargo first; VERBOSE=1 lists each; // DUMP=dir writes the first differing module of a failure, both sides, to dir/{ref,crate}..js) import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; import { join } from "node:path"; @@ -17,6 +20,7 @@ import { printSignature } from "./src/signature.ts"; const root = fileURLToPath(new URL("..", import.meta.url)); const lowerMode = process.argv.includes("--lower"); +const projectMode = process.argv.includes("--project"); const filter = process.argv.slice(2).find(a => !a.startsWith("--")); const verbose = !!process.env.VERBOSE; type Req = { id: string; entry: string; files: Record; extra?: string[] }; @@ -78,15 +82,16 @@ function diff(a: any, b: any, at = ""): string | null { execFileSync("cargo", ["build", "--release", "--quiet"], { cwd: join(root, "compiler"), stdio: "inherit" }); if (lowerMode) process.exit(await lower()); +if (projectMode) process.exit(await project()); const out = execFileSync(join(root, "compiler/target/release/rxc"), ["conform"], { input: JSON.stringify(reqs), maxBuffer: 1 << 30 }).toString(); const got = new Map((JSON.parse(out) as any[]).map(x => [x.id, x])); let bad = 0; for (const r of reqs) { - const want = refOf(r); const { id: _, ...have } = got.get(r.id) ?? {}; - const d = diff(want, have); + const want = refOf(r); const { id: _, spans, ...have } = got.get(r.id) ?? {}; + const d = diff(want, have) ?? (spans && Object.keys(spans).length ? `spans (CP-8): ${JSON.stringify(spans).slice(0, 400)}` : null); if (d) { bad++; console.log(`FAIL ${r.id}\n ${d}`); } else if (verbose) console.log(`ok ${r.id}`); } -console.log(`${reqs.length - bad}/${reqs.length} programs: the crate agrees with ref/ (AST, diagnostics, signatures, resolved model)`); +console.log(`${reqs.length - bad}/${reqs.length} programs: the crate agrees with ref/ (AST, diagnostics, signatures, resolved model), and its spans hold (CP-8)`); process.exit(bad ? 1 : 0); /** --lower: ref/compile's modules against the crate's, through Solid's compiler for each target. */ @@ -186,3 +191,28 @@ function checkMap(code: string, map: string | undefined, prog: any, text: string if (target === "dom") { const seen = new Set(toks.map(t => t[2] + 1)); const miss = [...els].find(l => !seen.has(l)); if (miss) return `element on line ${miss} is not mapped`; } return null; } + +/** --project: types/project.ts's projection against the crate's, and the crate's mappings onto the `.rx`. */ +async function project(): Promise { + const { projectModules, PRELUDE } = await import("./types/project.ts"); + const out = JSON.parse(execFileSync(join(root, "compiler/target/release/rxc"), ["project"], { input: JSON.stringify(reqs), maxBuffer: 1 << 30 }).toString()); + const got = new Map((out.programs as any[]).map(x => [x.id, x])); + let bad = 0, n = 0, maps = 0, files = 0; + if (out.prelude !== PRELUDE) { bad++; console.log("FAIL prelude: compiler/src/prelude.ts is not types/project.ts's PRELUDE"); } + for (const r of reqs) { + const read = (p: string) => r.files[p]; + let set; try { set = loadModules(r.entry, read); } catch { continue; } + if (checkModules(set).diags.length) continue; // the checker refused it: nothing is projected (types/run.ts) + n++; + const want = projectModules(set).map(p => ({ path: p.path, rx: p.rx, code: p.code, lines: p.lines })); + const have = got.get(r.id); + const d = have?.files ? diff(want, have.files.map((f: any) => ({ path: f.path, rx: f.rx, code: f.code, lines: f.lines }))) + ?? (have.files.some((f: any) => f.unverified) ? `mappings that do not hold: ${have.files.filter((f: any) => f.unverified).map((f: any) => `${f.path} ${f.unverified}`).join(", ")}` : null) + : `crate: ${JSON.stringify(have).slice(0, 300)}`; + if (d) { bad++; console.log(`FAIL ${r.id}\n ${d}`); continue; } + for (const f of have.files) { files++; maps += f.mappings.length; } + if (verbose) console.log(`ok ${r.id}`); + } + console.log(`${n - bad}/${n} accepted programs: the crate's projection is types/project.ts's (${files} files, ${maps} mappings onto .rx, all holding)`); + return bad ? 1 : 0; +} diff --git a/ref/types/run.ts b/ref/types/run.ts index 30c36e2..7ffd9cd 100644 --- a/ref/types/run.ts +++ b/ref/types/run.ts @@ -2,6 +2,7 @@ // A program the checker accepts must type-check; a rejected variant `-- reject: TS2322` must fail with that code, on its // `.rx` line. One TypeScript program holds every projection, so the libraries are read once. // node --experimental-strip-types types/run.ts [filter] DEBUG=1 prints each projection with its errors +// RX_CRATE=1: the projection is the crate's (`rxc project`, compiler/), not types/project.ts's (CP-5) import ts from "typescript"; import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -9,6 +10,19 @@ import { dirname, relative, join } from "node:path"; import { parseScenario, moduleSet, type Scenario } from "../src/scenario.ts"; import { checkModules } from "../src/check.ts"; import { projectModules, PRELUDE, type Projected } from "./project.ts"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { loadModules } from "../src/modules.ts"; + +const rxc = fileURLToPath(new URL("../../compiler/target/release/rxc", import.meta.url)); +/** RX_CRATE=1: the crate's projection of the program, read from the same files ref/ reads. */ +function crateProjection(sc: Scenario, src: string): Projected[] { + const files: Record = {}; + const read = (p: string) => { const s = p === sc.entry ? src : sc.modules.get(p) ?? (existsSync(p) ? readFileSync(p, "utf8") : undefined); if (s !== undefined) files[p] = s; return s; }; + loadModules(sc.entry, read); + const out = JSON.parse(execFileSync(rxc, ["project"], { input: JSON.stringify([{ id: 0, entry: sc.entry, files }]), maxBuffer: 1 << 28 }).toString()); + return out.programs[0].files; +} const out = fileURLToPath(new URL("../.types/", import.meta.url)); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true }); const opts: ts.CompilerOptions = { @@ -26,7 +40,7 @@ function add(sc: Scenario, title: string, src: string, expect: string | null) { if (checkModules(set).diags.length) return; // the checker refused it: no types are asked const dir = join(out, `${sc.name}.${seq++}`); const root = dirname(set.entry.startsWith("/") ? set.entry : "/" + set.entry); const files = new Map(); - for (const p of projectModules(set)) { + for (const p of process.env.RX_CRATE ? crateProjection(sc, src) : projectModules(set)) { const rel = relative(root, p.path.startsWith("/") ? p.path : "/" + p.path); const f = join(dir, rel); mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, p.code); files.set(f, p); } @@ -61,5 +75,5 @@ for (const u of units) { } for (const d of byFile.get("(global)") ?? []) { fail++; console.log(`✗ global: TS${d.code} ${ts.flattenDiagnosticMessageText(d.messageText, " ")}`); } for (const [sc, lines] of scenarios) { console.log(`${lines.some(l => l.includes("✗")) ? "FAIL" : "PASS"} ${sc}`); for (const l of lines) console.log(l); } -console.log(`\n${pass} pass, ${fail} fail (TypeScript ${ts.version}, ${roots.length} projected modules, ${Date.now() - t0} ms)`); +console.log(`\n${pass} pass, ${fail} fail (TypeScript ${ts.version}, ${roots.length} projected modules${process.env.RX_CRATE ? " from the crate" : ""}, ${Date.now() - t0} ms)`); process.exit(fail ? 1 : 0); diff --git a/spec/11-compiler.md b/spec/11-compiler.md index 45873e5..056ac44 100644 --- a/spec/11-compiler.md +++ b/spec/11-compiler.md @@ -259,6 +259,50 @@ Every example app builds and runs in Chromium that way (`compiler/test/vite.mjs`). The runtime package of CP-4a is not published yet: the apps take `createApp` from `ref/compile/rt-web.ts`. +## CP-8 — Spans and the projection (for editors) + +Ruled 2026-10-27 (Ryan, on the editor-support plan: spans replace lines). +An editor's features are TypeScript's, asked of the TSX projection (13 +TY-1c), so every position has to cross between `.rx` and the projection. + +- **CP-8a (spans).** The crate's AST records the byte range each node was + parsed from: every expression and statement (a kind plus its span), every + declaration (from its first keyword, `export` included), markup item and + attribute (its name). A node the compiler makes has none (`Span::NONE`). + `ref/` keeps lines: its AST is the grammar's specification, and the spans + are where the crate's parser was, not a second grammar. +- **CP-8b (what a span promises).** Inside the source and inside its + parent's span; a declaration's and a markup item's span starts on the + line the node records; a name's span is the name, a member's ends with + its property, an element's starts with its tag. `compiler/src/spans.rs` + checks this, and `ref/crate.ts` runs it on every program. +- **CP-8c (the projection).** The crate projects a checked program as + `ref/types/project.ts` does, text for text (`ref/crate.ts --project`), and + records mappings: a piece of the projection that is a piece of the `.rx`, + the same text on both sides — every name, member, literal, written type, + tag and attribute the projection copies. `rxc project app.rx` prints the + files, their mappings (`[generated, source, length]`) and the prelude; + `RX_CRATE=1 npm --prefix ref run types` checks the scenarios on it. +- **CP-8d (diagnostics).** `rxc check --json` gives each diagnostic and + parse error a range, as an editor and an agent read it: 1-based lines, + 0-based columns in UTF-16 code units (LSP's), and byte offsets. The + checker is line-precise (CN-1a), so a diagnostic's range is its line's: + from the first node that starts on it to the furthest any such node + reaches on it (decision 9). +- **CP-8e (the editor).** An editor's parse recovers: a declaration that + does not parse is a parse error on its line and is skipped to the next + line that starts at column 0, and a missing expression is an empty name, + so the rest of the module still checks and projects. On a module that + parses, the recovering parse gives the strict parse's tree and no errors + (`rxc conform` checks it on every program). `rxc serve` answers a + language server a line at a time: per file, with the editor's unsaved + text as overlays, the projection with its mappings (exact text) and + ranges (what was written for an expression or statement, for placing + type errors) in UTF-16 offsets, and the file's parse errors, else its + checker diagnostics (CP-8d). `tooling/rx-language-server` is a Volar + server over the same requests, and `tooling/vscode-rx` its VS Code client. + The server runs the crate's WebAssembly build in process (decision 10). + ## Decisions taken by default (veto here) 1. **The helper API is CP-4b's** (pinned 2026-10-26, taken by default — @@ -287,8 +331,10 @@ yet: the apps take `createApp` from `ref/compile/rt-web.ts`. positions move to a map the crate keeps. The alternative is upstream: make `declared_before` independent of spans (e.g. from statement order in the semantic model), which would let the program keep `.rx` spans. -6. **`.rx` positions are lines** (CP-1c, 2026-10-27, taken by default — - veto here). A mapped position is the line of the declaration or markup +6. **`.rx` positions are lines** (CP-1c, 2026-10-27). *Superseded + 2026-10-27 by CP-8 (Ryan: spans replace lines) for the AST and + diagnostics.* Source maps of the lowered module still map lines, below; + they can now be made column-precise from the spans. A mapped position is the line of the declaration or markup it was lowered from, at that line's first non-blank column. The AST records lines (as `ref/`'s does, for diagnostics), not columns; columns need a span on every expression in both parsers, which nothing in the @@ -302,3 +348,24 @@ yet: the apps take `createApp` from `ref/compile/rt-web.ts`. project, so an app adds the package and nothing to its config. No WebAssembly fallback yet (StackBlitz and other unlisted platforms): `@solidjs/compiler`'s `wasm32-wasip1-threads` build shows how. +8. **The projection maps only text it copies** (CP-8c, 2026-10-27, taken + by default — veto here). A mapping joins the same text on both sides, + so a position inside it maps exactly and `ref/crate.ts --project` checks + every one. What the projection writes of its own (`__.set(…)`, + `__.await_(…)`, a rewritten `==`) maps nowhere; an editor asking there + gets the nearest mapped piece, as Volar does for Vue's and Svelte's. +9. **A diagnostic's range is its line's** (CP-8d, 2026-10-27, taken by + default — veto here). The checker reports lines (CN-1a); narrowing each + rule to the node it is about is per-rule work in both checkers, left + for when an editor shows where it falls short. +10. **The language server runs the crate as WebAssembly** (CP-8e, + 2026-10-27, taken by default — veto here). Volar makes a file's virtual + code synchronously, so the requests are synchronous calls into the + crate built for `wasm32-unknown-unknown` (`compiler/src/wasm.rs`, a C + ABI over linear memory with the host reading files), loaded once: one + `.vsix` for every platform, about a millisecond per analysis. `$RXC` + names a native `rxc` to run as `rxc serve` per request instead; both + give the same answers (the LSP tests run on each). The Node-API build + of decision 7 would serve the same requests, but one binary per + platform; this build answers only `serve.rs`'s requests, so it is not + decision 7's missing WebAssembly fallback for compiling. diff --git a/spec/13-types.md b/spec/13-types.md index ae1da65..7aa2a89 100644 --- a/spec/13-types.md +++ b/spec/13-types.md @@ -24,7 +24,8 @@ does for `.tsrx`. Nothing here is a second type system. DAG). - **TY-1c.** In the product the compiler emits the projection beside the JavaScript (11, CP-1a) and hands it to TypeScript; an editor uses the - same projection. + same projection. The crate emits it with mappings onto the `.rx` (11 + CP-8c: `rxc project`), the same text as `ref/`'s. ## TY-2 — What each form is, as a value diff --git a/tooling/RELEASING.md b/tooling/RELEASING.md new file mode 100644 index 0000000..47b9695 --- /dev/null +++ b/tooling/RELEASING.md @@ -0,0 +1,64 @@ +# Releasing the editor tools + +A release is a version bump merged to `main`. The `editor` workflow +(`.github/workflows/editor.yml`) builds and tests as on every change, then +publishes each package whose version is new, from the `release` environment, +and tags it: + +| package | where | how it signs in | tag | +| --- | --- | --- | --- | +| `tooling/rx-language-server` | npm, `@reactivescript/language-server` | trusted publishing (OIDC), no token | `language-server-v` | +| `tooling/vscode-rx` | VS Marketplace | a Microsoft Entra identity (OIDC), or the secret `VSCE_PAT` | `vscode-v` | +| `tooling/vscode-rx` | Open VSX (Cursor, VSCodium) | the secret `OVSX_PAT` | (the same) | + +Nothing is published until the repository variable `RELEASES` is `on`. + +## Once + +**npm.** Create the `reactivescript` org on npmjs.com. npm configures trusted +publishing only on a package that exists, so it needs a first version by hand: +a placeholder, with no build, + +```sh +npm login +npx setup-npm-trusted-publish @reactivescript/language-server +``` + +It publishes `0.0.0`, marked as a placeholder; the workflow's first release is +`0.1.0`. + +Then, on the package's **Settings › Trusted publishing**, add GitHub Actions: +owner `ryansolid`, repository `reactivescript`, workflow `editor.yml`, +environment `release`. (If the repository moves, change the owner here.) Every +later version is published by the workflow, with provenance. + +**VS Marketplace.** Create the `reactivescript` publisher +(marketplace.visualstudio.com/manage). Then either: + +- a token: an Azure DevOps personal access token with **Marketplace › Manage** + for all accessible organizations, as the secret `VSCE_PAT` on the `release` + environment. Azure DevOps retires such global tokens on 2026-12-01; or +- no token: a Microsoft Entra managed identity (or app registration) with a + federated credential for GitHub Actions (issuer + `https://token.actions.githubusercontent.com`, subject + `repo:ryansolid/reactivescript:environment:release`), added to an Azure + DevOps organization in the same tenant (so that it has a Marketplace + profile) and to the publisher's **Members** as a Contributor; then the + repository variables `AZURE_CLIENT_ID` and `AZURE_TENANT_ID`. The workflow + signs in with `azure/login` and runs `vsce publish --azure-credential`. + +**Open VSX.** Sign in at open-vsx.org with GitHub, sign the publisher +agreement, create a token, run `npx ovsx create-namespace reactivescript -p +`, and store the token as the secret `OVSX_PAT` on the `release` +environment. (Open VSX's trusted publishing is server-side work in progress, +eclipse-openvsx/openvsx#1980; switch when `ovsx` supports it.) + +Finally set the repository variable `RELEASES` to `on`. + +## Each release + +Bump `version` in `tooling/rx-language-server/package.json` and/or +`tooling/vscode-rx/package.json` (and their `package-lock.json`, with +`npm version --no-git-tag-version` in that folder), and +merge. A channel whose secret or identity is unset is skipped, and its tag is +not made, so it publishes on a later run. diff --git a/tooling/rx-language-server/.gitignore b/tooling/rx-language-server/.gitignore new file mode 100644 index 0000000..30c77b4 --- /dev/null +++ b/tooling/rx-language-server/.gitignore @@ -0,0 +1,3 @@ +.test/ +src/rxc.wasm +*.tgz diff --git a/tooling/rx-language-server/README.md b/tooling/rx-language-server/README.md new file mode 100644 index 0000000..fa0d960 --- /dev/null +++ b/tooling/rx-language-server/README.md @@ -0,0 +1,101 @@ +# rx-language-server + +The `.rx` language server (spec/11 CP-8e), built on [Volar](https://volarjs.dev). +A `.rx` file's virtual code is its TSX projection (spec/13 TY-1c), which the +crate makes with mappings back onto the `.rx` (CP-8c). TypeScript answers hover, +go to definition, references, rename, completion and type errors on the +projection, and Volar maps the answers back. The crate's own diagnostics (parse +errors, else the checker's, each with its rule) are a second service. + +- `src/rxc.js` asks the crate for a file's analysis, with the editor's unsaved + text as overlays: its WebAssembly build in process (`src/wasm.rs`; one build + for every platform, about a millisecond a file once loaded), or, when `RXC` + names a binary, `rxc serve` as a process per request. It writes the + projection's prelude to `node_modules/.reactivescript/prelude.d.ts`. +- `src/index.js` is the language plugin (exact mappings carry every feature; + ranges only place type errors) and the diagnostics service. +- `bin/server.js` is the server, over stdio or IPC. + +The project needs a `tsconfig.json` with Solid's JSX (`"jsx": "preserve"`, +`"jsxImportSource": "@solidjs/web"`) and Solid in its `node_modules`. + +## Running it + +From npm (published from this folder, `../RELEASING.md`): + +```sh +npx @reactivescript/language-server --stdio +``` + +From the repository: + +```sh +cargo build --lib --release --target wasm32-unknown-unknown --manifest-path ../../compiler/Cargo.toml +npm install +node bin/server.js --stdio +``` + +`initializationOptions: { typescript: { tsdk } }` picks TypeScript's `lib` +folder; without it, the server uses its own `typescript`. + +## Editors + +**VS Code** (and Cursor, VSCodium): the extension in +[`../vscode-rx`](../vscode-rx), which bundles this server and the WebAssembly +build. + +**Neovim** (0.11+), in `init.lua`: + +```lua +vim.filetype.add({ extension = { rx = "reactivescript" } }) +vim.lsp.config("rx", { + cmd = { "node", "/path/to/reactivescript/tooling/rx-language-server/bin/server.js", "--stdio" }, + filetypes = { "reactivescript" }, + root_markers = { "tsconfig.json", "package.json" }, +}) +vim.lsp.enable("rx") +``` + +Neovim has no `.rx` grammar yet; the server's semantic tokens color names. + +**Helix**, in `languages.toml`: + +```toml +[language-server.rx] +command = "node" +args = ["/path/to/reactivescript/tooling/rx-language-server/bin/server.js", "--stdio"] + +[[language]] +name = "reactivescript" +scope = "source.rx" +file-types = ["rx"] +roots = ["tsconfig.json", "package.json"] +language-servers = ["rx"] +``` + +**JetBrains IDEs** (WebStorm, IntelliJ): install the +[LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) plugin, add a +language server with the command +`node /path/to/reactivescript/tooling/rx-language-server/bin/server.js --stdio`, +map the file name pattern `*.rx` to it, and add the `tooling/vscode-rx` +folder under Settings › Editor › TextMate Bundles for highlighting. + +**Zed** needs an extension of its own (a Zed extension declares the language +and starts the server); there isn't one yet. + +The `/path/to/reactivescript` paths are a checkout of this repository; with +the npm package, the command is `npx @reactivescript/language-server --stdio` +instead (or `rx-language-server --stdio` once installed). + +## Tests + +```sh +cargo build --release --manifest-path ../../compiler/Cargo.toml +npm install && node test/run.js # VERBOSE=1 prints each answer +``` + +`test/run.js` drives the server over LSP on a copy of `examples/shop` (Solid's +types from `ref/node_modules`, via `ref/scripts/solid-next.sh`): hover, go to +definition across modules, references, completion while typing (in code and in +markup), a parse error, a TypeScript type error and a crate diagnostic, each +placed on the `.rx`. diff --git a/tooling/rx-language-server/bin/server.js b/tooling/rx-language-server/bin/server.js new file mode 100644 index 0000000..6655ac2 --- /dev/null +++ b/tooling/rx-language-server/bin/server.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// The .rx language server over stdio (or whatever transport the client asks for): Volar's TypeScript project with the +// .rx language plugin, TypeScript's services, and the crate's diagnostics. +const { createConnection, createServer, createTypeScriptProject, loadTsdkByPath } = require("@volar/language-server/node"); +const { create: createTypeScriptServices } = require("volar-service-typescript"); +const { rxLanguagePlugin, rxDiagnostics } = require("../src/index.js"); + +const connection = createConnection(); +const server = createServer(connection); + +connection.listen(); + +connection.onInitialize(params => { + const tsdkPath = params.initializationOptions?.typescript?.tsdk ?? require("node:path").dirname(require.resolve("typescript")); + const tsdk = loadTsdkByPath(tsdkPath, params.locale); + return server.initialize( + params, + createTypeScriptProject(tsdk.typescript, tsdk.diagnosticMessages, () => ({ languagePlugins: [rxLanguagePlugin(tsdk.typescript)] })), + [...createTypeScriptServices(tsdk.typescript), rxDiagnostics()], + ); +}); + +connection.onInitialized(server.initialized); +connection.onShutdown(server.shutdown); diff --git a/tooling/rx-language-server/package-lock.json b/tooling/rx-language-server/package-lock.json new file mode 100644 index 0000000..43fecd6 --- /dev/null +++ b/tooling/rx-language-server/package-lock.json @@ -0,0 +1,267 @@ +{ + "name": "@reactivescript/language-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@reactivescript/language-server", + "version": "0.1.0", + "dependencies": { + "@volar/language-core": "2.4.30", + "@volar/language-server": "2.4.30", + "@volar/typescript": "2.4.30", + "typescript": "^6.0.3", + "volar-service-typescript": "0.0.71", + "vscode-uri": "^3.0.8" + }, + "bin": { + "rx-language-server": "bin/server.js" + }, + "devDependencies": { + "@volar/test-utils": "2.4.30" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.30.tgz", + "integrity": "sha512-73fjwEV8wat6aOZ6U/y/hqtE30ocrIWECg67b0txGczOMNw+MffLmvJ5fnFfN9drVUEYZzLdaNo6VAy6G6avMw==", + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.30" + } + }, + "node_modules/@volar/language-server": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.30.tgz", + "integrity": "sha512-tcP9fzzUBbsLIFHhgnRU4ncQjMn9G9Vax0ffkQmdCrXVEYqRroUepmyC5AVgiju5bCiLD6a55qen5iOTQHkb3g==", + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.30", + "@volar/language-service": "2.4.30", + "@volar/typescript": "2.4.30", + "path-browserify": "^1.0.1", + "request-light": "^0.7.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@volar/language-service": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.30.tgz", + "integrity": "sha512-w/xDsASiqC71ggCOm7sTDXDNk/l72XEptodeRC68tgZ0e5QM4hM3zAqBGC7mBctvhrpYwKfMG+t9xcUo2hEFtA==", + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.30", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.30.tgz", + "integrity": "sha512-zym1ob/KwDDyN+0jGVtxwbxJSRRnUvv5HspCtz3yEfVVdCG4/dzxJktMGPAgemqRuVs81O0o/y0q3qOUErR8kg==", + "license": "MIT" + }, + "node_modules/@volar/test-utils": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/test-utils/-/test-utils-2.4.30.tgz", + "integrity": "sha512-juUg/EoBxFA/7FgbzznnF7gg2v1pYNMnvLpsm7sGZXw0fa9i20R40V6eftcjudSHH02SLqkuislpgJzskrvQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.30", + "@volar/language-server": "2.4.30", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/typescript": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.30.tgz", + "integrity": "sha512-/DgA5tgYFPfrLFuSjIMXMGHk1LQv2+P8eny6+BhGNzFJELWIW7UZlc56ZLIRFhLsPpMfIFxM8/HvOPBggrjTZg==", + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.30", + "@volar/language-service": "2.4.30", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.8" + } + }, + "node_modules/volar-service-typescript": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz", + "integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==", + "license": "MIT", + "dependencies": { + "path-browserify": "^1.0.1", + "semver": "^7.6.2", + "typescript-auto-import-cache": "^0.3.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-10.0.0.tgz", + "integrity": "sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA==", + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.5.tgz", + "integrity": "sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "10.0.0", + "vscode-languageserver-types": "3.18.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.16.tgz", + "integrity": "sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.5.tgz", + "integrity": "sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw==", + "license": "MIT" + }, + "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/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/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "license": "MIT" + } + } +} diff --git a/tooling/rx-language-server/package.json b/tooling/rx-language-server/package.json new file mode 100644 index 0000000..ea0f48c --- /dev/null +++ b/tooling/rx-language-server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@reactivescript/language-server", + "version": "0.1.0", + "description": "The .rx language server (spec/11 CP-8): Volar over the crate's TSX projection, TypeScript's answers mapped back onto .rx, the crate's own diagnostics.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ryansolid/reactivescript.git", + "directory": "tooling/rx-language-server" + }, + "type": "commonjs", + "bin": { + "rx-language-server": "./bin/server.js" + }, + "main": "./src/index.js", + "files": [ + "bin", + "src" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node test/run.js", + "prepack": "node scripts/wasm.js" + }, + "dependencies": { + "@volar/language-core": "2.4.30", + "@volar/language-server": "2.4.30", + "@volar/typescript": "2.4.30", + "volar-service-typescript": "0.0.71", + "vscode-uri": "^3.0.8", + "typescript": "^6.0.3" + }, + "devDependencies": { + "@volar/test-utils": "2.4.30" + } +} diff --git a/tooling/rx-language-server/scripts/wasm.js b/tooling/rx-language-server/scripts/wasm.js new file mode 100644 index 0000000..e12365a --- /dev/null +++ b/tooling/rx-language-server/scripts/wasm.js @@ -0,0 +1,12 @@ +// Puts the crate's WebAssembly build in the package (src/rxc.wasm, where src/rxc.js looks first), before `npm pack` or +// `npm publish`: $RX_WASM, else the repository's build +// (`cargo build --lib --release --target wasm32-unknown-unknown --manifest-path compiler/Cargo.toml`). +const fs = require("node:fs"); +const path = require("node:path"); + +const from = process.env.RX_WASM || path.resolve(__dirname, "../../../compiler/target/wasm32-unknown-unknown/release/reactivescript.wasm"); +if (!fs.existsSync(from)) { + console.error(`no ${from}: cargo build --lib --release --target wasm32-unknown-unknown --manifest-path compiler/Cargo.toml`); + process.exit(1); +} +fs.copyFileSync(from, path.join(__dirname, "../src/rxc.wasm")); diff --git a/tooling/rx-language-server/src/index.js b/tooling/rx-language-server/src/index.js new file mode 100644 index 0000000..0171b82 --- /dev/null +++ b/tooling/rx-language-server/src/index.js @@ -0,0 +1,98 @@ +// The .rx language plugin for Volar (spec/11 CP-8). A `.rx` file's virtual code is its TSX projection (spec/13 TY-1c), +// made by the crate with mappings onto the `.rx`: TypeScript's hover, definition, references, rename, completion and +// type errors come back through them. The crate's own diagnostics (rule IDs, spec/99) are a service of their own. +const { analyze, preludeFor } = require("./rxc"); + +/** Every feature, where the projection copies the `.rx`'s text (a name, a member, a literal, a type, a tag). */ +const EXACT = { verification: true, completion: true, semantic: true, navigation: true, structure: true, format: false }; +/** Where it wrote text for a piece of the `.rx` (an expression, a statement): only for placing type errors. */ +const COARSE = { verification: true }; + +/** A module: `.rx`, not a declaration file (`.d.rx`, MO-8/MO-9), which the crate reads beside the modules that import it. */ +const isModule = f => f.endsWith(".rx") && !f.endsWith(".d.rx"); + +const snapshotOf = text => ({ getText: (s, e) => text.slice(s, e), getLength: () => text.length, getChangeRange: () => undefined }); + +/** The last analysis of each file, by its text: the virtual code and the diagnostics share one `rxc` run. */ +const cache = new Map(); +function analysis(file, text) { + const hit = cache.get(file); + if (hit && hit.text === text) return hit.result; + let result; + try { result = analyze(file, text, preludeFor(file)); } catch (e) { + console.error(`rx: analyzing ${file} failed: ${e?.stack ?? e}`); // the server's log, else the failure is silent + result = { code: null, mappings: [], ranges: [], diagnostics: [], error: String(e) }; + } + cache.set(file, { text, result }); + return result; +} + +function virtualCode(file, snapshot) { + const text = snapshot.getText(0, snapshot.getLength()); + const a = analysis(file, text); + const code = a.code ?? ""; + const mappings = [ + ...(a.mappings ?? []).map(([g, s, n]) => ({ sourceOffsets: [s], generatedOffsets: [g], lengths: [n], data: EXACT })), + ...(a.ranges ?? []).map(([g, gn, s, n]) => ({ sourceOffsets: [s], generatedOffsets: [g], lengths: [n], generatedLengths: [gn], data: COARSE })), + ]; + return { + id: "root", + languageId: "reactivescript", + snapshot, + mappings: [{ sourceOffsets: [0], generatedOffsets: [0], lengths: [text.length], data: { verification: true, completion: true, semantic: true, navigation: true, structure: true, format: true } }], + embeddedCodes: [{ id: "tsx", languageId: "typescriptreact", snapshot: snapshotOf(code), mappings }], + }; +} + +/** @param {typeof import("typescript")} ts */ +function rxLanguagePlugin(ts, asFileName = uri => uri.fsPath ?? String(uri)) { + return { + getLanguageId(id) { return isModule(String(id.path ?? id)) ? "reactivescript" : undefined; }, + createVirtualCode(id, languageId, snapshot) { + if (languageId !== "reactivescript" || !isModule(asFileName(id))) return undefined; + return virtualCode(asFileName(id), snapshot); + }, + typescript: { + extraFileExtensions: [{ extension: "rx", isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }], + getServiceScript(root) { + const tsx = root.embeddedCodes?.find(c => c.id === "tsx"); + return tsx ? { code: tsx, extension: ".tsx", scriptKind: ts.ScriptKind.TSX } : undefined; + }, + }, + }; +} + +/** The crate's diagnostics on a `.rx` document: the editor's parse's errors, else the checker's (CP-8d). Volar hands + * the service the root virtual code's document, whose URI encodes the `.rx` file's. */ +function rxDiagnostics() { + return { + name: "reactivescript-diagnostics", + capabilities: { diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false } }, + create(context) { + return { + provideDiagnostics(document) { + if (document.languageId !== "reactivescript") return; + const { URI } = require("vscode-uri"); + const uri = URI.parse(document.uri); + const source = context.decodeEmbeddedDocumentUri(uri)?.[0] ?? uri; + if (source.scheme !== "file") return; + const file = source.fsPath; + if (!isModule(file)) return; + const a = analysis(file, document.getText()); + return (a.diagnostics ?? []).filter(d => d.range).map(d => ({ + range: { + start: { line: d.range.start.line - 1, character: d.range.start.column }, + end: { line: d.range.end.line - 1, character: d.range.end.column }, + }, + severity: 1, + source: "rx", + code: d.code, + message: `${d.message} (${d.rule})`, + })); + }, + }; + }, + }; +} + +module.exports = { rxLanguagePlugin, rxDiagnostics, analysis }; diff --git a/tooling/rx-language-server/src/rxc.js b/tooling/rx-language-server/src/rxc.js new file mode 100644 index 0000000..c3c02bd --- /dev/null +++ b/tooling/rx-language-server/src/rxc.js @@ -0,0 +1,94 @@ +// The crate's side of the server: `rxc serve`'s requests (spec/11 CP-8e). Volar asks for a file's virtual code +// synchronously, so the requests are synchronous calls: into the crate's WebAssembly build, loaded in process (one build +// for every platform), or, when $RXC names a binary, a short `rxc serve` run per request. +const { spawnSync } = require("node:child_process"); +const { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } = require("node:fs"); +const path = require("node:path"); + +const repo = path.resolve(__dirname, "../../.."); + +/** The `rxc` binary, when $RXC names one. */ +function rxcPath() { + return process.env.RXC || path.join(repo, "compiler/target/release/rxc"); +} + +/** The WebAssembly build: $RX_WASM, else the one bundled beside this file (the packaged extension), else the + * repository's (`cargo build --lib --release --target wasm32-unknown-unknown`). */ +function wasmPath() { + if (process.env.RX_WASM) return process.env.RX_WASM; + for (const f of [path.join(__dirname, "rxc.wasm"), path.join(repo, "compiler/target/wasm32-unknown-unknown/release/reactivescript.wasm")]) if (existsSync(f)) return f; + return undefined; +} + +/** The crate in process, over `src/wasm.rs`'s ABI. */ +function loadWasm(file) { + let x; + const enc = new TextEncoder(), dec = new TextDecoder(); + const put = bytes => { + const p = x.rx_alloc(bytes.length + 4); + const m = new Uint8Array(x.memory.buffer); + new DataView(x.memory.buffer).setUint32(p, bytes.length, true); + m.set(bytes, p + 4); + return p; + }; + const env = { + rx_read(ptr, len) { + const f = dec.decode(new Uint8Array(x.memory.buffer, ptr, len)); + try { if (!statSync(f).isFile()) return 0; return put(readFileSync(f)); } catch { return 0; } + }, + }; + x = new WebAssembly.Instance(new WebAssembly.Module(readFileSync(file)), { env }).exports; + return (method, params) => { + const req = enc.encode(JSON.stringify({ method, params })); + const p = x.rx_alloc(req.length); + new Uint8Array(x.memory.buffer).set(req, p); + const out = x.rx_call(p, req.length); + const n = new DataView(x.memory.buffer).getUint32(out, true); + const text = dec.decode(new Uint8Array(x.memory.buffer, out + 4, n)); + x.rx_free(out, n + 4); + return JSON.parse(text); + }; +} + +function viaProcess(method, params) { + const r = spawnSync(rxcPath(), ["serve"], { input: JSON.stringify({ id: 1, method, params }) + "\n", maxBuffer: 1 << 28 }); + if (r.error) throw r.error; + const line = r.stdout.toString().split("\n").find(Boolean); + if (!line) throw new Error(`rxc serve: no answer (${r.stderr.toString().slice(0, 300)})`); + return JSON.parse(line).result; +} + +let wasm; +function ask(method, params) { + if (process.env.RXC || !wasmPath()) return viaProcess(method, params); + try { + return (wasm ??= loadWasm(wasmPath()))(method, params); + } catch (e) { + wasm = undefined; // a trap (a panic in the crate) leaves the instance unusable: the next request makes a new one + throw e; + } +} + +/** A file's projection (`code`, `mappings`, `ranges`, UTF-16 offsets) and its diagnostics, as the editor has it. */ +function analyze(file, text, prelude) { + return ask("analyze", { file, overlays: { [file]: text }, prelude }); +} + +/** Where a file's projection imports the prelude from: written once beside the nearest `node_modules`, so its own + * `@solidjs/web` resolves; a `.d.ts`, so the import needs no extension. */ +const preludes = new Map(); +function preludeFor(file) { + let dir = path.dirname(file); + while (!existsSync(path.join(dir, "node_modules")) && path.dirname(dir) !== dir) dir = path.dirname(dir); + const at = path.join(dir, "node_modules", ".reactivescript", "prelude"); + if (!preludes.has(at)) { + const text = ask("prelude", {}); + mkdirSync(path.dirname(at), { recursive: true }); + const f = at + ".d.ts"; + if (!existsSync(f) || readFileSync(f, "utf8") !== text) writeFileSync(f, text); + preludes.set(at, true); + } + return at; +} + +module.exports = { analyze, preludeFor, rxcPath, wasmPath }; diff --git a/tooling/rx-language-server/test/run.js b/tooling/rx-language-server/test/run.js new file mode 100644 index 0000000..6751e34 --- /dev/null +++ b/tooling/rx-language-server/test/run.js @@ -0,0 +1,114 @@ +// The language server, driven over LSP as an editor drives it, on a copy of examples/shop: hover, go to definition across +// modules, completion while typing, TypeScript's type errors and the crate's diagnostics, each placed on the .rx. +// Needs compiler/ built (cargo build --release) and Solid's types (ref/node_modules, via ref/scripts/solid-next.sh). +// node test/run.js VERBOSE=1 prints each answer +const { startLanguageServer } = require("@volar/test-utils"); +const { URI } = require("vscode-uri"); +const fs = require("node:fs"); +const path = require("node:path"); + +const repo = path.resolve(__dirname, "../../.."); +const ws = path.join(__dirname, "../.test/shop"); +fs.rmSync(ws, { recursive: true, force: true }); +fs.mkdirSync(ws, { recursive: true }); +for (const f of fs.readdirSync(path.join(repo, "examples/shop"))) fs.copyFileSync(path.join(repo, "examples/shop", f), path.join(ws, f)); +fs.writeFileSync(path.join(ws, "api.d.ts"), "export declare function checkout(items: { id: number; qty: number }[], coupon: string): Promise<{ id: number }>;\n"); +fs.symlinkSync(path.join(repo, "ref/node_modules"), path.join(ws, "node_modules"), "dir"); +fs.writeFileSync(path.join(ws, "tsconfig.json"), JSON.stringify({ + compilerOptions: { jsx: "preserve", jsxImportSource: "@solidjs/web", strict: true, noImplicitAny: false, target: "ES2022", module: "ESNext", moduleResolution: "Bundler", allowImportingTsExtensions: true, noEmit: true, skipLibCheck: true, lib: ["ES2022", "DOM"], types: [] }, + include: ["*.rx", "*.ts"], +}, null, 2)); + +let pass = 0, fail = 0; +function check(name, ok, got) { + if (ok) pass++; else fail++; + const shown = Array.isArray(got) ? got.map(d => (d?.source ? { source: d.source, code: d.code, message: d.message, range: d.range } : d)) : got; + console.log(`${ok ? " ✓" : " ✗"} ${name}${!ok || process.env.VERBOSE ? `\n ${JSON.stringify(shown)?.slice(0, ok ? 600 : 4000)}` : ""}`); +} +/** The position of the `n`th `needle` in `text`, `delta` characters into it. */ +function at(text, needle, delta = 0, n = 1) { + let i = -1; + for (let k = 0; k < n; k++) { i = text.indexOf(needle, i + 1); if (i < 0) throw new Error(`no ${needle}`); } + i += delta; + const before = text.slice(0, i).split("\n"); + return { line: before.length - 1, character: before[before.length - 1].length }; +} +const hoverText = h => !h ? "" : typeof h.contents === "string" ? h.contents : Array.isArray(h.contents) ? h.contents.map(c => c.value ?? c).join("\n") : h.contents.value; + +(async () => { + // SERVER: another build of it, such as the extension's bundle (tooling/vscode-rx/dist/server.js) + const server = startLanguageServer(process.env.SERVER ? path.resolve(process.env.SERVER) : path.join(__dirname, "../bin/server.js"), ws); + await server.initialize(URI.file(ws).toString(), { typescript: { tsdk: path.dirname(require.resolve("typescript")) } }); + const cartFile = path.join(ws, "cart.rx"); + const cart = await server.openTextDocument(cartFile, "reactivescript"); + const text = cart.getText(); + + console.log("hover"); + const h1 = await server.sendHoverRequest(cart.uri, at(text, "count = cart.reduce", 9)); + check("a source's type: `cart` is Line[]", /Line\[\]/.test(hoverText(h1)), h1); + const h2 = await server.sendHoverRequest(cart.uri, at(text, "export subtotal", 8)); + check("an equation's type: `subtotal` is number", /subtotal: number/.test(hoverText(h2)), h2); + const h3 = await server.sendHoverRequest(cart.uri, at(text, "qtyOf(p.id)", 1)); + check("a function: `qtyOf(id: number): number`", /qtyOf\(id: number\): number/.test(hoverText(h3)), h3); + + console.log("go to definition"); + const d1 = await server.sendDefinitionRequest(cart.uri, at(text, "products.reduce", 2)); + const t1 = d1?.[0]?.targetUri ?? d1?.[0]?.uri; + check("`products` goes to catalog.rx", String(t1).endsWith("catalog.rx"), d1); + const d2 = await server.sendDefinitionRequest(cart.uri, at(text, "qtyOf(p.id)", 1)); + const r2 = d2?.[0]?.targetSelectionRange ?? d2?.[0]?.range; + check("`qtyOf` goes to its declaration on line 13", String(d2?.[0]?.targetUri ?? d2?.[0]?.uri).endsWith("cart.rx") && r2?.start.line === at(text, "export fn qtyOf").line, d2); + + console.log("references and rename"); + const refs = await server.sendReferencesRequest(cart.uri, at(text, "export count", 8), { includeDeclaration: true }); + check("`count` is referenced in cart.rx and beyond", (refs ?? []).length >= 3, refs?.map(r => `${path.basename(r.uri)}:${r.range.start.line + 1}`)); + + console.log("completion while typing"); + const lines = text.split("\n"); const end = { line: lines.length - 1, character: lines[lines.length - 1].length }; + const typed = await server.updateTextDocument(cart.uri, [{ range: { start: end, end }, newText: "\nnewest = cart." }]); + const tt = typed.getText(); + const c1 = await server.sendCompletionRequest(cart.uri, at(tt, "newest = cart.", "newest = cart.".length)); + const labels = (c1?.items ?? []).map(i => i.label); + check("after `cart.`: array members (`reduce`, `length`)", labels.includes("reduce") && labels.includes("length"), labels.slice(0, 20)); + const c2 = await server.sendCompletionRequest(cart.uri, at(tt, "newest = ca", "newest = ca".length)); + check("in an expression: names in scope (`cart`, `count`, `qtyOf`)", ["cart", "count", "qtyOf"].every(l => (c2?.items ?? []).some(i => i.label === l)), (c2?.items ?? []).map(i => i.label).slice(0, 30)); + + console.log("diagnostics"); + const dg0 = await server.sendDocumentDiagnosticRequest(cart.uri); + check("a half-typed line is a parse error on that line (07)", (dg0.items ?? []).some(d => d.code === "PARSE" && d.range.start.line === at(tt, "newest").line), dg0.items); + await server.updateTextDocument(cart.uri, [{ range: { start: at(tt, "newest = cart."), end: at(tt, "newest = cart.", "newest = cart.".length) }, newText: "newest = cart" }]); + const typeErr = await server.updateTextDocument(cart.uri, [{ range: { start: at(text, 'orderError: string := ""', 22), end: at(text, 'orderError: string := ""', 24) }, newText: "42" }]); + const te = typeErr.getText(); + const dg1 = await server.sendDocumentDiagnosticRequest(cart.uri); + const ts1 = (dg1.items ?? []).find(d => String(d.code) === "2345" || String(d.code) === "2322"); + check("a type error (`string := 42`) is TypeScript's, on the `42`", ts1 && ts1.range.start.line === at(te, ":= 42").line && ts1.range.start.character === at(te, ":= 42", 3).character, dg1.items); + const unb = await server.updateTextDocument(cart.uri, [{ range: { start: at(te, ":= 42", 3), end: at(te, ":= 42", 5) }, newText: '""' }]); + const ut = unb.getText(); + const unb2 = await server.updateTextDocument(cart.uri, [{ range: { start: at(ut, "newest = cart", 9), end: at(ut, "newest = cart", 13) }, newText: "nope" }]); + const nt = unb2.getText(); + const dg2 = await server.sendDocumentDiagnosticRequest(cart.uri); + const rx = (dg2.items ?? []).find(d => d.source === "rx"); + check("an undeclared name is the crate's diagnostic, with its rule", rx && rx.range.start.line === at(nt, "newest = nope").line && /SCOPE|RG-/.test(rx.message), dg2.items); + + console.log("views"); + const main = await server.openTextDocument(path.join(ws, "main.rx"), "reactivescript"); + const mt = main.getText(); + const h4 = await server.sendHoverRequest(main.uri, at(mt, "`)", /function Row/.test(hoverText(h4)), h4); + const d4 = await server.sendDefinitionRequest(main.uri, at(mt, "` goes to ui.rx", String(d4?.[0]?.targetUri ?? d4?.[0]?.uri).endsWith("ui.rx"), d4); + const h5 = await server.sendHoverRequest(main.uri, at(mt, "product={p}", 1)); + check("a prop hovers with its type (`product: Product`)", /product: Product/.test(hoverText(h5)), h5); + const ui = await server.openTextDocument(path.join(ws, "ui.rx"), "reactivescript"); + const uit = ui.getText(); + const typedUi = await server.updateTextDocument(ui.uri, [{ range: { start: at(uit, "{props.product.name}", 15), end: at(uit, "{props.product.name}", 19) }, newText: "" }]); + const c3 = await server.sendCompletionRequest(ui.uri, at(typedUi.getText(), "{props.product.}", 15)); + const l3 = (c3?.items ?? []).map(i => i.label); + check("in markup, after `props.product.`: Product's fields", l3.includes("name") && l3.includes("price"), l3.slice(0, 20)); + const h6 = await server.sendHoverRequest(ui.uri, at(uit, "onClick", 2)); + check("an attribute hovers as Solid's JSX type", /onClick/.test(hoverText(h6)), h6); + + await server.shutdown(); + console.log(`\n${pass} pass, ${fail} fail`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tooling/vscode-rx/.gitignore b/tooling/vscode-rx/.gitignore new file mode 100644 index 0000000..579066e --- /dev/null +++ b/tooling/vscode-rx/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.vsix diff --git a/tooling/vscode-rx/.vscodeignore b/tooling/vscode-rx/.vscodeignore new file mode 100644 index 0000000..6565299 --- /dev/null +++ b/tooling/vscode-rx/.vscodeignore @@ -0,0 +1,4 @@ +node_modules/** +client.js +build.js +.gitignore diff --git a/tooling/vscode-rx/README.md b/tooling/vscode-rx/README.md index e307931..c4cf9e0 100644 --- a/tooling/vscode-rx/README.md +++ b/tooling/vscode-rx/README.md @@ -1,13 +1,57 @@ # ReactiveScript for VS Code -Syntax highlighting for `.rx` and `.d.rx`: TypeScript's grammar for expressions, +Highlighting for `.rx` and `.d.rx`: TypeScript's grammar for expressions, statements and JSX, with ReactiveScript's forms injected on top — `source`, `:=`, equations, `effect`, `action`, `server fn`, `context`/`provide`/`use`, `loading`/`errored`/`fallback`, `for … in`, `pre`/`peek`/`latest`/`pending?`, `await*`, `->`, the `pure`/`server`/`client` import rows, and scoped `