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
4 changes: 2 additions & 2 deletions crates/libgraphql-core-v1/src/error_note.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use crate::span::Span;
/// defined, or where a conflicting field exists)
///
/// This is the schema-layer analogue of
/// [`libgraphql_parser::GraphQLErrorNote`](libgraphql_parser::GraphQLErrorNote)
/// [`libgraphql_parser::GraphQLErrorNote`]
/// in the parser crate. The key difference is that spans here
/// are [`libgraphql_core::Span`]s (deferred byte-offset + source map ID) rather
/// are [`Span`]s (deferred byte-offset + source map ID) rather
/// than pre-resolved `SourceSpan`s, since schema errors may
/// reference locations across multiple source files loaded at
/// different times.
Expand Down
148 changes: 130 additions & 18 deletions crates/libgraphql-core-v1/src/schema/schema_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ use indexmap::IndexMap;
use libgraphql_parser::ast;
use libgraphql_parser::ByteSpan;
use libgraphql_parser::GraphQLErrorNoteKind;
use std::path::Path;
use std::path::PathBuf;

/// Accumulates GraphQL type definitions, directive definitions,
/// and schema metadata, then validates and produces an immutable
Expand Down Expand Up @@ -409,9 +411,113 @@ impl SchemaBuilder {
/// errors are collected into the returned `Err` variant
/// with their original parser spans translated to our
/// [`Span`] type.
///
/// The registered source map carries no source path; when
/// loading multiple sources, prefer
/// [`load_str_with_source_path()`](Self::load_str_with_source_path) so
/// diagnostics can identify which source a location refers
/// to.
pub fn load_str(
&mut self,
source: &str,
) -> Result<&mut Self, Vec<SchemaBuildError>> {
self.load_str_impl(source, /* source_path = */ None)
}

/// Like [`load_str()`](Self::load_str), but labels the
/// registered [`SchemaSourceMap`] with `source_path` (typically
/// the path the source text was read from).
///
/// The source_path is stored as the source map's
/// [`file_path()`](SchemaSourceMap::file_path) and is
/// surfaced by diagnostics that resolve spans from this
/// source, which is especially useful when a schema is
/// assembled from multiple source strings.
///
/// # Example
///
/// ```rust
/// # use libgraphql_core_v1 as libgraphql_core;
/// use libgraphql_core::schema::SchemaBuilder;
/// use std::path::Path;
///
/// let mut builder = SchemaBuilder::new();
/// builder.load_str_with_source_path(
/// "type Query { hello: String }",
/// "schemas/main.graphql",
/// ).unwrap();
/// let schema = builder.build().unwrap();
///
/// assert_eq!(
/// schema.source_maps()[1].file_path(),
/// Some(Path::new("schemas/main.graphql")),
/// );
/// ```
pub fn load_str_with_source_path(
&mut self,
source: &str,
source_path: impl AsRef<Path>,
) -> Result<&mut Self, Vec<SchemaBuildError>> {
self.load_str_impl(source, Some(source_path.as_ref().to_path_buf()))
}

/// Convenience: creates a new `SchemaBuilder` and loads
/// `source` into it in one step.
///
/// Equivalent to [`SchemaBuilder::new()`](Self::new)
/// followed by [`load_str()`](Self::load_str). Additional
/// sources can still be loaded into the returned builder
/// before calling [`build()`](Self::build).
///
/// The error type mirrors `load_str`'s
/// (`Vec<SchemaBuildError>`, the load-phase shape) rather than
/// [`build()`](Self::build)'s `SchemaErrors` — `from_str` is a
/// load-phase API; only `build*` methods return `SchemaErrors`.
///
/// # Example
///
/// ```rust
/// # use libgraphql_core_v1 as libgraphql_core;
/// use libgraphql_core::schema::SchemaBuilder;
///
/// let builder = SchemaBuilder::from_str(
/// "type Query { hello: String }",
/// ).unwrap();
/// let schema = builder.build().unwrap();
///
/// assert!(schema.object_type("Query").is_some());
/// ```
// An inherent `from_str` is intentional (mirroring the other
// string entry points and the v0 API) rather than a
// `std::str::FromStr` impl, which would force callers to
// import the trait for a builder-construction convenience.
#[allow(clippy::should_implement_trait)]
pub fn from_str(source: &str) -> Result<Self, Vec<SchemaBuildError>> {
let mut builder = Self::new();
builder.load_str(source)?;
Ok(builder)
}

/// Like [`from_str()`](Self::from_str), but labels the
/// registered [`SchemaSourceMap`] with `source_path` (see
/// [`load_str_with_source_path()`](Self::load_str_with_source_path)).
pub fn from_str_with_source_path(
source: &str,
source_path: impl AsRef<Path>,
) -> Result<Self, Vec<SchemaBuildError>> {
let mut builder = Self::new();
builder.load_str_with_source_path(source, source_path)?;
Ok(builder)
}

/// Shared implementation for the `load_str` family:
/// registers a [`SchemaSourceMap`] (with `source_path` recorded,
/// if provided) for `source`, parses it, and loads all
/// definitions into this builder.
fn load_str_impl(
&mut self,
source: &str,
source_path: Option<PathBuf>,
) -> Result<&mut Self, Vec<SchemaBuildError>> {
let parse_result =
libgraphql_parser::parse_schema(source);
Expand All @@ -434,7 +540,7 @@ impl SchemaBuilder {
},
};
self.source_maps.push(
SchemaSourceMap::from_source(source, None),
SchemaSourceMap::from_source(source, source_path),
);

// Report parse-level errors with proper spans
Expand Down Expand Up @@ -791,11 +897,11 @@ impl SchemaBuilder {
/// 3. **Empty type checks** -- rejects object/interface types
/// with no fields, unions with no members, and enums with
/// no values.
/// 4. **Type-system validators** -- runs
/// [`ObjectOrInterfaceTypeValidator`],
/// [`UnionTypeValidator`],
/// [`InputObjectTypeValidator`], and
/// [`validate_directive_definitions`] to enforce
/// 4. **Type-system validators** -- runs the (internal)
/// `ObjectOrInterfaceTypeValidator`,
/// `UnionTypeValidator`,
/// `InputObjectTypeValidator`, and
/// `validate_directive_definitions` passes to enforce
/// cross-type reference rules.
///
/// See [Schema](https://spec.graphql.org/September2025/#sec-Schema).
Expand Down Expand Up @@ -1024,9 +1130,24 @@ impl SchemaBuilder {
pub fn build_from_str(
source: &str,
) -> Result<Schema, SchemaErrors> {
let mut sb = Self::new();
sb.load_str(source).map_err(SchemaErrors::new)?;
sb.build()
Self::from_str(source)
.map_err(SchemaErrors::new)
.and_then(Self::build)
}

/// Like [`build_from_str()`](Self::build_from_str), but
/// records `source_path` on the registered [`SchemaSourceMap`] with `source_path`
/// (see [`load_str_with_source_path()`](Self::load_str_with_source_path)).
// TODO: SchemaErrors wraps Vec<SchemaBuildError> which is
// large. Consider boxing once error strategy is finalized.
#[allow(clippy::result_large_err)]
pub fn build_from_str_with_source_path(
source: &str,
source_path: impl AsRef<Path>,
) -> Result<Schema, SchemaErrors> {
Self::from_str_with_source_path(source, source_path)
.map_err(SchemaErrors::new)
.and_then(Self::build)
}

// ---------------------------------------------------------
Expand Down Expand Up @@ -1107,15 +1228,6 @@ impl SchemaBuilder {
pub(crate) fn errors(&self) -> &[SchemaBuildError] {
&self.errors
}

/// Returns the mutation root type name binding (for test
/// inspection).
// TODO: Remove #[allow(dead_code)] once mutation root type
// tests are added or build() consumes this field.
#[allow(dead_code)]
pub(crate) fn mutation_type_name(&self) -> Option<&(TypeName, Span)> {
self.mutation_type_name.as_ref()
}
}

// ---------------------------------------------------------
Expand Down
46 changes: 46 additions & 0 deletions crates/libgraphql-core-v1/src/schema/schema_def.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::names::DirectiveName;
use crate::names::TypeName;
use crate::schema_source_map::LineCol;
use crate::schema_source_map::SchemaSourceMap;
use crate::span::Span;
use crate::types::DirectiveDefinition;
use crate::types::EnumType;
use crate::types::GraphQLType;
Expand Down Expand Up @@ -265,4 +267,48 @@ impl Schema {
pub fn source_maps(&self) -> &[SchemaSourceMap] {
&self.source_maps
}

// ---------------------------------------------------------
// Span resolution
// ---------------------------------------------------------

/// Resolves the start of a **schema-originated** [`Span`] to
/// a 0-based [`LineCol`] position using this schema's stored
/// source maps.
///
/// # Accepted span domain
///
/// This method only accepts spans that originated from this
/// `Schema` — i.e. spans found on its types, fields,
Comment on lines +281 to +282

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the answer is probably no, but I'm curious if there are any clever tricks we might employ to help ensure -- at the type level -- that a given Span is correctly associated with a given Schema? Not the end of the world if not (or if such a trick would impose overhead on the API and/or error message story or would otherwise have unworthy trade-offs), but I'd like you to think about it and tell me what you can come up with (if anything).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about this a fair bit. There ARE tricks; I think none of the instance-level ones are worth their cost here, but there's a domain-level middle ground worth considering for Task 18. The menu:

1. Lifetime branding / generativity (the only true type-level instance guarantee). GhostCell-style: Schema construction introduces a fresh invariant lifetime 'brand (via a closure: SchemaBuilder::build_with(|schema: Schema<'brand>| ...)), spans it hands out become Span<'brand>, and resolve_span(Span<'brand>) only accepts same-brand spans. Two different Schemas can never exchange spans — the compiler proves it. Why I'd reject it here:

  • It's viral. Every span-carrying type grows the brand: GraphQLType<'brand>, FieldDefinition<'brand>, every error type… the entire crate surface.
  • Brands are closure-scoped. You can't store a branded Schema in a plain struct or return it up the stack without the brand infecting the container. That kills the "build a schema, keep it around, serve requests" usage pattern that is this crate's whole point.
  • Fatal conflict with AD6/macro embedding: Schema must be 'static + serde for bincode embedding. Brands don't survive serialization — a deserialized Span<'brand> is meaningless, so the macro path can't exist under branding.

2. Per-schema runtime identity tag. Stamp each Schema with a unique id at build(), widen Span to carry it, resolve_span returns None on mismatch. Honest assessment: it only upgrades "wrong position" to "None", costs 4–8 bytes on a Copy type that's stored everywhere, and muddies serde/PartialEq determinism (bincode round-trip equality would need the id excluded or regenerated). Not worth it.

3. Domain-level newtypes (the one I'd actually consider). The realistic misuse class per AD18 isn't schema-A-vs-schema-B (one schema per process is overwhelmingly the norm) — it's schema-span vs operation-span, once Task 18 introduces operation documents whose spans use the same dense id space. That confusion CAN be caught at compile time cheaply: keep Span as the raw representation, but have operation-side types store/expose a DocSpan(Span) newtype (or a ZST-marker generic) so Schema::resolve_span simply won't accept an operation span and vice versa. Zero runtime cost, no serde impact (schema-side Span unchanged), and the churn lands entirely in operation types that don't exist yet — free if we decide before 18, expensive after.

Recommendation: keep resolve_span as-is for instance-level (the Option + documented span-domain contract), and if you like #3 I'll amend Task 18's spec to make operation spans a distinct type at API boundaries. Happy to do that as part of this PR's plan-doc or as a one-liner in the next one — say the word.


Generated by Claude Code

/// directive definitions, and the errors produced while
/// building it. A [`Span`]'s `source_map_id` is an index
/// that is only meaningful relative to the artifact that
/// produced it, so passing a span from any *other* artifact
/// (an operation, an executable document, or a different
/// `Schema`) yields `None` or a plausible-but-wrong
/// location.
///
/// # Return value
///
/// - `Some(LineCol)` for spans whose `source_map_id` is in
/// range for this schema. Spans on built-in definitions
/// (e.g. the `Boolean` scalar or `@skip` directive, which
/// have no user-authored source) resolve against the
/// synthetic built-in source map to line `0`, column `0`.
/// - `None` when the span's `source_map_id` is out of range
/// for this schema.
///
/// Because `Schema` does not retain source text, the
/// returned [`LineCol`]'s `col_utf8` always equals its
/// `col_linestart_byte_offset` (exact for ASCII source; a
/// byte-based approximation when the line contains
/// multi-byte characters).
pub fn resolve_span(&self, span: Span) -> Option<LineCol> {
let source_map = self.source_maps
.get(span.source_map_id.0 as usize)?;
Some(source_map.resolve_offset(
span.byte_span.start,
/* source = */ None,
))
}
}
1 change: 1 addition & 0 deletions crates/libgraphql-core-v1/src/schema/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod schema_build_error_tests;
mod schema_builder_extension_tests;
mod schema_builder_tests;
mod schema_def_tests;
mod schema_errors_tests;
mod type_validation_error_tests;
Loading
Loading