wcl_lsp 0.11.2-alpha

WCL Language Server Protocol implementation
Documentation
use async_lsp::lsp_types::{
    Documentation, ParameterInformation, ParameterLabel, SignatureHelp, SignatureInformation,
};

pub fn signature_help(
    source: &str,
    offset: usize,
    analysis: Option<&crate::state::AnalysisResult>,
) -> Option<SignatureHelp> {
    let before = &source[..offset.min(source.len())];
    let (fn_name, active_param) = find_call_context(before)?;

    // Try function signatures (builtins + custom) from analysis, or fallback to builtins
    let builtin_sigs;
    let sig = if let Some(analysis) = analysis {
        find_signature(analysis, &fn_name)
    } else {
        builtin_sigs = wcl_lang::eval::builtin_signatures();
        find_signature_in_slice(&builtin_sigs, &fn_name)
    };
    if let Some(sig) = sig {
        let params: Vec<ParameterInformation> = sig
            .params
            .iter()
            .map(|p| ParameterInformation {
                label: ParameterLabel::Simple(p.to_string()),
                documentation: None,
            })
            .collect();

        let label = format!("{}({})", sig.name, sig.params.join(", "));

        return Some(SignatureHelp {
            signatures: vec![SignatureInformation {
                label,
                documentation: Some(Documentation::String(sig.doc.to_string())),
                parameters: Some(params),
                active_parameter: Some(active_param),
            }],
            active_signature: Some(0),
            active_parameter: Some(active_param),
        });
    }

    // Try user-defined macros from the macro registry
    if let Some(analysis) = analysis {
        if let Some(md) = analysis.macro_registry.function_macros.get(&fn_name) {
            let params: Vec<ParameterInformation> = md
                .params
                .iter()
                .map(|p| {
                    let label = if let Some(te) = &p.type_constraint {
                        format!("{}: {}", p.name.name, type_expr_label(te))
                    } else {
                        p.name.name.clone()
                    };
                    ParameterInformation {
                        label: ParameterLabel::Simple(label),
                        documentation: None,
                    }
                })
                .collect();

            let param_strs: Vec<String> = md
                .params
                .iter()
                .map(|p| {
                    if let Some(te) = &p.type_constraint {
                        format!("{}: {}", p.name.name, type_expr_label(te))
                    } else {
                        p.name.name.clone()
                    }
                })
                .collect();

            let label = format!("{}({})", md.name.name, param_strs.join(", "));

            return Some(SignatureHelp {
                signatures: vec![SignatureInformation {
                    label,
                    documentation: Some(Documentation::String("user-defined macro".to_string())),
                    parameters: Some(params),
                    active_parameter: Some(active_param),
                }],
                active_signature: Some(0),
                active_parameter: Some(active_param),
            });
        }
    }

    None
}

fn find_signature<'a>(
    analysis: &'a crate::state::AnalysisResult,
    fn_name: &str,
) -> Option<&'a wcl_lang::eval::FunctionSignature> {
    if let Some(qualified) = analysis.namespace_aliases.get(fn_name) {
        if let Some(sig) = analysis
            .function_signatures
            .iter()
            .find(|s| s.name == *qualified)
        {
            return Some(sig);
        }
    }
    find_signature_in_slice(&analysis.function_signatures, fn_name)
}

fn find_signature_in_slice<'a>(
    sigs: &'a [wcl_lang::eval::FunctionSignature],
    fn_name: &str,
) -> Option<&'a wcl_lang::eval::FunctionSignature> {
    if let Some(sig) = sigs.iter().find(|s| s.name == fn_name) {
        return Some(sig);
    }

    if fn_name.contains("::") {
        return None;
    }

    let mut matches = sigs.iter().filter(|s| {
        s.name
            .rsplit_once("::")
            .map(|(_, tail)| tail == fn_name)
            .unwrap_or(false)
    });
    let first = matches.next()?;
    if matches.next().is_none() {
        Some(first)
    } else {
        None
    }
}

fn type_expr_label(te: &wcl_lang::lang::ast::TypeExpr) -> String {
    match te {
        wcl_lang::lang::ast::TypeExpr::String(_) => "string".to_string(),
        wcl_lang::lang::ast::TypeExpr::I8(_) => "i8".to_string(),
        wcl_lang::lang::ast::TypeExpr::U8(_) => "u8".to_string(),
        wcl_lang::lang::ast::TypeExpr::I16(_) => "i16".to_string(),
        wcl_lang::lang::ast::TypeExpr::U16(_) => "u16".to_string(),
        wcl_lang::lang::ast::TypeExpr::I32(_) => "i32".to_string(),
        wcl_lang::lang::ast::TypeExpr::U32(_) => "u32".to_string(),
        wcl_lang::lang::ast::TypeExpr::I64(_) => "i64".to_string(),
        wcl_lang::lang::ast::TypeExpr::U64(_) => "u64".to_string(),
        wcl_lang::lang::ast::TypeExpr::I128(_) => "i128".to_string(),
        wcl_lang::lang::ast::TypeExpr::U128(_) => "u128".to_string(),
        wcl_lang::lang::ast::TypeExpr::F32(_) => "f32".to_string(),
        wcl_lang::lang::ast::TypeExpr::F64(_) => "f64".to_string(),
        wcl_lang::lang::ast::TypeExpr::Date(_) => "date".to_string(),
        wcl_lang::lang::ast::TypeExpr::Duration(_) => "duration".to_string(),
        wcl_lang::lang::ast::TypeExpr::Bool(_) => "bool".to_string(),
        wcl_lang::lang::ast::TypeExpr::Any(_) => "any".to_string(),
        _ => "any".to_string(),
    }
}

