use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tower_lsp::lsp_types::*;
#[allow(dead_code)]
#[path = "../src/position.rs"]
mod position;
#[allow(dead_code)]
#[path = "../src/code_actions.rs"]
mod code_actions;
fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"bynk-lsp-code-actions-{}-{}",
test_name,
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create test root");
for (rel, contents) in files {
let p = root.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(&p, contents).expect("write file");
}
root.canonicalize().unwrap_or(root)
}
const UNUSED_CAP: &str = "\
context billing.charge
capability Clock {
fn now() -> Effect[Int]
}
provides Clock = FixedClock {
fn now() -> Effect[Int] {
42
}
}
service charge {
on call(cents: Int) -> Effect[Int] given Clock {
cents
}
}
";
fn apply_text_edits(text: &str, edits: &[OneOf<TextEdit, AnnotatedTextEdit>]) -> String {
let mut plain: Vec<&TextEdit> = edits
.iter()
.map(|e| match e {
OneOf::Left(t) => t,
OneOf::Right(_) => panic!("unexpected annotated edit"),
})
.collect();
plain.sort_by_key(|e| (e.range.start.line, e.range.start.character));
let mut out = String::with_capacity(text.len());
let mut last = 0;
for e in plain {
let start = position::position_to_offset(text, e.range.start).expect("start offset");
let end = position::position_to_offset(text, e.range.end).expect("end offset");
out.push_str(&text[last..start]);
out.push_str(&e.new_text);
last = end;
}
out.push_str(&text[last..]);
out
}
#[test]
fn unused_capability_quick_fix_round_trips() {
let root = setup_project("unused", &[("billing/charge.bynk", UNUSED_CAP)]);
let result = bynk_ide::diagnose_project(&root, &HashMap::new());
let file = result
.files
.iter()
.find(|f| f.source_path == Path::new("billing/charge.bynk"))
.expect("context file analysed");
let diag = file
.diagnostics
.iter()
.find(|d| d.error.category == "bynk.given.unused_capability")
.expect("the unused-capability diagnostic");
let uri = Url::from_file_path(root.join("billing/charge.bynk")).unwrap();
let actions = code_actions::quick_fixes(
&file.text,
&file.diagnostics,
diag.error.span,
&uri,
Some(3),
);
assert_eq!(actions.len(), 1, "exactly one quick-fix offered");
let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
panic!("expected a CodeAction");
};
assert_eq!(action.title, "remove `Clock` from 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.len(), 1);
assert_eq!(doc_edits[0].text_document.version, Some(3));
assert_eq!(doc_edits[0].text_document.uri, uri);
let fixed = apply_text_edits(&file.text, &doc_edits[0].edits);
assert_eq!(fixed, UNUSED_CAP.replace(" given Clock", ""));
let abs = root.join("billing/charge.bynk");
let canonical = abs.canonicalize().unwrap_or(abs);
let mut overlay = HashMap::new();
overlay.insert(canonical, fixed);
let post = bynk_ide::diagnose_project(&root, &overlay);
assert!(
post.files.iter().all(|f| f.diagnostics.is_empty()),
"applied fix re-diagnoses clean"
);
}
#[test]
fn range_away_from_the_diagnostic_offers_nothing() {
let root = setup_project("away", &[("billing/charge.bynk", UNUSED_CAP)]);
let result = bynk_ide::diagnose_project(&root, &HashMap::new());
let file = result
.files
.iter()
.find(|f| f.source_path == Path::new("billing/charge.bynk"))
.expect("context file analysed");
let uri = Url::from_file_path(root.join("billing/charge.bynk")).unwrap();
let actions = code_actions::quick_fixes(
&file.text,
&file.diagnostics,
bynk_syntax::span::Span::new(0, 0),
&uri,
None,
);
assert!(actions.is_empty());
}
const BYNK_LIST_DEPRECATED: &str = "\
commons demo
uses bynk.list
fn doubled(xs: List[Int]) -> List[Int] {
map(xs, (x) => x * 2)
}
fn firstBig(xs: List[Int]) -> Option[Int] {
find(xs, (x) => x > 10)
}
";
#[test]
fn bynk_list_deprecation_quick_fix_rewrites_to_method() {
let root = setup_project("bynklist", &[("demo/demo.bynk", BYNK_LIST_DEPRECATED)]);
let result = bynk_ide::diagnose_project(&root, &HashMap::new());
let file = result
.files
.iter()
.find(|f| f.source_path == Path::new("demo/demo.bynk"))
.expect("commons file analysed");
let uri = Url::from_file_path(root.join("demo/demo.bynk")).unwrap();
let depr: Vec<_> = file
.diagnostics
.iter()
.filter(|d| d.error.category == "bynk.list.deprecated_function")
.collect();
assert_eq!(depr.len(), 2, "map + find both flagged");
let map_diag = depr
.iter()
.find(|d| d.error.message.contains("bynk.list.map"))
.expect("map deprecation");
let actions = code_actions::quick_fixes(
&file.text,
&file.diagnostics,
map_diag.error.span,
&uri,
Some(1),
);
let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
panic!("expected a CodeAction");
};
let Some(DocumentChanges::Edits(doc_edits)) = &action.edit.as_ref().unwrap().document_changes
else {
panic!("expected versioned document edits");
};
let fixed = apply_text_edits(&file.text, &doc_edits[0].edits);
assert!(
fixed.contains("xs.map((x) => x * 2)"),
"map fix yields the method form; got:\n{fixed}"
);
let find_diag = depr
.iter()
.find(|d| d.error.message.contains("bynk.list.find"))
.expect("find deprecation");
let actions = code_actions::quick_fixes(
&file.text,
&file.diagnostics,
find_diag.error.span,
&uri,
Some(1),
);
let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
panic!("expected a CodeAction");
};
let Some(DocumentChanges::Edits(doc_edits)) = &action.edit.as_ref().unwrap().document_changes
else {
panic!("expected versioned document edits");
};
let fixed = apply_text_edits(&file.text, &doc_edits[0].edits);
assert!(
fixed.contains("xs.filter((x) => x > 10).first()"),
"find fix yields filter(...).first(); got:\n{fixed}"
);
}