use harn_parser::format_type;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use crate::constants::{builtin_doc, builtin_signature, keyword_doc};
use crate::document_kind::DocumentKind;
use crate::helpers::{is_member_access, word_span_at_position};
use crate::source_text::SourceText;
use crate::symbols::{
format_flow_attributes_block, format_shape_expanded, format_union_shapes_expanded,
HarnSymbolKind, SymbolInfo,
};
use crate::HarnLsp;
#[derive(Debug, Clone)]
pub(crate) enum HoverTarget {
Builtin(String),
Keyword(String),
Symbol(Box<SymbolInfo>),
}
pub(crate) fn resolve_hover_target(
source: &SourceText,
symbols: &[SymbolInfo],
position: Position,
) -> Option<HoverTarget> {
let (word, word_start) = word_span_at_position(source, position)?;
let member_access = is_member_access(source, word_start);
if !member_access {
if let Some(doc) = builtin_doc(&word) {
return Some(HoverTarget::Builtin(doc));
}
if let Some(doc) = keyword_doc(&word) {
return Some(HoverTarget::Keyword(doc));
}
}
let cursor_offset = source.offset(position);
let mut best: Option<&SymbolInfo> = None;
for sym in symbols {
if sym.name != word {
continue;
}
if member_access && sym.impl_type.is_none() {
continue;
}
let in_scope = if sym.impl_type.is_some() {
true
} else {
match sym.scope_span {
Some(sp) => cursor_offset >= sp.start && cursor_offset <= sp.end,
None => true,
}
};
if !in_scope {
continue;
}
match best {
None => best = Some(sym),
Some(prev) => {
let prev_scope_size = match prev.scope_span {
Some(sp) => sp.end.saturating_sub(sp.start),
None => usize::MAX,
};
let this_scope_size = match sym.scope_span {
Some(sp) => sp.end.saturating_sub(sp.start),
None => usize::MAX,
};
if this_scope_size < prev_scope_size {
best = Some(sym);
}
}
}
}
best.map(|sym| HoverTarget::Symbol(Box::new(sym.clone())))
}
fn line_prefix_at_position(source: &SourceText, position: Position) -> Option<&str> {
let offset = source.offset(position);
if offset == source.len() && source.position(offset).line < position.line {
return None;
}
let line_start = source[..offset]
.rfind('\n')
.map(|idx| idx + '\n'.len_utf8())
.unwrap_or(0);
Some(&source[line_start..offset])
}
impl HarnLsp {
pub(super) async fn handle_hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let uri = ¶ms.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let docs = self.documents.lock().unwrap();
let state = match docs.get(uri) {
Some(s) => s,
None => return Ok(None),
};
let kind = state.kind;
let source = state.source.clone();
let symbols = state.symbols.clone();
drop(docs);
match kind {
DocumentKind::Harn => {}
DocumentKind::Prompt => return Ok(crate::prompt::hover(&source, position)),
DocumentKind::Other => return Ok(None),
}
let markup = |value: String| {
Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: None,
}))
};
let sym = match resolve_hover_target(&source, &symbols, position) {
None => return Ok(None),
Some(HoverTarget::Builtin(doc)) | Some(HoverTarget::Keyword(doc)) => {
return markup(doc)
}
Some(HoverTarget::Symbol(sym)) => sym,
};
{
let sym = sym.as_ref();
let mut hover_text = String::new();
if let Some(ref sig) = sym.signature {
let display_sig = if let Some(ref impl_ty) = sym.impl_type {
format!("impl {impl_ty}\n{sig}")
} else {
sig.clone()
};
hover_text.push_str(&format!("```harn\n{display_sig}\n```\n"));
} else {
let keyword = match sym.kind {
HarnSymbolKind::Variable => "let",
HarnSymbolKind::Parameter => "param",
_ => "",
};
if let Some(ref ty) = sym.type_info {
hover_text.push_str(&format!(
"```harn\n{keyword} {}: {}\n```\n",
sym.name,
format_type(ty)
));
} else {
let kind_str = match sym.kind {
HarnSymbolKind::Pipeline => "pipeline",
HarnSymbolKind::Function => "function",
HarnSymbolKind::Variable => "variable",
HarnSymbolKind::Parameter => "parameter",
HarnSymbolKind::Enum => "enum",
HarnSymbolKind::Struct => "struct",
HarnSymbolKind::Interface => "interface",
};
hover_text.push_str(&format!("**{kind_str}** `{}`", sym.name));
}
}
if sym.signature.is_none() {
if let Some(ref ty) = sym.type_info {
if matches!(ty, harn_parser::TypeExpr::Shape(_)) {
let expanded = format_shape_expanded(ty, 0);
if !expanded.is_empty() {
hover_text.push_str(&format!("\n{expanded}"));
}
} else if matches!(ty, harn_parser::TypeExpr::Union(_)) {
let expanded = format_union_shapes_expanded(ty);
if !expanded.is_empty() {
hover_text.push_str(&format!("\n{expanded}"));
}
}
}
}
if let Some(ref doc) = sym.doc_comment {
hover_text.push_str(&format!("\n---\n\n{doc}"));
}
let derived = sym.derived_example.as_deref();
if let Some(meta) = sym.stdlib_metadata.as_ref().filter(|m| !m.is_empty()) {
hover_text.push_str("\n\n---\n\n");
hover_text.push_str(&meta.to_markdown_with_derived_example(derived));
} else if let Some(derived) = derived {
hover_text.push_str(&format!(
"\n\n---\n\n**Example** _(derived from signature)_\n\n```harn\n{derived}\n```"
));
}
if let Some(block) = format_flow_attributes_block(&sym.attributes) {
hover_text.push_str(&block);
}
markup(hover_text)
}
}
pub(super) async fn handle_signature_help(
&self,
params: SignatureHelpParams,
) -> Result<Option<SignatureHelp>> {
let uri = ¶ms.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let source = {
let docs = self.documents.lock().unwrap();
match docs.get(uri) {
Some(s) if s.kind.is_harn() => s.source.clone(),
_ => return Ok(None),
}
};
let Some(prefix) = line_prefix_at_position(&source, position) else {
return Ok(None);
};
let mut depth = 0i32;
let mut comma_count = 0u32;
let mut open_paren_pos = None;
for (i, ch) in prefix.char_indices().rev() {
match ch {
')' => depth += 1,
'(' => {
if depth == 0 {
open_paren_pos = Some(i);
break;
}
depth -= 1;
}
',' if depth == 0 => comma_count += 1,
_ => {}
}
}
let paren_pos = match open_paren_pos {
Some(p) => p,
None => return Ok(None),
};
let before = &prefix[..paren_pos];
let name: String = before
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if name.is_empty() {
return Ok(None);
}
let sig_str = match builtin_signature(&name) {
Some(sig) => sig,
None => return Ok(None),
};
let params_str = sig_str
.split('(')
.nth(1)
.and_then(|s| s.split(')').next())
.unwrap_or("");
let params_list: Vec<ParameterInformation> = if params_str.is_empty() {
vec![]
} else {
params_str
.split(',')
.map(|p| ParameterInformation {
label: ParameterLabel::Simple(p.trim().to_string()),
documentation: None,
})
.collect()
};
Ok(Some(SignatureHelp {
signatures: vec![SignatureInformation {
label: sig_str.to_string(),
documentation: builtin_doc(&name).map(|d| {
Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: d,
})
}),
parameters: Some(params_list.clone()),
active_parameter: Some(if params_list.is_empty() {
0
} else {
comma_count.min(params_list.len() as u32 - 1)
}),
}],
active_signature: Some(0),
active_parameter: Some(if params_list.is_empty() {
0
} else {
comma_count.min(params_list.len() as u32 - 1)
}),
}))
}
pub(super) async fn handle_inlay_hint(
&self,
params: InlayHintParams,
) -> Result<Option<Vec<InlayHint>>> {
let uri = params.text_document.uri;
let docs = self.documents.lock().unwrap();
let Some(state) = docs.get(&uri) else {
return Ok(None);
};
let range = params.range;
let hints: Vec<InlayHint> = state
.inlay_hints
.iter()
.filter(|h| {
let line = h.line.saturating_sub(1) as u32;
line >= range.start.line && line <= range.end.line
})
.map(|h| InlayHint {
position: Position::new(
h.line.saturating_sub(1) as u32,
h.column.saturating_sub(1) as u32,
),
label: InlayHintLabel::String(h.label.clone()),
kind: Some(InlayHintKind::TYPE),
text_edits: None,
tooltip: None,
padding_left: None,
padding_right: None,
data: None,
})
.collect();
Ok(if hints.is_empty() { None } else { Some(hints) })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signature_prefix_uses_utf16_position_without_slicing_mid_char() {
let source = SourceText::new("éé(log(");
let prefix = line_prefix_at_position(&source, Position::new(0, 7)).unwrap();
assert_eq!(prefix, source.as_str());
}
#[test]
fn signature_prefix_rejects_out_of_range_line() {
assert!(line_prefix_at_position(&SourceText::new("log("), Position::new(4, 0)).is_none());
}
}