use bynk_syntax::error::Applicability;
use bynk_syntax::span::Span;
use tower_lsp::lsp_types::*;
pub fn quick_fixes(
text: &str,
diagnostics: &[bynk_ide::Diagnostic],
requested: Span,
uri: &Url,
version: Option<i32>,
) -> Vec<CodeActionOrCommand> {
let mut out = Vec::new();
for d in diagnostics {
if !intersects(d.error.span, requested) {
continue;
}
for s in &d.error.suggestions {
if s.applicability != Applicability::MachineApplicable {
continue;
}
let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = s
.edits
.iter()
.map(|(span, replacement)| {
OneOf::Left(TextEdit {
range: crate::position::span_to_range(text, *span),
new_text: replacement.clone(),
})
})
.collect();
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: s.message.clone(),
kind: Some(CodeActionKind::QUICKFIX),
edit: Some(WorkspaceEdit {
changes: None,
document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
text_document: OptionalVersionedTextDocumentIdentifier {
uri: uri.clone(),
version,
},
edits,
}])),
change_annotations: None,
}),
..Default::default()
}));
}
}
out
}
fn intersects(a: Span, b: Span) -> bool {
a.start <= b.end && b.start <= a.end
}
pub fn filter_by_only(
actions: Vec<CodeActionOrCommand>,
only: Option<&[CodeActionKind]>,
) -> Vec<CodeActionOrCommand> {
let Some(only) = only else {
return actions;
};
actions
.into_iter()
.filter(|action| {
let CodeActionOrCommand::CodeAction(action) = action else {
return false;
};
action
.kind
.as_ref()
.is_some_and(|kind| only.iter().any(|requested| kind_matches(kind, requested)))
})
.collect()
}
fn kind_matches(kind: &CodeActionKind, requested: &CodeActionKind) -> bool {
let (kind, requested) = (kind.as_str(), requested.as_str());
kind == requested
|| kind
.strip_prefix(requested)
.is_some_and(|rest| rest.starts_with('.'))
}
#[cfg(test)]
mod tests {
use super::*;
use bynk_syntax::error::CompileError;
fn diag_with_suggestion() -> bynk_ide::Diagnostic {
bynk_ide::Diagnostic {
severity: bynk_syntax::Severity::Error,
error: CompileError::new(
"bynk.given.undeclared_capability",
Span::new(17, 21),
"capability `Used` is used but not listed",
)
.with_suggestion(
"add `Used` to the `given` clause",
vec![(Span::new(14, 14), ", Used".to_string())],
Applicability::MachineApplicable,
),
}
}
#[test]
fn keyed_on_the_diagnostic_span_not_the_edit_span() {
let text = "-> T given Cap { Used.op() }";
let uri = Url::parse("file:///a.bynk").unwrap();
let on_diag = quick_fixes(
text,
&[diag_with_suggestion()],
Span::new(18, 18),
&uri,
Some(7),
);
assert_eq!(on_diag.len(), 1);
let on_edit = quick_fixes(
text,
&[diag_with_suggestion()],
Span::new(14, 14),
&uri,
Some(7),
);
assert!(on_edit.is_empty());
}
#[test]
fn action_carries_a_versioned_quickfix_edit() {
let text = "-> T given Cap { Used.op() }";
let uri = Url::parse("file:///a.bynk").unwrap();
let actions = quick_fixes(
text,
&[diag_with_suggestion()],
Span::new(17, 21),
&uri,
Some(7),
);
let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
panic!("expected a CodeAction");
};
assert_eq!(action.title, "add `Used` to the `given` clause");
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
let Some(DocumentChanges::Edits(doc_edits)) =
&action.edit.as_ref().unwrap().document_changes
else {
panic!("expected versioned document edits");
};
assert_eq!(doc_edits[0].text_document.version, Some(7));
assert_eq!(doc_edits[0].text_document.uri, uri);
let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
panic!("expected a plain TextEdit");
};
assert_eq!(edit.new_text, ", Used");
assert_eq!(edit.range.start, edit.range.end);
assert_eq!(edit.range.start.character, 14);
}
#[test]
fn only_filters_out_non_matching_kinds() {
let text = "-> T given Cap { Used.op() }";
let uri = Url::parse("file:///a.bynk").unwrap();
let actions = quick_fixes(
text,
&[diag_with_suggestion()],
Span::new(18, 18),
&uri,
Some(7),
);
assert_eq!(actions.len(), 1);
let only = [CodeActionKind::REFACTOR_EXTRACT];
assert!(filter_by_only(actions.clone(), Some(&only)).is_empty());
let only = [CodeActionKind::QUICKFIX];
assert_eq!(filter_by_only(actions.clone(), Some(&only)).len(), 1);
assert_eq!(filter_by_only(actions, None).len(), 1);
}
#[test]
fn only_matches_dotted_children_by_prefix() {
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "extract".to_string(),
kind: Some(CodeActionKind::REFACTOR_EXTRACT),
..Default::default()
});
let only = [CodeActionKind::REFACTOR];
assert_eq!(filter_by_only(vec![action.clone()], Some(&only)).len(), 1);
let only = [CodeActionKind::new("refactorx")];
assert!(filter_by_only(vec![action], Some(&only)).is_empty());
}
#[test]
fn only_quickfix_drops_the_extract_variable_action() {
let text = "context c\n\nfn f() -> Int {\n let y = 1 + 2\n y\n}\n";
let uri = Url::parse("file:///a.bynk").unwrap();
let start = text.find("1 + 2").unwrap();
let span = Span::new(start, start + "1 + 2".len());
let diag = bynk_ide::Diagnostic {
severity: bynk_syntax::Severity::Error,
error: CompileError::new("bynk.test", span, "msg").with_suggestion(
"a fix",
vec![(span, "0".to_string())],
Applicability::MachineApplicable,
),
};
let mut actions = quick_fixes(text, &[diag], span, &uri, Some(1));
actions.extend(crate::extract::extract_variable(text, span, &uri, Some(1)));
assert_eq!(actions.len(), 2, "both actions are offered unfiltered");
let only = [CodeActionKind::QUICKFIX];
let filtered = filter_by_only(actions, Some(&only));
assert_eq!(filtered.len(), 1);
let CodeActionOrCommand::CodeAction(action) = &filtered[0] else {
panic!("expected a CodeAction");
};
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
}
#[test]
fn placeholder_suggestions_are_not_offered() {
let text = "x";
let uri = Url::parse("file:///a.bynk").unwrap();
let d = bynk_ide::Diagnostic {
severity: bynk_syntax::Severity::Error,
error: CompileError::new("bynk.test", Span::new(0, 1), "msg").with_suggestion(
"fill in <T>",
vec![(Span::new(0, 1), "<T>".to_string())],
Applicability::HasPlaceholders,
),
};
assert!(quick_fixes(text, &[d], Span::new(0, 1), &uri, None).is_empty());
}
}