Skip to content
Merged
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
155 changes: 112 additions & 43 deletions core/src/package/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<ProjectContext> {
let config_path = package_path.join(format!("config.{}", CONGREGATION_EXTENSION));
let config_name = config_path.file_name().unwrap().to_str().unwrap();
Expand All @@ -43,13 +47,83 @@ pub fn compile_package(package_path: &Path) -> Result<ProjectContext> {
)
}

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<String>,
schemas: Vec<(Vec<String>, String)>,
}

impl PackageSources {
pub fn new() -> Self {
Self::default()
}

Ok(latest_project)
/// The `config.<ext>` (congregation) source. If never set, [`compile`] uses
/// a minimal synthesised congregation.
///
/// [`compile`]: Self::compile
pub fn config(mut self, source: impl Into<String>) -> Self {
self.config = Some(source.into());
self
}

/// Add one schema: its namespace segments and its source.
pub fn schema(
mut self,
namespace: impl IntoIterator<Item = impl Into<String>>,
source: impl Into<String>,
) -> 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<ProjectContext> {
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:
Expand Down Expand Up @@ -80,68 +154,63 @@ pub fn build(package_path: &Path) -> Result<BuildResult> {
})
}

/// 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 `<package>/src/**/*.<ext>`, 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>, String)> = Vec::new();
for result in glob::glob(&pattern)? {
let schema_path = result?;
if !schema_path.is_file() {
bail!(
"Expected a schema file but got a directory at '{}'",
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::<Vec<_>>();

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>, 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
Expand Down
10 changes: 3 additions & 7 deletions core/src/package/config/ir/compiler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions core/src/package/config/ir/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
93 changes: 93 additions & 0 deletions core/tests/package/from_sources.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! `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());
}
}

#[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]
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}");
}
1 change: 1 addition & 0 deletions core/tests/package/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mod from_sources;
mod schema_loading;
Loading