Skip to content

Commit 407b198

Browse files
clauderyo33
authored andcommitted
Fix flatten context: deny_unknown_extensions is no-op in any flatten context
- deny_unknown_extensions() now skips validation in any flatten context, not just Extension scope. This makes validation order-independent. - Added UnknownExtension error kind for proper extension error messages - Removed allow_unknown_fields workaround from schema content types (ParsedIntegerSchema, ParsedFloatSchema, ParsedArraySchema, etc.) - ParsedSchemaNode::parse uses flatten context for child parsers, leaving extension validation to the converter which knows about $types
1 parent 17f0645 commit 407b198

File tree

3 files changed

+49
-105
lines changed

3 files changed

+49
-105
lines changed

crates/eure-document/src/parse.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -644,22 +644,20 @@ impl<'doc> ParseContext<'doc> {
644644

645645
/// Finish parsing with Deny policy (error if unknown extensions exist).
646646
///
647-
/// **Flatten behavior**: If this context has a flatten_ctx with Extension scope
648-
/// (i.e., is a child in a flatten chain), this is a no-op. Only root parsers validate.
647+
/// **Flatten behavior**: If this context is in a flatten chain (has flatten_ctx),
648+
/// this is a no-op. Only root parsers validate.
649649
pub fn deny_unknown_extensions(&self) -> Result<(), ParseError> {
650-
// If child (has flatten_ctx with Extension scope), no-op - parent will validate
651-
if let Some(fc) = &self.flatten_ctx
652-
&& fc.scope() == ParserScope::Extension
653-
{
650+
// If child (in any flatten context), no-op - parent will validate
651+
if self.flatten_ctx.is_some() {
654652
return Ok(());
655653
}
656654

657-
// Root parser or Record scope - validate using accessed set
655+
// Root parser - validate using accessed set
658656
for (ident, _) in self.node().extensions.iter() {
659657
if !self.accessed.has_ext(ident) {
660658
return Err(ParseError {
661659
node_id: self.node_id,
662-
kind: ParseErrorKind::UnknownField(format!("$ext-type.{}", ident)),
660+
kind: ParseErrorKind::UnknownExtension(ident.clone()),
663661
});
664662
}
665663
}
@@ -858,6 +856,10 @@ pub enum ParseErrorKind {
858856
#[error("unknown field: {0}")]
859857
UnknownField(String),
860858

859+
/// Unknown extension on node.
860+
#[error("unknown extension: ${0}")]
861+
UnknownExtension(Identifier),
862+
861863
/// Invalid key type in record (expected string).
862864
#[error("invalid key type in record: expected string key, got {0:?}")]
863865
InvalidKeyType(crate::value::ObjectKey),

crates/eure-macros/tests/records/flatten_ext.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ fn test_record_with_single_ext_rejects_unknown() {
272272
let result = doc.parse::<RecordWithSingleExt>(doc.get_root_id());
273273
assert_eq!(
274274
result.unwrap_err().kind,
275-
ParseErrorKind::UnknownField("$ext-type.unknown".to_string())
275+
ParseErrorKind::UnknownExtension("unknown".parse().unwrap())
276276
);
277277
}
278278

crates/eure-schema/src/parse.rs

Lines changed: 38 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -103,127 +103,71 @@ impl ParseDocument<'_> for crate::SchemaRef {
103103
// ============================================================================
104104

105105
/// Parsed integer schema - syntactic representation with range as string.
106-
#[derive(Debug, Clone)]
106+
#[derive(Debug, Clone, eure_macros::ParseDocument)]
107+
#[eure(crate = eure_document, rename_all = "kebab-case")]
107108
pub struct ParsedIntegerSchema {
108109
/// Range constraint as string (e.g., "[0, 100)", "(-∞, 0]")
110+
#[eure(default)]
109111
pub range: Option<String>,
110112
/// Multiple-of constraint
113+
#[eure(default)]
111114
pub multiple_of: Option<BigInt>,
112115
}
113116

114-
impl ParseDocument<'_> for ParsedIntegerSchema {
115-
type Error = ParseError;
116-
fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
117-
let rec = ctx.parse_record()?;
118-
let range = rec.field_optional("range");
119-
let multiple_of = rec.field_optional("multiple-of");
120-
rec.allow_unknown_fields()?;
121-
Ok(ParsedIntegerSchema {
122-
range: range.map(|ctx| ctx.parse()).transpose()?,
123-
multiple_of: multiple_of.map(|ctx| ctx.parse()).transpose()?,
124-
})
125-
}
126-
}
127-
128117
/// Parsed float schema - syntactic representation with range as string.
129-
#[derive(Debug, Clone)]
118+
#[derive(Debug, Clone, eure_macros::ParseDocument)]
119+
#[eure(crate = eure_document, rename_all = "kebab-case")]
130120
pub struct ParsedFloatSchema {
131121
/// Range constraint as string
122+
#[eure(default)]
132123
pub range: Option<String>,
133124
/// Multiple-of constraint
125+
#[eure(default)]
134126
pub multiple_of: Option<f64>,
135127
/// Precision constraint ("f32" or "f64")
128+
#[eure(default)]
136129
pub precision: Option<String>,
137130
}
138131

139-
impl ParseDocument<'_> for ParsedFloatSchema {
140-
type Error = ParseError;
141-
fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
142-
let rec = ctx.parse_record()?;
143-
let range = rec.field_optional("range");
144-
let multiple_of = rec.field_optional("multiple-of");
145-
let precision = rec.field_optional("precision");
146-
rec.allow_unknown_fields()?;
147-
Ok(ParsedFloatSchema {
148-
range: range.map(|ctx| ctx.parse()).transpose()?,
149-
multiple_of: multiple_of.map(|ctx| ctx.parse()).transpose()?,
150-
precision: precision.map(|ctx| ctx.parse()).transpose()?,
151-
})
152-
}
153-
}
154-
155132
/// Parsed array schema with NodeId references.
156-
#[derive(Debug, Clone)]
133+
#[derive(Debug, Clone, eure_macros::ParseDocument)]
134+
#[eure(crate = eure_document, rename_all = "kebab-case")]
157135
pub struct ParsedArraySchema {
158136
/// Schema for array elements
159137
pub item: NodeId,
160138
/// Minimum number of elements
139+
#[eure(default)]
161140
pub min_length: Option<u32>,
162141
/// Maximum number of elements
142+
#[eure(default)]
163143
pub max_length: Option<u32>,
164144
/// All elements must be unique
145+
#[eure(default)]
165146
pub unique: bool,
166147
/// Array must contain at least one element matching this schema
148+
#[eure(default)]
167149
pub contains: Option<NodeId>,
168150
/// Binding style for formatting
151+
#[eure(ext, default)]
169152
pub binding_style: Option<BindingStyle>,
170153
}
171154

