use std::collections::HashMap;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use crate::helpers::{
extract_backtick_name, find_word_in_region, lsp_position_to_offset, offset_to_position,
span_to_range,
};
use crate::HarnLsp;
impl HarnLsp {
pub(super) async fn handle_formatting(
&self,
params: DocumentFormattingParams,
) -> Result<Option<Vec<TextEdit>>> {
let uri = ¶ms.text_document.uri;
let source = {
let docs = self.documents.lock().unwrap();
match docs.get(uri) {
Some(s) => s.source.clone(),
None => return Ok(None),
}
};
Ok(format_whole_document_edit(&source).map(|edit| vec![edit]))
}
pub(super) async fn handle_on_type_formatting(
&self,
params: DocumentOnTypeFormattingParams,
) -> Result<Option<Vec<TextEdit>>> {
if params.ch != ";" && params.ch != "}" {
return Ok(None);
}
let uri = ¶ms.text_document_position.text_document.uri;
let source = {
let docs = self.documents.lock().unwrap();
match docs.get(uri) {
Some(s) => s.source.clone(),
None => return Ok(None),
}
};
Ok(format_whole_document_edit(&source).map(|edit| vec![edit]))
}
pub(super) async fn handle_code_action(
&self,
params: CodeActionParams,
) -> Result<Option<CodeActionResponse>> {
let uri = ¶ms.text_document.uri;
let mut actions = Vec::new();
let (source, lint_diags, type_diags) = {
let docs = self.documents.lock().unwrap();
let state = match docs.get(uri) {
Some(s) => s,
None => return Ok(Some(actions)),
};
(
state.source.clone(),
state.lint_diagnostics.clone(),
state.type_diagnostics.clone(),
)
};
for diag in ¶ms.context.diagnostics {
let msg = &diag.message;
if let Some(ld) = lint_diags.iter().find(|ld| {
msg.contains(&format!("[{}]", ld.rule)) && span_to_range(&ld.span) == diag.range
}) {
if let Some(ref fix_edits) = ld.fix {
let text_edits: Vec<TextEdit> = fix_edits
.iter()
.map(|fe| TextEdit {
range: Range {
start: offset_to_position(&source, fe.span.start),
end: offset_to_position(&source, fe.span.end),
},
new_text: fe.replacement.clone(),
})
.collect();
let title = match ld.rule {
"mutable-never-reassigned" => "Change `var` to `let`".to_string(),
"comparison-to-bool" => "Simplify boolean comparison".to_string(),
"unnecessary-else-return" => "Remove unnecessary else".to_string(),
"unused-import" => {
let name =
extract_backtick_name(msg).unwrap_or_else(|| "name".to_string());
format!("Remove unused import `{name}`")
}
"invalid-binary-op-literal" => {
"Convert to string interpolation".to_string()
}
"unnecessary-cast" => "Remove unnecessary cast".to_string(),
_ => ld
.suggestion
.clone()
.unwrap_or_else(|| "Apply fix".to_string()),
};
let mut changes = HashMap::new();
changes.insert(uri.clone(), text_edits);
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}));
continue;
}
}
if diag.source.as_deref() == Some("harn-typecheck") {
if let Some(td) = type_diags.iter().find(|td| {
td.message == *msg && td.span.as_ref().map(span_to_range) == Some(diag.range)
}) {
if let Some(ref fix_edits) = td.fix {
let text_edits: Vec<TextEdit> = fix_edits
.iter()
.map(|fe| TextEdit {
range: Range {
start: offset_to_position(&source, fe.span.start),
end: offset_to_position(&source, fe.span.end),
},
new_text: fe.replacement.clone(),
})
.collect();
let mut changes = HashMap::new();
changes.insert(uri.clone(), text_edits);
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Convert to string interpolation".to_string(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}));
continue;
}
if let (
Some(harn_parser::DiagnosticDetails::NonExhaustiveMatch { missing }),
Some(span),
) = (td.details.as_ref(), td.span.as_ref())
{
if let Some(edit) = build_missing_arms_edit(&source, span, missing) {
let mut changes = HashMap::new();
changes.insert(uri.clone(), vec![edit]);
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: if missing.len() == 1 {
format!("Add missing match arm {}", missing[0])
} else {
format!("Add missing match arms ({})", missing.len())
},
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
is_preferred: Some(true),
..Default::default()
}));
continue;
}
}
}
}
if msg.contains("[unused-variable]") || msg.contains("[unused-parameter]") {
if let Some(name) = extract_backtick_name(msg) {
let offset = lsp_position_to_offset(&source, diag.range.start);
let end_offset = lsp_position_to_offset(&source, diag.range.end)
.max(offset + 1)
.min(source.len());
let search_region = &source[offset..end_offset];
if let Some(name_pos) = find_word_in_region(search_region, &name) {
let abs_pos = offset + name_pos;
let start = offset_to_position(&source, abs_pos);
let end = offset_to_position(&source, abs_pos + name.len());
let edit_range = Range { start, end };
let mut changes = HashMap::new();
changes.insert(
uri.clone(),
vec![TextEdit {
range: edit_range,
new_text: format!("_{name}"),
}],
);
let label = if msg.contains("[unused-variable]") {
"variable"
} else {
"parameter"
};
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Prefix {label} `{name}` with `_`"),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}));
}
}
}
}
if fix_all_requested(params.context.only.as_deref()) {
let mut all_edits: Vec<harn_lexer::FixEdit> = Vec::new();
for ld in &lint_diags {
if let Some(fix) = &ld.fix {
all_edits.extend(fix.iter().cloned());
}
}
for td in &type_diags {
if let Some(fix) = &td.fix {
all_edits.extend(fix.iter().cloned());
}
}
all_edits.sort_by_key(|e| std::cmp::Reverse(e.span.start));
let mut accepted: Vec<harn_lexer::FixEdit> = Vec::new();
for edit in all_edits {
let overlaps = accepted
.iter()
.any(|prev| edit.span.start < prev.span.end && edit.span.end > prev.span.start);
if !overlaps {
accepted.push(edit);
}
}
if !accepted.is_empty() {
let text_edits: Vec<TextEdit> = accepted
.iter()
.map(|fe| TextEdit {
range: Range {
start: offset_to_position(&source, fe.span.start),
end: offset_to_position(&source, fe.span.end),
},
new_text: fe.replacement.clone(),
})
.collect();
let mut changes = HashMap::new();
changes.insert(uri.clone(), text_edits);
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Apply all Harn autofixes".to_string(),
kind: Some(CodeActionKind::new("source.fixAll.harn")),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}));
}
}
Ok(Some(actions))
}
}
fn format_whole_document_edit(source: &str) -> Option<TextEdit> {
let formatted = harn_fmt::format_source(source).ok()?;
if formatted == source {
return None;
}
let line_count = source.lines().count() as u32;
let last_line_len = source.lines().last().map_or(0, |l| l.len()) as u32;
Some(TextEdit {
range: Range {
start: Position::new(0, 0),
end: Position::new(line_count, last_line_len),
},
new_text: formatted,
})
}
fn fix_all_requested(only: Option<&[CodeActionKind]>) -> bool {
let Some(kinds) = only else {
return false;
};
kinds.iter().any(|k| {
let s = k.as_str();
s == "source.fixAll" || s == "source.fixAll.harn" || s == "source"
})
}
pub(super) fn build_missing_arms_edit(
source: &str,
match_span: &harn_lexer::Span,
missing: &[String],
) -> Option<TextEdit> {
if missing.is_empty() {
return None;
}
let close_brace_byte = match_span.end.checked_sub(1)?;
let bytes = source.as_bytes();
if close_brace_byte >= bytes.len() || bytes[close_brace_byte] != b'}' {
return None;
}
let line_start = source[..close_brace_byte]
.rfind('\n')
.map(|n| n + 1)
.unwrap_or(0);
let indent_slice = &source[line_start..close_brace_byte];
let brace_indent: String = indent_slice
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
let arm_indent = format!("{brace_indent} ");
let mut inserted = String::new();
for pattern in missing {
inserted.push('\n');
inserted.push_str(&arm_indent);
inserted.push_str(pattern);
inserted.push_str(" -> { unreachable(\"TODO: handle ");
inserted.push_str(pattern);
inserted.push_str("\") }");
}
inserted.push('\n');
inserted.push_str(&brace_indent);
let brace_pos = offset_to_position(source, close_brace_byte);
Some(TextEdit {
range: Range {
start: brace_pos,
end: brace_pos,
},
new_text: inserted,
})
}
#[cfg(test)]
mod tests {
use super::{build_missing_arms_edit, format_whole_document_edit};
use harn_lexer::Span;
#[test]
fn missing_arms_edit_inserts_each_variant_before_close_brace() {
let source = "pipeline default() {\n match v {\n \"pass\" -> { }\n }\n}\n";
let start = source.find("match").unwrap();
let end = source[start..].find('\n').unwrap();
let match_block_start = start;
let match_block_end_brace = source
.match_indices('\n')
.filter(|(idx, _)| *idx > start)
.nth(2)
.map(|(idx, _)| idx)
.unwrap();
let close_brace_pos = source[match_block_start..match_block_end_brace]
.rfind('}')
.map(|r| match_block_start + r)
.unwrap();
let span = Span {
start: match_block_start,
end: close_brace_pos + 1,
line: 2,
column: 3,
end_line: 4,
};
let missing = vec!["\"fail\"".to_string(), "\"skip\"".to_string()];
let _ = end;
let edit = build_missing_arms_edit(source, &span, &missing)
.expect("expected edit for well-formed match");
assert!(edit.new_text.contains("\"fail\" -> "), "{:?}", edit);
assert!(edit.new_text.contains("\"skip\" -> "), "{:?}", edit);
assert!(
edit.new_text.contains("unreachable"),
"edit should scaffold with unreachable: {:?}",
edit
);
assert!(
edit.new_text.contains("\n \"fail\""),
"expected 4-space arm indent, got: {:?}",
edit.new_text
);
}
#[test]
fn missing_arms_edit_returns_none_when_close_brace_missing() {
let source = "not a match expression";
let span = Span {
start: 0,
end: source.len(),
line: 1,
column: 1,
end_line: 1,
};
let edit = build_missing_arms_edit(source, &span, &["\"x\"".to_string()]);
assert!(edit.is_none());
}
#[test]
fn whole_document_format_edit_reuses_formatter_for_on_type_formatting() {
let source = "fn main(){\nlet x=1;\n}\n";
let edit = format_whole_document_edit(source).expect("expected formatting edit");
assert!(edit.new_text.contains("fn main() {"), "{}", edit.new_text);
assert!(edit.new_text.contains("let x = 1"), "{}", edit.new_text);
}
}