Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/editor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand Down
4 changes: 3 additions & 1 deletion compiler/src/prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export declare function eq<T>(v: T, ok: Async<T> extends true ? "MISSING_AWAIT:
export declare function first<T, I>(v: T, init: I): T | I;
export declare function pre<T>(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<T, K extends string>(v: T, ...keys: K[]): Omit<T, K>;
export declare function caught<T, R>(v: T, alt: (e: any) => R): T | R;
export declare function raise(e: unknown): never;
Expand All @@ -30,6 +30,8 @@ export declare function wire<A extends unknown[], R>(f: (...a: A) => Promise<R>,
/** 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<T> { readonly __ctx: T }
export declare function ctx<T>(d?: T): Ctx<T>;
export declare function use<T>(c: Ctx<T>): T;
Expand Down
33 changes: 33 additions & 0 deletions compiler/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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();
Expand Down Expand Up @@ -1002,3 +1006,32 @@ fn class_map(css: &str) -> String {
let fields: Vec<String> = 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 <Badge n={n} />\n}\n"),
("app/badge.rx", "export component Badge(props: { n: number }) {\n <b>{props.n}</b>\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;"));
}
}
4 changes: 4 additions & 0 deletions ref/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export declare function wire<A extends unknown[], R>(f: (...a: A) => Promise<R>,
/** 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<T> { readonly __ctx: T }
export declare function ctx<T>(d?: T): Ctx<T>;
export declare function use<T>(c: Ctx<T>): T;
Expand Down Expand Up @@ -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));
Expand Down
16 changes: 14 additions & 2 deletions tooling/rx-language-server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -47,19 +49,29 @@ 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);
},
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;
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;
},
},
};
Expand Down
45 changes: 39 additions & 6 deletions tooling/rx-language-server/test/tsc.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -16,16 +18,27 @@ fs.writeFileSync(path.join(ws, "api.d.ts"), [
"export declare function validateCoupon(code: string): Promise<number>;",
"",
].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 };
}
Expand All @@ -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);
Loading