172-
impl ParseDocument<'_> for ParsedArraySchema {
173-
type Error = ParseError;
174-
fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
175-
let rec = ctx.parse_record()?;
176-
let item = rec.field("item")?;
177-
let min_length = rec.field_optional("min-length");
178-
let max_length = rec.field_optional("max-length");
179-
let unique = rec.field_optional("unique");
180-
let contains = rec.field_optional("contains");
181-
rec.allow_unknown_fields()?;
182-
183-
let binding_style = ctx.parse_ext_optional("binding-style")?;
184-
185-
Ok(ParsedArraySchema {
186-
item: item.node_id(),
187-
min_length: min_length.map(|ctx| ctx.parse()).transpose()?,
188-
max_length: max_length.map(|ctx| ctx.parse()).transpose()?,
189-
unique: unique.map(|ctx| ctx.parse()).transpose()?.unwrap_or(false),
190-
contains: contains.map(|ctx| Ok(ctx.node_id())).transpose()?,
191-
binding_style,
192-
})
193-
}
194-
}
195-
196155
/// Parsed map schema with NodeId references.
197-
#[derive(Debug, Clone)]
156+
#[derive(Debug, Clone, eure_macros::ParseDocument)]
157+
#[eure(crate = eure_document, rename_all = "kebab-case")]
198158
pub struct ParsedMapSchema {
199159
/// Schema for keys
200160
pub key: NodeId,
201161
/// Schema for values
202162
pub value: NodeId,
203163
/// Minimum number of key-value pairs
164+
#[eure(default)]
204165
pub min_size: Option<u32>,
205166
/// Maximum number of key-value pairs
167+
#[eure(default)]
206168
pub max_size: Option<u32>,
207169
}
208170

