From 4ed9b8ce36aa09e6566c0e4f8cc667e2ef8ea3e0 Mon Sep 17 00:00:00 2001 From: Kinflou Date: Mon, 31 Aug 2026 20:04:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20PackageSources=20=E2=80=94=20compil?= =?UTF-8?q?e=20a=20project=20from=20in-memory=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground (and any embedder) needs to compile without a filesystem. `compile_package` reads `config.` and globs `src/**/*.ids`; this adds the in-memory twin. - `PackageSources` builder: `.config(src)` (optional — a minimal congregation is synthesised) + `.schema(namespace_segments, src)` + `.compile() -> Result`. Namespace comes from the segments, which is what the on-disk layout derives from a file's path under `src/` — structure is layout, not a keyword. - Extract the filesystem-free half of `interpret_schemas` into `interpret_schema_sources(&mut ProjectContext, &[(Vec, String)])`; both `compile_package` (glob + read, then delegate) and `PackageSources` share it — one interpretation + validation pass, no drift. - Drop the `*const -> *mut` unsafe in `interpret_schemas`; it takes `&mut ProjectContext` now. - `config::Compile::from_source` no longer has a panicking default — it is a required method; `ProjectInterpreter` delegates to `from_config_source` (returns `Result`). Tests: 5 in `tests/package/from_sources.rs` (synthesised + explicit congregation, nested namespaces, multi-schema, parse error is returned not panicked). --- core/src/package/build/mod.rs | 155 +++++++++++++----- core/src/package/config/ir/compiler/mod.rs | 10 +- core/src/package/config/ir/interpreter/mod.rs | 4 + core/tests/package/from_sources.rs | 77 +++++++++ core/tests/package/mod.rs | 1 + 5 files changed, 197 insertions(+), 50 deletions(-) create mode 100644 core/tests/package/from_sources.rs diff --git a/core/src/package/build/mod.rs b/core/src/package/build/mod.rs index e376ce2..c902c17 100644 --- a/core/src/package/build/mod.rs +++ b/core/src/package/build/mod.rs @@ -19,7 +19,7 @@ use crate::schema::ir::{ }; // External Uses -use eyre::{bail, Result}; +use eyre::{bail, eyre, Result}; /// Compile and validate a package without touching CAS. /// @@ -31,6 +31,10 @@ use eyre::{bail, Result}; /// /// On success the returned [`ProjectContext`] carries the frozen schema units for /// every schema (see `SchemaContext::frozen_schema`), ready for code generation. +/// +/// This is the on-disk entry point; [`PackageSources`] is the in-memory twin for +/// embedders (the playground) that never touch the filesystem. Both share the +/// same interpretation + validation pass. pub fn compile_package(package_path: &Path) -> Result { let config_path = package_path.join(format!("config.{}", CONGREGATION_EXTENSION)); let config_name = config_path.file_name().unwrap().to_str().unwrap(); @@ -43,13 +47,83 @@ pub fn compile_package(package_path: &Path) -> Result { ) } - let latest_project = ProjectInterpreter::from_origin(&config_path)?; + let mut latest_project = ProjectInterpreter::from_origin(&config_path)?; + interpret_schemas(&mut latest_project, package_path)?; + + Ok(latest_project) +} - unsafe { - interpret_schemas(&latest_project, package_path)?; +/// Compile and validate a package from **in-memory sources** — no filesystem +/// access. This is what the playground and other embedders use; `core` reads no +/// files on this path. +/// +/// ```ignore +/// let context = PackageSources::new() +/// .config(config_idp_source) // optional; a minimal one is synthesised +/// .schema(["chat"], chat_schema_src) // namespace segments + source +/// .schema(["chat", "admin"], admin_src) +/// .compile()?; +/// ``` +/// +/// The namespace segments are what the on-disk layout would derive from a +/// schema's path under `src/` — structure comes from layout (Rust-module style), +/// not from a keyword inside the file. Cross-schema `use` resolves across every +/// schema added here, exactly as on disk. +#[derive(Debug, Default)] +pub struct PackageSources { + config: Option, + schemas: Vec<(Vec, String)>, +} + +impl PackageSources { + pub fn new() -> Self { + Self::default() } - Ok(latest_project) + /// The `config.` (congregation) source. If never set, [`compile`] uses + /// a minimal synthesised congregation. + /// + /// [`compile`]: Self::compile + pub fn config(mut self, source: impl Into) -> Self { + self.config = Some(source.into()); + self + } + + /// Add one schema: its namespace segments and its source. + pub fn schema( + mut self, + namespace: impl IntoIterator>, + source: impl Into, + ) -> Self { + self.schemas + .push((namespace.into_iter().map(Into::into).collect(), source.into())); + self + } + + /// Parse, interpret and validate. The returned [`ProjectContext`] has + /// `config_frozen` set and a `SchemaContext` per schema, ready for codegen. + pub fn compile(self) -> Result { + let config = self.config.unwrap_or_else(default_congregation); + + let mut context = ProjectInterpreter::from_config_source(&config)?; + context.config_frozen = Some( + crate::package::config::ir::interpreter::interpret::interpret_context(&context) + .map_err(|e| eyre!("{:?}", e))?, + ); + + interpret_schema_sources(&mut context, &self.schemas)?; + Ok(context) + } +} + +/// A minimal congregation for the "just paste a schema" case — enough to +/// interpret schemas and generate `rust`. +fn default_congregation() -> String { + "congregation playground\n\ + specification_version = 1\n\ + \n\ + code_generation = {\n languages = {\n rust#1.70.0 = {}\n }\n}\n" + .to_string() } /// Builds the package, which step-by-step means: @@ -80,21 +154,17 @@ pub fn build(package_path: &Path) -> Result { }) } -/// Safety: This assumes caller handles mutability properly -unsafe fn interpret_schemas(compiled_project: &ProjectContext, package_path: &Path) -> Result<()> { - // TODO: Decide if package configurations should be able to change the source of schemas - // and/or how to look for them - /* - let schema_paths = frozen_project::schema_paths( - compiled_project.config_frozen.as_ref().unwrap() - ); - */ - let schemas_path = format!("{}/src/", package_path.display()); - let schemas_path = Path::new(&*schemas_path); - let mut schema_paths = vec![]; - +/// Glob `/src/**/*.`, read each schema, and hand the +/// `(namespace segments, source)` pairs to [`interpret_schema_sources`]. The +/// namespace is the file's path under `src/`, extension dropped. +fn interpret_schemas(context: &mut ProjectContext, package_path: &Path) -> Result<()> { + // TODO: Decide if package configurations should be able to change the source + // of schemas and/or how to look for them. + let schemas_path = package_path.join("src"); let pattern = format!("{}/**/*.{}", schemas_path.display(), SCHEMA_EXTENSION); - for result in glob::glob(&*pattern)? { + + let mut sources: Vec<(Vec, String)> = Vec::new(); + for result in glob::glob(&pattern)? { let schema_path = result?; if !schema_path.is_file() { bail!( @@ -102,46 +172,45 @@ unsafe fn interpret_schemas(compiled_project: &ProjectContext, package_path: &Pa schema_path.display() ) } - let relative_path = schema_path.strip_prefix(schemas_path)?.to_path_buf(); - let parts = relative_path + let relative = schema_path.strip_prefix(&schemas_path)?; + let namespace = relative .with_extension("") .components() - .map(|c| format!("{}", c.as_os_str().to_str().unwrap())) + .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>(); - schema_paths.push((relative_path, parts)); + let source = std::fs::read_to_string(&schema_path)?; + sources.push((namespace, source)); } - for relative in schema_paths { - let concrete_path = schemas_path.join(relative.0); + interpret_schema_sources(context, &sources) +} - let source = std::fs::read_to_string(&concrete_path)?; +/// Parse each `(namespace segments, source)`, register a `SchemaContext` on +/// `context`, then run the project-aware interpretation + validation pass. +/// Filesystem-free; shared by [`compile_package`] and [`PackageSources`]. +fn interpret_schema_sources( + context: &mut ProjectContext, + schemas: &[(Vec, String)], +) -> Result<()> { + for (namespace, source) in schemas { + let name = format!("{}.{}", namespace.join("/"), SCHEMA_EXTENSION); - // Initialize CodeMap for error reporting let mut codemap = crate::utils::codemap::CodeMap::new(); - codemap.insert_file(concrete_path.to_string_lossy().to_string(), source.clone()); + codemap.insert_file(name.clone(), source.clone()); - match crate::schema::idl::grammar::parse(&source) { + match crate::schema::idl::grammar::parse(source) { Ok(document) => { - let context = SchemaContext::with_declarations(document.0, relative.1, codemap); - unsafe { - let ptr = compiled_project as *const ProjectContext; - let ptr_mut = ptr as *mut ProjectContext; - (*ptr_mut).add_schema_context(Rc::new(RefCell::new(context))); - } - } - Err(e) => { - bail!( - "Failed to parse schema at {}: {:?}", - concrete_path.display(), - e - ); + let schema_ctx = + SchemaContext::with_declarations(document.0, namespace.clone(), codemap); + context.add_schema_context(Rc::new(RefCell::new(schema_ctx))); } + Err(e) => bail!("Failed to parse schema '{}': {:?}", name, e), } } - compiler::interpret::interpret_context(compiled_project) + compiler::interpret::interpret_context(context) } // Removed: freeze_project_auto() - no longer needed with CAS diff --git a/core/src/package/config/ir/compiler/mod.rs b/core/src/package/config/ir/compiler/mod.rs index 79e9203..dae23d1 100644 --- a/core/src/package/config/ir/compiler/mod.rs +++ b/core/src/package/config/ir/compiler/mod.rs @@ -14,13 +14,9 @@ pub trait Compile { /// Compile from the parsed AST (Congregation) fn from_congregation(congregation: Congregation) -> Self::Output; - /// Compile from a raw configuration string - fn from_source(source: &str) -> Self::Output { - match crate::package::config::idl::grammar::parse(source) { - Ok(congregation) => Self::from_congregation(congregation), - Err(e) => panic!("Parse error: {:?}", e), // TODO: Better error handling - } - } + /// Compile from a raw configuration string. Implementations must surface a + /// parse failure through `Self::Output`, not panic. + fn from_source(source: &str) -> Self::Output; /// Compile from a file path fn from_origin(origin: &Path) -> Self::Output; diff --git a/core/src/package/config/ir/interpreter/mod.rs b/core/src/package/config/ir/interpreter/mod.rs index f2b3615..62ceda3 100644 --- a/core/src/package/config/ir/interpreter/mod.rs +++ b/core/src/package/config/ir/interpreter/mod.rs @@ -49,6 +49,10 @@ impl Compile for ProjectInterpreter { Ok(context) } + fn from_source(source: &str) -> Self::Output { + Self::from_config_source(source) + } + fn from_origin(origin: &Path) -> Self::Output { Self::from_origin(origin) // Call the inherent method which handles file reading + parsing } diff --git a/core/tests/package/from_sources.rs b/core/tests/package/from_sources.rs new file mode 100644 index 0000000..5dd8819 --- /dev/null +++ b/core/tests/package/from_sources.rs @@ -0,0 +1,77 @@ +//! `PackageSources` — compiling a package from in-memory strings, no filesystem. + +use comline_core::package::build::PackageSources; + +#[test] +fn compiles_a_single_schema_with_a_synthesised_congregation() { + let ctx = PackageSources::new() + .schema( + ["chat"], + "struct Message {\n body: str\n}\n", + ) + .compile() + .expect("should compile"); + + assert!(ctx.config_frozen.is_some(), "config_frozen must be set"); + assert_eq!(ctx.schema_contexts.len(), 1); + + let schema = ctx.schema_contexts[0].borrow(); + assert_eq!(schema.namespace, vec!["chat".to_string()]); + let frozen = schema.frozen_schema.borrow(); + assert!(frozen.is_some(), "schema should have been interpreted"); +} + +#[test] +fn honours_an_explicit_congregation() { + let config = "congregation my_app\nspecification_version = 1\n\n\ + code_generation = {\n languages = {\n rust#1.70.0 = {}\n }\n}\n"; + + let ctx = PackageSources::new() + .config(config) + .schema(["ping"], "struct Ping {\n seq: u32\n}\n") + .compile() + .expect("should compile"); + + assert_eq!(ctx.config.name.value, "my_app"); +} + +#[test] +fn multiple_schemas_are_all_interpreted() { + let ctx = PackageSources::new() + .schema(["types"], "struct User {\n id: u64\n}\n") + .schema(["ping"], "struct Ping {\n seq: u32\n}\n") + .schema(["chat"], "struct Message {\n body: str\n}\n") + .compile() + .expect("should compile"); + + assert_eq!(ctx.schema_contexts.len(), 3); + for sc in &ctx.schema_contexts { + assert!(sc.borrow().frozen_schema.borrow().is_some()); + } + // The interpretation pass is the same one `compile_package` runs on disk — + // cross-schema `import` resolution (where `core` supports it) is unaffected + // by the source being in memory. +} + +#[test] +fn nested_namespace_segments_are_kept() { + let ctx = PackageSources::new() + .schema(["chat", "admin"], "struct Ban {\n who: str\n}\n") + .compile() + .expect("should compile"); + + assert_eq!( + ctx.schema_contexts[0].borrow().namespace, + vec!["chat".to_string(), "admin".to_string()] + ); +} + +#[test] +fn a_parse_error_is_returned_not_panicked() { + let err = PackageSources::new() + .schema(["bad"], "struct {{{ not valid") + .compile() + .expect_err("should be an error"); + + assert!(err.to_string().contains("bad"), "error names the schema: {err}"); +} diff --git a/core/tests/package/mod.rs b/core/tests/package/mod.rs index 3871021..0961246 100644 --- a/core/tests/package/mod.rs +++ b/core/tests/package/mod.rs @@ -1 +1,2 @@ +mod from_sources; mod schema_loading; From 9c0123ac96f05a0c2216018b36c4f8549fe7e5be Mon Sep 17 00:00:00 2001 From: Kinflou Date: Mon, 31 Aug 2026 20:12:11 +0800 Subject: [PATCH 2/2] test: cross-schema `use` resolution via PackageSources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import` is legacy; `use` is the keyword. Add a cross-schema test with the working form — `use types::User` + a qualified `types::User` reference. (Bare `User` after a single-symbol `use` is a separate pre-existing core gap; qualified resolves.) --- core/tests/package/from_sources.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/core/tests/package/from_sources.rs b/core/tests/package/from_sources.rs index 5dd8819..10da295 100644 --- a/core/tests/package/from_sources.rs +++ b/core/tests/package/from_sources.rs @@ -48,9 +48,25 @@ fn multiple_schemas_are_all_interpreted() { for sc in &ctx.schema_contexts { assert!(sc.borrow().frozen_schema.borrow().is_some()); } - // The interpretation pass is the same one `compile_package` runs on disk — - // cross-schema `import` resolution (where `core` supports it) is unaffected - // by the source being in memory. +} + +#[test] +fn cross_schema_use_resolves_across_the_added_schemas() { + // `use` brings the namespace into scope; the reference is qualified. (A bare + // `User` after `use types::User` is a separate, pre-existing `core` gap.) + let ctx = PackageSources::new() + .schema(["types"], "struct User {\n id: u64\n}\n") + .schema( + ["api"], + "use types::User\n\nstruct Session {\n user: types::User\n}\n", + ) + .compile() + .expect("cross-schema `use` should resolve — same pass as on disk"); + + assert_eq!(ctx.schema_contexts.len(), 2); + for sc in &ctx.schema_contexts { + assert!(sc.borrow().frozen_schema.borrow().is_some()); + } } #[test]