KCL: Fix 'cryptic' error when referencing a variable in its own declaration (#7325)

Previously, `x = cos(x)` would just say "`x` is undefined". Now it says that `x` cannot be referenced in its own definition, try using a different variable instead.

To do this, I've added a new `Option<String>` field to the mod-local executor context, tracking the current variable declaration. This means cloning some strings, implying a small performance hit. I think it's fine, for the better diagnostics.

In the future we could refactor this to use a &str or store variable labels in stack-allocated strings like docs.rs/compact_str or something.

Closes https://github.com/KittyCAD/modeling-app/issues/6072
This commit is contained in:
Adam Chalmers
2025-06-02 17:25:55 -05:00
committed by GitHub
parent 2bb6c74f42
commit 7680605085
15 changed files with 230 additions and 39 deletions

View File

@ -164,10 +164,13 @@ impl ExecutorContext {
let mut mod_value = mem.get_from(&mod_name, env_ref, import_item.into(), 0).cloned();
if value.is_err() && ty.is_err() && mod_value.is_err() {
return Err(KclError::new_undefined_value(KclErrorDetails::new(
format!("{} is not defined in module", import_item.name.name),
vec![SourceRange::from(&import_item.name)],
)));
return Err(KclError::new_undefined_value(
KclErrorDetails::new(
format!("{} is not defined in module", import_item.name.name),
vec![SourceRange::from(&import_item.name)],
),
None,
));
}
// Check that the item is allowed to be imported (in at least one namespace).
@ -301,7 +304,10 @@ impl ExecutorContext {
let annotations = &variable_declaration.outer_attrs;
let value = self
// During the evaluation of the variable's LHS, set context that this is all happening inside a variable
// declaration, for the given name. This helps improve user-facing error messages.
exec_state.mod_local.being_declared = Some(variable_declaration.inner.name().to_owned());
let rhs_result = self
.execute_expr(
&variable_declaration.declaration.init,
exec_state,
@ -309,10 +315,14 @@ impl ExecutorContext {
annotations,
StatementKind::Declaration { name: &var_name },
)
.await?;
.await;
// Declaration over, so unset this context.
exec_state.mod_local.being_declared = None;
let rhs = rhs_result?;
exec_state
.mut_stack()
.add(var_name.clone(), value.clone(), source_range)?;
.add(var_name.clone(), rhs.clone(), source_range)?;
// Track exports.
if let ItemVisibility::Export = variable_declaration.visibility {
@ -326,7 +336,7 @@ impl ExecutorContext {
}
}
// Variable declaration can be the return value of a module.
last_expr = matches!(body_type, BodyType::Root).then_some(value);
last_expr = matches!(body_type, BodyType::Root).then_some(rhs);
}
BodyItem::TypeDeclaration(ty) => {
let metadata = Metadata::from(&**ty);
@ -913,10 +923,13 @@ impl Node<MemberExpression> {
if let Some(value) = map.get(&property) {
Ok(value.to_owned())
} else {
Err(KclError::new_undefined_value(KclErrorDetails::new(
format!("Property '{property}' not found in object"),
vec![self.clone().into()],
)))
Err(KclError::new_undefined_value(
KclErrorDetails::new(
format!("Property '{property}' not found in object"),
vec![self.clone().into()],
),
None,
))
}
}
(KclValue::Object { .. }, Property::String(property), true) => {
@ -938,10 +951,13 @@ impl Node<MemberExpression> {
if let Some(value) = value_of_arr {
Ok(value.to_owned())
} else {
Err(KclError::new_undefined_value(KclErrorDetails::new(
format!("The array doesn't have any item at index {index}"),
vec![self.clone().into()],
)))
Err(KclError::new_undefined_value(
KclErrorDetails::new(
format!("The array doesn't have any item at index {index}"),
vec![self.clone().into()],
),
None,
))
}
}
// Singletons and single-element arrays should be interchangeable, but only indexing by 0 should work.

View File

@ -318,10 +318,13 @@ impl Node<CallExpressionKw> {
if let KclValue::Function { meta, .. } = func {
source_ranges = meta.iter().map(|m| m.source_range).collect();
};
KclError::new_undefined_value(KclErrorDetails::new(
format!("Result of user-defined function {} is undefined", fn_name),
source_ranges,
))
KclError::new_undefined_value(
KclErrorDetails::new(
format!("Result of user-defined function {} is undefined", fn_name),
source_ranges,
),
None,
)
})?;
Ok(result)

View File

@ -367,10 +367,10 @@ impl ProgramMemory {
let name = var.trim_start_matches(TYPE_PREFIX).trim_start_matches(MODULE_PREFIX);
Err(KclError::new_undefined_value(KclErrorDetails::new(
format!("`{name}` is not defined"),
vec![source_range],
)))
Err(KclError::new_undefined_value(
KclErrorDetails::new(format!("`{name}` is not defined"), vec![source_range]),
Some(name.to_owned()),
))
}
/// Iterate over all key/value pairs in the specified environment which satisfy the provided
@ -488,10 +488,10 @@ impl ProgramMemory {
};
}
Err(KclError::new_undefined_value(KclErrorDetails::new(
format!("`{}` is not defined", var),
vec![],
)))
Err(KclError::new_undefined_value(
KclErrorDetails::new(format!("`{}` is not defined", var), vec![]),
Some(var.to_owned()),
))
}
}

View File

@ -80,6 +80,11 @@ pub(super) struct ModuleState {
/// The current value of the pipe operator returned from the previous
/// expression. If we're not currently in a pipeline, this will be None.
pub pipe_value: Option<KclValue>,
/// The closest variable declaration being executed in any parent node in the AST.
/// This is used to provide better error messages, e.g. noticing when the user is trying
/// to use the variable `length` inside the RHS of its own definition, like `length = tan(length)`.
/// TODO: Make this a reference.
pub being_declared: Option<String>,
/// Identifiers that have been exported from the current module.
pub module_exports: Vec<String>,
/// Settings specified from annotations.
@ -342,6 +347,7 @@ impl ModuleState {
id_generator: IdGenerator::new(module_id),
stack: memory.new_stack(),
pipe_value: Default::default(),
being_declared: Default::default(),
module_exports: Default::default(),
explicit_length_units: false,
path,