Name the shapes the record lowering refuses

This commit is contained in:
Dennis Kobert
2026-09-15 14:33:27 +02:00
parent 652c1c0e1b
commit 7fb55cf59f
2 changed files with 61 additions and 18 deletions
+29 -18
View File
@@ -312,26 +312,35 @@ pub(crate) fn unbounded_generic(parsed: &ParsedNodeFn, ty: &Type) -> Option<Iden
} }
pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option<RecordShape> { pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option<RecordShape> {
record_shape_checked(parsed).ok().flatten()
}
/// [`record_shape`] with its refusals named. `Ok(None)` is "not a record node",
/// which the other classes still answer for; `Err` is a node that declares
/// attribute io the record lowering cannot serve, and only a diagnostic ends it.
pub(crate) fn record_shape_checked(parsed: &ParsedNodeFn) -> Result<Option<RecordShape>, &'static str> {
let source = is_async_source(parsed); let source = is_async_source(parsed);
let value = match kernel_kind(&parsed.output_type) { let value = match kernel_kind(&parsed.output_type) {
KernelKind::Plain => parsed.output_type.clone(), KernelKind::Plain => parsed.output_type.clone(),
KernelKind::Interrupt(inner) => inner, KernelKind::Interrupt(inner) => inner,
_ if source => slot_value_type(&parsed.output_type), _ if source => slot_value_type(&parsed.output_type),
_ => return None, _ => return Ok(None),
}; };
let writes = record_writes(&value); let writes = record_writes(&value);
let has_reads = parsed.fields.iter().any(|field| !field.attribute_reads.is_empty() && matches!(field.ty, ParsedFieldType::Regular(_))); let has_reads = parsed.fields.iter().any(|field| !field.attribute_reads.is_empty() && matches!(field.ty, ParsedFieldType::Regular(_)));
if !has_reads && writes.is_none() { if !has_reads && writes.is_none() {
return None; return Ok(None);
} }
// An async source's slot stores the kernel's plain tuple; the per-eval lift // An async source's slot stores the kernel's plain tuple; the per-eval lift
// writes it through the claim, and the reads have no input to bind against. // writes it through the claim, and the reads have no input to bind against.
if source && (has_reads || writes.is_none()) { if source && (has_reads || writes.is_none()) {
return None; return Err("an async source writes its tuple through the claim, so it takes writes and no reads");
} }
let carrier_field = parsed.fields.first()?; let Some(carrier_field) = parsed.fields.first() else {
return Err("attribute io needs a primary input as the first parameter after the context");
};
if carrier_field.is_data_field { if carrier_field.is_data_field {
return None; return Err("a `#[data]` field is not a record carrier");
} }
// A first-field lazy carrier: the kernel evaluates the derived content // A first-field lazy carrier: the kernel evaluates the derived content
// itself and returns its opaque row token beside the write set. // itself and returns its opaque row token beside the write set.
@@ -343,30 +352,32 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option<RecordShape> {
ParsedFieldType::Regular(_) => false, ParsedFieldType::Regular(_) => false,
}; };
if parsed.fields.iter().skip(lazy_carrier as usize).any(unsupported_lazy_secondary) { if parsed.fields.iter().skip(lazy_carrier as usize).any(unsupported_lazy_secondary) {
return None; return Err("a lazy secondary consumes a plain element, so it takes no served, ranked, or reading input");
} }
let reads_well_placed = parsed.fields.iter().enumerate().all(|(index, field)| { let reads_well_placed = parsed.fields.iter().enumerate().all(|(index, field)| {
field.attribute_reads.is_empty() || (lazy_carrier && index == 0) || (!field.is_data_field && matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: None, .. }))) field.attribute_reads.is_empty() || (lazy_carrier && index == 0) || (!field.is_data_field && matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: None, .. })))
}); });
if !reads_well_placed { if !reads_well_placed {
return None; return Err("an attribute read needs an owned regular input, not a `#[data]` field or a `&T`");
} }
if lazy_carrier { if lazy_carrier {
let ParsedFieldType::Node(NodeParsedField { output_type, .. }) = &carrier_field.ty else { let ParsedFieldType::Node(NodeParsedField { output_type, .. }) = &carrier_field.ty else {
unreachable!("guarded by the lazy_carrier match"); unreachable!("guarded by the lazy_carrier match");
}; };
let token = unbounded_generic(parsed, output_type)?; let Some(token) = unbounded_generic(parsed, output_type) else {
return Err("a lazy carrier passes an unbounded generic row token through");
};
let element = match writes { let element = match writes {
Some(RecordWrites { element, .. }) => element, Some(RecordWrites { element, .. }) => element,
None => value, None => value,
}; };
if !matches!(bare_ident(&element), Some(ident) if ident == &token) { if !matches!(bare_ident(&element), Some(ident) if ident == &token) {
return None; return Err("a lazy carrier returns the row token it received, so the element is that generic");
} }
return Some(RecordShape { carrier: RecordCarrier::LazyToken }); return Ok(Some(RecordShape { carrier: RecordCarrier::LazyToken }));
} }
let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, implementations, .. }) = &carrier_field.ty else { let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, implementations, .. }) = &carrier_field.ty else {
return None; return Err("a record carrier is an owned value input, not a `&T` or a lazy one");
}; };
// A gathered subject is never read as an element, so its generic stays open. // A gathered subject is never read as an element, so its generic stays open.
let gathers = crate::codegen::ir::gathers_lane(parsed); let gathers = crate::codegen::ir::gathers_lane(parsed);
@@ -378,7 +389,7 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option<RecordShape> {
Some(token) => Some(token), Some(token) => Some(token),
None => { None => {
if !gathers && !carrier_rows && contains_open_generic(parsed, ty) { if !gathers && !carrier_rows && contains_open_generic(parsed, ty) {
return None; return Err("a reading carrier's element is monomorphic; spell it concretely or give it an `#[implementations]` list");
} }
None None
} }
@@ -398,29 +409,29 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option<RecordShape> {
// fresh one instead of passing the token through. // fresh one instead of passing the token through.
Some(_) if opaque_reading_carrier(parsed).is_some() => { Some(_) if opaque_reading_carrier(parsed).is_some() => {
if contains_open_generic(parsed, &element) { if contains_open_generic(parsed, &element) {
return None; return Err("an opaque reading carrier writes a concrete element, since its own is never read");
} }
} }
Some(token) => { Some(token) => {
if !matches!(bare_ident(&element), Some(ident) if ident == token) { if !matches!(bare_ident(&element), Some(ident) if ident == token) {
return None; return Err("a passthrough generic element returns unchanged in the first tuple position");
} }
} }
None => { None => {
if !gathers && !carrier_rows && contains_open_generic(parsed, &element) { if !gathers && !carrier_rows && contains_open_generic(parsed, &element) {
return None; return Err("a written element is monomorphic; spell it concretely or give the carrier an `#[implementations]` list");
} }
} }
} }
if matches!(carrier, RecordCarrier::None) && !removes.is_empty() { if matches!(carrier, RecordCarrier::None) && !removes.is_empty() {
return None; return Err("a node with no carrier has no columns to remove");
} }
// The byte-carried token never becomes a value, so it cannot cross a // The byte-carried token never becomes a value, so it cannot cross a
// future boundary. // future boundary.
if source && matches!(carrier, RecordCarrier::Token) { if source && matches!(carrier, RecordCarrier::Token) {
return None; return Err("a byte-carried token never becomes a value, so it cannot cross a future boundary");
} }
Some(RecordShape { carrier }) Ok(Some(RecordShape { carrier }))
} }
pub(crate) fn is_poll_kernel(output: &Type) -> bool { pub(crate) fn is_poll_kernel(output: &Type) -> bool {
+32
View File
@@ -15,6 +15,7 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
validate_lend_fields, validate_lend_fields,
validate_record_io, validate_record_io,
validate_lazy_reads, validate_lazy_reads,
validate_lowering_supported,
]; ];
for validator in validators { for validator in validators {
@@ -24,6 +25,28 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
Ok(()) Ok(())
} }
/// A signature no lowering claims generates no `Node` impl at all, so the node
/// compiles and is simply missing at runtime. Naming the gap is the only thing
/// that ends it, since nothing downstream can tell "declined" from "absent".
fn validate_lowering_supported(parsed: &ParsedNodeFn) {
// A record-io refusal already reported its own reason.
if crate::codegen::classify::record_shape_checked(parsed).is_err() || crate::codegen::classify::analyze(parsed).is_some() {
return;
}
// An async kernel with lazy inputs is refused by `validate_async_source`, which
// names the spawn boundary rather than the missing lowering.
if parsed.is_async && parsed.fields.iter().any(|field| matches!(field.ty, ParsedFieldType::Node(_))) {
return;
}
emit_error!(
parsed.fn_name.span(),
"no lowering supports this signature, so the node would generate no `Node` impl";
help = "a node is one of: attribute io (`Attr<..>` reads or writes), routing (an unbounded generic forwarded whole), a flipped kernel over an owned primary, or one with a ranked `IList` input"
);
}
fn validate_record_io(parsed: &ParsedNodeFn) { fn validate_record_io(parsed: &ParsedNodeFn) {
let value = crate::codegen::slot_value_type(&parsed.output_type); let value = crate::codegen::slot_value_type(&parsed.output_type);
if let Type::Tuple(tuple) = &value { if let Type::Tuple(tuple) = &value {
@@ -44,6 +67,15 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
return; return;
} }
// A shape the record lowering refuses generates no node impl at all, so without a
// diagnostic the node compiles and is simply absent from the registry. The lowering
// is the authority on what it can serve, so its refusal is the one error reported:
// the checks below describe a shape it already accepted.
if let Err(reason) = crate::codegen::classify::record_shape_checked(parsed) {
emit_error!(parsed.fn_name.span(), "this node declares attribute io the record lowering cannot serve: {}", reason);
return;
}
let async_source = crate::codegen::classify::is_async_source(parsed); let async_source = crate::codegen::classify::is_async_source(parsed);
if async_source && has_reads { if async_source && has_reads {
emit_error!(parsed.fn_name.span(), "attribute reads are not supported on async source kernels, only writes"); emit_error!(parsed.fn_name.span(), "attribute reads are not supported on async source kernels, only writes");