use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use super::protocol::LspCompletionItem;
use super::server::LspServer;
const CURSOR_MARKER: &str = "$0";
#[derive(Deserialize)]
struct Scenario {
package: String,
file: String,
overlay: String,
#[serde(default)]
also_open: Vec<OpenDocument>,
expect: Expect,
}
#[derive(Deserialize)]
struct OpenDocument {
file: String,
text: String,
}
#[derive(Default, Deserialize)]
struct Expect {
#[serde(default)]
includes: Vec<ExpectItem>,
#[serde(default)]
excludes: Vec<ExpectItem>,
#[serde(default)]
exact: Option<Vec<String>>,
#[serde(default)]
selecting: Option<String>,
#[serde(default)]
replaces: Option<String>,
}
#[derive(Deserialize)]
struct ExpectItem {
label: String,
detail: String,
insert_text: Option<String>,
}
pub(super) async fn run_completion_scenarios(category: &str) {
let scenarios = load(category);
assert!(
!scenarios.is_empty(),
"no scenarios found for category {category:?}"
);
for (path, scenario) in scenarios {
run_completion(&path, &scenario).await;
}
}
fn load(category: &str) -> Vec<(PathBuf, Scenario)> {
let pattern = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/lsp/scenarios")
.join(category)
.join("*.toml");
let mut scenarios = glob::glob(pattern.to_str().expect("scenario glob is valid UTF-8"))
.expect("scenario glob pattern compiles")
.map(|entry| {
let path = entry.expect("scenario path is readable");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("read scenario {}: {err}", path.display()));
let scenario: Scenario = toml::from_str(&text)
.unwrap_or_else(|err| panic!("parse scenario {}: {err}", path.display()));
(path, scenario)
})
.collect::<Vec<_>>();
scenarios.sort_by(|(left, _), (right, _)| left.cmp(right));
scenarios
}
async fn run_completion(path: &Path, scenario: &Scenario) {
let name = path.file_name().unwrap().to_string_lossy();
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path();
copy_package(
&Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/lsp/packages")
.join(&scenario.package),
root,
);
let mut server = LspServer::new();
server.package_root = Some(tokio::fs::canonicalize(root).await.unwrap());
let (buffer, position) = split_cursor(&scenario.overlay).unwrap_or_else(|| {
panic!("scenario {name}: overlay is missing the {CURSOR_MARKER} marker")
});
server
.open_document(json!({
"textDocument": {
"uri": file_uri(root, &scenario.file),
"version": 1,
"text": buffer,
}
}))
.unwrap();
for (index, document) in scenario.also_open.iter().enumerate() {
server
.open_document(json!({
"textDocument": {
"uri": file_uri(root, &document.file),
"version": (index as i64) + 2,
"text": document.text,
}
}))
.unwrap();
}
let completions = server
.completion_items(json!({
"textDocument": { "uri": file_uri(root, &scenario.file) },
"position": { "line": position.0, "character": position.1 },
}))
.await
.unwrap();
assert_expectations(&name, &scenario.expect, position, &completions);
}
fn assert_expectations(
name: &str,
expect: &Expect,
position: (usize, usize),
completions: &[LspCompletionItem],
) {
for item in &expect.includes {
let found = completions
.iter()
.find(|c| c.label == item.label && c.detail == item.detail);
let found = found.unwrap_or_else(|| {
panic!(
"scenario {name}: missing completion {} ({})\ngot: {}",
item.label,
item.detail,
describe(completions)
)
});
if let Some(insert_text) = &item.insert_text {
assert_eq!(
found.insert_text.as_deref(),
Some(insert_text.as_str()),
"scenario {name}: completion {} has wrong insert text",
item.label
);
}
}
for item in &expect.excludes {
assert!(
!completions
.iter()
.any(|c| c.label == item.label && c.detail == item.detail),
"scenario {name}: unexpected completion {} ({})",
item.label,
item.detail
);
}
if let Some(exact) = &expect.exact {
let mut got = completions
.iter()
.map(|c| c.label.clone())
.collect::<Vec<_>>();
got.sort();
let mut want = exact.clone();
want.sort();
assert_eq!(got, want, "scenario {name}: completion label set mismatch");
}
if let Some(selecting) = &expect.selecting {
let item = completions
.iter()
.find(|c| &c.label == selecting)
.unwrap_or_else(|| panic!("scenario {name}: no completion labeled {selecting}"));
let edit = item
.text_edit
.as_ref()
.unwrap_or_else(|| panic!("scenario {name}: {selecting} has no textEdit"));
let (line, cursor) = position;
let replaces = expect.replaces.clone().unwrap_or_default();
let start = cursor - replaces.encode_utf16().count();
assert_eq!(
(edit.range.start.line, edit.range.start.character),
(line, start),
"scenario {name}: {selecting} textEdit start covers the wrong span"
);
assert_eq!(
(edit.range.end.line, edit.range.end.character),
(line, cursor),
"scenario {name}: {selecting} textEdit end is not at the cursor"
);
}
}
fn describe(completions: &[LspCompletionItem]) -> String {
completions
.iter()
.map(|c| format!("{} ({})", c.label, c.detail))
.collect::<Vec<_>>()
.join(", ")
}
fn split_cursor(overlay: &str) -> Option<(String, (usize, usize))> {
let marker = overlay.find(CURSOR_MARKER)?;
let before = &overlay[..marker];
let line = before.matches('\n').count();
let line_start = before.rfind('\n').map(|index| index + 1).unwrap_or(0);
let column = before[line_start..].encode_utf16().count();
let mut buffer = String::with_capacity(overlay.len() - CURSOR_MARKER.len());
buffer.push_str(before);
buffer.push_str(&overlay[marker + CURSOR_MARKER.len()..]);
Some((buffer, (line, column)))
}
fn file_uri(root: &Path, file: &str) -> String {
format!("file://{}", root.join(file).display())
}
fn copy_package(source: &Path, destination: &Path) {
for entry in std::fs::read_dir(source)
.unwrap_or_else(|err| panic!("read fixture {}: {err}", source.display()))
{
let entry = entry.unwrap();
let target = destination.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
std::fs::create_dir_all(&target).unwrap();
copy_package(&entry.path(), &target);
} else {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::copy(entry.path(), &target).unwrap();
}
}
}