diff --git a/src/lsp.rs b/src/lsp.rs index 634e454..bcbaf4f 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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::(req) { + let req = match cast_request::(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::(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 { 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> { + 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, ¶m_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, ¶m_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> { + let mut params = HashMap::new(); + for decl in &program.declarations { + if let crate::ast::Declaration::Function(f) = decl { + let names: Vec = 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> { 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>, + hints: &mut Vec, +) { + 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); diff --git a/src/symbol_table.rs b/src/symbol_table.rs index 58871b6..dbf1268 100644 --- a/src/symbol_table.rs +++ b/src/symbol_table.rs @@ -263,7 +263,21 @@ impl SymbolTable { .collect::>() .join(", ")) }; - let type_sig = format!("fn {}({}): {}{}", f.name.name, param_types.join(", "), return_type, effects); + let properties = if f.properties.is_empty() { + String::new() + } else { + format!(" is {}", f.properties.iter() + .map(|p| match p { + crate::ast::BehavioralProperty::Pure => "pure", + crate::ast::BehavioralProperty::Total => "total", + crate::ast::BehavioralProperty::Idempotent => "idempotent", + crate::ast::BehavioralProperty::Deterministic => "deterministic", + crate::ast::BehavioralProperty::Commutative => "commutative", + }) + .collect::>() + .join(", ")) + }; + let type_sig = format!("fn {}({}): {}{}{}", f.name.name, param_types.join(", "), return_type, properties, effects); let symbol = self.new_symbol( f.name.name.clone(), diff --git a/src/typechecker.rs b/src/typechecker.rs index 4c5fd90..1651309 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -759,6 +759,17 @@ impl TypeChecker { self.env.bindings.get(name) } + /// Get the inferred type of a binding as a display string (for LSP inlay hints) + pub fn get_inferred_type(&self, name: &str) -> Option { + let scheme = self.env.bindings.get(name)?; + let type_str = scheme.typ.to_string(); + // Skip unhelpful types + if type_str == "" || type_str.contains('?') { + return None; + } + Some(type_str) + } + /// Get auto-generated migrations from type checking /// Returns: type_name -> from_version -> migration_body pub fn get_auto_migrations(&self) -> &HashMap> {