diff --git a/crates/graphcal-cli/src/json_input.rs b/crates/graphcal-cli/src/json_input.rs index 21b47bd10..b4db47048 100644 --- a/crates/graphcal-cli/src/json_input.rs +++ b/crates/graphcal-cli/src/json_input.rs @@ -730,10 +730,10 @@ fn convert_indexed(obj: &ExactJsonObject, param_name: &str) -> Result { assert_eq!(entries.len(), 3); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Maneuver"); + let MapEntryKey::Discrete { index, .. } = &entries[0].keys[0] else { + panic!("expected a discrete map key") + }; + assert_eq!(index.value.to_string(), "Maneuver"); } other => panic!("expected MapLiteral, got {other:?}"), } @@ -971,7 +974,10 @@ mod tests { match &overrides[&DeclName::expect_valid("series")].kind { ExprKind::MapLiteral { entries } => { - assert_eq!(entries[0].keys[0].index.value.to_string(), "lib.Phase"); + let MapEntryKey::Discrete { index, .. } = &entries[0].keys[0] else { + panic!("expected a discrete map key") + }; + assert_eq!(index.value.to_string(), "lib.Phase"); } other => panic!("expected MapLiteral, got {other:?}"), } diff --git a/crates/graphcal-compiler/src/desugar/convert.rs b/crates/graphcal-compiler/src/desugar/convert.rs index 68b8fae0e..ada7543f0 100644 --- a/crates/graphcal-compiler/src/desugar/convert.rs +++ b/crates/graphcal-compiler/src/desugar/convert.rs @@ -635,7 +635,23 @@ impl From> for ExprKind { impl From> for MapEntry { fn from(m: MapEntry) -> Self { Self { - keys: m.keys, + keys: m.keys.map(|key| match key { + crate::syntax::ast::MapEntryKey::Discrete { + index, + additional_index_spans, + entry, + } => crate::syntax::ast::MapEntryKey::Discrete { + index, + additional_index_spans, + entry, + }, + crate::syntax::ast::MapEntryKey::Expression { axis, expr } => { + crate::syntax::ast::MapEntryKey::Expression { + axis, + expr: expr.into(), + } + } + }), value: m.value.into(), } } diff --git a/crates/graphcal-compiler/src/desugar/desugared_ast.rs b/crates/graphcal-compiler/src/desugar/desugared_ast.rs index 54f57bd8d..943967252 100644 --- a/crates/graphcal-compiler/src/desugar/desugared_ast.rs +++ b/crates/graphcal-compiler/src/desugar/desugared_ast.rs @@ -56,6 +56,7 @@ pub type IndexDeclKind = crate::syntax::ast::IndexDeclKind; pub type Expr = crate::syntax::ast::Expr; pub type ExprKind = crate::syntax::ast::ExprKind; pub(crate) type MapEntry = crate::syntax::ast::MapEntry; +pub(crate) type MapEntryKey = crate::syntax::ast::MapEntryKey; pub(crate) type IndexArg = crate::syntax::ast::IndexArg; pub(crate) type FieldInit = crate::syntax::ast::FieldInit; pub(crate) type MatchArm = crate::syntax::ast::MatchArm; @@ -73,8 +74,8 @@ pub type FieldDecl = crate::syntax::ast::FieldDecl; pub use crate::syntax::ast::{ AmbiguousGenericArg, Attribute, AttributeArg, BaseDimDecl, BinOp, BindableVisibility, DomainBoundKind, EncodingChannel, ForBinding, ForBindingIndex, GenericConstraint, Ident, - ImportDecl, ImportItem, ImportItemNamespace, ImportKind, MapEntryKey, MarkType, MatchPattern, - ModulePath, MulDivOp, MultiDataRow, MultiDecl, MultiDeclSlice, MultiDeclSlot, MultiHeaderCell, - MultiSlotAxis, MultiSlotColumnSpan, MultiSlotKind, NatExpr, PatternBinding, TableIndexSpec, - UnaryOp, UnitExpr, UnitExprItem, Visibility, + ImportDecl, ImportItem, ImportItemNamespace, ImportKind, MapKeyAxisSyntax, MarkType, + MatchPattern, ModulePath, MulDivOp, MultiDataRow, MultiDecl, MultiDeclSlice, MultiDeclSlot, + MultiHeaderCell, MultiSlotAxis, MultiSlotColumnSpan, MultiSlotKind, NatExpr, PatternBinding, + TableIndexSpec, UnaryOp, UnitExpr, UnitExprItem, Visibility, }; diff --git a/crates/graphcal-compiler/src/hir/expr.rs b/crates/graphcal-compiler/src/hir/expr.rs index 3439795f3..a13b21c65 100644 --- a/crates/graphcal-compiler/src/hir/expr.rs +++ b/crates/graphcal-compiler/src/hir/expr.rs @@ -796,6 +796,11 @@ fn collect_expr_dependencies_into_inner(expr: &Expr, deps: &mut ExprDependencies } ExprKind::MapLiteral { entries } => { for entry in entries { + for key in &entry.keys { + if let MapEntryKey::Expression { expr, .. } = key { + collect_expr_dependencies_into(expr, deps); + } + } collect_expr_dependencies_into(&entry.value, deps); } } @@ -893,9 +898,14 @@ fn visit_expr_inner(expr: &Expr, visitor: &mut impl FnMut(&Expr)) { ExprKind::ConstructorCall { fields, .. } => fields .iter() .for_each(|field| visit_expr(&field.value, visitor)), - ExprKind::MapLiteral { entries } => entries - .iter() - .for_each(|entry| visit_expr(&entry.value, visitor)), + ExprKind::MapLiteral { entries } => entries.iter().for_each(|entry| { + entry.keys.iter().for_each(|key| { + if let MapEntryKey::Expression { expr, .. } = key { + visit_expr(expr, visitor); + } + }); + visit_expr(&entry.value, visitor); + }), ExprKind::ForComp { body, .. } => visit_expr(body, visitor), ExprKind::IndexAccess { expr: inner, args } => { visit_expr(inner, visitor); @@ -1100,9 +1110,16 @@ fn find_extern_call_inner(expr: &Expr) -> Option<(&ExternFnRef, Span)> { ExprKind::ConstructorCall { fields, .. } => fields .iter() .find_map(|field| find_extern_call(&field.value)), - ExprKind::MapLiteral { entries } => entries - .iter() - .find_map(|entry| find_extern_call(&entry.value)), + ExprKind::MapLiteral { entries } => entries.iter().find_map(|entry| { + entry + .keys + .iter() + .find_map(|key| match key { + MapEntryKey::Expression { expr, .. } => find_extern_call(expr), + MapEntryKey::IndexVariant(_) | MapEntryKey::FinitePosition { .. } => None, + }) + .or_else(|| find_extern_call(&entry.value)) + }), ExprKind::ForComp { body, .. } => find_extern_call(body), ExprKind::IndexAccess { expr: inner, args } => find_extern_call(inner).or_else(|| { args.iter().find_map(|arg| match arg { @@ -1160,10 +1177,18 @@ pub struct MapEntry { } /// A single resolved map key. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub enum MapEntryKey { IndexVariant(IndexVariantRef), FinitePosition { size: u64, position: Spanned }, + Expression { axis: MapKeyAxis, expr: Box }, +} + +/// Where an expression-shaped map key obtains its semantic axis. +#[derive(Debug, Clone)] +pub enum MapKeyAxis { + Explicit(Spanned), + Contextual, } /// A resolved for-comprehension binding. @@ -1315,9 +1340,19 @@ impl<'a> ExprLowerer<'a> { ast::ExprKind::ConstructorCall { fields, .. } => { fields.iter().map(|field| &field.value).collect() } - ast::ExprKind::MapLiteral { entries } => { - entries.iter().map(|entry| &entry.value).collect() - } + ast::ExprKind::MapLiteral { entries } => entries + .iter() + .flat_map(|entry| { + entry + .keys + .iter() + .filter_map(|key| match key { + ast::MapEntryKey::Expression { expr, .. } => Some(expr), + ast::MapEntryKey::Discrete { .. } => None, + }) + .chain(std::iter::once(&entry.value)) + }) + .collect(), ast::ExprKind::ForComp { body, .. } => vec![body], ast::ExprKind::IndexAccess { expr, args } => std::iter::once(expr.as_ref()) .chain(args.iter().filter_map(|arg| match arg { @@ -2348,22 +2383,45 @@ impl<'a> ExprLowerer<'a> { } fn lower_map_entry_key( - &self, + &mut self, key: &ast::MapEntryKey, map_span: Span, ) -> Result { - match (&key.index.value, &key.variant.value) { + if let ast::MapEntryKey::Expression { axis, expr } = key { + let axis = match axis { + ast::MapKeyAxisSyntax::Explicit(index) => { + let resolved = self + .ctx + .resolver + .resolve_index_path(self.ctx.owner, &index.value) + .map_err(|source| ExprLowerError::ModuleResolve { + source, + span: index.span, + })?; + MapKeyAxis::Explicit(Spanned::new(resolved, index.span)) + } + ast::MapKeyAxisSyntax::Contextual => MapKeyAxis::Contextual, + }; + return Ok(MapEntryKey::Expression { + axis, + expr: Box::new(self.lower_expr(expr)), + }); + } + let ast::MapEntryKey::Discrete { + index, + additional_index_spans, + entry, + } = key + else { + return Err(ExprLowerError::InvalidMapEntryKey { span: map_span }); + }; + match (&index.value, &entry.value) { ( crate::syntax::ast::MapEntryIndex::Named(index_path), IndexEntryKey::Named(variant_name), ) => { let variant = self - .resolve_index_variant_parts( - index_path, - variant_name, - key.index.span, - key.variant.span, - ) + .resolve_index_variant_parts(index_path, variant_name, index.span, entry.span) .map_err(|err| match err { ExprLowerError::ModuleResolve { source: ModuleResolveError::UnknownIndexVariant { index, variant }, @@ -2377,9 +2435,9 @@ impl<'a> ExprLowerer<'a> { })?; Ok(MapEntryKey::IndexVariant(IndexVariantRef { variant, - index_span: Some(key.index.span), - additional_index_spans: key.additional_index_spans.clone(), - variant_span: key.variant.span, + index_span: Some(index.span), + additional_index_spans: additional_index_spans.clone(), + variant_span: entry.span, })) } ( @@ -2387,13 +2445,11 @@ impl<'a> ExprLowerer<'a> { IndexEntryKey::Position(position), ) => Ok(MapEntryKey::FinitePosition { size: *size, - position: Spanned::new(*position, key.variant.span), + position: Spanned::new(*position, entry.span), }), (crate::syntax::ast::MapEntryIndex::Named(_), IndexEntryKey::Position(_)) | (crate::syntax::ast::MapEntryIndex::Finite(_), IndexEntryKey::Named(_)) => { - Err(ExprLowerError::InvalidMapEntryKey { - span: key.variant.span, - }) + Err(ExprLowerError::InvalidMapEntryKey { span: entry.span }) } } } diff --git a/crates/graphcal-compiler/src/ir/lower.rs b/crates/graphcal-compiler/src/ir/lower.rs index 8a0bdf311..1e7b8cd21 100644 --- a/crates/graphcal-compiler/src/ir/lower.rs +++ b/crates/graphcal-compiler/src/ir/lower.rs @@ -1322,14 +1322,15 @@ impl UnfrozenIR { .iter() .map(|entry| { cancellation.checkpoint()?; + let type_ann = lower_type_annotation_in( + &entry.type_ann, + &entry.type_resolution_owner, + entry.type_src.resolve(src), + )?; Ok(ConstEntry { name: entry.name.clone(), declaration_owner: entry.declaration_owner.clone(), - type_ann: lower_type_annotation_in( - &entry.type_ann, - &entry.type_resolution_owner, - entry.type_src.resolve(src), - )?, + type_ann, expr: lower_in( &entry.expr, &entry.body_resolution_owner, @@ -1347,6 +1348,11 @@ impl UnfrozenIR { .iter() .map(|entry| { cancellation.checkpoint()?; + let type_ann = lower_type_annotation_in( + &entry.type_ann, + &entry.type_resolution_owner, + entry.type_src.resolve(src), + )?; let default_expr = match (&entry.default_expr, &entry.default_src) { (Some(expr), Some(default_src)) => Some(lower_in( expr, @@ -1368,11 +1374,7 @@ impl UnfrozenIR { Ok(ParamEntry { name: entry.name.clone(), declaration_owner: entry.declaration_owner.clone(), - type_ann: lower_type_annotation_in( - &entry.type_ann, - &entry.type_resolution_owner, - entry.type_src.resolve(src), - )?, + type_ann, default_expr, span: entry.span, type_src: entry.type_src.clone(), @@ -1387,14 +1389,15 @@ impl UnfrozenIR { .iter() .map(|entry| { cancellation.checkpoint()?; + let type_ann = lower_type_annotation_in( + &entry.type_ann, + &entry.type_resolution_owner, + entry.type_src.resolve(src), + )?; Ok(NodeEntry { name: entry.name.clone(), declaration_owner: entry.declaration_owner.clone(), - type_ann: lower_type_annotation_in( - &entry.type_ann, - &entry.type_resolution_owner, - entry.type_src.resolve(src), - )?, + type_ann, expr: lower_in( &entry.expr, &entry.body_resolution_owner, @@ -2545,9 +2548,15 @@ impl ExprVisitor for NominalOverridePreflight<' ) -> Result<(), Self::Error> { for entry in entries { for key in &entry.keys { - if let crate::syntax::ast::MapEntryIndex::Named(index_name) = &key.index.value { + if let crate::desugar::desugared_ast::MapEntryKey::Discrete { + index: key_index, + entry: key_entry, + .. + } = key + && let crate::syntax::ast::MapEntryIndex::Named(index_name) = &key_index.value + { let index = IndexName::from_atom(index_name.leaf().clone()); - self.check_label(&index, format!("`{}.{}`", index_name, key.variant.value))?; + self.check_label(&index, format!("`{}.{}`", index_name, key_entry.value))?; } } self.visit_expr(&entry.value)?; @@ -2826,13 +2835,18 @@ impl ExprVisitorMut for IndexSubstituter<'_> { if let ExprKind::MapLiteral { entries } = &mut expr.kind { for entry in entries.iter_mut() { for key in &mut entry.keys { - if let crate::syntax::ast::MapEntryIndex::Named(index_name) = &key.index.value + if let crate::desugar::desugared_ast::MapEntryKey::Discrete { + index: key_index, + .. + } = key + && let crate::syntax::ast::MapEntryIndex::Named(index_name) = + &key_index.value && let Some(new) = self .bindings .get(index_name.leaf().as_str()) .and_then(types::IndexBindingTarget::declared_name) { - key.index.value = + key_index.value = crate::syntax::ast::MapEntryIndex::Named(new.clone().into()); } } @@ -4214,12 +4228,16 @@ fn collect_finite_indexes_from_expr( ExprKind::MapLiteral { entries } => { for entry in entries { for key in &entry.keys { - if let crate::syntax::ast::MapEntryIndex::Finite(cardinality) = - &key.index.value + if let crate::desugar::desugared_ast::MapEntryKey::Discrete { + index, + .. + } = key + && let crate::syntax::ast::MapEntryIndex::Finite(cardinality) = + &index.value { ensure_concrete_finite_index( *cardinality, - key.index.span, + index.span, self.registry, self.src, )?; @@ -4780,16 +4798,6 @@ fn coordinate_invalid( } } -/// One centralized binary64 endpoint comparison for coordinate construction. -fn coordinate_values_equal(actual: f64, expected: f64, scale_hint: f64) -> bool { - let scale = actual.abs().max(expected.abs()).max(scale_hint.abs()); - // Keep the tolerance relative even for coordinates near zero. A unit-scale - // floor would accept steps that miss tiny endpoints by a large fraction. - // The subnormal floor covers a small number of binary64 ULPs at zero. - let tolerance = (scale * (32.0 * f64::EPSILON)).max(f64::from_bits(32)); - (actual - expected).abs() <= tolerance -} - /// Exact numeric endpoint equality after finite-value validation. fn coordinate_endpoints_equal(start: f64, end: f64) -> bool { matches!(start.partial_cmp(&end), Some(std::cmp::Ordering::Equal)) @@ -4839,7 +4847,13 @@ fn checked_range_cardinality( } let intervals = raw_intervals.round(); let reconstructed_end = intervals.mul_add(step, start); - if intervals < 1.0 || !coordinate_values_equal(reconstructed_end, end, intervals * step) { + if intervals < 1.0 + || !crate::registry::index::coordinate_values_equal( + reconstructed_end, + end, + intervals * step, + ) + { return Err(coordinate_invalid( name, format!("step {step} does not land on endpoint {end}"), diff --git a/crates/graphcal-compiler/src/registry/index.rs b/crates/graphcal-compiler/src/registry/index.rs index 8752d7aaf..a3f6c31a6 100644 --- a/crates/graphcal-compiler/src/registry/index.rs +++ b/crates/graphcal-compiler/src/registry/index.rs @@ -116,6 +116,34 @@ impl CoordinateIndexData { pub const fn cardinality(&self) -> usize { self.cardinality.get() } + + /// Return the position whose generated coordinate equals `value` under + /// the same binary64 tolerance used to validate coordinate ranges. + #[must_use] + pub fn position_of(&self, value: f64) -> Option { + let scale_hint = match self.spacing { + CoordinateSpacing::Step { step } => step, + CoordinateSpacing::Linspace => self.end - self.start, + }; + (0..self.cardinality()).find(|&position| { + coordinate_values_equal(self.coordinate_value(position), value, scale_hint) + }) + } + + /// Return the closest generated coordinate, for an off-grid diagnostic. + #[must_use] + pub fn nearest_coordinate(&self, value: f64) -> Option<(usize, f64)> { + (0..self.cardinality()) + .map(|position| (position, self.coordinate_value(position))) + .min_by(|(_, lhs), (_, rhs)| (lhs - value).abs().total_cmp(&(rhs - value).abs())) + } +} + +/// Central binary64 comparison policy for generated coordinates. +pub(crate) fn coordinate_values_equal(actual: f64, expected: f64, scale_hint: f64) -> bool { + let scale = actual.abs().max(expected.abs()).max(scale_hint.abs()); + let tolerance = (scale * (32.0 * f64::EPSILON)).max(f64::from_bits(32)); + (actual - expected).abs() <= tolerance } const fn index_position_key(position: usize) -> IndexEntryKey { diff --git a/crates/graphcal-compiler/src/syntax/ast/format_equivalent.rs b/crates/graphcal-compiler/src/syntax/ast/format_equivalent.rs index d75623b62..a4e124505 100644 --- a/crates/graphcal-compiler/src/syntax/ast/format_equivalent.rs +++ b/crates/graphcal-compiler/src/syntax/ast/format_equivalent.rs @@ -1372,17 +1372,37 @@ impl FormatEquivalent for MultiDeclSharedAxes { impl FormatEquivalent for MapEntryKey { fn format_equivalent(&self, other: &Self) -> bool { - let Self { - index, - additional_index_spans: _, - variant, - } = self; - let Self { - index: other_index, - additional_index_spans: _, - variant: other_variant, - } = other; - index.format_equivalent(other_index) && variant.format_equivalent(other_variant) + match (self, other) { + ( + Self::Discrete { index, entry, .. }, + Self::Discrete { + index: other_index, + entry: other_entry, + .. + }, + ) => index.format_equivalent(other_index) && entry.format_equivalent(other_entry), + ( + Self::Expression { axis, expr }, + Self::Expression { + axis: other_axis, + expr: other_expr, + }, + ) => { + let axes_equivalent = match (axis, other_axis) { + ( + crate::syntax::ast::MapKeyAxisSyntax::Explicit(axis), + crate::syntax::ast::MapKeyAxisSyntax::Explicit(other_axis), + ) => axis.format_equivalent(other_axis), + ( + crate::syntax::ast::MapKeyAxisSyntax::Contextual, + crate::syntax::ast::MapKeyAxisSyntax::Contextual, + ) => true, + _ => false, + }; + axes_equivalent && expr.format_equivalent(other_expr) + } + _ => false, + } } } @@ -1675,18 +1695,23 @@ impl FormatEquivalent for RawExprSugar { } #[derive(PartialEq, Eq, Hash)] -struct SpanFreeMapEntryKey<'a> { +struct SpanFreeDiscreteMapEntryKey<'a> { index: &'a crate::syntax::ast::MapEntryIndex, - variant: &'a IndexEntryKey, + entry: &'a IndexEntryKey, } -fn span_free_table_entry_key(entry: &MapEntry) -> Vec> { +fn span_free_discrete_table_entry_key( + entry: &MapEntry, +) -> Option>> { entry .keys .iter() - .map(|key| SpanFreeMapEntryKey { - index: &key.index.value, - variant: &key.variant.value, + .map(|key| match key { + MapEntryKey::Discrete { index, entry, .. } => Some(SpanFreeDiscreteMapEntryKey { + index: &index.value, + entry: &entry.value, + }), + MapEntryKey::Expression { .. } => None, }) .collect() } @@ -1714,30 +1739,48 @@ fn table_entries_format_equivalent_by( return true; } - // Reordered entries still have multiset semantics. Index by the typed, - // span-free key so each entry searches only the values for its exact key, - // rather than rescanning the whole right-hand table. - let mut rhs_by_key: HashMap>, Vec<&MapEntry>> = rhs.iter().fold( - HashMap::with_capacity(rhs.len()), - |mut entries_by_key, entry| { - entries_by_key - .entry(span_free_table_entry_key(entry)) - .or_default() - .push(entry); - entries_by_key - }, - ); + // Preserve the linear hashed path for the common discrete-key case. + if lhs + .iter() + .chain(rhs) + .all(|entry| span_free_discrete_table_entry_key(entry).is_some()) + { + let mut rhs_by_key: HashMap>, Vec<&MapEntry>> = + HashMap::with_capacity(rhs.len()); + for entry in rhs { + let Some(key) = span_free_discrete_table_entry_key(entry) else { + return false; + }; + rhs_by_key.entry(key).or_default().push(entry); + } + return lhs.iter().all(|entry| { + let Some(key) = span_free_discrete_table_entry_key(entry) else { + return false; + }; + rhs_by_key + .get_mut(&key) + .and_then(|candidates| { + candidates + .iter() + .position(|candidate| entries_equivalent(entry, candidate)) + .map(|position| candidates.swap_remove(position)) + }) + .is_some() + }); + } + // Coordinate keys carry structured expressions and intentionally are not + // flattened into an ad-hoc string/hash encoding. Match those entries + // structurally while tracking consumed candidates. + let mut consumed = vec![false; rhs.len()]; lhs.iter().all(|entry| { - rhs_by_key - .get_mut(&span_free_table_entry_key(entry)) - .and_then(|candidates| { - candidates - .iter() - .position(|candidate| entries_equivalent(entry, candidate)) - .map(|position| candidates.swap_remove(position)) + rhs.iter() + .enumerate() + .find(|(index, candidate)| !consumed[*index] && entries_equivalent(entry, candidate)) + .is_some_and(|(index, _)| { + consumed[index] = true; + true }) - .is_some() }) } diff --git a/crates/graphcal-compiler/src/syntax/ast/value.rs b/crates/graphcal-compiler/src/syntax/ast/value.rs index e2aaf71fa..42c300db0 100644 --- a/crates/graphcal-compiler/src/syntax/ast/value.rs +++ b/crates/graphcal-compiler/src/syntax/ast/value.rs @@ -988,17 +988,62 @@ impl TableIndexSpec { } } -/// A single key in a map literal entry: `Index.Variant`. +/// A single key in a map literal entry. /// /// Table sugar can mention the same semantic index more than once for one /// desugared key: once in `table[...]` and again in a qualified slice or /// heterogeneous header label. `additional_index_spans` preserves those /// source references for editor features without duplicating the key value. #[derive(Debug, Clone)] -pub struct MapEntryKey { - pub index: Spanned, - pub additional_index_spans: Vec, - pub variant: Spanned, +pub enum MapEntryKey { + /// A named-label or structural finite position key. + Discrete { + index: Spanned, + additional_index_spans: Vec, + entry: Spanned, + }, + /// An expression-shaped key whose coordinate semantics are checked later. + Expression { + axis: MapKeyAxisSyntax, + expr: Expr

, + }, +} + +/// Where the axis for an expression-shaped map key comes from. +#[derive(Debug, Clone)] +pub enum MapKeyAxisSyntax { + /// Written in table metadata such as `table[Altitude]`. + Explicit(Spanned), + /// Inferred from this map literal's expected indexed type. + Contextual, +} + +#[cfg(test)] +impl MapEntryKey

