use harn_parser::{format_type, ShapeField, TypeExpr};
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use crate::constants::{
builtin_details, builtin_doc, keyword_doc, DICT_METHODS, KEYWORDS, LIST_METHODS, STRING_METHODS,
};
use crate::document_kind::DocumentKind;
use crate::helpers::{
char_before_position, infer_dot_receiver_name, infer_dot_receiver_type, position_in_span,
};
use crate::source_text::SourceText;
use crate::symbols::{EnumVariantInfo, HarnSymbolKind, SymbolInfo};
use crate::HarnLsp;
impl HarnLsp {
pub(super) async fn handle_completion(
&self,
params: CompletionParams,
) -> Result<Option<CompletionResponse>> {
let uri = ¶ms.text_document_position.text_document.uri;
let position = params.text_document_position.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();
let ast = state.cached_ast.clone();
drop(docs);
match kind {
DocumentKind::Harn => {}
DocumentKind::Prompt => {
return Ok(Some(CompletionResponse::Array(crate::prompt::completions(
&source, position,
))));
}
DocumentKind::Other => return Ok(None),
}
let mut items = Vec::new();
if char_before_position(&source, position) == Some('.') {
let mut items = namespace_dot_completion_items(uri, &source, position);
if items.is_empty() {
items = dot_completion_items(&source, position, &symbols);
}
return Ok(Some(CompletionResponse::Array(items)));
}
if let Some(ast) = ast.as_ref() {
if let Some(discriminator_items) =
discriminator_value_completions(ast, &source, position, &symbols)
{
if !discriminator_items.is_empty() {
return Ok(Some(CompletionResponse::Array(discriminator_items)));
}
}
}
for sym in &symbols {
let visible = match sym.scope_span {
None => true,
Some(ref scope) => position_in_span(&position, scope, &source),
};
if !visible {
continue;
}
let (kind, detail) = match sym.kind {
HarnSymbolKind::Pipeline => (CompletionItemKind::FUNCTION, "pipeline"),
HarnSymbolKind::Function => (CompletionItemKind::FUNCTION, "function"),
HarnSymbolKind::Variable => (CompletionItemKind::VARIABLE, "variable"),
HarnSymbolKind::Parameter => (CompletionItemKind::VARIABLE, "parameter"),
HarnSymbolKind::Enum => (CompletionItemKind::ENUM, "enum"),
HarnSymbolKind::Struct => (CompletionItemKind::STRUCT, "struct"),
HarnSymbolKind::Interface => (CompletionItemKind::INTERFACE, "interface"),
};
items.push(CompletionItem {
label: sym.name.clone(),
kind: Some(kind),
detail: Some(sym.signature.as_deref().unwrap_or(detail).to_string()),
..Default::default()
});
}
for detail in builtin_details() {
items.push(CompletionItem {
label: detail.name.clone(),
kind: Some(CompletionItemKind::FUNCTION),
detail: Some(detail.signature.clone()),
data: Some(resolve_tag(ResolveKind::Builtin)),
..Default::default()
});
}
for kw in KEYWORDS {
items.push(CompletionItem {
label: kw.to_string(),
kind: Some(CompletionItemKind::KEYWORD),
data: Some(resolve_tag(ResolveKind::Keyword)),
..Default::default()
});
}
Ok(Some(CompletionResponse::Array(items)))
}
pub(super) async fn handle_completion_resolve(
&self,
item: CompletionItem,
) -> Result<CompletionItem> {
Ok(resolve_completion_documentation(item))
}
}
pub(super) fn resolve_completion_documentation(mut item: CompletionItem) -> CompletionItem {
if item.documentation.is_some() {
return item;
}
let doc = match resolve_kind_of(&item) {
Some(ResolveKind::Builtin) => builtin_doc(&item.label),
Some(ResolveKind::Keyword) => keyword_doc(&item.label),
None => None,
};
if let Some(doc) = doc {
item.documentation = Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: doc,
}));
}
item
}
#[derive(Clone, Copy)]
enum ResolveKind {
Builtin,
Keyword,
}
impl ResolveKind {
fn as_str(self) -> &'static str {
match self {
ResolveKind::Builtin => "builtin",
ResolveKind::Keyword => "keyword",
}
}
}
fn resolve_tag(kind: ResolveKind) -> serde_json::Value {
serde_json::json!({ "harn_resolve": kind.as_str() })
}
fn resolve_kind_of(item: &CompletionItem) -> Option<ResolveKind> {
match item
.data
.as_ref()
.and_then(|data| data.get("harn_resolve"))
.and_then(|value| value.as_str())?
{
"builtin" => Some(ResolveKind::Builtin),
"keyword" => Some(ResolveKind::Keyword),
_ => None,
}
}
fn namespace_dot_completion_items(
uri: &Url,
source: &SourceText,
position: Position,
) -> Vec<CompletionItem> {
let Some(alias) = infer_dot_receiver_name(source, position) else {
return Vec::new();
};
let Ok(current_path) = uri.to_file_path() else {
return Vec::new();
};
let module_graph = harn_modules::build(std::slice::from_ref(¤t_path));
let Some(namespaces) = module_graph.namespace_imports_for_file(¤t_path) else {
return Vec::new();
};
let Some(ns) = namespaces.iter().find(|ns| ns.alias == alias) else {
return Vec::new();
};
let mut items: Vec<CompletionItem> = ns
.member_names
.iter()
.map(|name| CompletionItem {
label: name.clone(),
kind: Some(CompletionItemKind::FIELD),
detail: Some(format!("export from {}", ns.raw_path)),
..Default::default()
})
.collect();
items.sort_by(|a, b| a.label.cmp(&b.label));
items
}
pub(super) fn dot_completion_items(
source: &SourceText,
position: Position,
symbols: &[SymbolInfo],
) -> Vec<CompletionItem> {
let mut items = Vec::new();
let receiver_type = infer_dot_receiver_type(source, position, symbols);
let receiver_name = infer_dot_receiver_name(source, position);
if let Some(receiver_type) = receiver_type.as_ref() {
match receiver_type {
TypeExpr::Shape(fields) => {
push_field_items(&mut items, fields);
}
TypeExpr::Named(name) if name == "string" => {
push_method_items(&mut items, STRING_METHODS);
}
TypeExpr::Named(name) if name == "list" => {
push_method_items(&mut items, LIST_METHODS);
}
TypeExpr::Named(name) if name == "dict" => {
push_method_items(&mut items, DICT_METHODS);
}
TypeExpr::Named(name) => {
if let Some(fields) = struct_fields(symbols, name) {
push_field_items(&mut items, &fields);
push_impl_method_items(&mut items, symbols, name);
} else if let Some(variants) = enum_variants(symbols, name) {
if receiver_name.as_deref() == Some(name) {
push_enum_variant_items(&mut items, &variants);
} else {
items.push(CompletionItem {
label: "variant".to_string(),
kind: Some(CompletionItemKind::FIELD),
detail: Some("string".to_string()),
..Default::default()
});
items.push(CompletionItem {
label: "fields".to_string(),
kind: Some(CompletionItemKind::FIELD),
detail: Some("list<any>".to_string()),
..Default::default()
});
}
}
}
_ => {}
}
}
if items.is_empty() {
push_method_items(&mut items, STRING_METHODS);
push_method_items(&mut items, LIST_METHODS);
push_method_items(&mut items, DICT_METHODS);
}
items.sort_by(|a, b| a.label.cmp(&b.label));
items.dedup_by(|a, b| a.label == b.label && a.kind == b.kind);
items
}
fn push_method_items(items: &mut Vec<CompletionItem>, methods: &[&str]) {
for method in methods {
items.push(CompletionItem {
label: method.to_string(),
kind: Some(CompletionItemKind::METHOD),
..Default::default()
});
}
}
fn push_field_items(items: &mut Vec<CompletionItem>, fields: &[ShapeField]) {
for field in fields {
items.push(CompletionItem {
label: field.name.clone(),
kind: Some(CompletionItemKind::FIELD),
detail: Some(format_type(&field.type_expr)),
..Default::default()
});
}
}
fn push_enum_variant_items(items: &mut Vec<CompletionItem>, variants: &[EnumVariantInfo]) {
for variant in variants {
let detail = if variant.fields.is_empty() {
"enum variant".to_string()
} else {
let fields = variant
.fields
.iter()
.map(|field| format!("{}: {}", field.name, format_type(&field.type_expr)))
.collect::<Vec<_>>()
.join(", ");
format!("enum variant ({fields})")
};
items.push(CompletionItem {
label: variant.name.clone(),
kind: Some(CompletionItemKind::ENUM_MEMBER),
detail: Some(detail),
..Default::default()
});
}
}
fn push_impl_method_items(
items: &mut Vec<CompletionItem>,
symbols: &[SymbolInfo],
type_name: &str,
) {
for sym in symbols.iter().filter(|sym| {
sym.kind == HarnSymbolKind::Function && sym.impl_type.as_deref() == Some(type_name)
}) {
items.push(CompletionItem {
label: sym.name.clone(),
kind: Some(CompletionItemKind::METHOD),
detail: sym.signature.clone(),
..Default::default()
});
}
}
fn struct_fields(symbols: &[SymbolInfo], type_name: &str) -> Option<Vec<ShapeField>> {
symbols
.iter()
.find(|sym| {
sym.kind == HarnSymbolKind::Struct && sym.name == type_name && !sym.fields.is_empty()
})
.map(|sym| sym.fields.clone())
}
fn enum_variants(symbols: &[SymbolInfo], type_name: &str) -> Option<Vec<EnumVariantInfo>> {
symbols
.iter()
.find(|sym| {
sym.kind == HarnSymbolKind::Enum
&& sym.name == type_name
&& !sym.enum_variants.is_empty()
})
.map(|sym| sym.enum_variants.clone())
}
pub(super) fn discriminator_value_completions(
ast: &[harn_parser::SNode],
source: &SourceText,
position: Position,
symbols: &[SymbolInfo],
) -> Option<Vec<CompletionItem>> {
let cursor_offset = source.offset(position);
let match_node = find_innermost_match_at(ast, cursor_offset)?;
let harn_parser::Node::MatchExpr { value, arms } = &match_node.node else {
return None;
};
if cursor_offset <= value.span.end {
return None;
}
for arm in arms {
let body_span_start = arm.pattern.span.end;
let body_span_end = arm
.body
.last()
.map(|b| b.span.end)
.unwrap_or(arm.pattern.span.end);
if cursor_offset > body_span_start && cursor_offset < body_span_end {
return None;
}
}
let harn_parser::Node::PropertyAccess { object, property } = &value.node else {
return Some(Vec::new());
};
let harn_parser::Node::Identifier(obj_name) = &object.node else {
return Some(Vec::new());
};
let sym = symbols.iter().find(|s| s.name == *obj_name)?;
let ty = sym.type_info.as_ref()?;
let resolved = resolve_type_alias_from_ast(ty, ast);
let TypeExpr::Union(members) = resolved else {
return Some(Vec::new());
};
let mut already_covered: Vec<String> = Vec::new();
for arm in arms {
collect_literal_alternatives(&arm.pattern, &mut already_covered);
}
let mut seen: Vec<String> = Vec::new();
let mut out = Vec::new();
for member in members {
let TypeExpr::Shape(fields) = member else {
continue;
};
let Some(field) = fields.iter().find(|f| f.name == *property) else {
continue;
};
let (label, insert) = match &field.type_expr {
TypeExpr::LitString(s) => (format!("\"{s}\""), format!("\"{s}\"")),
TypeExpr::LitInt(v) => (v.to_string(), v.to_string()),
_ => continue,
};
if seen.contains(&label) || already_covered.contains(&label) {
continue;
}
seen.push(label.clone());
out.push(CompletionItem {
label,
kind: Some(CompletionItemKind::ENUM_MEMBER),
detail: Some(format!("{obj_name}.{property} variant")),
insert_text: Some(insert),
..Default::default()
});
}
Some(out)
}
fn resolve_type_alias_from_ast(ty: &TypeExpr, ast: &[harn_parser::SNode]) -> TypeExpr {
let mut current = ty.clone();
let mut seen: Vec<String> = Vec::new();
loop {
let TypeExpr::Named(name) = ¤t else {
return current;
};
if seen.contains(name) {
return current;
}
seen.push(name.clone());
let Some(body) = find_type_alias_body(ast, name) else {
return current;
};
current = body;
}
}
fn find_type_alias_body(ast: &[harn_parser::SNode], name: &str) -> Option<TypeExpr> {
let mut found: Option<TypeExpr> = None;
visit_nodes(ast, &mut |node| {
if found.is_some() {
return;
}
if let harn_parser::Node::TypeDecl {
name: n, type_expr, ..
} = &node.node
{
if n == name {
found = Some(type_expr.clone());
}
}
});
found
}
fn collect_literal_alternatives(pattern: &harn_parser::SNode, out: &mut Vec<String>) {
match &pattern.node {
harn_parser::Node::StringLiteral(s) => out.push(format!("\"{s}\"")),
harn_parser::Node::IntLiteral(v) => out.push(v.to_string()),
harn_parser::Node::OrPattern(alts) => {
for alt in alts {
collect_literal_alternatives(alt, out);
}
}
_ => {}
}
}
fn find_innermost_match_at(
ast: &[harn_parser::SNode],
offset: usize,
) -> Option<&harn_parser::SNode> {
let mut best: Option<&harn_parser::SNode> = None;
visit_nodes(ast, &mut |node| {
if !matches!(node.node, harn_parser::Node::MatchExpr { .. }) {
return;
}
let span = node.span;
if offset < span.start || offset > span.end {
return;
}
if let Some(current) = best {
let current_len = current.span.end - current.span.start;
let node_len = span.end - span.start;
if node_len < current_len {
best = Some(node);
}
} else {
best = Some(node);
}
});
best
}
fn visit_nodes<'a, F>(nodes: &'a [harn_parser::SNode], visitor: &mut F)
where
F: FnMut(&'a harn_parser::SNode),
{
for node in nodes {
visit_node(node, visitor);
}
}
fn visit_node<'a, F>(node: &'a harn_parser::SNode, visitor: &mut F)
where
F: FnMut(&'a harn_parser::SNode),
{
visitor(node);
for child in harn_parser::visit::immediate_children(node) {
visit_node(child, visitor);
}
}
#[cfg(test)]
mod tests {
use super::{
discriminator_value_completions, dot_completion_items, resolve_completion_documentation,
resolve_tag, ResolveKind,
};
use crate::document::DocumentState;
use tower_lsp::lsp_types::{CompletionItem, Position};
fn completion_items_at(source: &str, marker: &str) -> Vec<(String, Option<String>)> {
let state = DocumentState::new(source.to_string());
let mut location = None;
for (line_index, line) in source.lines().enumerate() {
if let Some(column) = line.find(marker) {
location = Some(Position::new(
line_index as u32,
(column + marker.len()) as u32,
));
break;
}
}
let position = location.expect("marker should exist in source");
dot_completion_items(&state.source, position, &state.symbols)
.into_iter()
.map(|item| (item.label, item.detail))
.collect()
}
#[test]
fn dot_completion_prefers_shape_fields() {
let items = completion_items_at(
r#"pipeline test() {
const data = {name: "Ada", count: 3}
data.name
}"#,
"data.",
);
assert!(
items
.iter()
.any(|(label, detail)| { label == "name" && detail.as_deref() == Some("string") }),
"items: {items:?}"
);
assert!(
items
.iter()
.any(|(label, detail)| { label == "count" && detail.as_deref() == Some("int") }),
"items: {items:?}"
);
assert!(
!items.iter().any(|(label, _)| label == "merging"),
"items: {items:?}"
);
}
#[test]
fn dot_completion_includes_struct_fields_and_methods() {
let items = completion_items_at(
r#"pipeline test() {
struct Person { name: string, age: int }
impl Person {
fn greet(self) -> string { return self.name }
}
const person = Person({name: "Ada", age: 3})
person.name
}"#,
"person.",
);
assert!(
items
.iter()
.any(|(label, detail)| { label == "name" && detail.as_deref() == Some("string") }),
"items: {items:?}"
);
assert!(
items.iter().any(|(label, detail)| {
label == "greet"
&& detail
.as_deref()
.is_some_and(|detail| detail.contains("fn greet"))
}),
"items: {items:?}"
);
}
#[test]
fn dot_completion_includes_enum_variants_with_field_details() {
let items = completion_items_at(
r"pipeline test() {
enum Event {
Click(x: int, y: int),
Quit,
}
Event.Click
}",
"Event.",
);
assert!(
items.iter().any(|(label, detail)| {
label == "Click"
&& detail.as_deref().is_some_and(|detail| {
detail.contains("x: int") && detail.contains("y: int")
})
}),
"items: {items:?}"
);
assert!(
items.iter().any(|(label, detail)| {
label == "Quit" && detail.as_deref() == Some("enum variant")
}),
"items: {items:?}"
);
}
fn discriminator_items_at(source: &str, marker: &str) -> Vec<String> {
let state = DocumentState::new(source.to_string());
let mut location = None;
for (line_index, line) in source.lines().enumerate() {
if let Some(column) = line.find(marker) {
location = Some(Position::new(
line_index as u32,
(column + marker.len()) as u32,
));
break;
}
}
let position = location.expect("marker should exist in source");
let ast = state
.cached_ast
.as_ref()
.expect("ast should parse — check the test fixture for syntax issues");
discriminator_value_completions(ast, &state.source, position, &state.symbols)
.unwrap_or_default()
.into_iter()
.map(|item| item.label)
.collect()
}
#[test]
fn resolve_attaches_builtin_documentation_lazily() {
use crate::constants::{builtin_details, builtin_doc};
let documented = builtin_details()
.iter()
.find(|detail| builtin_doc(&detail.name).is_some())
.expect("at least one builtin should have documentation");
let item = CompletionItem {
label: documented.name.clone(),
data: Some(resolve_tag(ResolveKind::Builtin)),
..Default::default()
};
assert!(item.documentation.is_none(), "docs must start unresolved");
let resolved = resolve_completion_documentation(item);
assert!(
resolved.documentation.is_some(),
"expected resolved docs for builtin `{}`",
documented.name
);
}
#[test]
fn resolve_attaches_keyword_documentation_lazily() {
use crate::constants::{keyword_doc, KEYWORDS};
let documented = KEYWORDS
.iter()
.find(|kw| keyword_doc(kw).is_some())
.expect("at least one keyword should have documentation");
let item = CompletionItem {
label: (*documented).to_string(),
data: Some(resolve_tag(ResolveKind::Keyword)),
..Default::default()
};
let resolved = resolve_completion_documentation(item);
assert!(
resolved.documentation.is_some(),
"expected resolved docs for keyword `{documented}`"
);
}
#[test]
fn resolve_leaves_untagged_items_unchanged() {
let item = CompletionItem {
label: "my_local".to_string(),
detail: Some("variable".to_string()),
..Default::default()
};
let resolved = resolve_completion_documentation(item);
assert!(resolved.documentation.is_none());
assert_eq!(resolved.detail.as_deref(), Some("variable"));
}
#[test]
fn discriminator_completion_suggests_tagged_shape_union_literals() {
let items = discriminator_items_at(
r#"type Msg = {kind: "ping", ttl: int} | {kind: "pong", latency_ms: int}
fn handle(m: Msg) -> string {
return match m.kind {
MARK_ -> { "todo" }
}
}
pipeline default() { }"#,
"MARK",
);
assert!(
items.iter().any(|l| l == "\"ping\""),
"expected \"ping\" completion, got: {items:?}"
);
assert!(
items.iter().any(|l| l == "\"pong\""),
"expected \"pong\" completion, got: {items:?}"
);
}
#[test]
fn discriminator_completion_excludes_already_covered_arms() {
let items = discriminator_items_at(
r#"type Msg = {kind: "ping", ttl: int} | {kind: "pong", latency_ms: int}
fn handle(m: Msg) -> string {
return match m.kind {
"ping" -> { "p" }
MARK_ -> { "todo" }
}
}
pipeline default() { }"#,
"MARK",
);
assert!(
!items.iter().any(|l| l == "\"ping\""),
"expected \"ping\" filtered out after explicit arm, got: {items:?}"
);
assert!(
items.iter().any(|l| l == "\"pong\""),
"expected \"pong\" still offered, got: {items:?}"
);
}
}