From 3ead7e93d01af1d5eb2f59b28cc06e6623e84b41 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 13 Nov 2026 20:56:57 -0600 Subject: [PATCH 1/2] Type an entry's default export; stop rx-tsc crashing on .d.rx The TSX projection of a module with `export default component` now ends with a default export typed as the prelude's `Main`, a function of the app giving the view `app.mount` takes, as the compiled `main(__rt)` is. A `.d.rx` that a tsconfig's `include` picks up becomes an empty module in the language plugin instead of a file with no script. --- compiler/src/prelude.ts | 4 ++- compiler/src/project.rs | 33 ++++++++++++++++++ ref/types/project.ts | 4 +++ tooling/rx-language-server/src/index.js | 16 +++++++-- tooling/rx-language-server/test/tsc.js | 45 +++++++++++++++++++++---- 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/compiler/src/prelude.ts b/compiler/src/prelude.ts index f31a76a..ee855d1 100644 --- a/compiler/src/prelude.ts +++ b/compiler/src/prelude.ts @@ -9,7 +9,7 @@ export declare function eq(v: T, ok: Async extends true ? "MISSING_AWAIT: export declare function first(v: T, init: I): T | I; export declare function pre(init?: T): T; export declare function pending(v: unknown): boolean; -/** RG-4b: an object rest, \`{ a, ...rest } = e\` (Solid's \`omit\`). */ +/** RG-4b: an object rest, `{ a, ...rest } = e` (Solid's `omit`). */ export declare function omit(v: T, ...keys: K[]): Omit; export declare function caught(v: T, alt: (e: any) => R): T | R; export declare function raise(e: unknown): never; @@ -30,6 +30,8 @@ export declare function wire(f: (...a: A) => Promise, /** 07 §6: a view (`props.children: View`); R-f-10: the class a panic is. */ export type View = JSX.Element; export type Panic = Error; +/** CP-4a: an entry's default export, a function of the app (`createApp`'s, either build) giving the view `app.mount` takes. */ +export type Main = (app: object) => () => View; export interface Ctx { readonly __ctx: T } export declare function ctx(d?: T): Ctx; export declare function use(c: Ctx): T; diff --git a/compiler/src/project.rs b/compiler/src/project.rs index 062a2a3..d1a3751 100644 --- a/compiler/src/project.rs +++ b/compiler/src/project.rs @@ -349,6 +349,10 @@ impl Proj<'_> { self.markup(&p.view, " "); self.w.emit(Code::of(");"), p.view[p.view.len() - 1].line.unwrap_or(1)); } + if let Some(Decl::Component { line, .. }) = p.decls.iter().find(|d| matches!(d, Decl::Component { is_default: true, .. })) { + // CP-4a: what JS imports from an entry + self.w.emit(Code::of("declare const __main: __.Main; export default __main;"), *line); + } 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(); @@ -1002,3 +1006,32 @@ fn class_map(css: &str) -> String { let fields: Vec = r.class_map.into_iter().map(|c| format!("{}: \"\" as string", js_str(&c.class_name))).collect(); format!("({{ {} }} as const)", fields.join(", ")) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::load_modules; + use std::collections::HashMap; + + #[test] + fn an_entry_default_is_the_apps_main_and_its_component_keeps_its_mappings() { + let files: HashMap<&str, &str> = [ + ("app/main.rx", "import { Badge } from \"./badge.rx\"\nexport default component App() {\n source n := 0\n \n}\n"), + ("app/badge.rx", "export component Badge(props: { n: number }) {\n {props.n}\n}\n"), + ] + .into_iter() + .collect(); + let set = load_modules("app/main.rx", &|p| files.get(p).map(|s| s.to_string())).expect("loads"); + let out = project_modules(&set); + let main = out.iter().find(|p| p.rx == "app/main.rx").unwrap(); + assert!(main.code.ends_with("\ndeclare const __main: __.Main; export default __main;"), "{}", main.code); + assert_eq!(main.code.matches("export default").count(), 1); + assert_eq!(main.lines.len(), main.code.lines().count()); + let at = main.code.find("export function App(").unwrap() + "export function ".len(); + let src = files["app/main.rx"].find("App").unwrap(); + assert!(main.mappings.iter().any(|m| m.gen as usize == at && m.src as usize == src && m.len == 3)); + let dep = out.iter().find(|p| p.rx == "app/badge.rx").unwrap(); + assert!(!dep.code.contains("export default"), "{}", dep.code); + assert!(PRELUDE.contains("export type Main = (app: object) => () => View;")); + } +} diff --git a/ref/types/project.ts b/ref/types/project.ts index 4cded7c..7c24ac4 100644 --- a/ref/types/project.ts +++ b/ref/types/project.ts @@ -53,6 +53,8 @@ export declare function wire(f: (...a: A) => Promise, /** 07 §6: a view (\`props.children: View\`); R-f-10: the class a panic is. */ export type View = JSX.Element; export type Panic = Error; +/** CP-4a: an entry's default export, a function of the app (\`createApp\`'s, either build) giving the view \`app.mount\` takes. */ +export type Main = (app: object) => () => View; export interface Ctx { readonly __ctx: T } export declare function ctx(d?: T): Ctx; export declare function use(c: Ctx): T; @@ -125,6 +127,8 @@ class Proj { const body = new W(); const saved = this.w; this.w = body; this.decls(p.decls, "", true); if (p.view.length) { this.w.emit(`export const __view = (`, p.view[0].line ?? 1); this.markup(p.view, " "); this.w.emit(`);`, p.view[p.view.length - 1].line ?? 1); } + const main = p.decls.find(d => d.k === "component" && d.isDefault); + if (main) this.w.emit(`declare const __main: __.Main; export default __main;`, main.line); // CP-4a: what JS imports from an entry this.w = saved; const prelude = [...this.used].filter(n => !this.declared.has(n)); const types = ["View", "Panic"].filter(n => !this.declared.has(n)); diff --git a/tooling/rx-language-server/src/index.js b/tooling/rx-language-server/src/index.js index b6f7582..fea7d2a 100644 --- a/tooling/rx-language-server/src/index.js +++ b/tooling/rx-language-server/src/index.js @@ -13,6 +13,8 @@ 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"); +/** `include` globs take `.d.rx` too; Volar's tsc stops on a program file with no script, so it is an empty module. */ +const isDeclaration = f => f.endsWith(".d.rx"); const snapshotOf = text => ({ getText: (s, e) => text.slice(s, e), getLength: () => text.length, getChangeRange: () => undefined }); @@ -47,11 +49,19 @@ function virtualCode(file, snapshot) { }; } +function declarationCode(snapshot) { + return { id: "root", languageId: "reactivescript-declaration", snapshot, mappings: [], embeddedCodes: [{ id: "ts", languageId: "typescript", snapshot: snapshotOf("export {};\n"), 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; }, + getLanguageId(id) { + const f = String(id.path ?? id); + return isModule(f) ? "reactivescript" : isDeclaration(f) ? "reactivescript-declaration" : undefined; + }, createVirtualCode(id, languageId, snapshot) { + if (languageId === "reactivescript-declaration" || isDeclaration(asFileName(id))) return declarationCode(snapshot); if (languageId !== "reactivescript" || !isModule(asFileName(id))) return undefined; return virtualCode(asFileName(id), snapshot); }, @@ -59,7 +69,9 @@ function rxLanguagePlugin(ts, asFileName = uri => uri.fsPath ?? String(uri)) { 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; + if (tsx) return { code: tsx, extension: ".tsx", scriptKind: ts.ScriptKind.TSX }; + const decl = root.embeddedCodes?.find(c => c.id === "ts"); + return decl ? { code: decl, extension: ".ts", scriptKind: ts.ScriptKind.TS } : undefined; }, }, }; diff --git a/tooling/rx-language-server/test/tsc.js b/tooling/rx-language-server/test/tsc.js index de5cc42..532302d 100644 --- a/tooling/rx-language-server/test/tsc.js +++ b/tooling/rx-language-server/test/tsc.js @@ -1,5 +1,7 @@ // `rx-tsc` (bin/tsc.js) on a copy of examples/shop: a clean program passes; a TypeScript type error, a parse error and a -// checker error each fail it, placed on the .rx. Same prerequisites as test/run.js. +// checker error each fail it, placed on the .rx. The README's entry (`app.mount(main(app))`) type-checks against the +// runtime's types, and a `.d.rx` in `include` is no file of the program's. Same prerequisites as test/run.js, and the +// runtime built (`npm run build` in runtime/). // node test/tsc.js VERBOSE=1 prints each run's output const { spawnSync } = require("node:child_process"); const fs = require("node:fs"); @@ -16,16 +18,27 @@ fs.writeFileSync(path.join(ws, "api.d.ts"), [ "export declare function validateCoupon(code: string): Promise;", "", ].join("\n")); -fs.symlinkSync(path.join(repo, "ref/node_modules"), path.join(ws, "node_modules"), "dir"); -fs.writeFileSync(path.join(ws, "tsconfig.json"), JSON.stringify({ +const nm = path.join(ws, "node_modules"); +fs.mkdirSync(path.join(nm, "@reactivescript"), { recursive: true }); +for (const f of fs.readdirSync(path.join(repo, "ref/node_modules")).filter(f => !f.startsWith("."))) fs.symlinkSync(path.join(repo, "ref/node_modules", f), path.join(nm, f), "dir"); +fs.symlinkSync(path.join(repo, "runtime"), path.join(nm, "@reactivescript/runtime"), "dir"); +const tsconfig = include => 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"], + include, }, null, 2)); const original = Object.fromEntries(fs.readdirSync(ws).filter(f => f.endsWith(".rx")).map(f => [f, fs.readFileSync(path.join(ws, f), "utf8")])); -function run(edit) { +const added = new Set(); +function run(...edits) { + for (const f of added) fs.rmSync(path.join(ws, f)); + added.clear(); + tsconfig(["*.rx", "*.ts"]); for (const [f, t] of Object.entries(original)) fs.writeFileSync(path.join(ws, f), t); - if (edit) fs.writeFileSync(path.join(ws, edit.file), edit.text(original[edit.file])); + for (const edit of edits) { + if (edit.include) { tsconfig(edit.include); continue; } + if (!(edit.file in original)) added.add(edit.file); + fs.writeFileSync(path.join(ws, edit.file), edit.text(original[edit.file])); + } const r = spawnSync(process.execPath, [path.join(__dirname, "../bin/tsc.js")], { cwd: ws, encoding: "utf8" }); return { status: r.status, out: r.stdout + r.stderr }; } @@ -49,5 +62,25 @@ check("a parse error fails (no projection, so TypeScript alone would pass it)", const write = run({ file: "main.rx", text: t => t + "\naction wipe() { products := [] }\n" }); check("a checker error fails, with its rule", write.status !== 0 && /main\.rx:\d+:\d+ - error WRITE_TO_NON_SOURCE: .*\(MO-2\)/.test(write.out), write); +// the README's entry, as written: a compiled entry's default export is `main(__rt)` (spec/11 CP-4a) +const readme = fs.readFileSync(path.join(repo, "README.md"), "utf8").match(/```js\n\/\/ src\/main\.js\n([^]*?)```/)[1].replace("./app.rx", "./main.rx"); +const entry = run({ file: "entry.ts", text: () => readme }); +check("the README's entry type-checks (the projection's default is the app's main)", entry.status === 0 && entry.out.trim() === "", entry); + +const server = run({ file: "entry.ts", text: () => 'import { createApp } from "@reactivescript/runtime/server";\nimport main from "./main.rx";\nconst app = createApp();\napp.stream(main(app), { write() {}, end() {} });\n' }); +check("so does the server's (`app.stream(main(app), sink)`)", server.status === 0 && server.out.trim() === "", server); + +const uncalled = run({ file: "entry.ts", text: () => readme.replace("main(app)", "main") }); +check("the default is typed: mounting `main` uncalled fails", uncalled.status !== 0 && /entry\.ts\(\d+,\d+\): error TS2345/.test(uncalled.out), uncalled); + +// MO-8a: a `.d.rx` in `include` (the README's `"include": ["src"]`), with the JS module it declares +const decl = run( + { include: ["."] }, + { file: "names.d.rx", text: () => "export pure fn shout(s: string): string\n" }, + { file: "names.ts", text: () => "export const shout = (s: string): string => s.toUpperCase();\n" }, + { file: "main.rx", text: t => t.replace('import { Row, Panel } from "./ui.rx"', 'import { Row, Panel } from "./ui.rx"\nimport { shout } from "./names"').replace('title="The shop"', 'title={shout("The shop")}') }, +); +check("a .d.rx in include passes", decl.status === 0 && decl.out.trim() === "", decl); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); From 4c62f78f6b9a3cea8725753267e64b045f192e36 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 13 Nov 2026 21:09:10 -0600 Subject: [PATCH 2/2] Build the runtime before the language server tests in CI --- .github/workflows/editor.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/editor.yml b/.github/workflows/editor.yml index 10042da..2caa5db 100644 --- a/.github/workflows/editor.yml +++ b/.github/workflows/editor.yml @@ -13,9 +13,9 @@ name: editor on: push: branches: [main] - paths: ["compiler/**", "tooling/**", ".github/workflows/editor.yml"] + paths: ["compiler/**", "tooling/**", "runtime/**", "ref/compile/rt-*.ts", ".github/workflows/editor.yml"] pull_request: - paths: ["compiler/**", "tooling/**", ".github/workflows/editor.yml"] + paths: ["compiler/**", "tooling/**", "runtime/**", "ref/compile/rt-*.ts", ".github/workflows/editor.yml"] workflow_dispatch: jobs: @@ -41,6 +41,8 @@ jobs: 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: The runtime (test/tsc.js checks an entry against its types) + run: npm --prefix runtime ci && npm --prefix runtime run build - name: Language server tests working-directory: tooling/rx-language-server run: |