{ + pub(crate) fn discrete_index(&self) -> &Spanned { + let Self::Discrete { index, .. } = self else { + panic!("expected a discrete map key") + }; + index + } + + pub(crate) fn discrete_entry(&self) -> &Spanned { + let Self::Discrete { entry, .. } = self else { + panic!("expected a discrete map key") + }; + entry + } + + pub(crate) fn discrete_additional_index_spans(&self) -> &[Span] { + let Self::Discrete { + additional_index_spans, + .. + } = self + else { + panic!("expected a discrete map key") + }; + additional_index_spans + } } /// An entry in a map literal. @@ -1007,7 +1052,7 @@ pub struct MapEntryKey { /// Multi-axis: `(Phase.Launch, Maneuver.Departure): 2.46 km/s` (keys has 2+ elements) #[derive(Debug, Clone)] pub struct MapEntry { - pub keys: NonEmpty, + pub keys: NonEmpty>, pub value: Expr

, } diff --git a/crates/graphcal-compiler/src/syntax/desugar.rs b/crates/graphcal-compiler/src/syntax/desugar.rs index eccb5975b..3a85365e5 100644 --- a/crates/graphcal-compiler/src/syntax/desugar.rs +++ b/crates/graphcal-compiler/src/syntax/desugar.rs @@ -140,10 +140,10 @@ pub(crate) fn expand_multi_decl(multi: &MultiDecl) -> Vec { match col_span { MultiSlotColumnSpan::Single(col_idx) => { for row in slice.rows() { - let row_key = MapEntryKey { + let row_key = MapEntryKey::Discrete { index: row_index_name.clone(), additional_index_spans: Vec::new(), - variant: row.label().clone(), + entry: row.label().clone(), }; slot_entries.push(MapEntry { keys: multi_entry_keys(slice.prefix_keys().to_vec(), row_key, None), @@ -174,18 +174,18 @@ pub(crate) fn expand_multi_decl(multi: &MultiDecl) -> Vec { col_variants.iter().enumerate() { let global_col = start + local_col; - let row_key = MapEntryKey { + let row_key = MapEntryKey::Discrete { index: row_index_name.clone(), additional_index_spans: Vec::new(), - variant: row.label().clone(), + entry: row.label().clone(), }; - let extra_key = MapEntryKey { + let extra_key = MapEntryKey::Discrete { index: Spanned::new( MapEntryIndex::Named(column_axis.value.clone()), column_axis.span, ), additional_index_spans: vec![extra_axis.span], - variant: Spanned::new( + entry: Spanned::new( IndexEntryKey::named(col_variant.value.clone()), col_variant.span, ), diff --git a/crates/graphcal-compiler/src/syntax/parser/decl/multi.rs b/crates/graphcal-compiler/src/syntax/parser/decl/multi.rs index 1e50976b9..898e47c5b 100644 --- a/crates/graphcal-compiler/src/syntax/parser/decl/multi.rs +++ b/crates/graphcal-compiler/src/syntax/parser/decl/multi.rs @@ -411,13 +411,13 @@ impl Parser<'_> { span: label_axis.span.into(), }); } - keys.push(MapEntryKey { + keys.push(MapEntryKey::Discrete { index: Spanned::new( MapEntryIndex::Named(label_axis.value), label_axis.span, ), additional_index_spans: vec![axis.span], - variant: Spanned::new(IndexEntryKey::named(variant.value), variant.span), + entry: Spanned::new(IndexEntryKey::named(variant.value), variant.span), }); } TableIndexSpec::Finite { cardinality, span } => { @@ -439,10 +439,10 @@ impl Parser<'_> { }); } let variant_span = hash_span.merge(num_span); - keys.push(MapEntryKey { + keys.push(MapEntryKey::Discrete { index: Spanned::new(MapEntryIndex::Finite(*cardinality), *span), additional_index_spans: Vec::new(), - variant: Spanned::new(IndexEntryKey::position(value), variant_span), + entry: Spanned::new(IndexEntryKey::position(value), variant_span), }); } } @@ -1086,8 +1086,14 @@ param power_mode: Bool[Component, OperationMode] }) => { assert_eq!(indexes.len(), 2); assert_eq!(entries.len(), 4); // 2 components × 2 modes - assert_eq!(entries[0].keys[0].index.value.to_string(), "Component"); - assert_eq!(entries[0].keys[1].index.value.to_string(), "OperationMode"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Component" + ); + assert_eq!( + entries[0].keys[1].discrete_index().value.to_string(), + "OperationMode" + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -1138,7 +1144,10 @@ param q: Int[Phase, Component] assert_eq!(multi.slices().len(), 2); assert_eq!(multi.slices()[0].prefix_keys().len(), 1); assert_eq!( - multi.slices()[0].prefix_keys()[0].index.value.to_string(), + multi.slices()[0].prefix_keys()[0] + .discrete_index() + .value + .to_string(), "Phase" ); @@ -1159,8 +1168,8 @@ param q: Int[Phase, Component] assert_eq!(entries.len(), 2); // 2 phases × 1 component for e in entries { assert_eq!(e.keys.len(), 2); - assert_eq!(e.keys[0].index.value.to_string(), "Phase"); - assert_eq!(e.keys[1].index.value.to_string(), "Component"); + assert_eq!(e.keys[0].discrete_index().value.to_string(), "Phase"); + assert_eq!(e.keys[1].discrete_index().value.to_string(), "Component"); } } other => panic!("expected TableLiteral, got {other:?}"), @@ -1255,7 +1264,10 @@ param m: Bool[mission.Phase, mission.Component, mission.Mode] assert_eq!(axis.value.display_path(), "mission.Mode"); assert_eq!(variant.value.as_str(), "Safe"); assert_eq!( - multi.slices()[0].prefix_keys()[0].index.value.to_string(), + multi.slices()[0].prefix_keys()[0] + .discrete_index() + .value + .to_string(), "mission.Phase" ); } diff --git a/crates/graphcal-compiler/src/syntax/parser/expr.rs b/crates/graphcal-compiler/src/syntax/parser/expr.rs index 90ad7322c..2ac10d226 100644 --- a/crates/graphcal-compiler/src/syntax/parser/expr.rs +++ b/crates/graphcal-compiler/src/syntax/parser/expr.rs @@ -838,6 +838,11 @@ impl Parser<'_> { ident_span, )) } + } else if self.lexer.peek() == Some(&Token::Number) + || self.lexer.peek() == Some(&Token::Minus) + { + let quantity = self.parse_expr()?; + self.parse_coordinate_map_literal_after_first_entry(start_span, quantity) } else if self.lexer.peek() == Some(&Token::LParen) { // Could be tuple-key map literal: { (Index.Variant, ...): expr, ... } self.parse_tuple_key_map_literal(start_span) diff --git a/crates/graphcal-compiler/src/syntax/parser/table.rs b/crates/graphcal-compiler/src/syntax/parser/table.rs index e755da5ca..1d41baa0a 100644 --- a/crates/graphcal-compiler/src/syntax/parser/table.rs +++ b/crates/graphcal-compiler/src/syntax/parser/table.rs @@ -1,4 +1,6 @@ -use crate::syntax::ast::{Expr, ExprKind, MapEntry, MapEntryIndex, MapEntryKey, TableIndexSpec}; +use crate::syntax::ast::{ + Expr, ExprKind, MapEntry, MapEntryIndex, MapEntryKey, MapKeyAxisSyntax, TableIndexSpec, +}; use crate::syntax::index_name::{IndexEntryKey, IndexVariantName}; use crate::syntax::names::NamePath; use crate::syntax::non_empty::NonEmpty; @@ -9,17 +11,23 @@ use crate::syntax::token::{ContextualKeyword, Token}; use super::{ParseError, Parser}; enum TableColumnKeys { - Named(Vec>), + Explicit(Vec), Finite { cardinality: u64, span: Span }, } impl TableColumnKeys { - fn key_at(&self, position: usize) -> Option> { + fn key_at(&self, position: usize, index: &Spanned) -> Option { match self { - Self::Named(keys) => keys.get(position).cloned(), - Self::Finite { span, .. } => u64::try_from(position) - .ok() - .map(|position| Spanned::new(IndexEntryKey::position(position), *span)), + Self::Explicit(keys) => keys.get(position).cloned(), + Self::Finite { span, .. } => { + u64::try_from(position) + .ok() + .map(|position| MapEntryKey::Discrete { + index: index.clone(), + additional_index_spans: Vec::new(), + entry: Spanned::new(IndexEntryKey::position(position), *span), + }) + } } } } @@ -162,7 +170,7 @@ impl Parser<'_> { fn table_column_count(&self, columns: &TableColumnKeys, span: Span) -> Result { match columns { - TableColumnKeys::Named(keys) => self.table_count_from_len(keys.len(), span), + TableColumnKeys::Explicit(keys) => self.table_count_from_len(keys.len(), span), TableColumnKeys::Finite { cardinality, .. } => Ok(*cardinality), } } @@ -186,18 +194,26 @@ impl Parser<'_> { ) -> Result, ParseError> { let mut entries = Vec::new(); while self.lexer.peek() != Some(&Token::RBrace) { - let label = self.parse_any_ident()?; + let key = if self.lexer.peek().is_some_and(|token| token.is_identifier()) + && self.lexer.peek_second() == Some(&Token::Colon) + { + let label = self.parse_any_ident()?; + MapEntryKey::Discrete { + index: Self::named_index_spanned(index), + additional_index_spans: Vec::new(), + entry: Self::named_entry_key_spanned(label.into_spanned::()), + } + } else { + MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Explicit(index.clone()), + expr: self.parse_expr()?, + } + }; self.expect(Token::Colon)?; let value = self.parse_expr()?; self.expect(Token::Semicolon)?; entries.push(MapEntry { - keys: NonEmpty::singleton(MapEntryKey { - index: Self::named_index_spanned(index), - additional_index_spans: Vec::new(), - variant: Self::named_entry_key_spanned( - label.into_spanned::(), - ), - }), + keys: NonEmpty::singleton(key), value, }); } @@ -213,10 +229,10 @@ impl Parser<'_> { self.expect(Token::Semicolon)?; let i = self.table_count_from_len(entries.len(), value.span)?; entries.push(MapEntry { - keys: NonEmpty::singleton(MapEntryKey { + keys: NonEmpty::singleton(MapEntryKey::Discrete { index: index.clone(), additional_index_spans: Vec::new(), - variant: Self::finite_position_spanned(i, value.span), + entry: Self::finite_position_spanned(i, value.span), }), value, }); @@ -271,14 +287,30 @@ impl Parser<'_> { // - Named column axis: requires `: ColLabel1, ColLabel2, ...;` // - Finite column axis: no header; auto-generate `#0..#(n-1)` labels. let col_labels = match col_spec { - TableIndexSpec::Named(_) => { + TableIndexSpec::Named(axis) => { self.expect(Token::Colon)?; let mut labels = Vec::new(); loop { - let label = self.parse_any_ident()?; - labels.push(Self::named_entry_key_spanned( - label.into_spanned::(), - )); + if self.lexer.peek().is_some_and(|token| token.is_identifier()) + && matches!( + self.lexer.peek_second(), + Some(Token::Comma | Token::Semicolon) + ) + { + let label = self.parse_any_ident()?; + labels.push(MapEntryKey::Discrete { + index: col_index_template.clone(), + additional_index_spans: Vec::new(), + entry: Self::named_entry_key_spanned( + label.into_spanned::(), + ), + }); + } else { + labels.push(MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Explicit(axis.clone()), + expr: self.parse_expr()?, + }); + } if self.lexer.peek() == Some(&Token::Comma) { self.lexer.next_token(); } else { @@ -286,7 +318,7 @@ impl Parser<'_> { } } self.expect(Token::Semicolon)?; - TableColumnKeys::Named(labels) + TableColumnKeys::Explicit(labels) } TableIndexSpec::Finite { cardinality, span } => TableColumnKeys::Finite { cardinality: *cardinality, @@ -302,12 +334,34 @@ impl Parser<'_> { { // Determine the row label for this row. let (row_label, row_label_span) = match row_spec { - TableIndexSpec::Named(_) => { - let row_label_ident = self.parse_any_ident()?; - let span = row_label_ident.span; - let label = Self::named_entry_key_spanned( - row_label_ident.into_spanned::(), - ); + TableIndexSpec::Named(axis) => { + let (label, span) = + if self.lexer.peek().is_some_and(|token| token.is_identifier()) + && self.lexer.peek_second() == Some(&Token::Colon) + { + let row_label_ident = self.parse_any_ident()?; + let span = row_label_ident.span; + ( + MapEntryKey::Discrete { + index: row_index_template.clone(), + additional_index_spans: Vec::new(), + entry: Self::named_entry_key_spanned( + row_label_ident.into_spanned::(), + ), + }, + span, + ) + } else { + let quantity = self.parse_expr()?; + let span = quantity.span; + ( + MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Explicit(axis.clone()), + expr: quantity, + }, + span, + ) + }; self.expect(Token::Colon)?; (label, span) } @@ -316,7 +370,14 @@ impl Parser<'_> { // row-length mismatch logic below; the row is still parsed. let label = Self::finite_position_spanned(row_index_counter, *span); let span = self.lexer.peek_with_span().map_or(*span, |(_, s)| s); - (label, span) + ( + MapEntryKey::Discrete { + index: row_index_template.clone(), + additional_index_spans: Vec::new(), + entry: label, + }, + span, + ) } }; @@ -350,22 +411,15 @@ impl Parser<'_> { } for (col_idx, value) in row_values.into_iter().enumerate() { - let row_key = MapEntryKey { - index: row_index_template.clone(), - additional_index_spans: Vec::new(), - variant: row_label.clone(), - }; - let column_key = MapEntryKey { - index: col_index_template.clone(), - additional_index_spans: Vec::new(), - variant: col_labels.key_at(col_idx).ok_or_else(|| { - ParseError::InvalidNumber { + let row_key = row_label.clone(); + let column_key = + col_labels + .key_at(col_idx, &col_index_template) + .ok_or_else(|| ParseError::InvalidNumber { reason: "table column position does not fit in u64".to_string(), src: self.named_source(), span: value.span.into(), - } - })?, - }; + })?; entries.push(MapEntry { keys: table_entry_keys(prefix_keys.to_vec(), row_key, column_key), value, @@ -411,19 +465,28 @@ impl Parser<'_> { } match slice_index { TableIndexSpec::Named(axis) => { - let (index, variant, _) = self.parse_index_variant_path()?; - if index.value != axis.value { - return Err(self.unexpected_token( - &format!("slice axis `{}`", axis.value.display_path()), - &index.value.display_path(), - index.span, - )); + if self.lexer.peek().is_some_and(|token| token.is_identifier()) + && self.lexer.peek_second() == Some(&Token::Dot) + { + let (index, variant, _) = self.parse_index_variant_path()?; + if index.value != axis.value { + return Err(self.unexpected_token( + &format!("slice axis `{}`", axis.value.display_path()), + &index.value.display_path(), + index.span, + )); + } + prefix_keys.push(MapEntryKey::Discrete { + index: Spanned::new(MapEntryIndex::Named(index.value), index.span), + additional_index_spans: vec![axis.span], + entry: Self::named_entry_key_spanned(variant), + }); + } else { + prefix_keys.push(MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Explicit(axis.clone()), + expr: self.parse_expr()?, + }); } - prefix_keys.push(MapEntryKey { - index: Spanned::new(MapEntryIndex::Named(index.value), index.span), - additional_index_spans: vec![axis.span], - variant: Self::named_entry_key_spanned(variant), - }); } TableIndexSpec::Finite { cardinality, span } => { let (_, hash_span) = self.expect(Token::Hash)?; @@ -444,10 +507,10 @@ impl Parser<'_> { }); } let variant_span = hash_span.merge(num_span); - prefix_keys.push(MapEntryKey { + prefix_keys.push(MapEntryKey::Discrete { index: Self::finite_index_index_spanned(*cardinality, *span), additional_index_spans: Vec::new(), - variant: Self::finite_position_spanned(value, variant_span), + entry: Self::finite_position_spanned(value, variant_span), }); } } @@ -476,10 +539,10 @@ impl Parser<'_> { self.expect(Token::Colon)?; let value = self.parse_expr()?; let mut entries = vec![MapEntry { - keys: NonEmpty::singleton(MapEntryKey { + keys: NonEmpty::singleton(MapEntryKey::Discrete { index: Self::named_index_spanned_owned(first_index), additional_index_spans: Vec::new(), - variant: Self::named_entry_key_spanned(first_variant), + entry: Self::named_entry_key_spanned(first_variant), }), value, }]; @@ -493,10 +556,10 @@ impl Parser<'_> { self.expect(Token::Colon)?; let value = self.parse_expr()?; entries.push(MapEntry { - keys: NonEmpty::singleton(MapEntryKey { + keys: NonEmpty::singleton(MapEntryKey::Discrete { index: Self::named_index_spanned_owned(index), additional_index_spans: Vec::new(), - variant: Self::named_entry_key_spanned(variant), + entry: Self::named_entry_key_spanned(variant), }), value, }); @@ -506,6 +569,44 @@ impl Parser<'_> { Ok(Expr::new(ExprKind::MapLiteral { entries }, span)) } + /// Parse a quantity-keyed map literal after the first key expression. + pub(super) fn parse_coordinate_map_literal_after_first_entry( + &mut self, + brace_span: Span, + first_quantity: Expr, + ) -> Result { + self.expect(Token::Colon)?; + let value = self.parse_expr()?; + let mut entries = vec![MapEntry { + keys: NonEmpty::singleton(MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Contextual, + expr: first_quantity, + }), + value, + }]; + while self.lexer.peek() == Some(&Token::Comma) { + self.lexer.next_token(); + if self.lexer.peek() == Some(&Token::RBrace) { + break; + } + let quantity = self.parse_expr()?; + self.expect(Token::Colon)?; + let value = self.parse_expr()?; + entries.push(MapEntry { + keys: NonEmpty::singleton(MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Contextual, + expr: quantity, + }), + value, + }); + } + let (_, end_span) = self.expect(Token::RBrace)?; + Ok(Expr::new( + ExprKind::MapLiteral { entries }, + brace_span.merge(end_span), + )) + } + /// Parse a tuple-key map literal after `{` has been consumed. /// /// `{ (Index1.Variant1, Index2.Variant2): expr, ... }` @@ -519,21 +620,11 @@ impl Parser<'_> { break; } self.expect(Token::LParen)?; - let (index, variant, _) = self.parse_index_variant_path()?; - let first_key = MapEntryKey { - index: Self::named_index_spanned_owned(index), - additional_index_spans: Vec::new(), - variant: Self::named_entry_key_spanned(variant), - }; + let first_key = self.parse_tuple_map_key()?; let mut rest_keys = Vec::new(); while self.lexer.peek() == Some(&Token::Comma) { self.lexer.next_token(); - let (index, variant, _) = self.parse_index_variant_path()?; - rest_keys.push(MapEntryKey { - index: Self::named_index_spanned_owned(index), - additional_index_spans: Vec::new(), - variant: Self::named_entry_key_spanned(variant), - }); + rest_keys.push(self.parse_tuple_map_key()?); } self.expect(Token::RParen)?; self.expect(Token::Colon)?; @@ -552,6 +643,24 @@ impl Parser<'_> { let span = brace_span.merge(end_span); Ok(Expr::new(ExprKind::MapLiteral { entries }, span)) } + + fn parse_tuple_map_key(&mut self) -> Result { + if self.lexer.peek().is_some_and(|token| token.is_identifier()) + && self.lexer.peek_second() == Some(&Token::Dot) + { + let (index, variant, _) = self.parse_index_variant_path()?; + Ok(MapEntryKey::Discrete { + index: Self::named_index_spanned_owned(index), + additional_index_spans: Vec::new(), + entry: Self::named_entry_key_spanned(variant), + }) + } else { + Ok(MapEntryKey::Expression { + axis: MapKeyAxisSyntax::Contextual, + expr: self.parse_expr()?, + }) + } + } } #[cfg(test)] @@ -567,10 +676,22 @@ mod tests { DeclKind::Param(p) => match &p.value.as_ref().unwrap().kind { ExprKind::MapLiteral { entries } => { assert_eq!(entries.len(), 2); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Maneuver"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "Departure"); - assert_eq!(entries[1].keys[0].index.value.to_string(), "Maneuver"); - assert_eq!(entries[1].keys[0].variant.value.to_string(), "Correction"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Maneuver" + ); + assert_eq!( + entries[0].keys[0].discrete_entry().value.to_string(), + "Departure" + ); + assert_eq!( + entries[1].keys[0].discrete_index().value.to_string(), + "Maneuver" + ); + assert_eq!( + entries[1].keys[0].discrete_entry().value.to_string(), + "Correction" + ); } other => panic!("expected MapLiteral, got {other:?}"), }, @@ -610,10 +731,22 @@ mod tests { assert_eq!(named_index_name(&indexes[0]), "Maneuver"); assert_eq!(entries.len(), 3); assert_eq!(entries[0].keys.len(), 1); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Maneuver"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "Departure"); - assert_eq!(entries[1].keys[0].variant.value.to_string(), "Correction"); - assert_eq!(entries[2].keys[0].variant.value.to_string(), "Insertion"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Maneuver" + ); + assert_eq!( + entries[0].keys[0].discrete_entry().value.to_string(), + "Departure" + ); + assert_eq!( + entries[1].keys[0].discrete_entry().value.to_string(), + "Correction" + ); + assert_eq!( + entries[2].keys[0].discrete_entry().value.to_string(), + "Insertion" + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -638,10 +771,13 @@ mod tests { assert_eq!(indexes.len(), 1); assert_eq!(finite_index_size(&indexes[0]), 3); assert_eq!(entries.len(), 3); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Fin(3)"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "#0"); - assert_eq!(entries[1].keys[0].variant.value.to_string(), "#1"); - assert_eq!(entries[2].keys[0].variant.value.to_string(), "#2"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Fin(3)" + ); + assert_eq!(entries[0].keys[0].discrete_entry().value.to_string(), "#0"); + assert_eq!(entries[1].keys[0].discrete_entry().value.to_string(), "#1"); + assert_eq!(entries[2].keys[0].discrete_entry().value.to_string(), "#2"); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -682,13 +818,34 @@ mod tests { assert_eq!(named_index_name(&indexes[1]), "Maneuver"); assert_eq!(entries.len(), 9); assert_eq!(entries[0].keys.len(), 2); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Phase"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "Launch"); - assert_eq!(entries[0].keys[1].index.value.to_string(), "Maneuver"); - assert_eq!(entries[0].keys[1].variant.value.to_string(), "Departure"); - assert_eq!(entries[1].keys[1].variant.value.to_string(), "Correction"); - assert_eq!(entries[8].keys[0].variant.value.to_string(), "Arrival"); - assert_eq!(entries[8].keys[1].variant.value.to_string(), "Insertion"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Phase" + ); + assert_eq!( + entries[0].keys[0].discrete_entry().value.to_string(), + "Launch" + ); + assert_eq!( + entries[0].keys[1].discrete_index().value.to_string(), + "Maneuver" + ); + assert_eq!( + entries[0].keys[1].discrete_entry().value.to_string(), + "Departure" + ); + assert_eq!( + entries[1].keys[1].discrete_entry().value.to_string(), + "Correction" + ); + assert_eq!( + entries[8].keys[0].discrete_entry().value.to_string(), + "Arrival" + ); + assert_eq!( + entries[8].keys[1].discrete_entry().value.to_string(), + "Insertion" + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -738,12 +895,18 @@ mod tests { assert_eq!(finite_index_size(&indexes[0]), 2); assert_eq!(finite_index_size(&indexes[1]), 3); assert_eq!(entries.len(), 6); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Fin(2)"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "#0"); - assert_eq!(entries[0].keys[1].index.value.to_string(), "Fin(3)"); - assert_eq!(entries[0].keys[1].variant.value.to_string(), "#0"); - assert_eq!(entries[5].keys[0].variant.value.to_string(), "#1"); - assert_eq!(entries[5].keys[1].variant.value.to_string(), "#2"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Fin(2)" + ); + assert_eq!(entries[0].keys[0].discrete_entry().value.to_string(), "#0"); + assert_eq!( + entries[0].keys[1].discrete_index().value.to_string(), + "Fin(3)" + ); + assert_eq!(entries[0].keys[1].discrete_entry().value.to_string(), "#0"); + assert_eq!(entries[5].keys[0].discrete_entry().value.to_string(), "#1"); + assert_eq!(entries[5].keys[1].discrete_entry().value.to_string(), "#2"); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -768,11 +931,20 @@ mod tests { assert_eq!(named_index_name(&indexes[0]), "Phase"); assert_eq!(finite_index_size(&indexes[1]), 3); assert_eq!(entries.len(), 6); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Phase"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "Launch"); - assert_eq!(entries[0].keys[1].index.value.to_string(), "Fin(3)"); - assert_eq!(entries[0].keys[1].variant.value.to_string(), "#0"); - assert_eq!(entries[2].keys[1].variant.value.to_string(), "#2"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Phase" + ); + assert_eq!( + entries[0].keys[0].discrete_entry().value.to_string(), + "Launch" + ); + assert_eq!( + entries[0].keys[1].discrete_index().value.to_string(), + "Fin(3)" + ); + assert_eq!(entries[0].keys[1].discrete_entry().value.to_string(), "#0"); + assert_eq!(entries[2].keys[1].discrete_entry().value.to_string(), "#2"); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -798,12 +970,24 @@ mod tests { assert_eq!(finite_index_size(&indexes[0]), 2); assert_eq!(named_index_name(&indexes[1]), "Maneuver"); assert_eq!(entries.len(), 4); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Fin(2)"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "#0"); - assert_eq!(entries[0].keys[1].index.value.to_string(), "Maneuver"); - assert_eq!(entries[0].keys[1].variant.value.to_string(), "Departure"); - assert_eq!(entries[3].keys[0].variant.value.to_string(), "#1"); - assert_eq!(entries[3].keys[1].variant.value.to_string(), "Correction"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Fin(2)" + ); + assert_eq!(entries[0].keys[0].discrete_entry().value.to_string(), "#0"); + assert_eq!( + entries[0].keys[1].discrete_index().value.to_string(), + "Maneuver" + ); + assert_eq!( + entries[0].keys[1].discrete_entry().value.to_string(), + "Departure" + ); + assert_eq!(entries[3].keys[0].discrete_entry().value.to_string(), "#1"); + assert_eq!( + entries[3].keys[1].discrete_entry().value.to_string(), + "Correction" + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -837,15 +1021,36 @@ mod tests { assert_eq!(named_index_name(&indexes[2]), "Maneuver"); assert_eq!(entries.len(), 8); assert_eq!(entries[0].keys.len(), 3); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Time"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "T1"); - assert_eq!(entries[0].keys[1].index.value.to_string(), "Phase"); - assert_eq!(entries[0].keys[1].variant.value.to_string(), "Launch"); - assert_eq!(entries[0].keys[2].index.value.to_string(), "Maneuver"); - assert_eq!(entries[0].keys[2].variant.value.to_string(), "Departure"); - assert_eq!(entries[4].keys[0].variant.value.to_string(), "T2"); - assert_eq!(entries[4].keys[1].variant.value.to_string(), "Launch"); - assert_eq!(entries[4].keys[2].variant.value.to_string(), "Departure"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Time" + ); + assert_eq!(entries[0].keys[0].discrete_entry().value.to_string(), "T1"); + assert_eq!( + entries[0].keys[1].discrete_index().value.to_string(), + "Phase" + ); + assert_eq!( + entries[0].keys[1].discrete_entry().value.to_string(), + "Launch" + ); + assert_eq!( + entries[0].keys[2].discrete_index().value.to_string(), + "Maneuver" + ); + assert_eq!( + entries[0].keys[2].discrete_entry().value.to_string(), + "Departure" + ); + assert_eq!(entries[4].keys[0].discrete_entry().value.to_string(), "T2"); + assert_eq!( + entries[4].keys[1].discrete_entry().value.to_string(), + "Launch" + ); + assert_eq!( + entries[4].keys[2].discrete_entry().value.to_string(), + "Departure" + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -870,7 +1075,7 @@ mod tests { }; assert_eq!(axis.value.display_path(), "mission.Maneuver"); assert_eq!( - entries[0].keys[0].index.value.to_string(), + entries[0].keys[0].discrete_index().value.to_string(), "mission.Maneuver" ); } @@ -898,9 +1103,15 @@ mod tests { panic!("expected named axis") }; assert_eq!(axis.value.display_path(), "mission.Time"); - assert_eq!(entries[0].keys[0].index.value.to_string(), "mission.Time"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "T1"); - assert_eq!(entries[0].keys[0].additional_index_spans, vec![axis.span]); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "mission.Time" + ); + assert_eq!(entries[0].keys[0].discrete_entry().value.to_string(), "T1"); + assert_eq!( + entries[0].keys[0].discrete_additional_index_spans(), + vec![axis.span] + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -922,7 +1133,10 @@ mod tests { }) => { assert_eq!(indexes.len(), 1); assert_eq!(named_index_name(&indexes[0]), "step"); - assert_eq!(entries[0].keys[0].index.value.to_string(), "step"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "step" + ); } other => panic!("expected TableLiteral, got {other:?}"), }, @@ -999,11 +1213,20 @@ mod tests { assert_eq!(named_index_name(&indexes[1]), "Phase"); assert_eq!(named_index_name(&indexes[2]), "Maneuver"); assert_eq!(entries.len(), 8); - assert_eq!(entries[0].keys[0].index.value.to_string(), "Fin(2)"); - assert_eq!(entries[0].keys[0].variant.value.to_string(), "#0"); - assert_eq!(entries[0].keys[1].variant.value.to_string(), "Launch"); - assert_eq!(entries[0].keys[2].variant.value.to_string(), "Departure"); - assert_eq!(entries[4].keys[0].variant.value.to_string(), "#1"); + assert_eq!( + entries[0].keys[0].discrete_index().value.to_string(), + "Fin(2)" + ); + assert_eq!(entries[0].keys[0].discrete_entry().value.to_string(), "#0"); + assert_eq!( + entries[0].keys[1].discrete_entry().value.to_string(), + "Launch" + ); + assert_eq!( + entries[0].keys[2].discrete_entry().value.to_string(), + "Departure" + ); + assert_eq!(entries[4].keys[0].discrete_entry().value.to_string(), "#1"); } other => panic!("expected TableLiteral, got {other:?}"), }, diff --git a/crates/graphcal-compiler/src/syntax/visitor.rs b/crates/graphcal-compiler/src/syntax/visitor.rs index abfb5a8db..ac879eef2 100644 --- a/crates/graphcal-compiler/src/syntax/visitor.rs +++ b/crates/graphcal-compiler/src/syntax/visitor.rs @@ -245,6 +245,11 @@ pub(crate) trait ExprVisitor { entries: &[crate::syntax::ast::MapEntry

], ) -> Result<(), Self::Error> { for entry in entries { + for key in &entry.keys { + if let crate::syntax::ast::MapEntryKey::Expression { expr, .. } = key { + self.visit_expr(expr)?; + } + } self.visit_expr(&entry.value)?; } Ok(()) @@ -483,6 +488,11 @@ pub trait ExprVisitorMut { fn visit_map_literal_mut(&mut self, expr: &mut Expr

) -> Result<(), Self::Error> { if let ExprKind::MapLiteral { entries } = &mut expr.kind { for entry in entries { + for key in &mut entry.keys { + if let crate::syntax::ast::MapEntryKey::Expression { expr, .. } = key { + self.visit_expr_mut(expr)?; + } + } self.visit_expr_mut(&mut entry.value)?; } } diff --git a/crates/graphcal-compiler/src/tir/dim_check/infer/hir.rs b/crates/graphcal-compiler/src/tir/dim_check/infer/hir.rs index abe020fc0..ffdb69baa 100644 --- a/crates/graphcal-compiler/src/tir/dim_check/infer/hir.rs +++ b/crates/graphcal-compiler/src/tir/dim_check/infer/hir.rs @@ -21,14 +21,17 @@ use crate::hir::{self, ConstRef, FunctionRef, NominalConstructor, NominalTypeDef use crate::nat::NatOverflowError; use crate::registry::declared_type::IndexTypeRef; use crate::registry::error::GraphcalError; -use crate::registry::types::{IndexCardinality, SemanticRegistry, TypeGenericConstraint}; +use crate::registry::types::{ + IndexCardinality, IndexCategory, IndexKind, SemanticRegistry, TypeGenericConstraint, +}; use crate::syntax::ast::UnaryOp; -use crate::syntax::index_name::{IndexEntryKey, ResolvedIndexVariant}; +use crate::syntax::index_name::{IndexEntryKey, ResolvedIndexName, ResolvedIndexVariant}; use crate::syntax::module_name::ScopedName; use crate::syntax::names::NamePath; use crate::syntax::non_empty::NonEmpty; use crate::syntax::span::Span; use crate::syntax::type_name::{FieldName, GenericParamName}; +use crate::tir::map_literal_fact::{CheckedMapLiteralAxes, MapLiteralKey}; use crate::tir::materialized_shape::{ MaterializedExpressionKey, MaterializedShape, MaterializedShapeError, }; @@ -83,6 +86,7 @@ enum NominalDependencyTracking { #[derive(Clone, Default)] pub(in crate::tir::dim_check) struct MaterializedShapeCollector { shapes: Rc>>, + map_literal_axes: Rc>>, } impl MaterializedShapeCollector { @@ -92,6 +96,31 @@ impl MaterializedShapeCollector { self.shapes.borrow().clone() } + pub(in crate::tir::dim_check) fn map_literal_axes_snapshot( + &self, + ) -> HashMap { + self.map_literal_axes.borrow().clone() + } + + fn record_map_literal_axes( + &self, + owner: Option<&ResolvedDeclName>, + expr: &hir::Expr, + axes: &[MapLiteralAxis], + ) { + let Some(owner) = owner else { + return; + }; + let axes = axes + .iter() + .map(|axis| axis.index.type_ref().clone()) + .collect(); + self.map_literal_axes.borrow_mut().insert( + MapLiteralKey::new(owner.clone(), expr.span), + CheckedMapLiteralAxes::new(axes), + ); + } + fn record( &self, owner: Option<&ResolvedDeclName>, @@ -574,8 +603,45 @@ pub(in crate::tir::dim_check) fn infer_hir_type_with_materialized_shapes_and_can ) } +#[expect( + clippy::too_many_arguments, + reason = "mirrors the ordinary materialized-shape inference entry point" +)] +pub(in crate::tir::dim_check) fn infer_hir_type_with_expected_and_materialized_shapes_and_cancellation( + expr: &hir::Expr, + expected: &InferredType, + owner_decl_name: Option<&ResolvedDeclName>, + declared_types: &HashMap, + dag: &crate::tir::typed::DagTIR, + tir: &crate::tir::typed::TIR, + registry: &SemanticRegistry, + builtin_fns: &crate::registry::builtins::BuiltinFunctions, + src: &NamedSource>, + cancellation: &crate::cancellation::CancellationToken, + collector: MaterializedShapeCollector, +) -> Result { + let locals = HirLocalTypes::root_with_materialized_shapes( + cancellation, + collector, + owner_decl_name.cloned(), + ); + infer_hir_type_with_expected( + expr, + Some(expected), + owner_decl_name, + declared_types, + &locals, + dag, + tir, + registry, + builtin_fns, + src, + ) +} + pub(in crate::tir::dim_check) fn infer_hir_type_with_nominal_dependencies_and_cancellation( expr: &hir::Expr, + expected: &InferredType, owner_decl_name: &ResolvedDeclName, declared_types: &HashMap, dag: &crate::tir::typed::DagTIR, @@ -586,8 +652,9 @@ pub(in crate::tir::dim_check) fn infer_hir_type_with_nominal_dependencies_and_ca cancellation: &crate::cancellation::CancellationToken, ) -> Result<(InferredType, HashSet), GraphcalError> { let (locals, collector) = HirLocalTypes::collecting_root(cancellation); - let inferred = infer_hir_type( + let inferred = infer_hir_type_with_expected( expr, + Some(expected), Some(owner_decl_name), declared_types, &locals, @@ -614,6 +681,36 @@ fn infer_hir_type( registry: &SemanticRegistry, builtin_fns: &crate::registry::builtins::BuiltinFunctions, src: &NamedSource>, +) -> Result { + infer_hir_type_with_expected( + expr, + None, + owner_decl_name, + declared_types, + local_types, + dag, + tir, + registry, + builtin_fns, + src, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "mirrors infer_hir_type's signature and adds contextual expected type" +)] +fn infer_hir_type_with_expected( + expr: &hir::Expr, + expected: Option<&InferredType>, + owner_decl_name: Option<&ResolvedDeclName>, + declared_types: &HashMap, + local_types: &HirLocalTypes<'_>, + dag: &crate::tir::typed::DagTIR, + tir: &crate::tir::typed::TIR, + registry: &SemanticRegistry, + builtin_fns: &crate::registry::builtins::BuiltinFunctions, + src: &NamedSource>, ) -> Result { local_types.checkpoint()?; // Recursion choke point: inference recurses once per tree level @@ -621,6 +718,7 @@ fn infer_hir_type( crate::stack::with_stack_growth(|| { infer_hir_type_inner( expr, + expected, owner_decl_name, declared_types, local_types, @@ -639,6 +737,7 @@ fn infer_hir_type( )] fn infer_hir_type_inner( expr: &hir::Expr, + expected: Option<&InferredType>, owner_decl_name: Option<&ResolvedDeclName>, declared_types: &HashMap, local_types: &HirLocalTypes<'_>, @@ -885,6 +984,7 @@ fn infer_hir_type_inner( hir::ExprKind::MapLiteral { entries } => infer_hir_map_literal( expr, entries, + expected, owner_decl_name, declared_types, local_types, @@ -4245,20 +4345,30 @@ fn resolve_applied_generic_args( #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum MapLiteralVariantKey { Declared(ResolvedIndexVariant), - Finite { form: NatPolyForm, position: u64 }, + Coordinate { + index: ResolvedIndexName, + position: u64, + }, + Finite { + form: NatPolyForm, + position: u64, + }, } impl MapLiteralVariantKey { fn entry_key(&self) -> IndexEntryKey { match self { Self::Declared(resolved) => IndexEntryKey::named(resolved.variant().clone()), - Self::Finite { position, .. } => IndexEntryKey::position(*position), + Self::Coordinate { position, .. } | Self::Finite { position, .. } => { + IndexEntryKey::position(*position) + } } } fn display(&self) -> String { match self { Self::Declared(resolved) => resolved.to_string(), + Self::Coordinate { index, position } => format!("{index}.#{position}"), Self::Finite { form, position } => format!("Fin({}).#{position}", form.format()), } } @@ -4267,6 +4377,7 @@ impl MapLiteralVariantKey { #[derive(Debug, Clone)] struct MapLiteralAxis { index: InferredIndex, + category: IndexCategory, entry_keys: Vec, } @@ -4340,26 +4451,39 @@ fn first_missing_map_tuple( impl MapLiteralAxis { fn variant_key(&self, key: IndexEntryKey) -> Result { - match (self.index.type_ref(), key) { - (IndexTypeRef::Declared(reference), IndexEntryKey::Named(variant)) => { - Ok(MapLiteralVariantKey::Declared(ResolvedIndexVariant::new( - reference.resolved().clone(), - variant, - ))) - } - (IndexTypeRef::Finite(reference), IndexEntryKey::Position(position)) => { - Ok(MapLiteralVariantKey::Finite { - form: reference.form(), - position, - }) - } - (_, incompatible) => Err(incompatible), + match (self.category, self.index.type_ref(), key) { + ( + IndexCategory::Named, + IndexTypeRef::Declared(reference), + IndexEntryKey::Named(variant), + ) => Ok(MapLiteralVariantKey::Declared(ResolvedIndexVariant::new( + reference.resolved().clone(), + variant, + ))), + ( + IndexCategory::Coordinate, + IndexTypeRef::Declared(reference), + IndexEntryKey::Position(position), + ) => Ok(MapLiteralVariantKey::Coordinate { + index: reference.resolved().clone(), + position, + }), + ( + IndexCategory::Finite, + IndexTypeRef::Finite(reference), + IndexEntryKey::Position(position), + ) => Ok(MapLiteralVariantKey::Finite { + form: reference.form(), + position, + }), + (_, _, incompatible) => Err(incompatible), } } } fn inferred_index_for_hir_map_key( key: &hir::expr::MapEntryKey, + contextual_axis: Option<&InferredIndex>, src: &NamedSource>, ) -> Result { match key { @@ -4370,20 +4494,208 @@ fn inferred_index_for_hir_map_key( InferredIndex::from_finite_index_form(NatPolyForm::from_constant(*size)) .map_err(|err| finite_index_error(err, src, position.span)) } + hir::expr::MapEntryKey::Expression { axis, expr } => match axis { + hir::expr::MapKeyAxis::Explicit(index) => { + Ok(InferredIndex::from_resolved(index.value.clone())) + } + hir::expr::MapKeyAxis::Contextual => { + contextual_axis + .cloned() + .ok_or_else(|| GraphcalError::EvalError { + message: "expression-shaped map keys require an expected coordinate index" + .to_string(), + src: src.clone(), + span: expr.span.into(), + }) + } + }, + } +} + +pub(in crate::tir::dim_check) fn static_coordinate_quantity( + expr: &hir::Expr, + tir: &crate::tir::typed::TIR, + src: &NamedSource>, +) -> Result<(f64, Dimension), GraphcalError> { + let error = |message: String, span: Span| GraphcalError::EvalError { + message, + src: src.clone(), + span: span.into(), + }; + let finite = |value: f64, span: Span| { + value.is_finite().then_some(value).ok_or_else(|| { + error( + format!("coordinate key must evaluate to a finite quantity, got {value}"), + span, + ) + }) + }; + match &expr.kind { + hir::ExprKind::Number(value) => Ok((finite(*value, expr.span)?, Dimension::dimensionless())), + hir::ExprKind::QuantityLiteral { value, unit } => { + let mut dimension = Dimension::dimensionless(); + let mut scale = 1.0; + for term in &unit.terms { + let info = tir.unit_info(term.name.value.resolved()).ok_or_else(|| { + error(format!("unknown unit `{}`", term.name.value), term.name.span) + })?; + let Some(term_scale) = info.scale.as_static() else { + return Err(error( + "coordinate keys cannot use dynamic units".to_string(), + term.name.span, + )); + }; + let term_dimension = info.dimension.pow(term.power).map_err(|_| { + GraphcalError::DimensionOverflow { src: src.clone(), span: term.name.span.into() } + })?; + let powered_scale = crate::registry::unit::pow_scale(term_scale, term.power); + match term.op { + crate::syntax::ast::MulDivOp::Mul => { + dimension = dimension.checked_mul(&term_dimension).map_err(|_| GraphcalError::DimensionOverflow { src: src.clone(), span: unit.span.into() })?; + scale *= powered_scale; + } + crate::syntax::ast::MulDivOp::Div => { + dimension = dimension.checked_div(&term_dimension).map_err(|_| GraphcalError::DimensionOverflow { src: src.clone(), span: unit.span.into() })?; + scale /= powered_scale; + } + } + } + Ok((finite(*value * scale, expr.span)?, dimension)) + } + hir::ExprKind::ConstRef(target) => match &target.value { + ConstRef::Builtin(value) => Ok((value.value(), Dimension::dimensionless())), + _ => Err(error( + "coordinate keys must be statically evaluable quantities; runtime references are not supported".to_string(), + target.span, + )), + }, + hir::ExprKind::UnaryOp { op: UnaryOp::Neg, operand } => { + let (value, dimension) = static_coordinate_quantity(operand, tir, src)?; + Ok((finite(-value, expr.span)?, dimension)) + } + hir::ExprKind::BinOp { op, lhs, rhs } => { + let (lhs_value, lhs_dimension) = static_coordinate_quantity(lhs, tir, src)?; + let (rhs_value, rhs_dimension) = static_coordinate_quantity(rhs, tir, src)?; + let overflow = || GraphcalError::DimensionOverflow { src: src.clone(), span: expr.span.into() }; + let (value, dimension) = match op { + crate::syntax::ast::BinOp::Add | crate::syntax::ast::BinOp::Sub => { + if lhs_dimension != rhs_dimension { + return Err(error("coordinate key addition or subtraction requires matching dimensions".to_string(), expr.span)); + } + let value = if *op == crate::syntax::ast::BinOp::Add { lhs_value + rhs_value } else { lhs_value - rhs_value }; + (value, lhs_dimension) + } + crate::syntax::ast::BinOp::Mul => (lhs_value * rhs_value, lhs_dimension.checked_mul(&rhs_dimension).map_err(|_| overflow())?), + crate::syntax::ast::BinOp::Div => (lhs_value / rhs_value, lhs_dimension.checked_div(&rhs_dimension).map_err(|_| overflow())?), + _ => return Err(error("coordinate keys support only static quantity arithmetic".to_string(), expr.span)), + }; + Ok((finite(value, expr.span)?, dimension)) + } + _ => Err(error( + "coordinate keys must be statically evaluable quantities; runtime expressions are not supported".to_string(), + expr.span, + )), } } -fn hir_map_entry_key(key: &hir::expr::MapEntryKey) -> IndexEntryKey { +fn hir_map_entry_key( + key: &hir::expr::MapEntryKey, + axis: &MapLiteralAxis, + tir: &crate::tir::typed::TIR, + src: &NamedSource>, +) -> Result { match key { hir::expr::MapEntryKey::IndexVariant(variant) => { - IndexEntryKey::named(variant.variant.variant().clone()) + Ok(IndexEntryKey::named(variant.variant.variant().clone())) } hir::expr::MapEntryKey::FinitePosition { position, .. } => { - IndexEntryKey::position(position.value) + Ok(IndexEntryKey::position(position.value)) + } + hir::expr::MapEntryKey::Expression { + axis: key_axis, + expr, + } => { + let IndexTypeRef::Declared(index_ref) = axis.index.type_ref() else { + return Err(error_for_coordinate_key( + "expression-shaped map keys require a declared coordinate index".to_string(), + src, + expr.span, + )); + }; + let index = index_ref.resolved(); + if let hir::expr::MapKeyAxis::Explicit(explicit) = key_axis + && explicit.value != *index + { + return Err(GraphcalError::IndexMismatch { + expected: axis.index.name(), + found: explicit.value.to_unowned_def_name(), + src: src.clone(), + span: explicit.span.into(), + }); + } + let definition = + tir.declared_index_def(index) + .ok_or_else(|| GraphcalError::UnknownIndex { + name: index.to_unowned_def_name(), + src: src.clone(), + span: expr.span.into(), + })?; + let IndexKind::Coordinate(data) = &definition.kind else { + return Err(error_for_coordinate_key( + format!("index `{index}` is not a coordinate index"), + src, + expr.span, + )); + }; + let (value, dimension) = static_coordinate_quantity(expr, tir, src)?; + if dimension != data.dimension { + return Err(error_for_coordinate_key( + format!( + "coordinate key dimension for `{}` does not match: expected {:?}, found {:?}", + index, data.dimension, dimension + ), + src, + expr.span, + )); + } + let Some(position) = data.position_of(value) else { + let display_coordinate = |coordinate: f64| { + let value = coordinate / data.display_scale; + data.display_label + .as_ref() + .map_or_else(|| value.to_string(), |label| format!("{value} {label}")) + }; + let nearest = data.nearest_coordinate(value).map_or_else( + || "none".to_string(), + |(_, nearest)| display_coordinate(nearest), + ); + return Err(error_for_coordinate_key( + format!( + "coordinate key {} does not lie on coordinate index `{}`; nearest grid point is {nearest}", + display_coordinate(value), + index.as_str(), + ), + src, + expr.span, + )); + }; + Ok(IndexEntryKey::position(position as u64)) } } } +fn error_for_coordinate_key( + message: String, + src: &NamedSource>, + span: Span, +) -> GraphcalError { + GraphcalError::EvalError { + message, + src: src.clone(), + span: span.into(), + } +} + #[expect(clippy::too_many_arguments, reason = "map literal expression context")] #[expect( clippy::too_many_lines, @@ -4392,6 +4704,7 @@ fn hir_map_entry_key(key: &hir::expr::MapEntryKey) -> IndexEntryKey { fn infer_hir_map_literal( expr: &hir::Expr, entries: &[hir::expr::MapEntry], + expected: Option<&InferredType>, owner_decl_name: Option<&ResolvedDeclName>, declared_types: &HashMap, local_types: &HirLocalTypes<'_>, @@ -4422,6 +4735,17 @@ fn infer_hir_map_literal( }); }; let arity = first_entry.keys.len(); + let mut expected_axes = Vec::with_capacity(arity); + let mut expected_element = expected.cloned(); + for _ in 0..arity { + let Some(InferredType::Indexed { index, element }) = expected_element else { + expected_axes.clear(); + expected_element = None; + break; + }; + expected_axes.push(index); + expected_element = Some(*element); + } for entry in entries.iter().skip(1) { if entry.keys.len() != arity { return Err(GraphcalError::EvalError { @@ -4436,8 +4760,8 @@ fn infer_hir_map_literal( } let mut axes = Vec::with_capacity(arity); - for key in &first_entry.keys { - let index = inferred_index_for_hir_map_key(key, src)?; + for (position, key) in first_entry.keys.iter().enumerate() { + let index = inferred_index_for_hir_map_key(key, expected_axes.get(position), src)?; let idx_def = super::index_def_for_inferred(&index, Some(dag), registry).ok_or_else(|| { GraphcalError::UnknownIndex { @@ -4446,24 +4770,15 @@ fn infer_hir_map_literal( span: expr.span.into(), } })?; - if idx_def.is_coordinate() { - return Err(GraphcalError::EvalError { - message: format!( - "coordinate index `{}` cannot be used as a map/table literal key; use a `for` comprehension instead", - index.name() - ), - src: src.clone(), - span: expr.span.into(), - }); - } axes.push(MapLiteralAxis { index, + category: idx_def.category(), entry_keys: idx_def.entry_keys(), }); } for entry in entries.iter().skip(1) { for (i, key) in entry.keys.iter().enumerate() { - let key_index = inferred_index_for_hir_map_key(key, src)?; + let key_index = inferred_index_for_hir_map_key(key, expected_axes.get(i), src)?; if key_index != axes[i].index { return Err(GraphcalError::IndexMismatch { expected: axes[i].index.name(), @@ -4475,6 +4790,10 @@ fn infer_hir_map_literal( } } + if let Some((collector, owner)) = &local_types.control.materialized_shapes { + collector.record_map_literal_axes(owner.as_ref(), expr, &axes); + } + let incompatible_key_error = |key: IndexEntryKey| GraphcalError::EvalError { message: format!("map entry key `{key}` does not match its index category"), src: src.clone(), @@ -4500,7 +4819,7 @@ fn infer_hir_map_literal( .iter() .enumerate() .map(|(i, key)| { - let entry_key = hir_map_entry_key(key); + let entry_key = hir_map_entry_key(key, &axes[i], tir, src)?; if !axes[i].entry_keys.contains(&entry_key) { return match (arity, entry_key) { (1, extra) => Err(GraphcalError::ExtraVariants { @@ -4583,8 +4902,9 @@ fn infer_hir_map_literal( }); } - let first_type = infer_hir_type( + let first_type = infer_hir_type_with_expected( &first_entry.value, + expected_element.as_ref(), owner_decl_name, declared_types, local_types, @@ -4606,8 +4926,9 @@ fn infer_hir_map_literal( } } for entry in entries.iter().skip(1) { - let entry_type = infer_hir_type( + let entry_type = infer_hir_type_with_expected( &entry.value, + expected_element.as_ref(), owner_decl_name, declared_types, local_types, diff --git a/crates/graphcal-compiler/src/tir/dim_check/mod.rs b/crates/graphcal-compiler/src/tir/dim_check/mod.rs index a80f66793..8c7eeeb2a 100644 --- a/crates/graphcal-compiler/src/tir/dim_check/mod.rs +++ b/crates/graphcal-compiler/src/tir/dim_check/mod.rs @@ -463,18 +463,21 @@ fn check_decl_expr_type( body_ctx.src, DiagnosticAnchor::Source(*type_ann_span), )?; - let inferred = infer::hir::infer_hir_type_with_materialized_shapes_and_cancellation( - hir_expr, - Some(&owner), - body_ctx.declared_types, - dag, - body_ctx.tir, - body_ctx.registry, - body_ctx.builtin_fns, - body_ctx.src, - body_ctx.cancellation, - body_ctx.materialized_shapes.clone(), - )?; + let expected = InferredType::from(declared); + let inferred = + infer::hir::infer_hir_type_with_expected_and_materialized_shapes_and_cancellation( + hir_expr, + &expected, + Some(&owner), + body_ctx.declared_types, + dag, + body_ctx.tir, + body_ctx.registry, + body_ctx.builtin_fns, + body_ctx.src, + body_ctx.cancellation, + body_ctx.materialized_shapes.clone(), + )?; let matches = body_ctx .dag .and_then(|dag| dag.resolved_decl_types.get(name)) @@ -1019,11 +1022,16 @@ pub fn check_dimensions_tir_with_cancellation( cancellation, &collector, )?; - Ok((dag_id.clone(), collector.snapshot(), plot_shapes)) + Ok(( + dag_id.clone(), + collector.snapshot(), + collector.map_literal_axes_snapshot(), + plot_shapes, + )) }) .collect::, GraphcalError>>()?; let mut checked_plot_shapes = HashMap::new(); - for (dag_id, shapes, plot_shapes) in checked_dag_facts { + for (dag_id, shapes, map_literal_axes, plot_shapes) in checked_dag_facts { let dag = tir.dags.get_mut(&dag_id).ok_or_else(|| { GraphcalError::internal_error( format!("checked DAG `{dag_id}` disappeared while installing shape facts"), @@ -1032,6 +1040,7 @@ pub fn check_dimensions_tir_with_cancellation( ) })?; dag.semantic.materialized_shapes = shapes; + dag.semantic.map_literal_axes = map_literal_axes; checked_plot_shapes.insert(dag_id, plot_shapes); } @@ -1173,9 +1182,18 @@ pub fn collect_override_dependency_summary_with_cancellation( body_src, DiagnosticAnchor::Source(param.span), )?; + let expected = declared_types + .get(¶m.name) + .map(InferredType::from) + .ok_or_else(|| GraphcalError::InternalError { + message: format!("no declared type recorded for parameter `{}`", param.name), + src: body_src.clone(), + span: param.span.into(), + })?; let (_, mut dependencies) = infer::hir::infer_hir_type_with_nominal_dependencies_and_cancellation( default_expr, + &expected, &owner, &declared_types, dag, diff --git a/crates/graphcal-compiler/src/tir/dim_check/presentation.rs b/crates/graphcal-compiler/src/tir/dim_check/presentation.rs index 1b6da2ee6..a775cfa9e 100644 --- a/crates/graphcal-compiler/src/tir/dim_check/presentation.rs +++ b/crates/graphcal-compiler/src/tir/dim_check/presentation.rs @@ -442,12 +442,24 @@ impl PresentationResolver<'_> { src: &NamedSource>, span: Span, ) -> Result { + let axes = self + .dag(dag_id, DiagnosticAnchor::Source(span))? + .map_literal_axes(owner, span) + .map(|axes| axes.as_slice().to_vec()); let mut result = None; for entry in entries { let path = entry .keys .iter() - .map(map_key_part) + .enumerate() + .map(|(position, key)| { + map_key_part( + key, + axes.as_ref().and_then(|axes| axes.get(position)), + self.tir, + src, + ) + }) .collect::, _>>()?; let leaf = self.expression(owner, dag_id, &entry.value, src)?; let nested = singleton_indexed_path(&path, leaf); @@ -561,6 +573,9 @@ fn presentation_index( fn map_key_part( key: &hir::expr::MapEntryKey, + checked_axis: Option<&IndexTypeRef>, + tir: &crate::tir::typed::TIR, + src: &NamedSource>, ) -> Result<(IndexTypeRef, IndexEntryKey), GraphcalError> { match key { hir::expr::MapEntryKey::IndexVariant(variant) => Ok(( @@ -581,6 +596,55 @@ fn map_key_part( IndexEntryKey::position(position.value), )) } + hir::expr::MapEntryKey::Expression { expr, .. } => { + let surface_axis = match key { + hir::expr::MapEntryKey::Expression { + axis: hir::expr::MapKeyAxis::Explicit(index), + .. + } => Some(IndexTypeRef::from_resolved(index.value.clone())), + _ => None, + }; + let axis = checked_axis.or(surface_axis.as_ref()).ok_or_else(|| { + GraphcalError::internal_error( + "contextual map key has no checked axis facts", + src, + DiagnosticAnchor::Source(expr.span), + ) + })?; + let Some(index) = axis.declared_resolved() else { + return Err(GraphcalError::InternalError { + message: "checked expression-shaped map key has a finite axis".to_string(), + src: src.clone(), + span: expr.span.into(), + }); + }; + let definition = + tir.declared_index_def(index) + .ok_or_else(|| GraphcalError::UnknownIndex { + name: index.to_unowned_def_name(), + src: src.clone(), + span: expr.span.into(), + })?; + let data = + definition + .coordinate_data() + .ok_or_else(|| GraphcalError::InternalError { + message: format!( + "checked coordinate map key uses non-coordinate index `{index}`" + ), + src: src.clone(), + span: expr.span.into(), + })?; + let (value, _) = infer::hir::static_coordinate_quantity(expr, tir, src)?; + let position = data + .position_of(value) + .ok_or_else(|| GraphcalError::InternalError { + message: format!("checked coordinate map key {value} is not on `{index}`"), + src: src.clone(), + span: expr.span.into(), + })?; + Ok((axis.clone(), IndexEntryKey::position(position as u64))) + } } } diff --git a/crates/graphcal-compiler/src/tir/map_literal_fact.rs b/crates/graphcal-compiler/src/tir/map_literal_fact.rs new file mode 100644 index 000000000..cb6fe28d2 --- /dev/null +++ b/crates/graphcal-compiler/src/tir/map_literal_fact.rs @@ -0,0 +1,35 @@ +//! Checked semantic axes for map literals whose surface keys are contextual. + +use crate::registry::declared_type::IndexTypeRef; +use crate::syntax::decl_name::ResolvedDeclName; +use crate::syntax::span::Span; + +/// Stable identity of one map literal within a checked declaration body. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MapLiteralKey { + owner: ResolvedDeclName, + span: Span, +} + +impl MapLiteralKey { + #[must_use] + pub const fn new(owner: ResolvedDeclName, span: Span) -> Self { + Self { owner, span } + } +} + +/// Axes established by type checking, in source key order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckedMapLiteralAxes(Vec); + +impl CheckedMapLiteralAxes { + #[must_use] + pub const fn new(axes: Vec) -> Self { + Self(axes) + } + + #[must_use] + pub fn as_slice(&self) -> &[IndexTypeRef] { + &self.0 + } +} diff --git a/crates/graphcal-compiler/src/tir/mod.rs b/crates/graphcal-compiler/src/tir/mod.rs index 751d0963a..198fe5604 100644 --- a/crates/graphcal-compiler/src/tir/mod.rs +++ b/crates/graphcal-compiler/src/tir/mod.rs @@ -2,6 +2,7 @@ #[warn(clippy::arithmetic_side_effects)] pub mod dim_check; +pub mod map_literal_fact; pub mod materialized_shape; pub mod presentation; pub mod typed; diff --git a/crates/graphcal-compiler/src/tir/typed.rs b/crates/graphcal-compiler/src/tir/typed.rs index 6913351fb..c579816fb 100644 --- a/crates/graphcal-compiler/src/tir/typed.rs +++ b/crates/graphcal-compiler/src/tir/typed.rs @@ -599,6 +599,7 @@ fn type_resolve_dag( type_defs, decl_bindings: HashMap::new(), materialized_shapes: HashMap::new(), + map_literal_axes: HashMap::new(), presentation: crate::tir::presentation::DagPresentationFacts::default(), }; @@ -1581,8 +1582,14 @@ impl HirPolicyChecker<'_> { hir::ExprKind::MapLiteral { entries } => { for entry in entries { for key in &entry.keys { - if let hir::expr::MapEntryKey::IndexVariant(variant) = key { - self.check_variant_literal(variant, check_pub_bind_literals)?; + match key { + hir::expr::MapEntryKey::IndexVariant(variant) => { + self.check_variant_literal(variant, check_pub_bind_literals)?; + } + hir::expr::MapEntryKey::Expression { expr, .. } => { + recurse(expr)?; + } + hir::expr::MapEntryKey::FinitePosition { .. } => {} } } recurse(&entry.value)?; diff --git a/crates/graphcal-compiler/src/tir/typed/collect.rs b/crates/graphcal-compiler/src/tir/typed/collect.rs index 4296d23ca..433a99646 100644 --- a/crates/graphcal-compiler/src/tir/typed/collect.rs +++ b/crates/graphcal-compiler/src/tir/typed/collect.rs @@ -578,6 +578,18 @@ fn collect_resolved_collection_refs_from_expr_inner( )?; } hir::expr::MapEntryKey::FinitePosition { .. } => {} + hir::expr::MapEntryKey::Expression { axis, expr } => { + if let hir::expr::MapKeyAxis::Explicit(index) = axis { + record_resolved_collection_index( + &index.value, + ctx, + src, + index.span, + refs, + )?; + } + collect_resolved_collection_refs_from_expr(expr, ctx, src, refs)?; + } } } collect_resolved_collection_refs_from_expr(&entry.value, ctx, src, refs)?; diff --git a/crates/graphcal-compiler/src/tir/typed/model.rs b/crates/graphcal-compiler/src/tir/typed/model.rs index ec43058c7..0f6cd79af 100644 --- a/crates/graphcal-compiler/src/tir/typed/model.rs +++ b/crates/graphcal-compiler/src/tir/typed/model.rs @@ -1244,6 +1244,11 @@ pub struct DagSemanticBody { crate::tir::materialized_shape::MaterializedExpressionKey, crate::tir::materialized_shape::MaterializedShape, >, + /// Type-checked semantic axes for map literals, including contextual keys. + pub map_literal_axes: HashMap< + crate::tir::map_literal_fact::MapLiteralKey, + crate::tir::map_literal_fact::CheckedMapLiteralAxes, + >, /// Checked structured display and plot-channel presentation facts. pub presentation: crate::tir::presentation::DagPresentationFacts, } @@ -1719,6 +1724,21 @@ impl DagTIR { ) } + /// Look up the semantic axes established for one checked map literal. + #[must_use] + pub fn map_literal_axes( + &self, + owner: &ResolvedDeclName, + span: Span, + ) -> Option<&crate::tir::map_literal_fact::CheckedMapLiteralAxes> { + self.semantic + .map_literal_axes + .get(&crate::tir::map_literal_fact::MapLiteralKey::new( + owner.clone(), + span, + )) + } + /// Explicit template-instance edges owned by this DAG. #[must_use] pub fn instances(&self) -> &[crate::ir::instance::InstanceRecord] { diff --git a/crates/graphcal-eval/src/eval_expr/hir_eval.rs b/crates/graphcal-eval/src/eval_expr/hir_eval.rs index ae59e0abf..4ba602504 100644 --- a/crates/graphcal-eval/src/eval_expr/hir_eval.rs +++ b/crates/graphcal-eval/src/eval_expr/hir_eval.rs @@ -224,6 +224,7 @@ fn eval_hir_expr_inner( hir::ExprKind::MapLiteral { entries } => eval_hir_map_literal( expr.span, entries, + 0, values, presentation_values, local_values, @@ -1789,6 +1790,7 @@ fn ensure_index_ref_matches_resolved( fn map_entry_index_ref( key: &hir::expr::MapEntryKey, + checked_axis: &IndexTypeRef, ctx: &EvalContext<'_>, ) -> Result { match key { @@ -1800,12 +1802,53 @@ fn map_entry_index_ref( .map_err(|err| ctx.eval_error(err.to_string(), position.span))?; Ok(IndexTypeRef::from_finite_index(finite_index)) } + hir::expr::MapEntryKey::Expression { axis, expr } => { + if let hir::expr::MapKeyAxis::Explicit(index) = axis { + ensure_index_ref_matches_resolved(checked_axis, &index.value, index.span, ctx)?; + } + if checked_axis.declared_resolved().is_none() { + return Err(ctx.internal_error( + "checked expression-shaped map key has a finite axis", + expr.span, + )); + } + Ok(checked_axis.clone()) + } + } +} + +fn explicit_map_entry_index_ref( + key: &hir::expr::MapEntryKey, + ctx: &EvalContext<'_>, +) -> Result { + match key { + hir::expr::MapEntryKey::IndexVariant(variant) => { + Ok(IndexTypeRef::from_resolved(variant.variant.index().clone())) + } + hir::expr::MapEntryKey::FinitePosition { size, position } => { + let finite_index = graphcal_compiler::registry::types::FiniteIndex::try_from_u64(*size) + .map_err(|err| ctx.eval_error(err.to_string(), position.span))?; + Ok(IndexTypeRef::from_finite_index(finite_index)) + } + hir::expr::MapEntryKey::Expression { + axis: hir::expr::MapKeyAxis::Explicit(index), + .. + } => Ok(IndexTypeRef::from_resolved(index.value.clone())), + hir::expr::MapEntryKey::Expression { + axis: hir::expr::MapKeyAxis::Contextual, + expr, + } => Err(ctx.internal_error( + "contextual map key reached evaluation without checked axis facts", + expr.span, + )), } } fn map_entry_variant_for_axis( key: &hir::expr::MapEntryKey, axis: &IndexTypeRef, + values: &RuntimeValueMap, + local_values: &HirLocalValueMap<'_>, ctx: &EvalContext<'_>, ) -> Result { match key { @@ -1821,26 +1864,52 @@ fn map_entry_variant_for_axis( hir::expr::MapEntryKey::FinitePosition { position, .. } => { Ok(IndexEntryKey::position(position.value)) } + hir::expr::MapEntryKey::Expression { + axis: key_axis, + expr, + } => { + if let hir::expr::MapKeyAxis::Explicit(index) = key_axis { + ensure_index_ref_matches_resolved(axis, &index.value, index.span, ctx)?; + } + let value = eval_hir_expr(expr, values, local_values, ctx)?; + let RuntimeValue::Quantity(value) = value else { + return Err( + ctx.internal_error("checked coordinate key is not a quantity", expr.span) + ); + }; + let index = axis.declared_resolved().ok_or_else(|| { + ctx.internal_error("checked coordinate key has a finite axis", expr.span) + })?; + let definition = ctx + .tir + .declared_index_def(index) + .ok_or_else(|| ctx.internal_error(format!("unknown index `{index}`"), expr.span))?; + let data = definition.coordinate_data().ok_or_else(|| { + ctx.internal_error(format!("index `{index}` is not coordinate"), expr.span) + })?; + let position = data.position_of(value).ok_or_else(|| { + ctx.internal_error( + format!("checked coordinate key {value} is not on `{index}`"), + expr.span, + ) + })?; + Ok(IndexEntryKey::position(position as u64)) + } } } fn map_entry_index_def<'a>( - key: &hir::expr::MapEntryKey, index_ref: &IndexTypeRef, ctx: &'a EvalContext<'_>, ) -> Option<&'a IndexDef> { - match key { - hir::expr::MapEntryKey::IndexVariant(variant) => { - ctx.tir.declared_index_def(variant.variant.index()) - } - hir::expr::MapEntryKey::FinitePosition { .. } => index_def_for_ref(index_ref, ctx), - } + index_def_for_ref(index_ref, ctx) } fn map_entry_key_span(key: &hir::expr::MapEntryKey) -> Span { match key { hir::expr::MapEntryKey::IndexVariant(variant) => variant.path_span(), hir::expr::MapEntryKey::FinitePosition { position, .. } => position.span, + hir::expr::MapEntryKey::Expression { expr, .. } => expr.span, } } @@ -1851,6 +1920,7 @@ fn map_entry_key_span(key: &hir::expr::MapEntryKey) -> Span { fn eval_hir_map_literal( map_span: Span, entries: &[hir::expr::MapEntry], + axis_offset: usize, values: &RuntimeValueMap, presentation_values: Option<&PresentationInstanceMap>, local_values: &HirLocalValueMap<'_>, @@ -1861,10 +1931,19 @@ fn eval_hir_map_literal( .ok_or_else(|| ctx.internal_error("empty map literal", map_span))?; let first_key = first.keys.first(); let arity = first.keys.len(); - let idx_name = map_entry_index_ref(first_key, ctx)?; + let checked_axis = ctx + .current_decl + .as_ref() + .and_then(|owner| ctx.current_dag.map_literal_axes(owner, map_span)) + .and_then(|axes| axes.as_slice().get(axis_offset)); + let idx_name = if let Some(checked_axis) = checked_axis { + map_entry_index_ref(first_key, checked_axis, ctx)? + } else { + explicit_map_entry_index_ref(first_key, ctx)? + }; if arity == 1 { - let idx_def = map_entry_index_def(first_key, &idx_name, ctx).ok_or_else(|| { + let idx_def = map_entry_index_def(&idx_name, ctx).ok_or_else(|| { ctx.internal_error( format!("unknown index `{idx_name}`"), map_entry_key_span(first_key), @@ -1873,7 +1952,7 @@ fn eval_hir_map_literal( let mut evaluated = IndexMap::new(); for entry in entries { let key = entry.keys.first(); - let variant = map_entry_variant_for_axis(key, &idx_name, ctx)?; + let variant = map_entry_variant_for_axis(key, &idx_name, values, local_values, ctx)?; let value = eval_hir_expr_evaluated( &entry.value, values, @@ -1909,7 +1988,7 @@ fn eval_hir_map_literal( )); } - let idx_def = map_entry_index_def(first_key, &idx_name, ctx).ok_or_else(|| { + let idx_def = map_entry_index_def(&idx_name, ctx).ok_or_else(|| { ctx.internal_error( format!("unknown index `{idx_name}`"), map_entry_key_span(first_key), @@ -1922,7 +2001,9 @@ fn eval_hir_map_literal( let mut sub_entries = Vec::new(); for entry in entries { let first_entry_key = entry.keys.first(); - if map_entry_variant_for_axis(first_entry_key, &idx_name, ctx)? != *variant { + if map_entry_variant_for_axis(first_entry_key, &idx_name, values, local_values, ctx)? + != *variant + { continue; } let keys = graphcal_compiler::syntax::non_empty::NonEmpty::try_from_vec( @@ -1950,6 +2031,9 @@ fn eval_hir_map_literal( let evaluated = eval_hir_map_literal( map_span, &sub_entries, + axis_offset + .checked_add(1) + .ok_or_else(|| ctx.internal_error("map literal axis offset overflow", map_span))?, values, presentation_values, local_values, diff --git a/crates/graphcal-eval/tests/edge_case_bugs.rs b/crates/graphcal-eval/tests/edge_case_bugs.rs index dd7408349..7a941279c 100644 --- a/crates/graphcal-eval/tests/edge_case_bugs.rs +++ b/crates/graphcal-eval/tests/edge_case_bugs.rs @@ -327,6 +327,139 @@ node values: Dimensionless[Tiny] = for t: Tiny { coord(t) }; ); } +// ============================================================================ +// #1370: quantity-keyed literals over concrete coordinate axes. +// ============================================================================ + +fn quantity_leaves(value: &Value) -> Vec { + match value { + Value::Quantity { si_value, .. } => vec![*si_value], + Value::Indexed { entries, .. } => entries.values().flat_map(quantity_leaves).collect(), + other => panic!("expected a quantity or indexed quantity, got {other:?}"), + } +} + +#[test] +fn coordinate_quantity_keys_populate_map_and_table_literals() { + let source = r#" + index Altitude = range(300.0 km, 320.0 km, step: 10.0 km); + index Stat = { Min, Max }; + + node map_1d: (Mass/Length^3)[Altitude] = { + 300.0 km: 1.0 kg/m^3, + 310.0 km: 2.0 kg/m^3, + 320.0 km: 3.0 kg/m^3, + }; + node table_1d: (Mass/Length^3)[Altitude] = table[Altitude] { + 300.0 km: 4.0 kg/m^3; + 310.0 km: 5.0 kg/m^3; + 320.0 km: 6.0 kg/m^3; + }; + node map_2d: (Mass/Length^3)[Altitude, Stat] = { + (300.0 km, Stat.Min): 7.0 kg/m^3, + (300.0 km, Stat.Max): 8.0 kg/m^3, + (310.0 km, Stat.Min): 9.0 kg/m^3, + (310.0 km, Stat.Max): 10.0 kg/m^3, + (320.0 km, Stat.Min): 11.0 kg/m^3, + (320.0 km, Stat.Max): 12.0 kg/m^3, + }; + node table_2d: (Mass/Length^3)[Altitude, Stat] = table[Altitude, Stat] { + : Min, Max; + 300.0 km: 13.0 kg/m^3, 14.0 kg/m^3; + 310.0 km: 15.0 kg/m^3, 16.0 kg/m^3; + 320.0 km: 17.0 kg/m^3, 18.0 kg/m^3; + }; + node table_coordinate_columns: (Mass/Length^3)[Stat, Altitude] = table[Stat, Altitude] { + : 300.0 km, 310.0 km, 320.0 km; + Min: 19.0 kg/m^3, 20.0 kg/m^3, 21.0 kg/m^3; + Max: 22.0 kg/m^3, 23.0 kg/m^3, 24.0 kg/m^3; + }; + "#; + let result = compile_and_eval(source).unwrap(); + + assert_eq!( + quantity_leaves(&find_entry(&result, "map_1d")), + [1.0, 2.0, 3.0] + ); + assert_eq!( + quantity_leaves(&find_entry(&result, "table_1d")), + [4.0, 5.0, 6.0] + ); + assert_eq!( + quantity_leaves(&find_entry(&result, "map_2d")), + [7.0, 8.0, 9.0, 10.0, 11.0, 12.0] + ); + assert_eq!( + quantity_leaves(&find_entry(&result, "table_2d")), + [13.0, 14.0, 15.0, 16.0, 17.0, 18.0] + ); + assert_eq!( + quantity_leaves(&find_entry(&result, "table_coordinate_columns")), + [19.0, 20.0, 21.0, 22.0, 23.0, 24.0] + ); +} + +#[test] +fn nested_coordinate_map_keys_use_the_nested_expected_axis() { + let source = r#" + index Scenario = { Nominal, Contingency }; + index Altitude = range(300.0 km, 310.0 km, step: 10.0 km); + + node values: Length[Scenario, Altitude] = { + Scenario.Nominal: { + 300.0 km: 1.0 m, + 310.0 km: 2.0 m, + }, + Scenario.Contingency: { + 300.0 km: 3.0 m, + 310.0 km: 4.0 m, + }, + }; + "#; + let result = compile_and_eval(source).unwrap(); + assert_eq!( + quantity_leaves(&find_entry(&result, "values")), + [1.0, 2.0, 3.0, 4.0] + ); +} + +#[test] +fn coordinate_quantity_key_rejects_wrong_dimension() { + let error = compile_and_eval( + r#" + index Altitude = range(300.0 km, 310.0 km, step: 10.0 km); + node values: (Mass/Length^3)[Altitude] = { + 300.0 s: 1.0 kg/m^3, + 310.0 s: 2.0 kg/m^3, + }; + "#, + ) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("coordinate key dimension"), "{message}"); + assert!(message.contains("Altitude"), "{message}"); +} + +#[test] +fn coordinate_quantity_key_rejects_off_grid_value_with_nearest_point() { + let error = compile_and_eval( + r#" + index Altitude = range(300.0 km, 310.0 km, step: 10.0 km); + node values: (Mass/Length^3)[Altitude] = { + 301.0 km: 1.0 kg/m^3, + 310.0 km: 2.0 kg/m^3, + }; + "#, + ) + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("does not lie on coordinate index `Altitude`"), + "{message}" + ); + assert!(message.contains("nearest grid point is 300"), "{message}"); +} + #[test] fn descending_range_and_linspace_are_monotone_with_exact_endpoints() { let source = r#" diff --git a/crates/graphcal-fmt/src/format/decl.rs b/crates/graphcal-fmt/src/format/decl.rs index 6b3643fc2..b52ad3cc4 100644 --- a/crates/graphcal-fmt/src/format/decl.rs +++ b/crates/graphcal-fmt/src/format/decl.rs @@ -908,7 +908,7 @@ pub fn format_multi_decl(fmt: &mut Formatter<'_>, info: &MultiDecl) -> RcDoc<'st let labels = slice .prefix_keys() .iter() - .map(format_multi_decl_key) + .map(|key| format_multi_decl_key(fmt, key)) .collect::>() .join(", "); out.push('\n'); @@ -963,15 +963,21 @@ pub fn format_multi_decl(fmt: &mut Formatter<'_>, info: &MultiDecl) -> RcDoc<'st text_with_hardlines(&out) } -fn format_multi_decl_key(key: &MapEntryKey) -> String { - match (&key.index.value, &key.variant.value) { - (MapEntryIndex::Named(index), IndexEntryKey::Named(variant)) => { - format!("{index}.{variant}") - } - (MapEntryIndex::Finite(_), IndexEntryKey::Position(position)) => { - format!("#{position}") +fn format_multi_decl_key(fmt: &Formatter<'_>, key: &MapEntryKey) -> String { + match key { + MapEntryKey::Discrete { index, entry, .. } => match (&index.value, &entry.value) { + (MapEntryIndex::Named(index), IndexEntryKey::Named(variant)) => { + format!("{index}.{variant}") + } + (MapEntryIndex::Finite(_), IndexEntryKey::Position(position)) => { + format!("#{position}") + } + (index, variant) => format!("{index}.{variant}"), + }, + MapEntryKey::Expression { expr, .. } => { + let mut key_fmt = fmt.fork_skipping_comments_before(expr.span.offset()); + render_doc_to_string(&format_expr(&mut key_fmt, expr)) } - (index, variant) => format!("{index}.{variant}"), } } diff --git a/crates/graphcal-fmt/src/format/expr.rs b/crates/graphcal-fmt/src/format/expr.rs index 2382d4992..8fc5752b4 100644 --- a/crates/graphcal-fmt/src/format/expr.rs +++ b/crates/graphcal-fmt/src/format/expr.rs @@ -1,6 +1,6 @@ use graphcal_compiler::syntax::ast::{ - BinOp, Expr, ExprKind, FieldInit, ForBinding, IndexArg, MapEntry, MatchArm, MatchPattern, - ModulePath, ParamBinding, PatternBinding, TableIndexSpec, UnaryOp, + BinOp, Expr, ExprKind, FieldInit, ForBinding, IndexArg, MapEntry, MapEntryKey, MatchArm, + MatchPattern, ModulePath, ParamBinding, PatternBinding, TableIndexSpec, UnaryOp, }; use graphcal_compiler::syntax::local_name::LocalName; use graphcal_compiler::syntax::span::Spanned; @@ -570,17 +570,16 @@ pub fn format_map_literal(fmt: &mut Formatter<'_>, entries: &[MapEntry]) -> RcDo let leading = fmt.drain_comments_before(e.value.span.offset()); let key_doc = if e.keys.len() == 1 { - RcDoc::text(format!( - "{}.{}", - e.keys[0].index.value, e.keys[0].variant.value - )) + format_map_key(fmt, &e.keys[0], true) } else { - let key_parts: Vec = e + let key_parts: Vec> = e .keys .iter() - .map(|k| format!("{}.{}", k.index.value, k.variant.value)) + .map(|key| format_map_key(fmt, key, true)) .collect(); - RcDoc::text(format!("({})", key_parts.join(", "))) + RcDoc::text("(") + .append(RcDoc::intersperse(key_parts, RcDoc::text(", "))) + .append(RcDoc::text(")")) }; let entry_doc = key_doc .append(RcDoc::text(": ")) @@ -604,6 +603,39 @@ pub fn format_map_literal(fmt: &mut Formatter<'_>, entries: &[MapEntry]) -> RcDo .append(RcDoc::text("}")) } +fn format_map_key( + fmt: &mut Formatter<'_>, + key: &MapEntryKey, + qualify_discrete: bool, +) -> RcDoc<'static> { + match key { + MapEntryKey::Discrete { index, entry, .. } if qualify_discrete => { + RcDoc::text(format!("{}.{}", index.value, entry.value)) + } + MapEntryKey::Discrete { entry, .. } => RcDoc::text(entry.value.to_string()), + MapEntryKey::Expression { expr, .. } => format_delimited_expr(fmt, expr), + } +} + +fn render_map_key(fmt: &Formatter<'_>, key: &MapEntryKey, qualify_discrete: bool) -> String { + let mut key_fmt = fmt.fork_skipping_comments_before(map_key_start(key)); + render_doc_to_string(&format_map_key(&mut key_fmt, key, qualify_discrete)) +} + +const fn map_key_start(key: &MapEntryKey) -> usize { + match key { + MapEntryKey::Discrete { index, .. } => index.span.offset(), + MapEntryKey::Expression { expr, .. } => expr.span.offset(), + } +} + +const fn map_key_end(key: &MapEntryKey) -> usize { + match key { + MapEntryKey::Discrete { entry, .. } => entry.span.offset() + entry.span.len(), + MapEntryKey::Expression { expr, .. } => expr.span.offset() + expr.span.len(), + } +} + /// Format a table literal expression: `table[Index1, Index2] { ... }` /// /// Handles 1D, 2D, and 3D+ tables with column-aligned output. @@ -649,7 +681,7 @@ fn format_table_1d( } else { entries .iter() - .map(|e| display_width(&e.keys[0].variant.value.to_string())) + .map(|e| display_width(&render_map_key(fmt, &e.keys[0], false))) .max() .unwrap_or(0) }; @@ -677,7 +709,7 @@ fn format_table_1d( let row_text = if finite_index { format!("{}{};", " ".repeat(value_padding), rendered) } else { - let label = e.keys[0].variant.value.to_string(); + let label = render_map_key(fmt, &e.keys[0], false); let padding = max_label_width - display_width(&label); format!( "{}:{} {};", @@ -744,7 +776,7 @@ fn format_table_2d_body( // Extract unique column labels (from the last key, preserving order) let mut col_labels: Vec = Vec::new(); for e in entries { - let col_label = e.keys[col_idx].variant.value.to_string(); + let col_label = render_map_key(fmt, &e.keys[col_idx], false); if !col_labels.contains(&col_label) { col_labels.push(col_label); } @@ -754,7 +786,7 @@ fn format_table_2d_body( // Extract unique row labels (from the second-to-last key, preserving order) let mut row_labels: Vec = Vec::new(); for e in entries { - let row_label = e.keys[row_idx].variant.value.to_string(); + let row_label = render_map_key(fmt, &e.keys[row_idx], false); if !row_labels.contains(&row_label) { row_labels.push(row_label); } @@ -764,8 +796,8 @@ fn format_table_2d_body( let mut grid: Vec> = vec![vec![String::new(); num_cols]; row_labels.len()]; let mut entry_indices: Vec>> = vec![vec![None; num_cols]; row_labels.len()]; for (ei, e) in entries.iter().enumerate() { - let row_label = e.keys[row_idx].variant.value.to_string(); - let col_label = e.keys[col_idx].variant.value.to_string(); + let row_label = render_map_key(fmt, &e.keys[row_idx], false); + let col_label = render_map_key(fmt, &e.keys[col_idx], false); // Labels were built from the same entries, so lookup cannot miss. // If it somehow does, skip this entry rather than silently using row/col 0. let Some(ri) = row_labels.iter().position(|r| r == &row_label) else { @@ -884,11 +916,12 @@ fn format_table_sliced( let mut slices: Vec<(Vec, Vec)> = Vec::new(); for (idx, e) in entries.iter().enumerate() { let slice_key: Vec = (0..slice_dims) - .map(|i| match &indexes[i] { - TableIndexSpec::Named(_) => { - format!("{}.{}", e.keys[i].index.value, e.keys[i].variant.value) - } - TableIndexSpec::Finite { .. } => e.keys[i].variant.value.to_string(), + .map(|i| { + render_map_key( + fmt, + &e.keys[i], + matches!(&indexes[i], TableIndexSpec::Named(_)), + ) }) .collect(); @@ -906,12 +939,12 @@ fn format_table_sliced( // Drain leading comments before this slice header let first_idx = entry_indices[0]; - let first_key_offset = entries[first_idx].keys[0].index.span.offset(); + let first_key_offset = map_key_start(&entries[first_idx].keys[0]); let leading = fmt.drain_comments_before(first_key_offset); // Drain trailing comment on the same line as the slice header "]" let last_slice_key = &entries[first_idx].keys[slice_dims - 1]; - let header_end = last_slice_key.variant.span.offset() + last_slice_key.variant.span.len(); + let header_end = map_key_end(last_slice_key); let trailing = fmt .drain_trailing_comment(header_end) .unwrap_or_else(RcDoc::nil); diff --git a/crates/graphcal-fmt/tests/format_tests.rs b/crates/graphcal-fmt/tests/format_tests.rs index f88746eba..e027aa14e 100644 --- a/crates/graphcal-fmt/tests/format_tests.rs +++ b/crates/graphcal-fmt/tests/format_tests.rs @@ -831,6 +831,30 @@ param dv: Dimensionless[Maneuver] = table[Maneuver] { ); } +#[test] +fn coordinate_quantity_keys_round_trip_in_maps_and_tables() { + let source = r" +index Altitude = range(300.0 km, 310.0 km, step: 10.0 km); +node mapped: Length[Altitude] = { + 300.0 km: 1.0 m, + 310.0 km: 2.0 m, +}; +node tabulated: Length[Altitude] = table[Altitude] { + 300.0 km: 1.0 m; + 310.0 km: 2.0 m; +}; +index Stat = { Min, Max }; +node matrix: Length[Stat, Altitude] = table[Stat, Altitude] { + : 300.0 km, 310.0 km; + Min: 1.0 m, 2.0 m; + Max: 3.0 m, 4.0 m; +}; +"; + let formatted = format_source(source).unwrap(); + assert!(formatted.contains("300.0 km: 1.0 m"), "{formatted}"); + assert_eq!(format_source(&formatted).unwrap(), formatted); +} + #[test] fn format_table_1d_aligns_values() { let source = r" diff --git a/crates/graphcal-lsp/src/symbol_table.rs b/crates/graphcal-lsp/src/symbol_table.rs index 846d29f73..145d632cb 100644 --- a/crates/graphcal-lsp/src/symbol_table.rs +++ b/crates/graphcal-lsp/src/symbol_table.rs @@ -434,8 +434,21 @@ impl<'a> HirRefCollector<'a> { hir::ExprKind::MapLiteral { entries } => { for entry in entries { for key in &entry.keys { - if let hir::expr::MapEntryKey::IndexVariant(variant) = key { - Self::variant_reference(variant, table); + match key { + hir::expr::MapEntryKey::IndexVariant(variant) => { + Self::variant_reference(variant, table); + } + hir::expr::MapEntryKey::Expression { axis, expr } => { + if let hir::expr::MapKeyAxis::Explicit(index) = axis { + Self::reference( + table, + index.span, + SymbolKey::Index(index.value.clone()), + ); + } + self.walk(expr, table); + } + hir::expr::MapEntryKey::FinitePosition { .. } => {} } } self.walk(&entry.value, table); diff --git a/docs/language/indexes.md b/docs/language/indexes.md index 89ab35f30..172e3e1b2 100644 --- a/docs/language/indexes.md +++ b/docs/language/indexes.md @@ -498,6 +498,40 @@ The coordinate loop variable `t` is a key of the `TimeStep` axis; the quantity coordinate it stands on is extracted explicitly with `coord(t)` (see [Index Keys](#index-keys)). +### Construction via Quantity-Keyed Literals + +A concrete coordinate axis can also be populated directly with coordinate +quantities. In a plain map, the axis of each quantity key is inferred from the +declaration's indexed type: + +``` +index Altitude = range(300.0 km, 320.0 km, step: 10.0 km); + +node density: (Mass/Length^3)[Altitude] = { + 300.0 km: 1.20 kg/m^3, + 310.0 km: 1.15 kg/m^3, + 320.0 km: 1.10 kg/m^3, +}; +``` + +Quantity keys must be statically evaluable, have the coordinate index's +dimension, and lie on an actual generated grid point. Dynamic units and runtime +references are rejected. As with named-index maps, every grid point must appear +exactly once; entry order is not significant. + +Tuple map keys may mix coordinate quantities and named labels. A `table` +declares its axes explicitly, so coordinate quantities can be used as row, +column, or slice labels: + +``` +node envelope: Pressure[Maneuver, Altitude] = table[Maneuver, Altitude] { + : 300.0 km, 310.0 km, 320.0 km; + Departure: 101.0 kPa, 95.0 kPa, 90.0 kPa; + Correction: 100.0 kPa, 94.0 kPa, 89.0 kPa; + Insertion: 99.0 kPa, 93.0 kPa, 88.0 kPa; +}; +``` + ### Construction via Map Literal with `for` Values You can also use a map literal where each named-label entry contains a @@ -636,6 +670,8 @@ apply to multi-declaration shared axes: a `Fin` row axis has unlabeled rows, and a `Fin` slice axis uses `[#N]` sections. The `table` expression is pure syntax sugar -- it desugars to a map literal at parse time. +For a coordinate axis, use coordinate quantities instead of named labels in +the corresponding rows, columns, or slice headers. ## Multi-declarations diff --git a/docs/language/type-system.md b/docs/language/type-system.md index 2624e1bab..f34026cc9 100644 --- a/docs/language/type-system.md +++ b/docs/language/type-system.md @@ -996,6 +996,12 @@ param delta_v_budget: Velocity[Phase, Maneuver] = { Single-axis map literals use bare keys (`Maneuver.Departure: ...`); multi-axis map literals use tuple keys (`(Phase.Launch, Maneuver.Departure): ...`). +For a concrete coordinate index, a key may instead be a statically evaluable +quantity on that index's grid, such as `300.0 km`. Its dimension must match the +coordinate index, and every generated grid point must still be covered exactly +once. Tuple map keys and table labels may mix these quantity keys with named +labels. + Map (and `table`) literal entry order is not significant: the constructed value is normalized to the index order, so a literal that lists `Maneuver.Insertion` first is identical to one written in declaration order. diff --git a/grammar.ebnf b/grammar.ebnf index b4df95498..dd181c759 100644 --- a/grammar.ebnf +++ b/grammar.ebnf @@ -984,11 +984,13 @@ field_init = IDENT, ":", expr; (* --- Map literal --- *) -(* Single-axis map literals use qualified variant keys: +(* Single-axis map literals use qualified variant keys or coordinate quantities: { Index.Variant1: expr1, module.Index.Variant2: expr2, ... } + { 300.0 km: expr1, 310.0 km: expr2, ... } Multi-axis map literals use tuple keys: - { (I1.V1, I2.V2): expr1, (I1.V1, I2.V3): expr2, ... } - All labels of the index (or all label tuples) must be present + { (300.0 km, I2.V2): expr1, (310.0 km, I2.V3): expr2, ... } + Coordinate keys are semantically restricted to statically evaluable + quantities on the concrete axis grid. All keys (or all key tuples) must be present (totality requirement). *) map_literal = "{", map_entry, { ",", map_entry }, [ "," ], "}"; @@ -998,11 +1000,15 @@ map_entry | tuple_key_entry; single_key_entry - = ident_path, ":", expr; (* path must contain at least two segments *) + = map_key, ":", expr; tuple_key_entry - = "(", ident_path, - { ",", ident_path }, ")", ":", expr; (* each key path must contain at least two segments *) + = "(", map_key, + { ",", map_key }, ")", ":", expr; + +map_key + = qualified_variant + | expr; (* must be a static coordinate quantity *) (* --- Table expression (syntax sugar, desugars to map_literal) --- *) (* *) @@ -1032,6 +1038,7 @@ table_slice_section table_slice_label = qualified_variant (* for named axes *) + | expr (* for coordinate axes; static quantity *) | "#", NAT_LITERAL; (* for Fin axes *) (* When the column axis is Fin, `table_header_row` is omitted. *) @@ -1039,15 +1046,19 @@ table_single = [ table_header_row ], table_data_row, { table_data_row }; table_header_row - = ":", IDENT, { ",", IDENT }, ";"; + = ":", table_axis_label, { ",", table_axis_label }, ";"; + +table_axis_label + = IDENT (* for named axes *) + | expr; (* for coordinate axes; static quantity *) (* When the row axis is Fin, `IDENT, ":"` is omitted. *) table_data_row - = [ IDENT, ":" ], expr, { ",", expr }, ";"; + = [ table_axis_label, ":" ], expr, { ",", expr }, ";"; (* When the index is Fin, `IDENT, ":"` is omitted. *) table_data_row_1d - = [ IDENT, ":" ], expr, ";"; + = [ table_axis_label, ":" ], expr, ";"; qualified_variant = IDENT, ".", IDENT, { ".", IDENT };