Skip to content

Commit c2ca703

Browse files
timfennisclaude
andcommitted
feat(lsp): fix return type and parameter inlay hints 🔍
Introduce FunctionParameter to unify parameter representation in the AST, replacing TypeSignature on FunctionDeclaration. Move inferred return types to an AnalysisResult side table so the LSP can distinguish annotated vs inferred return types. Walk parameter lvalues in the visitor so parameter type hints are emitted. Also compute the LUB of return types across all overload candidates in dynamic bindings, improving type inference for overloaded functions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 338e4df commit c2ca703

7 files changed

Lines changed: 157 additions & 55 deletions

File tree

ndc_analyser/src/analyser.rs

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ use crate::scope::{ScopeTree, TypeBinding};
55
use itertools::{Itertools, izip};
66
use ndc_core::{StaticType, TypeSignature};
77
use ndc_lexer::Span;
8-
use ndc_parser::{Binding, Expression, ExpressionLocation, ForBody, ForIteration, Lvalue, NodeId};
8+
use ndc_parser::{
9+
Binding, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, Lvalue,
10+
NodeId,
11+
};
912

1013
/// Side table holding semantic information keyed by AST node identity.
1114
/// Keeps tooling-specific data (like per-expression types) out of the AST.
1215
#[derive(Debug, Default)]
1316
pub struct AnalysisResult {
1417
/// Maps each expression node to its inferred result type.
1518
pub expr_types: HashMap<NodeId, StaticType>,
19+
/// Inferred return types for functions without explicit annotations.
20+
/// Keyed by the FunctionDeclaration's `NodeId`.
21+
pub inferred_return_types: HashMap<NodeId, StaticType>,
1622
/// Errors accumulated during analysis. Non-empty when the analyser
1723
/// encountered problems but was able to continue with fallback types.
1824
pub errors: Vec<AnalysisError>,
@@ -98,7 +104,9 @@ impl Analyser {
98104
fn analyse_inner(
99105
&mut self,
100106
ExpressionLocation {
101-
expression, span, ..
107+
expression,
108+
span,
109+
id,
102110
}: &mut ExpressionLocation,
103111
) -> Result<StaticType, AnalysisError> {
104112
match expression {
@@ -269,12 +277,14 @@ impl Analyser {
269277
Expression::FunctionDeclaration {
270278
name,
271279
resolved_name,
272-
type_signature,
280+
parameters,
273281
body,
274282
return_type: return_type_slot,
275283
captures,
276284
..
277285
} => {
286+
let type_signature = FunctionParameter::to_type_signature(parameters);
287+
278288
// Pre-register the function before analysing its body so recursive calls can
279289
// resolve the name. The return type is unknown at this point so we use Any.
280290
let pre_slot =
@@ -302,7 +312,14 @@ impl Analyser {
302312

303313
self.scope_tree.new_function_scope();
304314
self.return_type_stack.push(None);
305-
let param_types = self.resolve_parameters_declarative(type_signature, *span);
315+
let param_types = self.resolve_parameters_declarative(&type_signature, *span);
316+
317+
// Fill inferred_type on parameter Lvalues for LSP hints.
318+
for (p, typ) in parameters.iter_mut().zip(&param_types) {
319+
if let Lvalue::Identifier { inferred_type, .. } = &mut p.lvalue {
320+
*inferred_type = Some(typ.clone());
321+
}
322+
}
306323

307324
let implicit_return = self.analyse_or_any(body);
308325
let explicit_return = self.return_type_stack.pop().unwrap();
@@ -315,8 +332,8 @@ impl Analyser {
315332
None => implicit_return,
316333
};
317334

318-
// If there is an annotated return type, validate and use it;
319-
// otherwise fall back to the inferred type.
335+
// If there is an annotated return type, validate it;
336+
// otherwise record the inferred type in the side table.
320337
if let Some(annotated) = return_type_slot {
321338
if !inferred_return.is_subtype(annotated) {
322339
self.emit(AnalysisError::mismatched_types(
@@ -326,16 +343,16 @@ impl Analyser {
326343
));
327344
}
328345
} else {
329-
*return_type_slot = Some(inferred_return);
346+
self.result
347+
.inferred_return_types
348+
.insert(*id, inferred_return.clone());
330349
}
331350

351+
let effective_return = return_type_slot.clone().unwrap_or(inferred_return);
352+
332353
let function_type = StaticType::Function {
333354
parameters: Some(param_types.clone()),
334-
return_type: Box::new(
335-
return_type_slot
336-
.clone()
337-
.expect("must have a value at this point"),
338-
),
355+
return_type: Box::new(effective_return),
339356
};
340357

341358
if let Some(slot) = pre_slot {
@@ -492,10 +509,22 @@ impl Analyser {
492509
}
493510
Binding::Resolved(res) => self.scope_tree.get_type(*res).clone(),
494511

495-
Binding::Dynamic(_) => StaticType::Function {
496-
parameters: None,
497-
return_type: Box::new(StaticType::Any),
498-
},
512+
Binding::Dynamic(candidates) => {
513+
let return_type = candidates
514+
.iter()
515+
.map(|c| self.scope_tree.get_type(*c).clone())
516+
.filter_map(|t| match t {
517+
StaticType::Function { return_type, .. } => Some(*return_type),
518+
_ => None,
519+
})
520+
.reduce(|a, b| a.lub(&b))
521+
.unwrap_or(StaticType::Any);
522+
523+
StaticType::Function {
524+
parameters: None,
525+
return_type: Box::new(return_type),
526+
}
527+
}
499528
};
500529

501530
*resolved = binding;

ndc_lsp/src/features/inlay_hints.rs

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::collections::HashMap;
33
use ndc_core::StaticType;
44
use ndc_interpreter::AnalysisResult;
55
use ndc_lexer::Span;
6-
use ndc_parser::ExpressionLocation;
6+
use ndc_parser::{ExpressionLocation, NodeId};
77
use tower_lsp::lsp_types::{InlayHint, InlayHintKind, InlayHintLabel};
88

99
use crate::util::position_from_offset;
@@ -79,18 +79,27 @@ impl AstVisitor for HintCollector<'_> {
7979
}
8080
}
8181

82-
fn on_function_declaration(&mut self, return_type: Option<&StaticType>, parameters_span: Span) {
83-
if let Some(rt) = return_type {
84-
self.hints.push(InlayHint {
85-
position: position_from_offset(self.text, parameters_span.end()),
86-
label: InlayHintLabel::String(format!(" -> {rt}")),
87-
kind: Some(InlayHintKind::TYPE),
88-
text_edits: None,
89-
tooltip: None,
90-
padding_left: None,
91-
padding_right: None,
92-
data: None,
93-
});
82+
fn on_function_declaration(
83+
&mut self,
84+
return_type: Option<&StaticType>,
85+
parameters_span: Span,
86+
node_id: NodeId,
87+
) {
88+
// return_type is Some only when explicitly annotated by the user — skip the hint.
89+
// Inferred return types are stored in the side table.
90+
if return_type.is_none() {
91+
if let Some(rt) = self.analysis_result.inferred_return_types.get(&node_id) {
92+
self.hints.push(InlayHint {
93+
position: position_from_offset(self.text, parameters_span.end()),
94+
label: InlayHintLabel::String(format!(" -> {rt}")),
95+
kind: Some(InlayHintKind::TYPE),
96+
text_edits: None,
97+
tooltip: None,
98+
padding_left: None,
99+
padding_right: None,
100+
data: None,
101+
});
102+
}
94103
}
95104
}
96105
}
@@ -129,4 +138,40 @@ mod tests {
129138
);
130139
assert_eq!(info.variable_types.get("value"), Some(&StaticType::Int));
131140
}
141+
142+
#[test]
143+
fn annotated_return_type_skips_inlay() {
144+
let info = collect_hints("fn foo(x: Int) -> Int { x + 1 }");
145+
assert!(!info.hints.iter().any(
146+
|hint| matches!(&hint.label, InlayHintLabel::String(label) if label.contains("->"))
147+
));
148+
}
149+
150+
#[test]
151+
fn inferred_return_type_gets_inlay() {
152+
let info = collect_hints("fn foo() { 42 }");
153+
assert!(info.hints.iter().any(
154+
|hint| matches!(&hint.label, InlayHintLabel::String(label) if label == " -> Int")
155+
));
156+
}
157+
158+
#[test]
159+
fn annotated_param_skips_inlay() {
160+
let info = collect_hints("fn foo(x: Int) { x }");
161+
assert!(
162+
!info.hints.iter().any(
163+
|hint| matches!(&hint.label, InlayHintLabel::String(label) if label == ": Int")
164+
)
165+
);
166+
}
167+
168+
#[test]
169+
fn unannotated_param_gets_inlay() {
170+
let info = collect_hints("fn foo(x) { x }");
171+
assert!(
172+
info.hints.iter().any(
173+
|hint| matches!(&hint.label, InlayHintLabel::String(label) if label == ": Any")
174+
)
175+
);
176+
}
132177
}

ndc_lsp/src/visitor.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use ndc_core::StaticType;
22
use ndc_lexer::Span;
3-
use ndc_parser::{Expression, ExpressionLocation, ForBody, ForIteration, Lvalue};
3+
use ndc_parser::{Expression, ExpressionLocation, ForBody, ForIteration, Lvalue, NodeId};
44

55
/// Trait for visiting interesting nodes during an AST walk.
66
///
@@ -26,6 +26,7 @@ pub trait AstVisitor {
2626
&mut self,
2727
_return_type: Option<&StaticType>,
2828
_parameters_span: Span,
29+
_node_id: NodeId,
2930
) {
3031
}
3132
}
@@ -50,11 +51,15 @@ fn walk_expression(visitor: &mut impl AstVisitor, expr: &ExpressionLocation) {
5051
}
5152
Expression::FunctionDeclaration {
5253
return_type,
54+
parameters,
5355
parameters_span,
5456
body,
5557
..
5658
} => {
57-
visitor.on_function_declaration(return_type.as_ref(), *parameters_span);
59+
for p in parameters {
60+
walk_lvalue(visitor, &p.lvalue, p.annotation.is_some());
61+
}
62+
visitor.on_function_declaration(return_type.as_ref(), *parameters_span, expr.id);
5863
walk_expression(visitor, body);
5964
}
6065
Expression::Statement(inner) | Expression::Grouping(inner) => {

ndc_parser/src/expression.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ pub enum Expression {
9393
FunctionDeclaration {
9494
name: Option<String>,
9595
resolved_name: Option<ResolvedVar>,
96-
type_signature: TypeSignature,
96+
parameters: Vec<FunctionParameter>,
9797
parameters_span: Span,
9898
body: Box<ExpressionLocation>,
9999
return_type: Option<StaticType>,
@@ -171,6 +171,29 @@ pub enum ForBody {
171171
},
172172
}
173173

174+
#[derive(Debug, Eq, PartialEq, Clone)]
175+
pub struct FunctionParameter {
176+
pub lvalue: Lvalue,
177+
pub annotation: Option<StaticType>,
178+
pub span: Span,
179+
}
180+
181+
impl FunctionParameter {
182+
pub fn to_type_signature(params: &[Self]) -> TypeSignature {
183+
TypeSignature::from_annotated_bindings(
184+
params
185+
.iter()
186+
.map(|p| {
187+
let Lvalue::Identifier { identifier, .. } = &p.lvalue else {
188+
panic!("expected identifier in parameter list: {:?}", p.lvalue);
189+
};
190+
(identifier.clone(), p.annotation.clone())
191+
})
192+
.collect(),
193+
)
194+
}
195+
}
196+
174197
#[derive(Debug, Eq, PartialEq, Clone)]
175198
pub enum Lvalue {
176199
// Example: `let foo = ...`

ndc_parser/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ mod operator;
33
mod parser;
44

55
pub use expression::{
6-
Binding, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration, Lvalue, NodeId,
7-
ResolvedVar,
6+
Binding, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration,
7+
FunctionParameter, Lvalue, NodeId, ResolvedVar,
88
};
99
pub use operator::{BinaryOperator, LogicalOperator, UnaryOperator};
1010
pub use parser::Error;

ndc_parser/src/parser.rs

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::fmt::Write;
22

33
use crate::expression::Expression;
4-
use crate::expression::{Binding, ExpressionLocation, ForBody, ForIteration, Lvalue, NodeId};
4+
use crate::expression::{
5+
Binding, ExpressionLocation, ForBody, ForIteration, FunctionParameter, Lvalue, NodeId,
6+
};
57
use crate::operator::{BinaryOperator, LogicalOperator, UnaryOperator};
68
use ndc_core::{Parameter, StaticType, TypeSignature};
79
use ndc_lexer::{Span, Token, TokenLocation};
@@ -1218,20 +1220,7 @@ impl Parser {
12181220
Ok(ExpressionLocation {
12191221
expression: Expression::FunctionDeclaration {
12201222
name: identifier,
1221-
type_signature: TypeSignature::from_annotated_bindings(
1222-
argument_list
1223-
.into_iter()
1224-
.map(|(lvalue, annotation)| {
1225-
let Lvalue::Identifier { identifier, .. } = lvalue else {
1226-
panic!(
1227-
"INTERNAL ERROR: expected identifier in argument list: {:?}",
1228-
lvalue
1229-
);
1230-
};
1231-
(identifier, annotation)
1232-
})
1233-
.collect(),
1234-
),
1223+
parameters: argument_list,
12351224
parameters_span,
12361225
body: Box::new(body),
12371226
return_type: annotated_return_type,
@@ -1453,7 +1442,7 @@ impl Parser {
14531442
Ok(StaticType::Tuple(types))
14541443
}
14551444

1456-
fn named_parameter(&mut self) -> Result<(Lvalue, Option<StaticType>), Error> {
1445+
fn named_parameter(&mut self) -> Result<FunctionParameter, Error> {
14571446
let maybe_lvalue = self.single_expression()?;
14581447
let lvalue_span = maybe_lvalue.span;
14591448

@@ -1465,14 +1454,24 @@ impl Parser {
14651454
));
14661455
};
14671456

1468-
let annotated_type = if self.peek_current_token() == Some(&Token::Colon) {
1457+
let annotation = if self.peek_current_token() == Some(&Token::Colon) {
14691458
self.advance();
14701459
Some(self.static_type()?)
14711460
} else {
14721461
None
14731462
};
14741463

1475-
Ok((lvalue, annotated_type))
1464+
let span = if annotation.is_some() {
1465+
lvalue_span.merge(self.tokens[self.current - 1].span)
1466+
} else {
1467+
lvalue_span
1468+
};
1469+
1470+
Ok(FunctionParameter {
1471+
lvalue,
1472+
annotation,
1473+
span,
1474+
})
14761475
}
14771476

14781477
pub fn named_binding(&mut self) -> Result<(Lvalue, Option<StaticType>), Error> {

ndc_vm/src/compiler.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use crate::{Object, Value};
44
use ndc_core::{StaticType, TypeSignature};
55
use ndc_lexer::Span;
66
use ndc_parser::{
7-
Binding, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration, LogicalOperator,
8-
Lvalue, ResolvedVar,
7+
Binding, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration,
8+
FunctionParameter, LogicalOperator, Lvalue, ResolvedVar,
99
};
1010
use std::rc::Rc;
1111

@@ -299,12 +299,13 @@ impl Compiler {
299299
name,
300300
resolved_name,
301301
body,
302-
type_signature,
302+
parameters,
303303
return_type,
304304
captures,
305305
pure,
306306
..
307307
} => {
308+
let type_signature = FunctionParameter::to_type_signature(&parameters);
308309
self.compile_function_decl(
309310
name,
310311
resolved_name,

0 commit comments

Comments
 (0)