Suggest a list of possible arg labels when an argument is unlabelled (#6755)

Signed-off-by: Nick Cameron <nrc@ncameron.org>
This commit is contained in:
Nick Cameron
2025-05-09 14:28:04 +12:00
committed by GitHub
parent 1ea66d6f23
commit 923feadfa5
16 changed files with 139 additions and 69 deletions

View File

@ -1295,13 +1295,20 @@ impl Node<CallExpressionKw> {
// Build a hashmap from argument labels to the final evaluated values. // Build a hashmap from argument labels to the final evaluated values.
let mut fn_args = IndexMap::with_capacity(self.arguments.len()); let mut fn_args = IndexMap::with_capacity(self.arguments.len());
let mut errors = Vec::new();
for arg_expr in &self.arguments { for arg_expr in &self.arguments {
let source_range = SourceRange::from(arg_expr.arg.clone()); let source_range = SourceRange::from(arg_expr.arg.clone());
let metadata = Metadata { source_range }; let metadata = Metadata { source_range };
let value = ctx let value = ctx
.execute_expr(&arg_expr.arg, exec_state, &metadata, &[], StatementKind::Expression) .execute_expr(&arg_expr.arg, exec_state, &metadata, &[], StatementKind::Expression)
.await?; .await?;
fn_args.insert(arg_expr.label.name.clone(), Arg::new(value, source_range)); let arg = Arg::new(value, source_range);
match &arg_expr.label {
Some(l) => {
fn_args.insert(l.name.clone(), arg);
}
None => errors.push(arg),
}
} }
let fn_args = fn_args; // remove mutability let fn_args = fn_args; // remove mutability
@ -1321,6 +1328,7 @@ impl Node<CallExpressionKw> {
KwArgs { KwArgs {
unlabeled, unlabeled,
labeled: fn_args, labeled: fn_args,
errors,
}, },
self.into(), self.into(),
ctx.clone(), ctx.clone(),
@ -1894,6 +1902,44 @@ fn type_check_params_kw(
} }
} }
if !args.errors.is_empty() {
let actuals = args.labeled.keys();
let formals: Vec<_> = function_expression
.params
.iter()
.filter_map(|p| {
if !p.labeled {
return None;
}
let name = &p.identifier.name;
if actuals.clone().any(|a| a == name) {
return None;
}
Some(format!("`{name}`"))
})
.collect();
let suggestion = if formals.is_empty() {
String::new()
} else {
format!("; suggested labels: {}", formals.join(", "))
};
let mut errors = args.errors.iter().map(|e| {
CompilationError::err(
e.source_range,
format!("This argument needs a label, but it doesn't have one{suggestion}"),
)
});
let first = errors.next().unwrap();
errors.for_each(|e| exec_state.err(e));
return Err(KclError::Semantic(first.into()));
}
if let Some(arg) = &mut args.unlabeled { if let Some(arg) = &mut args.unlabeled {
if let Some(p) = function_expression.params.iter().find(|p| !p.labeled) { if let Some(p) = function_expression.params.iter().find(|p| !p.labeled) {
if let Some(ty) = &p.type_ { if let Some(ty) = &p.type_ {
@ -2313,6 +2359,7 @@ mod test {
let args = KwArgs { let args = KwArgs {
unlabeled: None, unlabeled: None,
labeled, labeled,
errors: Vec::new(),
}; };
let exec_ctxt = ExecutorContext { let exec_ctxt = ExecutorContext {
engine: Arc::new(Box::new( engine: Arc::new(Box::new(
@ -2552,4 +2599,30 @@ a = foo()
parse_execute(program).await.unwrap_err(); parse_execute(program).await.unwrap_err();
} }
#[tokio::test(flavor = "multi_thread")]
async fn test_sensible_error_when_missing_equals_in_kwarg() {
for (i, call) in ["f(x=1,y)", "f(x=1,3,z)", "f(x=1,y,z=1)", "f(x=1, 3 + 4, z=1)"]
.into_iter()
.enumerate()
{
let program = format!(
"fn foo() {{ return 0 }}
y = 42
z = 0
fn f(x, y, z) {{ return 0 }}
{call}"
);
let err = parse_execute(&program).await.unwrap_err();
let msg = err.message();
assert!(
msg.contains("This argument needs a label, but it doesn't have one"),
"failed test {i}: {msg}"
);
assert!(msg.contains("`y`"), "failed test {i}, missing `y`: {msg}");
if i == 0 {
assert!(msg.contains("`z`"), "failed test {i}, missing `z`: {msg}");
}
}
}
} }

View File

@ -488,7 +488,9 @@ impl CallExpressionKw {
} }
hasher.update(slf.arguments.len().to_ne_bytes()); hasher.update(slf.arguments.len().to_ne_bytes());
for argument in slf.arguments.iter_mut() { for argument in slf.arguments.iter_mut() {
hasher.update(argument.label.compute_digest()); if let Some(l) = &mut argument.label {
hasher.update(l.compute_digest());
}
hasher.update(argument.arg.compute_digest()); hasher.update(argument.arg.compute_digest());
} }
}); });

View File

@ -460,7 +460,8 @@ impl Node<Program> {
crate::walk::Node::CallExpressionKw(call) => { crate::walk::Node::CallExpressionKw(call) => {
if call.inner.callee.inner.name.inner.name == "appearance" { if call.inner.callee.inner.name.inner.name == "appearance" {
for arg in &call.arguments { for arg in &call.arguments {
if arg.label.inner.name == "color" { if let Some(l) = &arg.label {
if l.inner.name == "color" {
// Get the value of the argument. // Get the value of the argument.
if let Expr::Literal(literal) = &arg.arg { if let Expr::Literal(literal) = &arg.arg {
add_color(literal); add_color(literal);
@ -469,6 +470,7 @@ impl Node<Program> {
} }
} }
} }
}
crate::walk::Node::Literal(literal) => { crate::walk::Node::Literal(literal) => {
// Check if the literal is a color. // Check if the literal is a color.
add_color(literal); add_color(literal);
@ -1872,7 +1874,7 @@ pub struct CallExpressionKw {
#[ts(export)] #[ts(export)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub struct LabeledArg { pub struct LabeledArg {
pub label: Node<Identifier>, pub label: Option<Node<Identifier>>,
pub arg: Expr, pub arg: Expr,
} }
@ -1917,7 +1919,7 @@ impl CallExpressionKw {
self.unlabeled self.unlabeled
.iter() .iter()
.map(|e| (None, e)) .map(|e| (None, e))
.chain(self.arguments.iter().map(|arg| (Some(&arg.label), &arg.arg))) .chain(self.arguments.iter().map(|arg| (arg.label.as_ref(), &arg.arg)))
} }
pub fn replace_value(&mut self, source_range: SourceRange, new_value: Expr) { pub fn replace_value(&mut self, source_range: SourceRange, new_value: Expr) {

View File

@ -2714,12 +2714,17 @@ fn pipe_sep(i: &mut TokenSlice) -> PResult<()> {
} }
fn labeled_argument(i: &mut TokenSlice) -> PResult<LabeledArg> { fn labeled_argument(i: &mut TokenSlice) -> PResult<LabeledArg> {
separated_pair( (
opt((
terminated(nameable_identifier, opt(whitespace)), terminated(nameable_identifier, opt(whitespace)),
terminated(one_of((TokenType::Operator, "=")), opt(whitespace)), terminated(one_of((TokenType::Operator, "=")), opt(whitespace)),
)),
expression, expression,
) )
.map(|(label, arg)| LabeledArg { label, arg }) .map(|(label, arg)| LabeledArg {
label: label.map(|(l, _)| l),
arg,
})
.parse_next(i) .parse_next(i)
} }
@ -3040,6 +3045,7 @@ fn fn_call_kw(i: &mut TokenSlice) -> PResult<Node<CallExpressionKw>> {
return Ok(result); return Ok(result);
} }
#[derive(Debug)]
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
enum ArgPlace { enum ArgPlace {
NonCode(Node<NonCodeNode>), NonCode(Node<NonCodeNode>),
@ -3068,24 +3074,17 @@ fn fn_call_kw(i: &mut TokenSlice) -> PResult<Node<CallExpressionKw>> {
} }
ArgPlace::UnlabeledArg(arg) => { ArgPlace::UnlabeledArg(arg) => {
let followed_by_equals = peek((opt(whitespace), equals)).parse_next(i).is_ok(); let followed_by_equals = peek((opt(whitespace), equals)).parse_next(i).is_ok();
let err = if followed_by_equals { if followed_by_equals {
ErrMode::Cut( return Err(ErrMode::Cut(
CompilationError::fatal( CompilationError::fatal(
SourceRange::from(arg), SourceRange::from(arg),
"This argument has a label, but no value. Put some value after the equals sign", "This argument has a label, but no value. Put some value after the equals sign",
) )
.into(), .into(),
) ));
} else { } else {
ErrMode::Cut( args.push(LabeledArg { label: None, arg });
CompilationError::fatal( }
SourceRange::from(arg),
"This argument needs a label, but it doesn't have one",
)
.into(),
)
};
return Err(err);
} }
} }
Ok((args, non_code_nodes)) Ok((args, non_code_nodes))
@ -3098,7 +3097,9 @@ fn fn_call_kw(i: &mut TokenSlice) -> PResult<Node<CallExpressionKw>> {
// Validate there aren't any duplicate labels. // Validate there aren't any duplicate labels.
let mut counted_labels = IndexMap::with_capacity(args.len()); let mut counted_labels = IndexMap::with_capacity(args.len());
for arg in &args { for arg in &args {
*counted_labels.entry(&arg.label.inner.name).or_insert(0) += 1; if let Some(l) = &arg.label {
*counted_labels.entry(&l.inner.name).or_insert(0) += 1;
}
} }
if let Some((duplicated, n)) = counted_labels.iter().find(|(_label, n)| n > &&1) { if let Some((duplicated, n)) = counted_labels.iter().find(|(_label, n)| n > &&1) {
let msg = format!( let msg = format!(
@ -4923,27 +4924,6 @@ bar = 1
crate::parsing::top_level_parse(some_program_string).unwrap(); // Updated import path crate::parsing::top_level_parse(some_program_string).unwrap(); // Updated import path
} }
#[test]
fn test_sensible_error_when_missing_equals_in_kwarg() {
for (i, program) in ["f(x=1,y)", "f(x=1,y,z)", "f(x=1,y,z=1)", "f(x=1, y, z=1)"]
.into_iter()
.enumerate()
{
let tokens = crate::parsing::token::lex(program, ModuleId::default()).unwrap();
let err = fn_call_kw.parse(tokens.as_slice()).unwrap_err();
let cause = err.inner().cause.as_ref().unwrap();
assert_eq!(
cause.message, "This argument needs a label, but it doesn't have one",
"failed test {i}: {program}"
);
assert_eq!(
cause.source_range.start(),
program.find("y").unwrap(),
"failed test {i}: {program}"
);
}
}
#[test] #[test]
fn test_sensible_error_when_missing_rhs_of_kw_arg() { fn test_sensible_error_when_missing_rhs_of_kw_arg() {
for (i, program) in ["f(x, y=)"].into_iter().enumerate() { for (i, program) in ["f(x, y=)"].into_iter().enumerate() {

View File

@ -62,6 +62,7 @@ pub struct KwArgs {
pub unlabeled: Option<Arg>, pub unlabeled: Option<Arg>,
/// Labeled args. /// Labeled args.
pub labeled: IndexMap<String, Arg>, pub labeled: IndexMap<String, Arg>,
pub errors: Vec<Arg>,
} }
impl KwArgs { impl KwArgs {

View File

@ -88,6 +88,7 @@ async fn call_map_closure(
let kw_args = KwArgs { let kw_args = KwArgs {
unlabeled: Some(Arg::new(input, source_range)), unlabeled: Some(Arg::new(input, source_range)),
labeled: Default::default(), labeled: Default::default(),
errors: Vec::new(),
}; };
let args = Args::new_kw( let args = Args::new_kw(
kw_args, kw_args,
@ -233,6 +234,7 @@ async fn call_reduce_closure(
let kw_args = KwArgs { let kw_args = KwArgs {
unlabeled: Some(Arg::new(elem, source_range)), unlabeled: Some(Arg::new(elem, source_range)),
labeled, labeled,
errors: Vec::new(),
}; };
let reduce_fn_args = Args::new_kw( let reduce_fn_args = Args::new_kw(
kw_args, kw_args,

View File

@ -430,6 +430,7 @@ async fn make_transform<T: GeometryTrait>(
let kw_args = KwArgs { let kw_args = KwArgs {
unlabeled: Some(Arg::new(repetition_num, source_range)), unlabeled: Some(Arg::new(repetition_num, source_range)),
labeled: Default::default(), labeled: Default::default(),
errors: Vec::new(),
}; };
let transform_fn_args = Args::new_kw( let transform_fn_args = Args::new_kw(
kw_args, kw_args,

View File

@ -405,9 +405,13 @@ impl CallExpressionKw {
impl LabeledArg { impl LabeledArg {
fn recast(&self, options: &FormatOptions, indentation_level: usize, ctxt: ExprContext) -> String { fn recast(&self, options: &FormatOptions, indentation_level: usize, ctxt: ExprContext) -> String {
let label = &self.label.name; let mut result = String::new();
let arg = self.arg.recast(options, indentation_level, ctxt); if let Some(l) = &self.label {
format!("{label} = {arg}") result.push_str(&l.name);
result.push_str(" = ");
}
result.push_str(&self.arg.recast(options, indentation_level, ctxt));
result
} }
} }

View File

@ -223,7 +223,7 @@ export function mutateKwArg(
): boolean | 'no-mutate' { ): boolean | 'no-mutate' {
for (let i = 0; i < node.arguments.length; i++) { for (let i = 0; i < node.arguments.length; i++) {
const arg = node.arguments[i] const arg = node.arguments[i]
if (arg.label.name === label) { if (arg.label?.name === label) {
if (isLiteralArrayOrStatic(val) && isLiteralArrayOrStatic(arg.arg)) { if (isLiteralArrayOrStatic(val) && isLiteralArrayOrStatic(arg.arg)) {
node.arguments[i].arg = val node.arguments[i].arg = val
return true return true
@ -259,7 +259,7 @@ export function mutateKwArgOnly(
): boolean { ): boolean {
for (let i = 0; i < node.arguments.length; i++) { for (let i = 0; i < node.arguments.length; i++) {
const arg = node.arguments[i] const arg = node.arguments[i]
if (arg.label.name === label) { if (arg.label?.name === label) {
node.arguments[i].arg = val node.arguments[i].arg = val
return true return true
} }
@ -273,7 +273,7 @@ Mutates the given node by removing the labeled arguments.
*/ */
export function removeKwArgs(labels: string[], node: CallExpressionKw) { export function removeKwArgs(labels: string[], node: CallExpressionKw) {
for (const label of labels) { for (const label of labels) {
const i = node.arguments.findIndex((la) => la.label.name === label) const i = node.arguments.findIndex((la) => la.label?.name === label)
if (i == -1) { if (i == -1) {
continue continue
} }

View File

@ -773,7 +773,7 @@ export async function editEdgeTreatment(
// find the index of an argument to update // find the index of an argument to update
const index = edgeTreatmentCall.node.arguments.findIndex( const index = edgeTreatmentCall.node.arguments.findIndex(
(arg) => arg.label.name === parameterName (arg) => arg.label?.name === parameterName
) )
// create a new argument with the updated value // create a new argument with the updated value

View File

@ -500,7 +500,7 @@ function modifyAstWithTagForCapFace(
// Check for existing tag with this parameter name // Check for existing tag with this parameter name
const existingTag = callExp.node.arguments.find( const existingTag = callExp.node.arguments.find(
(arg) => arg.label.name === tagParamName (arg) => arg.label?.name === tagParamName
) )
if (existingTag && existingTag.arg.type === 'TagDeclarator') { if (existingTag && existingTag.arg.type === 'TagDeclarator') {

View File

@ -190,7 +190,10 @@ const commonConstraintInfoHelper = (
if (lengthishIndex === undefined) { if (lengthishIndex === undefined) {
return [undefined, undefined] return [undefined, undefined]
} }
const lengthKey = callExp.arguments[lengthishIndex].label.name const lengthKey = callExp.arguments[lengthishIndex].label?.name
if (lengthKey === undefined) {
return [undefined, undefined]
}
const lengthVal = callExp.arguments[lengthishIndex].arg const lengthVal = callExp.arguments[lengthishIndex].arg
// Note: The order of keys here matters. // Note: The order of keys here matters.
// Always assumes the angle was the first param, and then the length followed. // Always assumes the angle was the first param, and then the length followed.
@ -1057,7 +1060,7 @@ export const tangentialArc: SketchLineHelperKw = {
} }
for (const arg of callExpression.arguments) { for (const arg of callExpression.arguments) {
if (arg.label.name !== ARG_END_ABSOLUTE && arg.label.name !== ARG_TAG) { if (arg.label?.name !== ARG_END_ABSOLUTE && arg.label?.name !== ARG_TAG) {
console.debug( console.debug(
'Trying to edit unsupported tangentialArc keyword arguments; skipping' 'Trying to edit unsupported tangentialArc keyword arguments; skipping'
) )

View File

@ -1375,12 +1375,12 @@ export function removeSingleConstraint({
// 1. Filter out any existing tag argument since it will be handled separately // 1. Filter out any existing tag argument since it will be handled separately
const filteredArgs = existingArgs.filter( const filteredArgs = existingArgs.filter(
(arg) => arg.label.name !== ARG_TAG (arg) => arg.label?.name !== ARG_TAG
) )
// 2. Map through the args, replacing only the one we want to change // 2. Map through the args, replacing only the one we want to change
const labeledArgs = filteredArgs.map((arg) => { const labeledArgs = filteredArgs.map((arg) => {
if (arg.label.name === toReplace) { if (arg.label?.name === toReplace) {
// Find the raw value to use for the argument being replaced // Find the raw value to use for the argument being replaced
const rawArgVersion = rawArgs.find( const rawArgVersion = rawArgs.find(
(a) => a.type === 'labeledArg' && a.key === toReplace (a) => a.type === 'labeledArg' && a.key === toReplace
@ -1430,7 +1430,7 @@ export function removeSingleConstraint({
const labeledArgs = existingArgs.map((arg) => { const labeledArgs = existingArgs.map((arg) => {
// Only modify the specific argument that matches the targeted key // Only modify the specific argument that matches the targeted key
if ( if (
arg.label.name === targetKey && arg.label?.name === targetKey &&
arg.arg.type === 'ArrayExpression' arg.arg.type === 'ArrayExpression'
) { ) {
// We're dealing with an array expression within a labeled argument // We're dealing with an array expression within a labeled argument
@ -1560,7 +1560,7 @@ export function removeSingleConstraint({
arrayInput[inputToReplace.key][inputToReplace.index] = arrayInput[inputToReplace.key][inputToReplace.index] =
rawLiteralArrayInObjectExpr rawLiteralArrayInObjectExpr
let existingKwgForKey = kwArgInput.find( let existingKwgForKey = kwArgInput.find(
(kwArg) => kwArg.label.name === currentArg.key (kwArg) => kwArg.label?.name === currentArg.key
) )
if (!existingKwgForKey) { if (!existingKwgForKey) {
existingKwgForKey = createLabeledArg( existingKwgForKey = createLabeledArg(
@ -1586,7 +1586,7 @@ export function removeSingleConstraint({
if (!arrayInput[currentArg.key]) arrayInput[currentArg.key] = [] if (!arrayInput[currentArg.key]) arrayInput[currentArg.key] = []
arrayInput[currentArg.key][currentArg.index] = currentArgExpr arrayInput[currentArg.key][currentArg.index] = currentArgExpr
let existingKwgForKey = kwArgInput.find( let existingKwgForKey = kwArgInput.find(
(kwArg) => kwArg.label.name === currentArg.key (kwArg) => kwArg.label?.name === currentArg.key
) )
if (!existingKwgForKey) { if (!existingKwgForKey) {
existingKwgForKey = createLabeledArg( existingKwgForKey = createLabeledArg(
@ -2190,7 +2190,7 @@ export function getConstraintLevelFromSourceRange(
} }
const arg = findKwArgAny(DETERMINING_ARGS, nodeMeta.node) const arg = findKwArgAny(DETERMINING_ARGS, nodeMeta.node)
if (arg === undefined) { if (arg === undefined) {
const argStr = nodeMeta.node.arguments.map((a) => a.label.name) const argStr = nodeMeta.node.arguments.map((a) => a.label?.name)
return new Error( return new Error(
`call to expression ${name} has unexpected args: ${argStr} ` `call to expression ${name} has unexpected args: ${argStr} `
) )

View File

@ -136,7 +136,7 @@ export function findKwArg(
call: CallExpressionKw call: CallExpressionKw
): Expr | undefined { ): Expr | undefined {
return call?.arguments?.find((arg) => { return call?.arguments?.find((arg) => {
return arg.label.name === label return arg.label?.name === label
})?.arg })?.arg
} }
@ -149,7 +149,7 @@ export function findKwArgWithIndex(
call: CallExpressionKw call: CallExpressionKw
): { expr: Expr; argIndex: number } | undefined { ): { expr: Expr; argIndex: number } | undefined {
const index = call.arguments.findIndex((arg) => { const index = call.arguments.findIndex((arg) => {
return arg.label.name === label return arg.label?.name === label
}) })
return index >= 0 return index >= 0
? { expr: call.arguments[index].arg, argIndex: index } ? { expr: call.arguments[index].arg, argIndex: index }
@ -164,7 +164,7 @@ export function findKwArgAny(
call: CallExpressionKw call: CallExpressionKw
): Expr | undefined { ): Expr | undefined {
return call.arguments.find((arg) => { return call.arguments.find((arg) => {
return labels.includes(arg.label.name) return labels.includes(arg.label?.name || '')
})?.arg })?.arg
} }
@ -176,7 +176,7 @@ export function findKwArgAnyIndex(
call: CallExpressionKw call: CallExpressionKw
): number | undefined { ): number | undefined {
const index = call.arguments.findIndex((arg) => { const index = call.arguments.findIndex((arg) => {
return labels.includes(arg.label.name) return labels.includes(arg.label?.name || '')
}) })
if (index == -1) { if (index == -1) {
return undefined return undefined

View File

@ -60,7 +60,7 @@ export async function retrieveArgFromPipedCallExpression(
name: string name: string
): Promise<KclCommandValue | undefined> { ): Promise<KclCommandValue | undefined> {
const arg = callExpression.arguments.find( const arg = callExpression.arguments.find(
(a) => a.label.type === 'Identifier' && a.label.name === name (a) => a.label?.type === 'Identifier' && a.label?.name === name
) )
if ( if (
arg?.type === 'LabeledArg' && arg?.type === 'LabeledArg' &&

View File

@ -41,7 +41,9 @@ export async function refreshPage(method = 'UI button') {
* Get all labels for a keyword call expression. * Get all labels for a keyword call expression.
*/ */
export function allLabels(callExpression: CallExpressionKw): string[] { export function allLabels(callExpression: CallExpressionKw): string[] {
return callExpression.arguments.map((a) => a.label.name) return callExpression.arguments
.map((a) => a.label?.name)
.filter((a) => a !== undefined)
} }
/** /**