209-
impl ParseDocument<'_> for ParsedMapSchema {
210-
type Error = ParseError;
211-
fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
212-
let rec = ctx.parse_record()?;
213-
let key = rec.field("key")?;
214-
let value = rec.field("value")?;
215-
let min_size = rec.field_optional("min-size");
216-
let max_size = rec.field_optional("max-size");
217-
rec.allow_unknown_fields()?;
218-
Ok(ParsedMapSchema {
219-
key: key.node_id(),
220-
value: value.node_id(),
221-
min_size: min_size.map(|ctx| ctx.parse()).transpose()?,
222-
max_size: max_size.map(|ctx| ctx.parse()).transpose()?,
223-
})
224-
}
225-
}
226-
227171
/// Parsed record field schema with NodeId reference.
228172
#[derive(Debug, Clone, eure_macros::ParseDocument)]
229173
#[eure(crate = eure_document, parse_ext, rename_all = "kebab-case")]
@@ -311,30 +255,16 @@ impl ParseDocument<'_> for ParsedRecordSchema {
311255
}
312256

313257
/// Parsed tuple schema with NodeId references.
314-
#[derive(Debug, Clone)]
258+
#[derive(Debug, Clone, eure_macros::ParseDocument)]
259+
#[eure(crate = eure_document, rename_all = "kebab-case")]
315260
pub struct ParsedTupleSchema {
316261
/// Schema for each element by position (NodeId references)
317262
pub elements: Vec<NodeId>,
318263
/// Binding style for formatting
264+
#[eure(ext, default)]
319265
pub binding_style: Option<BindingStyle>,
320266
}
321267

322-
impl ParseDocument<'_> for ParsedTupleSchema {
323-
type Error = ParseError;
324-
fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
325-
let rec = ctx.parse_record()?;
326-
let elements = rec.field("elements")?;
327-
rec.allow_unknown_fields()?;
328-
329-
let binding_style = ctx.parse_ext_optional("binding-style")?;
330-
331-
Ok(ParsedTupleSchema {
332-
elements: elements.parse()?,
333-
binding_style,
334-
})
335-
}
336-
}
337-
338268
/// Parsed union schema with NodeId references.
339269
#[derive(Debug, Clone)]
340270
pub struct ParsedUnionSchema {
@@ -699,9 +629,21 @@ impl ParseDocument<'_> for ParsedSchemaNodeContent {
699629
impl ParseDocument<'_> for ParsedSchemaNode {
700630
type Error = ParseError;
701631
fn parse(ctx: &ParseContext<'_>) -> Result<Self, Self::Error> {
702-
let content = ctx.parse::<ParsedSchemaNodeContent>()?;
703-
let metadata = ParsedSchemaMetadata::parse_from_extensions(ctx)?;
704-
let ext_types = parse_ext_types(ctx)?;
632+
// Create a flattened context so child parsers' deny_unknown_* are no-ops.
633+
// All accesses are recorded in the shared accessed set (via Rc).
634+
let flatten_ctx = ctx.flatten();
635+
636+
// Parse schema-level extensions - marks $ext-type, $description, etc. as accessed
637+
let ext_types = parse_ext_types(&flatten_ctx)?;
638+
let metadata = ParsedSchemaMetadata::parse_from_extensions(&flatten_ctx)?;
639+
640+
// Content parsing uses the flattened context
641+
let content = flatten_ctx.parse::<ParsedSchemaNodeContent>()?;
642+
643+
// Note: We do NOT validate unknown extensions here because:
644+
// 1. At the document root, $types extension is handled by the converter
645+
// 2. Content types use flatten context, so their deny is already no-op
646+
// The caller (e.g., Converter) should handle document-level validation if needed.
705647

706648
Ok(ParsedSchemaNode {
707649
content,

0 commit comments

Comments
 (0)