feat: enhance LSP with inlay hints, parameter hints, and improved hover

Add inlay type hints for let bindings, parameter name hints at call sites,
behavioral property documentation in hover, and long signature wrapping.

- Inlay hints: show inferred types for let bindings without annotations
- Parameter hints: show param names at call sites for multi-arg functions
- Hover: wrap long signatures, show behavioral property docs (pure, total, etc.)
- Rich docs: detailed hover for keywords like pure, total, idempotent, run, with
- TypeChecker: expose get_inferred_type() for LSP consumption
- Symbol table: include behavioral properties in function type signatures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 08:06:36 -05:00
parent 1fa599f856
commit d26fd975d1
3 changed files with 395 additions and 9 deletions

View File

@@ -19,7 +19,7 @@ use crate::formatter::{format as format_source, FormatConfig};
use lsp_server::{Connection, ExtractError, Message, Request, RequestId, Response};
use lsp_types::{
notification::{DidChangeTextDocument, DidOpenTextDocument, Notification},
request::{Completion, GotoDefinition, HoverRequest, References, DocumentSymbolRequest, Rename, SignatureHelpRequest, Formatting},
request::{Completion, GotoDefinition, HoverRequest, References, DocumentSymbolRequest, Rename, SignatureHelpRequest, Formatting, InlayHintRequest},
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidOpenTextDocumentParams,
GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams,
@@ -28,7 +28,8 @@ use lsp_types::{
TextDocumentSyncKind, Url, ReferenceParams, Location, DocumentSymbolParams,
DocumentSymbolResponse, SymbolInformation, RenameParams, WorkspaceEdit, TextEdit,
SignatureHelpParams, SignatureHelp, SignatureInformation, ParameterInformation,
SignatureHelpOptions, DocumentFormattingParams, TextDocumentIdentifier,
SignatureHelpOptions, DocumentFormattingParams,
InlayHint, InlayHintKind, InlayHintLabel, InlayHintParams,
};
use std::collections::HashMap;
use std::error::Error;
@@ -88,6 +89,7 @@ impl LspServer {
work_done_progress_options: Default::default(),
}),
document_formatting_provider: Some(lsp_types::OneOf::Left(true)),
inlay_hint_provider: Some(lsp_types::OneOf::Left(true)),
..Default::default()
})?;
@@ -191,7 +193,7 @@ impl LspServer {
Err(req) => req,
};
let _req = match cast_request::<Formatting>(req) {
let req = match cast_request::<Formatting>(req) {
Ok((id, params)) => {
let result = self.handle_formatting(params);
let resp = Response::new_ok(id, result);
@@ -201,6 +203,16 @@ impl LspServer {
Err(req) => req,
};
let _req = match cast_request::<InlayHintRequest>(req) {
Ok((id, params)) => {
let result = self.handle_inlay_hints(params);
let resp = Response::new_ok(id, result);
self.connection.sender.send(Message::Response(resp))?;
return Ok(());
}
Err(req) => req,
};
Ok(())
}
@@ -328,10 +340,16 @@ impl LspServer {
.map(|d| format!("\n\n{}", d))
.unwrap_or_default();
// Format signature: wrap long signatures onto multiple lines
let formatted_sig = format_signature_for_hover(signature);
// Add behavioral property documentation if present
let property_docs = extract_property_docs(signature);
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("```lux\n{}\n```\n\n*{}*{}", signature, kind_str, doc_str),
value: format!("```lux\n{}\n```\n\n*{}*{}{}", formatted_sig, kind_str, property_docs, doc_str),
}),
range: None,
});
@@ -343,19 +361,20 @@ impl LspServer {
// Extract the word at the cursor position
let word = self.get_word_at_position(source, position)?;
// Look up documentation for known symbols
let info = self.get_symbol_info(&word);
// Look up rich documentation for known symbols
let info = self.get_rich_symbol_info(&word)
.or_else(|| self.get_symbol_info(&word).map(|(s, d)| (s.to_string(), d.to_string())));
if let Some((signature, doc)) = info {
let formatted_sig = format_signature_for_hover(&signature);
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("```lux\n{}\n```\n\n{}", signature, doc),
value: format!("```lux\n{}\n```\n\n{}", formatted_sig, doc),
}),
range: None,
})
} else {
// Return generic info for unknown symbols
None
}
}
@@ -439,6 +458,84 @@ impl LspServer {
}
}
/// Rich documentation for behavioral properties and keywords
fn get_rich_symbol_info(&self, word: &str) -> Option<(String, String)> {
match word {
"pure" => Some((
"is pure".to_string(),
"**Behavioral Property: Pure**\n\n\
A pure function has no side effects and always produces the same output for the same inputs. \
The compiler can safely memoize calls, reorder them, or eliminate duplicates.\n\n\
```lux\nfn add(a: Int, b: Int): Int is pure = a + b\n```\n\n\
**Guarantees:**\n\
- No effect operations (Console, File, Http, etc.)\n\
- Referential transparency: `f(x)` can be replaced with its result\n\
- Enables memoization and common subexpression elimination".to_string(),
)),
"total" => Some((
"is total".to_string(),
"**Behavioral Property: Total**\n\n\
A total function always terminates and never throws exceptions. \
The compiler verifies termination through structural recursion analysis.\n\n\
```lux\nfn factorial(n: Int): Int is total =\n if n <= 0 then 1\n else n * factorial(n - 1)\n```\n\n\
**Guarantees:**\n\
- Always produces a result (no infinite loops)\n\
- Cannot use the `Fail` effect\n\
- Recursive calls must be structurally decreasing".to_string(),
)),
"idempotent" => Some((
"is idempotent".to_string(),
"**Behavioral Property: Idempotent**\n\n\
An idempotent function satisfies `f(f(x)) == f(x)` for all inputs. \
Applying it multiple times has the same effect as applying it once.\n\n\
```lux\nfn abs(x: Int): Int is idempotent =\n if x < 0 then 0 - x else x\n\n\
fn clamp(x: Int): Int is idempotent =\n if x < 0 then 0\n else if x > 100 then 100\n else x\n```\n\n\
**Guarantees:**\n\
- `f(f(x)) == f(x)` for all valid inputs\n\
- Safe to retry without changing outcome\n\
- Compiler can deduplicate consecutive calls".to_string(),
)),
"deterministic" => Some((
"is deterministic".to_string(),
"**Behavioral Property: Deterministic**\n\n\
A deterministic function always produces the same output for the same inputs, \
with no dependence on randomness, time, or external state.\n\n\
```lux\nfn multiply(a: Int, b: Int): Int is deterministic = a * b\n```\n\n\
**Guarantees:**\n\
- Cannot use `Random` or `Time` effects\n\
- Same inputs always produce same outputs\n\
- Results can be cached across runs".to_string(),
)),
"commutative" => Some((
"is commutative".to_string(),
"**Behavioral Property: Commutative**\n\n\
A commutative function satisfies `f(a, b) == f(b, a)`. \
The order of arguments doesn't affect the result.\n\n\
```lux\nfn add(a: Int, b: Int): Int is commutative = a + b\nfn max(a: Int, b: Int): Int is commutative =\n if a > b then a else b\n```\n\n\
**Guarantees:**\n\
- Must have exactly 2 parameters\n\
- `f(a, b) == f(b, a)` for all inputs\n\
- Compiler can normalize argument order for optimization".to_string(),
)),
"run" => Some((
"run expr with { handlers }".to_string(),
"**Effect Handler**\n\n\
Execute an effectful expression with explicit effect handlers. \
Must be bound to a variable at top level.\n\n\
```lux\nlet result = run myFunction() with {\n Console = { /* handler */ }\n}\n```\n\n\
Handlers intercept effect operations and provide implementations.".to_string(),
)),
"with" => Some((
"with {Effect1, Effect2}".to_string(),
"**Effect Declaration / Handler Block**\n\n\
Declares which effects a function may perform, or provides handlers in a `run` expression.\n\n\
```lux\n// In function signature:\nfn greet(name: String): Unit with {Console} =\n Console.print(\"Hello, \" + name)\n\n\
// In run expression:\nlet _ = run greet(\"world\") with {}\n```".to_string(),
)),
_ => None,
}
}
fn handle_completion(&self, params: CompletionParams) -> Option<CompletionResponse> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
@@ -1022,6 +1119,90 @@ impl LspServer {
})
}
fn handle_inlay_hints(&self, params: InlayHintParams) -> Option<Vec<InlayHint>> {
let uri = params.text_document.uri;
let doc = self.documents.get(&uri)?;
let source = &doc.text;
// Parse the document to get AST
let program = Parser::parse_source(source).ok()?;
// Type-check to get inferred types
let mut checker = TypeChecker::new();
let _ = checker.check_program(&program);
let mut hints = Vec::new();
// Collect parameter names for known functions (from symbol table)
let param_names = self.collect_function_params(&program);
for decl in &program.declarations {
match decl {
crate::ast::Declaration::Let(l) => {
// Show inferred type for let bindings without explicit type annotations
if l.typ.is_none() {
if let Some(inferred_type) = checker.get_inferred_type(&l.name.name) {
let type_str = format!(": {}", inferred_type);
let pos = offset_to_position(source, l.name.span.end);
hints.push(InlayHint {
position: pos,
label: InlayHintLabel::String(type_str),
kind: Some(InlayHintKind::TYPE),
text_edits: None,
tooltip: None,
padding_left: Some(false),
padding_right: Some(true),
data: None,
});
}
}
// Walk into the value expression for call-site parameter hints
collect_call_site_hints(source, &l.value, &param_names, &mut hints);
}
crate::ast::Declaration::Function(f) => {
// Walk into the function body for call-site parameter hints
collect_call_site_hints(source, &f.body, &param_names, &mut hints);
}
_ => {}
}
}
if hints.is_empty() {
None
} else {
Some(hints)
}
}
/// Collect parameter names for all functions defined in the program
fn collect_function_params(&self, program: &crate::ast::Program) -> HashMap<String, Vec<String>> {
let mut params = HashMap::new();
for decl in &program.declarations {
if let crate::ast::Declaration::Function(f) = decl {
let names: Vec<String> = f.params.iter()
.map(|p| p.name.name.clone())
.collect();
params.insert(f.name.name.clone(), names);
}
}
// Add builtin function parameter names
params.insert("map".into(), vec!["list".into(), "f".into()]);
params.insert("filter".into(), vec!["list".into(), "predicate".into()]);
params.insert("fold".into(), vec!["list".into(), "init".into(), "f".into()]);
params.insert("concat".into(), vec!["a".into(), "b".into()]);
params.insert("range".into(), vec!["start".into(), "end".into()]);
params.insert("get".into(), vec!["list".into(), "index".into()]);
params.insert("take".into(), vec!["list".into(), "n".into()]);
params.insert("drop".into(), vec!["list".into(), "n".into()]);
params.insert("split".into(), vec!["s".into(), "delimiter".into()]);
params.insert("join".into(), vec!["list".into(), "delimiter".into()]);
params.insert("replace".into(), vec!["s".into(), "old".into(), "new".into()]);
params.insert("substring".into(), vec!["s".into(), "start".into(), "end".into()]);
params.insert("contains".into(), vec!["s".into(), "substr".into()]);
params.insert("getOrElse".into(), vec!["opt".into(), "default".into()]);
params
}
fn handle_formatting(&self, params: DocumentFormattingParams) -> Option<Vec<TextEdit>> {
let uri = params.text_document.uri;
let doc = self.documents.get(&uri)?;
@@ -1061,6 +1242,186 @@ impl LspServer {
}
/// Convert byte offsets to LSP Position
/// Format a function signature for hover display, wrapping long lines
fn format_signature_for_hover(sig: &str) -> String {
// If it fits in ~60 chars, keep it on one line
if sig.len() <= 60 {
return sig.to_string();
}
// Try to break at parameter list for function signatures
if let Some(paren_start) = sig.find('(') {
if let Some(paren_end) = sig.rfind(')') {
let prefix = &sig[..paren_start + 1];
let params = &sig[paren_start + 1..paren_end];
let suffix = &sig[paren_end..];
// Split parameters and format each on its own line
let param_parts: Vec<&str> = params.split(", ").collect();
if param_parts.len() > 1 {
let indent = " ";
let formatted_params = param_parts.join(&format!(",\n{}", indent));
return format!("{}\n{}{}\n{}", prefix, indent, formatted_params, suffix);
}
}
}
sig.to_string()
}
/// Extract behavioral property documentation from a signature string
fn extract_property_docs(sig: &str) -> String {
let properties = [
("is pure", "**pure** — no side effects, same output for same inputs"),
("is total", "**total** — always terminates, no exceptions"),
("is idempotent", "**idempotent** — `f(f(x)) == f(x)`"),
("is deterministic", "**deterministic** — no randomness or time dependence"),
("is commutative", "**commutative** — `f(a, b) == f(b, a)`"),
];
let mut found = Vec::new();
for (keyword, description) in &properties {
if sig.contains(keyword) {
found.push(*description);
}
}
if found.is_empty() {
String::new()
} else {
format!("\n\n{}", found.join(" \n"))
}
}
/// Recursively collect parameter name hints at call sites
fn collect_call_site_hints(
source: &str,
expr: &crate::ast::Expr,
param_names: &HashMap<String, Vec<String>>,
hints: &mut Vec<InlayHint>,
) {
use crate::ast::Expr;
match expr {
Expr::Call { func, args, .. } => {
// Get the function name for parameter lookup
let func_name = match func.as_ref() {
Expr::Var(ident) => Some(ident.name.clone()),
// Module.method calls like List.map
Expr::Field { object, field, .. } => {
if let Expr::Var(_) = object.as_ref() {
Some(field.name.clone())
} else {
None
}
}
_ => None,
};
if let Some(name) = func_name {
if let Some(names) = param_names.get(&name) {
for (i, arg) in args.iter().enumerate() {
if let Some(param_name) = names.get(i) {
// Skip hint if the argument is already a variable with the same name
if let Expr::Var(ident) = arg {
if &ident.name == param_name {
continue;
}
}
// Skip hints for single-arg functions (obvious)
if args.len() <= 1 {
continue;
}
let pos = offset_to_position(source, arg.span().start);
hints.push(InlayHint {
position: pos,
label: InlayHintLabel::String(format!("{}:", param_name)),
kind: Some(InlayHintKind::PARAMETER),
text_edits: None,
tooltip: None,
padding_left: Some(false),
padding_right: Some(true),
data: None,
});
}
}
}
}
// Recurse into function expression and arguments
collect_call_site_hints(source, func, param_names, hints);
for arg in args {
collect_call_site_hints(source, arg, param_names, hints);
}
}
Expr::BinaryOp { left, right, .. } => {
collect_call_site_hints(source, left, param_names, hints);
collect_call_site_hints(source, right, param_names, hints);
}
Expr::UnaryOp { operand, .. } => {
collect_call_site_hints(source, operand, param_names, hints);
}
Expr::If { condition, then_branch, else_branch, .. } => {
collect_call_site_hints(source, condition, param_names, hints);
collect_call_site_hints(source, then_branch, param_names, hints);
collect_call_site_hints(source, else_branch, param_names, hints);
}
Expr::Let { value, body, .. } => {
collect_call_site_hints(source, value, param_names, hints);
collect_call_site_hints(source, body, param_names, hints);
}
Expr::Block { statements, result, .. } => {
for stmt in statements {
match stmt {
crate::ast::Statement::Expr(e) => {
collect_call_site_hints(source, e, param_names, hints);
}
crate::ast::Statement::Let { value, .. } => {
collect_call_site_hints(source, value, param_names, hints);
}
}
}
collect_call_site_hints(source, result, param_names, hints);
}
Expr::Match { scrutinee, arms, .. } => {
collect_call_site_hints(source, scrutinee, param_names, hints);
for arm in arms {
collect_call_site_hints(source, &arm.body, param_names, hints);
}
}
Expr::Lambda { body, .. } => {
collect_call_site_hints(source, body, param_names, hints);
}
Expr::Tuple { elements, .. } | Expr::List { elements, .. } => {
for e in elements {
collect_call_site_hints(source, e, param_names, hints);
}
}
Expr::Record { fields, .. } => {
for (_, e) in fields {
collect_call_site_hints(source, e, param_names, hints);
}
}
Expr::Field { object, .. } => {
collect_call_site_hints(source, object, param_names, hints);
}
Expr::Run { expr, handlers, .. } => {
collect_call_site_hints(source, expr, param_names, hints);
for (_, handler_expr) in handlers {
collect_call_site_hints(source, handler_expr, param_names, hints);
}
}
Expr::Resume { value, .. } => {
collect_call_site_hints(source, value, param_names, hints);
}
Expr::EffectOp { args, .. } => {
for arg in args {
collect_call_site_hints(source, arg, param_names, hints);
}
}
Expr::Literal { .. } | Expr::Var(_) => {}
}
}
fn span_to_range(source: &str, start: usize, end: usize) -> Range {
let start_pos = offset_to_position(source, start);
let end_pos = offset_to_position(source, end);