From 46ac5844fe09767b02095475ec64eba576024ba7 Mon Sep 17 00:00:00 2001 From: Kinflou <149606337+Kinflou@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:09:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(sim):=20describe=5Fproject=20=E2=80=94?= =?UTF-8?q?=20the=20frozen=20IR=20as=20a=20protocol=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1a of the playground simulation (docs: design/playground-simulation.md). `describe_project(files)` returns a `ProjectShape`: per schema, the `::`-namespace, `ir_hash`, every protocol (name, framing from `@framing`, functions), errors (ordinal, name, message, fields) and types (structs / enums) — all read straight off the frozen units `interpret_project` already produces. No new `comline-core` surface. Function shapes carry the 0-based index (the `Call` id `resolveKind` matches), `oneway` (`_return` is None), args and return as a `TypeRef` (`prim` / `ref` / `array` / `unit` / `union` — classified from the `Namespaced("T[]")` strings the freezer emits), and `throws` joined to the error table. `ir_hash` is `comline_core::schema::ir::frozen::schema_ir_hash(units)` — the exact call both generators make — so a sim connection's handshake mirrors a real one. Verified: `describe_project`'s `ir_hash` equals the `IR_HASH` constant `generate_project` emits for the same schema. `shape.ts` mirrors the output 1:1; the worker gains a `describeProject` command. No UI yet (that's 1d). --- app/src/sim/shape.ts | 70 +++++++++++ app/src/worker.ts | 18 ++- wasm/src/lib.rs | 280 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 app/src/sim/shape.ts diff --git a/app/src/sim/shape.ts b/app/src/sim/shape.ts new file mode 100644 index 0000000..70f4424 --- /dev/null +++ b/app/src/sim/shape.ts @@ -0,0 +1,70 @@ +/// The shape of a compiled project as the simulator sees it — a 1:1 mirror of +/// the WASM `describe_project` output. Everything here is derived from the +/// frozen IR; nothing is re-implemented. + +export interface ProjectShape { + schemas: SchemaShape[]; +} + +export interface SchemaShape { + /** `::`-joined, e.g. `chat` or `wire::frame`. */ + namespace: string; + /** `0x`-prefixed 16 hex digits — the value the generators emit as `IR_HASH`. */ + ir_hash: string; + protocols: ProtocolShape[]; + errors: ErrorShape[]; + /** Every struct / enum in the schema, for the call form's nested inputs. */ + types: TypeDef[]; +} + +export interface ProtocolShape { + name: string; + framing: "datagram" | "jsonrpc"; + functions: FnShape[]; +} + +export interface FnShape { + name: string; + /** 0-based position in the protocol — the `Call` id `resolveKind` matches. */ + index: number; + /** No return at all — a fire-and-forget `notify`. */ + oneway: boolean; + args: ArgShape[]; + returns: TypeRef | null; + throws: ThrowShape[]; +} + +export interface ArgShape { + name: string; + ty: TypeRef; +} + +export interface ThrowShape { + ordinal: number; + /** The error's name, or `""` for a bare `throws` slot. */ + name: string; +} + +export interface ErrorShape { + ordinal: number; + name: string; + message: string; + fields: FieldShape[]; +} + +export interface FieldShape { + name: string; + ty: TypeRef; + optional: boolean; +} + +export type TypeDef = + | { kind: "struct"; name: string; fields: FieldShape[] } + | { kind: "enum"; name: string; variants: string[] }; + +export type TypeRef = + | { kind: "prim"; name: string } + | { kind: "ref"; name: string } + | { kind: "array"; of: TypeRef } + | { kind: "unit" } + | { kind: "union"; of: TypeRef[] }; diff --git a/app/src/worker.ts b/app/src/worker.ts index f61fe4a..4487dd7 100644 --- a/app/src/worker.ts +++ b/app/src/worker.ts @@ -1,18 +1,22 @@ /// The compile worker: loads the Comline WASM module once, then answers -/// `compileProject` / `generateProject` / `semanticTokens` / `hover` / -/// `completions` requests off the main thread. The editor services call the -/// language server's handlers verbatim, so the editor matches `comline-lsp`; -/// `compileProject` / `generateProject` run the schema set as one package, so -/// cross-file `use` resolves the way `comline build` resolves it. +/// `compileProject` / `generateProject` / `describeProject` / `semanticTokens` / +/// `hover` / `completions` requests off the main thread. The editor services +/// call the language server's handlers verbatim, so the editor matches +/// `comline-lsp`; `compileProject` / `generateProject` / `describeProject` run +/// the schema set as one package, so cross-file `use` resolves the way +/// `comline build` resolves it. import init, { compile_project, generate_project, + describe_project, semantic_tokens, hover, completions, } from "./wasm/comline_playground_wasm.js"; +export type { ProjectShape } from "./sim/shape.ts"; + // ── result shapes (a thin slice of lsp-types) ───────────────────────────── export interface LspPosition { line: number; @@ -74,6 +78,7 @@ export interface Hover { type Req = | { id: number; cmd: "compileProject"; files: FileInput[] } | { id: number; cmd: "generateProject"; files: FileInput[]; target: string; mode: string } + | { id: number; cmd: "describeProject"; files: FileInput[] } | { id: number; cmd: "semanticTokens"; source: string } | { id: number; cmd: "hover"; source: string; line: number; character: number } | { id: number; cmd: "completions"; source: string; line: number; character: number }; @@ -92,6 +97,9 @@ self.onmessage = async (e: MessageEvent) => { case "generateProject": result = generate_project(req.files, req.target, req.mode); break; + case "describeProject": + result = describe_project(req.files); + break; case "semanticTokens": result = semantic_tokens(req.source); break; diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 4348d60..6bdb3d4 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -11,11 +11,15 @@ //! resolution, then per-file diagnostics (parse errors + `comline-core` //! validation) and per-file IR. //! - [`generate_project`] — the whole set's frozen IR → generated files. +//! - [`describe_project`] — the frozen IR as a machine-readable protocol +//! description (namespace, `ir_hash`, functions, errors, types) for the +//! simulation to drive. //! - [`semantic_tokens`] / [`hover`] / [`completions`] — the LSP handlers, run //! against the active file, so highlighting / hover / autocomplete match //! `comline-lsp`. use std::cell::RefCell; +use std::collections::HashSet; use std::rc::Rc; use serde::{Deserialize, Serialize}; @@ -23,8 +27,10 @@ use wasm_bindgen::prelude::*; use comline_core::package::config::ir::interpreter::ProjectInterpreter; use comline_core::schema::idl::grammar::Declaration; +use comline_core::schema::ir::compiler::interpreted::kind_search::{KindValue, Primitive}; use comline_core::schema::ir::compiler::interpreter::incremental::IncrementalInterpreter; use comline_core::schema::ir::context::SchemaContext; +use comline_core::schema::ir::frozen::schema_ir_hash; use comline_core::schema::ir::frozen::unit::FrozenUnit; use comline_core::schema::ir::validation::{self, ValidationError}; use comline_core::utils::codemap::CodeMap; @@ -310,6 +316,280 @@ fn plain_error(message: String) -> Diagnostic { } } +// ── protocol description (drives the simulation) ───────────────────────── + +#[derive(Serialize)] +struct ProjectShape { + schemas: Vec, +} + +#[derive(Serialize)] +struct SchemaShape { + /// `::`-joined, e.g. `chat` or `wire::frame`. + namespace: String, + /// `comline-core`'s `schema_ir_hash` as `0x`-prefixed 16 hex digits — the + /// value the generators emit as `IR_HASH`, so a sim connection's handshake + /// mirrors a real one. + ir_hash: String, + protocols: Vec, + errors: Vec, + /// Every struct / enum in the schema, so the call form can render nested + /// inputs. + types: Vec, +} + +#[derive(Serialize)] +struct ProtocolShape { + name: String, + /// `"datagram"` | `"jsonrpc"` — from the protocol's `@framing`, else the + /// datagram default. + framing: String, + functions: Vec, +} + +#[derive(Serialize)] +struct FnShape { + name: String, + /// 0-based position in the protocol — the `Call` id `resolveKind` matches. + index: u32, + /// No `_return` at all — a fire-and-forget `notify`, not `Some(Unit)`. + oneway: bool, + args: Vec, + returns: Option, + throws: Vec, +} + +#[derive(Serialize)] +struct ArgShape { + name: String, + ty: TypeRef, +} + +#[derive(Serialize)] +struct ThrowShape { + ordinal: u16, + /// The error's name, or `""` for a bare `throws` slot. + name: String, +} + +#[derive(Serialize)] +struct ErrorShape { + ordinal: u16, + name: String, + message: String, + fields: Vec, +} + +#[derive(Serialize)] +struct FieldShape { + name: String, + ty: TypeRef, + optional: bool, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum TypeDef { + Struct { name: String, fields: Vec }, + Enum { name: String, variants: Vec }, +} + +/// A type reference in a signature. Frozen function args / returns / fields are +/// almost always `KindValue::Namespaced(, None)`; this classifies that +/// string (`u64`, `Message`, `Message[]`, …) into a shape the UI can render. +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum TypeRef { + Prim { name: String }, + Ref { name: String }, + Array { of: Box }, + Unit, + Union { of: Vec }, +} + +const PRIM_NAMES: &[&str] = &[ + "bool", "u8", "u16", "u32", "u64", "u128", "s8", "s16", "s32", "s64", "s128", "f32", "f64", + "float", "str", "string", +]; + +/// Parse and freeze every file, then describe each frozen schema's protocols, +/// errors and types. `files` is `[{ path, source }]`. +#[wasm_bindgen] +pub fn describe_project(files: JsValue) -> JsValue { + let files: Vec = match serde_wasm_bindgen::from_value(files) { + Ok(f) => f, + Err(_) => return to_js(&ProjectShape { schemas: vec![] }), + }; + let frozen = interpret_project(&files).1; + + // Struct / enum names across the whole project, so a cross-file type + // reference still classifies as `ref` (not an opaque scalar). + let known: HashSet<&str> = frozen + .iter() + .flat_map(|(_, units)| units.iter()) + .filter_map(|u| match u { + FrozenUnit::Struct { name, .. } | FrozenUnit::Enum { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); + + let schemas = frozen + .iter() + .map(|(ns_path, units)| describe_schema(ns_path, units, &known)) + .collect(); + to_js(&ProjectShape { schemas }) +} + +fn describe_schema(ns_path: &str, units: &[FrozenUnit], known: &HashSet<&str>) -> SchemaShape { + let errors: Vec = units + .iter() + .filter_map(|u| match u { + FrozenUnit::Error { ordinal, name, message, fields, .. } => Some(ErrorShape { + ordinal: *ordinal, + name: name.clone(), + message: message.clone(), + fields: fields.iter().filter_map(|f| field_shape(f, known)).collect(), + }), + _ => None, + }) + .collect(); + + let types = units + .iter() + .filter_map(|u| match u { + FrozenUnit::Struct { name, fields, .. } => Some(TypeDef::Struct { + name: name.clone(), + fields: fields.iter().filter_map(|f| field_shape(f, known)).collect(), + }), + FrozenUnit::Enum { name, variants, .. } => Some(TypeDef::Enum { + name: name.clone(), + variants: variants.iter().filter_map(enum_variant_name).collect(), + }), + _ => None, + }) + .collect(); + + let protocols = units + .iter() + .filter_map(|u| match u { + FrozenUnit::Protocol { name, parameters, functions, .. } => Some(ProtocolShape { + name: name.clone(), + framing: framing_of(parameters), + functions: functions + .iter() + .enumerate() + .filter_map(|(i, fu)| fn_shape(i as u32, fu, &errors, known)) + .collect(), + }), + _ => None, + }) + .collect(); + + SchemaShape { + namespace: ns_path.replace('/', "::"), + ir_hash: format!("{:#018x}", schema_ir_hash(units)), + protocols, + errors, + types, + } +} + +fn framing_of(params: &[FrozenUnit]) -> String { + for p in params { + if let FrozenUnit::Property { name, expression } = p { + if name == "framing" { + return match expression.as_deref() { + Some("jsonrpc") | Some("jsonrpc-2.0") => "jsonrpc".to_string(), + _ => "datagram".to_string(), + }; + } + } + } + "datagram".to_string() +} + +fn field_shape(u: &FrozenUnit, known: &HashSet<&str>) -> Option { + match u { + FrozenUnit::Field { name, kind_value, optional, .. } => Some(FieldShape { + name: name.clone(), + ty: type_ref(kind_value, known), + optional: *optional, + }), + _ => None, + } +} + +fn enum_variant_name(u: &FrozenUnit) -> Option { + match u { + FrozenUnit::EnumVariant(kv, _) => Some(match kv { + KindValue::EnumVariant(n, _) | KindValue::Namespaced(n, _) => n.clone(), + KindValue::Primitive(p) => prim_name(p), + _ => "?".to_string(), + }), + _ => None, + } +} + +fn fn_shape(index: u32, u: &FrozenUnit, errors: &[ErrorShape], known: &HashSet<&str>) -> Option { + match u { + FrozenUnit::Function { name, arguments, _return, throws, .. } => Some(FnShape { + name: name.clone(), + index, + oneway: _return.is_none(), + args: arguments + .iter() + .map(|a| ArgShape { name: a.name.clone(), ty: type_ref(&a.kind, known) }) + .collect(), + returns: _return.as_ref().map(|k| type_ref(k, known)), + throws: throws + .iter() + .map(|ord| ThrowShape { + ordinal: *ord, + name: errors + .iter() + .find(|e| e.ordinal == *ord) + .map(|e| e.name.clone()) + .unwrap_or_else(|| "".to_string()), + }) + .collect(), + }), + _ => None, + } +} + +fn type_ref(kind: &KindValue, known: &HashSet<&str>) -> TypeRef { + match kind { + KindValue::Unit => TypeRef::Unit, + KindValue::Union(members) => TypeRef::Union { + of: members.iter().map(|m| type_ref(m, known)).collect(), + }, + KindValue::Primitive(p) => TypeRef::Prim { name: prim_name(p) }, + KindValue::EnumVariant(n, _) | KindValue::Namespaced(n, _) => name_ref(n, known), + } +} + +fn name_ref(n: &str, known: &HashSet<&str>) -> TypeRef { + if let Some(elem) = n.strip_suffix("[]") { + return TypeRef::Array { of: Box::new(name_ref(elem, known)) }; + } + if PRIM_NAMES.contains(&n) { + TypeRef::Prim { name: n.to_string() } + } else if known.contains(n) { + TypeRef::Ref { name: n.to_string() } + } else { + // an unresolved import or otherwise unknown name — an opaque scalar + TypeRef::Prim { name: n.to_string() } + } +} + +fn prim_name(p: &Primitive) -> String { + // strum's `Name` prop is empty for `String` / `Namespaced`. + match p.name() { + "" => "string".to_string(), + n => n.to_string(), + } +} + // ── per-file editor services (LSP handlers) ────────────────────────────── #[wasm_bindgen] From 962d80e6776fe1b98ca528d7d249f5515e204336 Mon Sep 17 00:00:00 2001 From: Kinflou <149606337+Kinflou@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:20:48 +0800 Subject: [PATCH 2/2] refactor(sim): drop the primitive-name list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review — it duplicated knowledge that lives in comline-core (which keeps its own private copy in validation::validator). It was also redundant: the grammar reserves the primitive keywords, so a declared struct/enum name can never be `u64` / `string` / etc. `name_ref` now only asks whether a name is a type declared in this project (the IR-built `known` set); anything else — a primitive or an unresolved import — renders as one scalar input, which is the intended fallback either way. Output is byte-identical: the 1a smoke (indices, oneway, throws, nested `string[]`, ir_hash parity) still passes. --- wasm/src/lib.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 6bdb3d4..2925ae4 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -407,11 +407,6 @@ enum TypeRef { Union { of: Vec }, } -const PRIM_NAMES: &[&str] = &[ - "bool", "u8", "u16", "u32", "u64", "u128", "s8", "s16", "s32", "s64", "s128", "f32", "f64", - "float", "str", "string", -]; - /// Parse and freeze every file, then describe each frozen schema's protocols, /// errors and types. `files` is `[{ path, source }]`. #[wasm_bindgen] @@ -568,17 +563,18 @@ fn type_ref(kind: &KindValue, known: &HashSet<&str>) -> TypeRef { } } +/// A frozen signature type is a plain string: `u64`, `Message`, `Message[]`. +/// The only distinction the UI needs is "a type declared in this project" +/// (render its fields) vs. "anything else" (one scalar input) — and the +/// grammar reserves the primitive keywords, so a declared name can never +/// collide with `u64` / `string` / …. That makes `known` (built from the IR) +/// the single source of truth; there is no primitive-name list to keep in +/// sync with `comline-core`. fn name_ref(n: &str, known: &HashSet<&str>) -> TypeRef { - if let Some(elem) = n.strip_suffix("[]") { - return TypeRef::Array { of: Box::new(name_ref(elem, known)) }; - } - if PRIM_NAMES.contains(&n) { - TypeRef::Prim { name: n.to_string() } - } else if known.contains(n) { - TypeRef::Ref { name: n.to_string() } - } else { - // an unresolved import or otherwise unknown name — an opaque scalar - TypeRef::Prim { name: n.to_string() } + match n.strip_suffix("[]") { + Some(elem) => TypeRef::Array { of: Box::new(name_ref(elem, known)) }, + None if known.contains(n) => TypeRef::Ref { name: n.to_string() }, + None => TypeRef::Prim { name: n.to_string() }, } }