/// Find the function name and active parameter index from text before cursor.
fn find_call_context(before: &str) -> Option<(String, u32)> {
    let mut depth = 0i32;
    let mut commas = 0u32;

    // Walk backwards to find the matching '('
    for (i, ch) in before.char_indices().rev() {
        match ch {
            ')' => depth += 1,
            '(' => {
                if depth == 0 {
                    // Found the opening paren, extract the function name before it
                    let prefix = before[..i].trim_end();
                    let name = prefix
                        .rsplit(|c: char| !c.is_alphanumeric() && c != '_' && c != ':')
                        .next()?;
                    if name.is_empty() || name.ends_with(':') {
                        return None;
                    }
                    return Some((name.to_string(), commas));
                }
                depth -= 1;
            }
            ',' if depth == 0 => commas += 1,
            _ => {}
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_find_call_context_simple() {
        let (name, param) = find_call_context("upper(").unwrap();
        assert_eq!(name, "upper");
        assert_eq!(param, 0);
    }

    #[test]
    fn test_find_call_context_namespaced() {
        let (name, param) = find_call_context("wdoc::icon(").unwrap();
        assert_eq!(name, "wdoc::icon");
        assert_eq!(param, 0);
    }

    #[test]
    fn test_find_call_context_second_param() {
        let (name, param) = find_call_context("replace(s, ").unwrap();
        assert_eq!(name, "replace");
        assert_eq!(param, 1);
    }

    #[test]
    fn test_signature_help_upper() {
        let help = signature_help("upper(", 6, None).unwrap();
        assert_eq!(help.signatures[0].label, "upper(s: string)");
        assert_eq!(help.active_parameter, Some(0));
    }

    #[test]
    fn test_signature_help_unknown_returns_none() {
        let result = signature_help("unknown_fn(", 11, None);
        assert!(result.is_none());
    }

    #[test]
    fn test_find_call_context_nested() {
        let (name, param) = find_call_context("join(split(x, \".\"), ").unwrap();
        assert_eq!(name, "join");
        assert_eq!(param, 1);
    }

    #[test]
    fn test_signature_help_builtin_with_analysis() {
        use crate::analysis::analyze;
        let source = "let x = upper(";
        let analysis = analyze(source, &wcl_lang::ParseOptions::default());
        let offset = source.len();
        let help = signature_help(source, offset, Some(&analysis)).unwrap();
        assert!(help.signatures[0].label.contains("upper"));
    }

    #[test]
    fn test_signature_help_with_macro() {
        use crate::analysis::analyze;
        // Macros are collected into macro_registry during analysis
        let source = "macro greet(name, greeting) { msg = greeting }\nlet x = greet(";
        let analysis = analyze(source, &wcl_lang::ParseOptions::default());
        let offset = source.len();
        let help = signature_help(source, offset, Some(&analysis));
        assert!(help.is_some(), "should find macro signature");
        let h = help.unwrap();
        assert!(h.signatures[0].label.contains("greet"));
        assert!(h.signatures[0].label.contains("name"));
        assert!(h.signatures[0].label.contains("greeting"));
    }

    #[test]
    fn test_signature_help_namespaced_custom_function() {
        use crate::analysis::analyze;
        use std::sync::Arc;

        let mut functions = wcl_lang::FunctionRegistry::new();
        let dummy: wcl_lang::BuiltinFn =
            Arc::new(|_: &[wcl_lang::Value]| Ok(wcl_lang::Value::Null));
        functions.register(
            "wdoc::icon",
            dummy,
            wcl_lang::FunctionSignature {
                name: "wdoc::icon".into(),
                params: vec!["name: string".into()],
                return_type: "string".into(),
                doc: "Render a named SVG icon".into(),
            },
        );
        let options = wcl_lang::ParseOptions {
            functions,
            ..Default::default()
        };
        let source = "let x = wdoc::icon(";
        let analysis = analyze(source, &options);
        let help = signature_help(source, source.len(), Some(&analysis)).unwrap();
        assert_eq!(help.signatures[0].label, "wdoc::icon(name: string)");
    }

    #[test]
    fn test_signature_help_use_alias_custom_function() {
        use crate::analysis::analyze;
        use std::sync::Arc;

        let mut functions = wcl_lang::FunctionRegistry::new();
        let dummy: wcl_lang::BuiltinFn =
            Arc::new(|_: &[wcl_lang::Value]| Ok(wcl_lang::Value::Null));
        functions.register(
            "wdoc::bold",
            dummy,
            wcl_lang::FunctionSignature {
                name: "wdoc::bold".into(),
                params: vec!["text: string".into()],
                return_type: "string".into(),
                doc: "Render bold text".into(),
            },
        );
        let options = wcl_lang::ParseOptions {
            functions,
            ..Default::default()
        };
        let source = "namespace wdoc { declare bold(text: string) -> string }\nuse wdoc::{bold}\nlet x = bold(";
        let analysis = analyze(source, &options);
        let help = signature_help(source, source.len(), Some(&analysis)).unwrap();
        assert_eq!(help.signatures[0].label, "wdoc::bold(text: string)");
    }
}