use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use base64::Engine;
use futures::future::MaybeDone;
use lsp_types::*;
use reflexo::ImmutPath;
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use sync_ls::{Connection, LspClientRoot, LspMessage, Message, ScheduleResult};
use tempfile::TempDir;
use tokio::runtime::{Builder, Runtime};
use tokio::sync::mpsc;
use typst::ecow::EcoString;
use crate::actor::editor::{EditorRequest, ProjVersion};
use crate::input::{FileChange, FileChangeResult, FsChangeParams};
use crate::project::{Interrupt, LspInterrupt, ProjectInsId};
use crate::{CompileFontArgs, Config, ConstConfig, ServerState};
const MAIN: &str = "main.typ";
const DEP: &str = "dep.typ";
const RENAMED_DEP: &str = "renamed.typ";
const CASE_RENAMED_DEP: &str = "Dep.typ";
const NEW_DEP: &str = "new.typ";
const DIR_DEP: &str = "dir/dep.typ";
const RENAMED_DIR_DEP: &str = "renamed-dir/dep.typ";
const OTHER_DEP: &str = "other.typ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiSensitivity {
GraphDependent,
SourceLocal,
Diagnostics,
SemanticTokens,
RenameAssistance,
ShadowOpen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RepresentativeProbe {
CreateDependency,
ContentUpdate,
TransientEmpty,
ReadErrorRecovery,
RemoveDependency,
DeleteThenRecreate,
AtomicReplace,
RenameStale,
RenameUpdated,
CaseRename,
RootBoundaryMove,
DirectoryRenameStale,
DirectoryRenameUpdated,
DirectoryDelete,
DirectoryRootMove,
MembershipRemoval,
MembershipAddition,
ShadowOpenRace,
SymlinkReadResult,
MixedBatch,
}
#[derive(Debug)]
struct LspMatrixRow {
id: &'static str,
sensitivities: &'static [ApiSensitivity],
represented_by: RepresentativeProbe,
}
const GRAPH_DIAG: &[ApiSensitivity] =
&[ApiSensitivity::GraphDependent, ApiSensitivity::Diagnostics];
const GRAPH_SOURCE_SEMANTIC: &[ApiSensitivity] = &[
ApiSensitivity::GraphDependent,
ApiSensitivity::SourceLocal,
ApiSensitivity::SemanticTokens,
];
const DIAG_SEMANTIC: &[ApiSensitivity] =
&[ApiSensitivity::Diagnostics, ApiSensitivity::SemanticTokens];
const GRAPH_DIAG_RENAME: &[ApiSensitivity] = &[
ApiSensitivity::GraphDependent,
ApiSensitivity::Diagnostics,
ApiSensitivity::RenameAssistance,
];
const SOURCE_SEMANTIC: &[ApiSensitivity] =
&[ApiSensitivity::SourceLocal, ApiSensitivity::SemanticTokens];
const SHADOW_SOURCE_SEMANTIC: &[ApiSensitivity] = &[
ApiSensitivity::SourceLocal,
ApiSensitivity::SemanticTokens,
ApiSensitivity::ShadowOpen,
];
const ALL_SENSITIVITIES: &[ApiSensitivity] = &[
ApiSensitivity::GraphDependent,
ApiSensitivity::SourceLocal,
ApiSensitivity::Diagnostics,
ApiSensitivity::SemanticTokens,
ApiSensitivity::RenameAssistance,
ApiSensitivity::ShadowOpen,
];
const LSP_WORKSPACE_CHANGE_MATRIX: &[LspMatrixRow] = &[
LspMatrixRow {
id: "O01",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::CreateDependency,
},
LspMatrixRow {
id: "O02",
sensitivities: GRAPH_SOURCE_SEMANTIC,
represented_by: RepresentativeProbe::ContentUpdate,
},
LspMatrixRow {
id: "O03",
sensitivities: DIAG_SEMANTIC,
represented_by: RepresentativeProbe::TransientEmpty,
},
LspMatrixRow {
id: "O04",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::ReadErrorRecovery,
},
LspMatrixRow {
id: "O05",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::RemoveDependency,
},
LspMatrixRow {
id: "O06",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::DeleteThenRecreate,
},
LspMatrixRow {
id: "O07",
sensitivities: SOURCE_SEMANTIC,
represented_by: RepresentativeProbe::AtomicReplace,
},
LspMatrixRow {
id: "O08",
sensitivities: GRAPH_DIAG_RENAME,
represented_by: RepresentativeProbe::RenameStale,
},
LspMatrixRow {
id: "O09",
sensitivities: GRAPH_DIAG_RENAME,
represented_by: RepresentativeProbe::RenameUpdated,
},
LspMatrixRow {
id: "O10",
sensitivities: GRAPH_DIAG_RENAME,
represented_by: RepresentativeProbe::CaseRename,
},
LspMatrixRow {
id: "O11",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::RootBoundaryMove,
},
LspMatrixRow {
id: "O12",
sensitivities: GRAPH_DIAG_RENAME,
represented_by: RepresentativeProbe::DirectoryRenameStale,
},
LspMatrixRow {
id: "O13",
sensitivities: GRAPH_DIAG_RENAME,
represented_by: RepresentativeProbe::DirectoryRenameUpdated,
},
LspMatrixRow {
id: "O14",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::DirectoryDelete,
},
LspMatrixRow {
id: "O15",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::DirectoryRootMove,
},
LspMatrixRow {
id: "O16",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::MembershipRemoval,
},
LspMatrixRow {
id: "O17",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::MembershipAddition,
},
LspMatrixRow {
id: "O18",
sensitivities: SHADOW_SOURCE_SEMANTIC,
represented_by: RepresentativeProbe::ShadowOpenRace,
},
LspMatrixRow {
id: "O19",
sensitivities: GRAPH_DIAG,
represented_by: RepresentativeProbe::SymlinkReadResult,
},
LspMatrixRow {
id: "O20",
sensitivities: ALL_SENSITIVITIES,
represented_by: RepresentativeProbe::MixedBatch,
},
];
struct LspHarness {
root: TempDir,
runtime: Runtime,
_client_root: LspClientRoot,
receiver: sync_ls::TConnectionRx<LspMessage>,
server: ServerState,
editor_rx: mpsc::UnboundedReceiver<EditorRequest>,
last_diag_revision: usize,
}
#[derive(Debug)]
struct DiagnosticPublication {
version: ProjVersion,
diagnostics: Option<tinymist_query::DiagnosticsMap>,
}
impl LspHarness {
fn new(files: &[(&str, &str)]) -> Self {
let root = tempfile::tempdir().expect("failed to create temp workspace");
for (path, source) in files {
write_fixture(root.path(), path, source);
}
let runtime = Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("failed to create test runtime");
let connection = Connection::<LspMessage>::channel();
let receiver = connection.receiver;
let client_root = LspClientRoot::new(runtime.handle().clone(), connection.sender);
let client = client_root.weak().to_typed::<ServerState>();
let (editor_tx, editor_rx) = mpsc::unbounded_channel();
let root_path: ImmutPath = root.path().into();
let main_path: ImmutPath = root.path().join(MAIN).as_path().into();
let mut config = Config::new(
ConstConfig::default(),
vec![root_path.clone()],
CompileFontArgs::default(),
);
config.entry_resolver.root_path = Some(root_path);
config.entry_resolver.entry = Some(main_path);
config.has_default_entry_path = true;
config.notify_status = false;
let mut harness = Self {
root,
runtime,
_client_root: client_root,
receiver,
server: ServerState::new(client, config, editor_tx),
editor_rx,
last_diag_revision: 0,
};
harness
.server
.pin_main_file(Some(harness.path(MAIN).as_path().into()))
.expect("failed to pin main file");
harness.drain_project_events();
harness
}
fn path(&self, rel: &str) -> PathBuf {
self.root.path().join(rel)
}
fn uri(&self, rel: &str) -> Url {
Url::from_file_path(self.path(rel)).expect("fixture path should convert to file URL")
}
fn text_document(&self, rel: &str) -> TextDocumentIdentifier {
TextDocumentIdentifier { uri: self.uri(rel) }
}
fn text_position(&self, rel: &str, line: u32, character: u32) -> TextDocumentPositionParams {
TextDocumentPositionParams {
text_document: self.text_document(rel),
position: Position { line, character },
}
}
fn position_of(&self, rel: &str, needle: &str, occurrence: usize, offset: usize) -> Position {
let source = fs::read_to_string(self.path(rel)).expect("failed to read fixture source");
position_in(&source, needle, occurrence, offset)
}
fn compile_primary(&mut self) {
self.server
.project
.interrupt(Interrupt::Compile(ProjectInsId::PRIMARY));
self.drain_project_events();
}
fn fs_insert(&mut self, rel: &str, source: &str) {
self.fs_batch(&[(rel, source)], &[], false);
}
fn fs_error(&mut self, rel: &str, error: &str) {
let change = FileChange {
uri: self.uri(rel).to_string(),
content: FileChangeResult::Err {
error: EcoString::from(error),
},
};
let response = self.server.fs_change(FsChangeParams {
inserts: vec![change],
removes: vec![],
is_sync: false,
});
self.resolve_json(response);
self.drain_project_events();
}
fn fs_remove(&mut self, rel: &str) {
self.fs_batch(&[], &[rel], false);
}
fn real_fs_insert(&mut self, rel: &str, source: &str) {
write_fixture(self.root.path(), rel, source);
}
fn real_fs_remove(&mut self, rel: &str) {
remove_existing_fixture(self.root.path(), rel);
}
fn fs_batch(&mut self, inserts: &[(&str, &str)], removes: &[&str], is_sync: bool) {
for rel in removes {
remove_existing_fixture(self.root.path(), rel);
}
for (rel, source) in inserts {
write_fixture(self.root.path(), rel, source);
}
let inserts = inserts
.iter()
.map(|(rel, source)| FileChange {
uri: self.uri(rel).to_string(),
content: FileChangeResult::Ok {
content: base64::engine::general_purpose::STANDARD.encode(source),
},
})
.collect();
let removes = removes
.iter()
.map(|rel| self.uri(rel).to_string())
.collect();
let response = self.server.fs_change(FsChangeParams {
inserts,
removes,
is_sync,
});
self.resolve_json(response);
self.drain_project_events();
}
fn open_source(&mut self, rel: &str, source: &str) {
self.server
.create_source(self.path(rel).as_path().into(), source.to_owned())
.expect("failed to open source in memory");
self.drain_project_events();
}
fn edit_open_source(&mut self, rel: &str, source: &str) {
let encoding = self.server.const_config().position_encoding;
self.server
.edit_source(
self.path(rel).as_path().into(),
vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: source.to_owned(),
}],
encoding,
)
.expect("failed to edit open source");
self.drain_project_events();
}
fn close_source(&mut self, rel: &str) {
self.server
.remove_source(self.path(rel).as_path().into())
.expect("failed to close source");
self.drain_project_events();
}
fn goto_definition_uris(&mut self, rel: &str, position: Position) -> Vec<Url> {
let response = self.server.goto_definition(GotoDefinitionParams {
text_document_position_params: self.text_position(
rel,
position.line,
position.character,
),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
});
let result: Option<GotoDefinitionResponse> = self.decode_egress_response(response);
match result {
Some(GotoDefinitionResponse::Scalar(location)) => vec![location.uri],
Some(GotoDefinitionResponse::Array(locations)) => {
locations.into_iter().map(|location| location.uri).collect()
}
Some(GotoDefinitionResponse::Link(links)) => {
links.into_iter().map(|link| link.target_uri).collect()
}
None => vec![],
}
}
fn reference_uris(&mut self, rel: &str, position: Position) -> Vec<Url> {
let response = self.server.references(ReferenceParams {
text_document_position: self.text_position(rel, position.line, position.character),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: ReferenceContext {
include_declaration: true,
},
});
let result: Option<Vec<Location>> = self.decode_egress_response(response);
result
.unwrap_or_default()
.into_iter()
.map(|location| location.uri)
.collect()
}
fn hover_text(&mut self, rel: &str, position: Position) -> Option<String> {
let response = self.server.hover(HoverParams {
text_document_position_params: self.text_position(
rel,
position.line,
position.character,
),
work_done_progress_params: WorkDoneProgressParams::default(),
});
let result: Option<Hover> = self.decode_egress_response(response);
result.map(|hover| match hover.contents {
HoverContents::Scalar(MarkedString::String(text)) => text,
HoverContents::Scalar(MarkedString::LanguageString(text)) => text.value,
HoverContents::Array(items) => items
.into_iter()
.map(|item| match item {
MarkedString::String(text) => text,
MarkedString::LanguageString(text) => text.value,
})
.collect::<Vec<_>>()
.join("\n"),
HoverContents::Markup(markup) => markup.value,
})
}
fn completion_labels(&mut self, rel: &str, position: Position) -> BTreeSet<String> {
let response = self.server.completion(CompletionParams {
text_document_position: self.text_position(rel, position.line, position.character),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: Some(CompletionContext {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
}),
});
let result: Option<CompletionList> = self.decode_egress_response(response);
result
.unwrap_or_else(|| CompletionList {
is_incomplete: false,
items: vec![],
})
.items
.into_iter()
.map(|item| item.label)
.collect()
}
fn workspace_symbol_uris(&mut self, query: &str) -> Vec<Url> {
let response = self.server.symbol(WorkspaceSymbolParams {
query: query.to_owned(),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
});
let result: Option<Vec<SymbolInformation>> = self.decode_egress_response(response);
result
.unwrap_or_default()
.into_iter()
.map(|symbol| symbol.location.uri)
.collect()
}
fn document_symbol_names(&mut self, rel: &str) -> BTreeSet<String> {
let response = self.server.document_symbol(DocumentSymbolParams {
text_document: self.text_document(rel),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
});
let result: Option<DocumentSymbolResponse> = self.decode_egress_response(response);
match result {
Some(DocumentSymbolResponse::Nested(symbols)) => symbols
.into_iter()
.flat_map(flatten_document_symbol)
.collect(),
Some(DocumentSymbolResponse::Flat(symbols)) => {
symbols.into_iter().map(|symbol| symbol.name).collect()
}
None => BTreeSet::new(),
}
}
fn semantic_full(&mut self, rel: &str) -> SemanticTokens {
let response = self.server.semantic_tokens_full(SemanticTokensParams {
text_document: self.text_document(rel),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
});
let result: Option<SemanticTokensResult> = self.decode_egress_response(response);
match result.expect("expected semantic token response") {
SemanticTokensResult::Tokens(tokens) => tokens,
SemanticTokensResult::Partial(_) => {
panic!("unexpected partial semantic token response")
}
}
}
fn semantic_delta_result_id(
&mut self,
rel: &str,
previous_result_id: String,
) -> Option<String> {
let response = self
.server
.semantic_tokens_full_delta(SemanticTokensDeltaParams {
text_document: self.text_document(rel),
previous_result_id,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
});
let result: Option<SemanticTokensFullDeltaResult> = self.decode_egress_response(response);
match result.expect("expected semantic token delta response") {
SemanticTokensFullDeltaResult::Tokens(tokens) => tokens.result_id,
SemanticTokensFullDeltaResult::TokensDelta(delta) => delta.result_id,
SemanticTokensFullDeltaResult::PartialTokensDelta { .. } => None,
}
}
fn rename_edit(&mut self, rel: &str, position: Position, new_name: &str) -> WorkspaceEdit {
let response = self.server.rename(RenameParams {
text_document_position: self.text_position(rel, position.line, position.character),
new_name: new_name.to_owned(),
work_done_progress_params: WorkDoneProgressParams::default(),
});
let result: Option<WorkspaceEdit> = self.decode_egress_response(response);
result.expect("expected rename workspace edit")
}
fn will_rename_edit(&mut self, old_rel: &str, new_rel: &str) -> Option<WorkspaceEdit> {
let response = self.server.will_rename_files(RenameFilesParams {
files: vec![FileRename {
old_uri: self.uri(old_rel).to_string(),
new_uri: self.uri(new_rel).to_string(),
}],
});
let result: Option<WorkspaceEdit> = self.decode_egress_response(response);
result
}
fn drain_diagnostic_publications(
&mut self,
mut on_publication: impl FnMut(DiagnosticPublication) -> bool,
) -> bool {
let mut matched = false;
while let Ok(request) = self.editor_rx.try_recv() {
if let EditorRequest::Diag(version, diagnostics) = request {
if version.id != ProjectInsId::PRIMARY {
continue;
}
if version.revision < self.last_diag_revision {
continue;
}
self.last_diag_revision = version.revision;
matched |= on_publication(DiagnosticPublication {
version,
diagnostics,
});
}
}
matched
}
fn assert_real_fs_diagnostics_empty(&mut self) {
let publication = self.wait_for_diagnostics(
Duration::from_secs(10),
"empty main diagnostics",
true,
|publication, main_uri| {
diagnostics_are_empty(publication.diagnostics.as_ref(), main_uri)
},
);
let diagnostics = publication.diagnostics.unwrap_or_default();
assert!(
diagnostics.values().all(|items| items.is_empty()),
"expected real filesystem diagnostics to settle empty at revision {}, got {diagnostics:?}",
publication.version.revision
);
}
fn assert_real_fs_diagnostics_present(&mut self) {
let publication = self.wait_for_diagnostics(
Duration::from_secs(10),
"non-empty main diagnostics",
true,
|publication, main_uri| {
diagnostics_are_present_on_main(publication.diagnostics.as_ref(), main_uri)
},
);
let diagnostics = publication.diagnostics.unwrap_or_default();
assert!(
diagnostics
.get(&self.uri(MAIN))
.is_some_and(|items| !items.is_empty()),
"expected real filesystem diagnostics on main at revision {}, got {diagnostics:?}",
publication.version.revision
);
}
fn wait_for_diagnostics(
&mut self,
timeout: Duration,
description: &str,
pump_events: bool,
mut matches: impl FnMut(&DiagnosticPublication, &Url) -> bool,
) -> DiagnosticPublication {
let deadline = Instant::now() + timeout;
let main_uri = self.uri(MAIN);
let mut matched = None;
let mut last_publication = None;
loop {
self.drain_diagnostic_publications(|publication| {
if matches(&publication, &main_uri) {
matched = Some(publication);
true
} else {
last_publication = Some(format!("{publication:?}"));
false
}
});
if let Some(publication) = matched.take() {
return publication;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {description}; last diagnostics: {}",
last_publication.unwrap_or_else(|| "<none>".to_owned())
);
if pump_events {
self.pump_project_event(Duration::from_millis(20));
} else {
std::thread::sleep(Duration::from_millis(10));
}
}
}
fn assert_latest_diagnostics_empty(&mut self) {
let publication = self.wait_for_diagnostics(
Duration::from_secs(2),
"empty diagnostics",
false,
|publication, main_uri| {
diagnostics_are_empty(publication.diagnostics.as_ref(), main_uri)
},
);
let diagnostics = publication.diagnostics.unwrap_or_default();
let main_uri = self.uri(MAIN);
assert!(
diagnostics.values().all(|items| items.is_empty()),
"expected diagnostics to be empty at revision {}, got {diagnostics:?}",
publication.version.revision
);
assert!(
diagnostics
.get(&main_uri)
.is_none_or(|items| items.is_empty()),
"expected main file diagnostics to be empty at revision {}, got {:?}",
publication.version.revision,
diagnostics.get(&main_uri)
);
}
fn assert_latest_diagnostics_present(&mut self) {
let publication = self.wait_for_diagnostics(
Duration::from_secs(2),
"main file diagnostics",
false,
|publication, main_uri| {
diagnostics_are_present_on_main(publication.diagnostics.as_ref(), main_uri)
},
);
let diagnostics = publication.diagnostics.unwrap_or_default();
let main_uri = self.uri(MAIN);
let main_diagnostics = diagnostics.get(&main_uri);
assert!(
main_diagnostics.is_some_and(|items| !items.is_empty()),
"expected main file diagnostics to be present at revision {}, got {diagnostics:?}",
publication.version.revision
);
}
fn resolve_json(&self, response: ScheduleResult) -> JsonValue {
let response = response.expect("request should be scheduled");
self.runtime
.block_on(async move {
match response {
MaybeDone::Done(result) => result,
MaybeDone::Future(future) => future.await,
MaybeDone::Gone => panic!("response was already consumed"),
}
})
.expect("request should complete successfully")
}
fn decode_egress_response<T: DeserializeOwned>(&self, response: ScheduleResult) -> T {
serde_json::from_value(self.resolve_json(response)).expect("failed to decode LSP response")
}
fn pump_project_event(&mut self, timeout: Duration) {
match self.receiver.event.recv_timeout(timeout) {
Ok(event) => {
if let Ok(interrupt) = event.downcast::<LspInterrupt>() {
self.server.project.interrupt(*interrupt);
return;
}
panic!("unexpected server event type");
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {}
}
while let Ok(message) = self.receiver.lsp.try_recv() {
if let Message::Lsp(sync_ls::lsp::Message::Request(request)) = message {
panic!("unexpected client request during test: {request:?}");
}
}
}
fn drain_project_events(&mut self) {
let deadline = Instant::now() + Duration::from_secs(10);
let quiet_for = Duration::from_millis(100);
let mut idle_since = None;
loop {
match self.receiver.event.recv_timeout(Duration::from_millis(20)) {
Ok(event) => {
if let Ok(interrupt) = event.downcast::<LspInterrupt>() {
idle_since = None;
self.server.project.interrupt(*interrupt);
continue;
}
panic!("unexpected server event type");
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
if self
.server
.project
.compiler
.primary
.ext
.compiling_since
.is_none()
{
let idle_since = idle_since.get_or_insert_with(Instant::now);
if idle_since.elapsed() >= quiet_for {
break;
}
continue;
}
idle_since = None;
assert!(
Instant::now() < deadline,
"timed out waiting for project compile event"
);
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
}
}
while let Ok(message) = self.receiver.lsp.try_recv() {
if let Message::Lsp(sync_ls::lsp::Message::Request(request)) = message {
panic!("unexpected client request during test: {request:?}");
}
}
}
}
fn diagnostics_are_empty(
diagnostics: Option<&tinymist_query::DiagnosticsMap>,
main_uri: &Url,
) -> bool {
diagnostics.is_none_or(|diagnostics| {
diagnostics.values().all(|items| items.is_empty())
&& diagnostics
.get(main_uri)
.is_none_or(|items| items.is_empty())
})
}
fn diagnostics_are_present_on_main(
diagnostics: Option<&tinymist_query::DiagnosticsMap>,
main_uri: &Url,
) -> bool {
diagnostics
.and_then(|diagnostics| diagnostics.get(main_uri))
.is_some_and(|items| !items.is_empty())
}
#[test]
fn lsp_workspace_change_matrix_maps_o01_through_o20() {
assert_eq!(LSP_WORKSPACE_CHANGE_MATRIX.len(), 20);
let ids = LSP_WORKSPACE_CHANGE_MATRIX
.iter()
.map(|row| row.id)
.collect::<BTreeSet<_>>();
let expected = (1..=20)
.map(|idx| format!("O{idx:02}"))
.collect::<BTreeSet<_>>();
assert_eq!(
ids,
expected.iter().map(String::as_str).collect::<BTreeSet<_>>()
);
for sensitivity in ALL_SENSITIVITIES {
assert!(
LSP_WORKSPACE_CHANGE_MATRIX
.iter()
.any(|row| row.sensitivities.contains(sensitivity)),
"missing sensitivity group {sensitivity:?}"
);
}
for row in LSP_WORKSPACE_CHANGE_MATRIX {
assert!(!row.sensitivities.is_empty(), "{} has no API group", row.id);
assert!(
matches!(
row.represented_by,
RepresentativeProbe::CreateDependency
| RepresentativeProbe::ContentUpdate
| RepresentativeProbe::TransientEmpty
| RepresentativeProbe::ReadErrorRecovery
| RepresentativeProbe::RemoveDependency
| RepresentativeProbe::DeleteThenRecreate
| RepresentativeProbe::AtomicReplace
| RepresentativeProbe::RenameStale
| RepresentativeProbe::RenameUpdated
| RepresentativeProbe::CaseRename
| RepresentativeProbe::RootBoundaryMove
| RepresentativeProbe::DirectoryRenameStale
| RepresentativeProbe::DirectoryRenameUpdated
| RepresentativeProbe::DirectoryDelete
| RepresentativeProbe::DirectoryRootMove
| RepresentativeProbe::MembershipRemoval
| RepresentativeProbe::MembershipAddition
| RepresentativeProbe::ShadowOpenRace
| RepresentativeProbe::SymlinkReadResult
| RepresentativeProbe::MixedBatch
),
"{} has an undocumented representative probe",
row.id
);
}
}
#[test]
fn o01_create_dependency_refreshes_missing_import_focus_apis() {
let main = main_source(NEW_DEP, "newer");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_present();
harness.fs_insert(NEW_DEP, "#let newer = 1\n");
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "newer", NEW_DEP);
assert_main_label_references_current_file(&mut harness);
}
#[test]
fn o02_content_update_refreshes_graph_source_and_semantic_apis() {
let initial = main_source_with_local(DEP, "alpha", "before_symbol");
let updated = main_source_with_local(DEP, "beta", "after");
let mut harness =
LspHarness::new(&[(MAIN, &initial), (DEP, "#let alpha = 1\n#let beta = 2\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.open_source(MAIN, &initial);
let before_tokens = harness.semantic_full(MAIN);
let before_id = before_tokens
.result_id
.clone()
.expect("expected semantic token result id");
harness.close_source(MAIN);
harness.fs_insert(MAIN, &updated);
harness.assert_latest_diagnostics_empty();
harness.open_source(MAIN, &updated);
assert_graph_resolves(&mut harness, MAIN, "beta", DEP);
assert_source_symbol_changed(
&mut harness,
MAIN,
"before_symbol",
"after",
before_tokens,
before_id,
);
}
#[test]
fn o03_transient_empty_dependency_publishes_diagnostics_and_empty_semantics() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_insert(DEP, "");
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", DEP);
harness.open_source(DEP, "");
assert!(!harness.document_symbol_names(DEP).contains("alpha"));
let empty_tokens = harness.semantic_full(DEP);
assert!(
empty_tokens.data.is_empty(),
"expected empty dependency to produce no semantic token data, got {empty_tokens:?}"
);
}
#[test]
fn o04_read_error_recovery_clears_diagnostics_and_restores_graph_apis() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_error(DEP, "permission denied");
harness.assert_latest_diagnostics_present();
harness.fs_insert(DEP, "#let alpha = 4\n");
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", DEP);
}
#[test]
fn o05_remove_dependency_retires_old_path_from_graph_apis() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_remove(DEP);
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DEP);
}
#[test]
fn o06_delete_then_recreate_restores_dependency_from_fresh_harness_state() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_remove(DEP);
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", DEP);
harness.fs_insert(DEP, "#let alpha = 6\n");
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", DEP);
}
#[test]
fn o07_atomic_replace_refreshes_source_local_and_semantic_apis() {
let initial = local_source("alpha_symbol");
let replaced = local_source("replacement");
let mut harness = LspHarness::new(&[(MAIN, &initial)]);
harness.open_source(MAIN, &initial);
let before_tokens = harness.semantic_full(MAIN);
let before_id = before_tokens
.result_id
.clone()
.expect("expected semantic token result id");
harness.edit_open_source(MAIN, &replaced);
assert_source_symbol_changed(
&mut harness,
MAIN,
"alpha_symbol",
"replacement",
before_tokens,
before_id,
);
}
#[test]
fn o08_rename_with_stale_references_keeps_old_path_out_of_graph_apis() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
assert_file_rename_assistance(&mut harness, DEP, RENAMED_DEP, "renamed");
harness.fs_batch(&[(RENAMED_DEP, "#let alpha = 8\n")], &[DEP], false);
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DEP);
}
#[test]
fn o09_rename_with_updated_references_follows_new_path_in_graph_apis() {
let main = main_source(DEP, "alpha");
let updated_main = main_source(RENAMED_DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
assert_file_rename_assistance(&mut harness, DEP, RENAMED_DEP, "renamed");
harness.fs_batch(
&[(RENAMED_DEP, "#let alpha = 9\n"), (MAIN, &updated_main)],
&[DEP],
false,
);
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", RENAMED_DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DEP);
}
#[test]
fn o10_case_rename_updates_references_without_retaining_old_case_path() {
let main = main_source(DEP, "alpha");
let updated_main = main_source(CASE_RENAMED_DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
assert_file_rename_assistance(&mut harness, DEP, CASE_RENAMED_DEP, "Dep");
harness.fs_batch(
&[
(CASE_RENAMED_DEP, "#let alpha = 10\n"),
(MAIN, &updated_main),
],
&[DEP],
false,
);
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", CASE_RENAMED_DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DEP);
}
#[test]
fn o11_root_boundary_move_retires_old_nested_path_from_graph_apis() {
let nested_dep = "nested/dep.typ";
let main = main_source(nested_dep, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (nested_dep, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_batch(&[(DEP, "#let alpha = 11\n")], &[nested_dep], false);
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", nested_dep);
assert_workspace_symbol_excludes(&mut harness, "alpha", nested_dep);
}
#[test]
fn o12_directory_rename_with_stale_references_retires_old_directory_path() {
let main = main_source(DIR_DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DIR_DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
assert_directory_rename_assistance(&mut harness, "dir", "renamed-dir");
harness.fs_batch(&[(RENAMED_DIR_DEP, "#let alpha = 12\n")], &[DIR_DEP], false);
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", DIR_DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DIR_DEP);
}
#[test]
fn o13_directory_rename_with_updated_references_follows_new_directory_path() {
let main = main_source(DIR_DEP, "alpha");
let updated_main = main_source(RENAMED_DIR_DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DIR_DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
assert_directory_rename_assistance(&mut harness, "dir", "renamed-dir");
harness.fs_batch(
&[
(RENAMED_DIR_DEP, "#let alpha = 13\n"),
(MAIN, &updated_main),
],
&[DIR_DEP],
false,
);
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", RENAMED_DIR_DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DIR_DEP);
}
#[test]
fn o14_directory_delete_reports_missing_dependency_and_clears_old_graph_path() {
let main = main_source(DIR_DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DIR_DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_remove(DIR_DEP);
harness.assert_latest_diagnostics_present();
assert_graph_does_not_resolve_to(&mut harness, MAIN, "alpha", DIR_DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DIR_DEP);
}
#[test]
fn o15_directory_root_move_with_updated_references_follows_root_path() {
let main = main_source(DIR_DEP, "alpha");
let updated_main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DIR_DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_batch(
&[(DEP, "#let alpha = 15\n"), (MAIN, &updated_main)],
&[DIR_DEP],
false,
);
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DIR_DEP);
}
#[test]
fn o16_membership_removal_keeps_server_clean_for_remaining_source_apis() {
let main = main_source(DEP, "alpha");
let updated_main = local_source("local_only");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_insert(MAIN, &updated_main);
harness.assert_latest_diagnostics_empty();
harness.open_source(MAIN, &updated_main);
assert_graph_resolves(&mut harness, MAIN, "local_only", MAIN);
assert!(harness.document_symbol_names(MAIN).contains("local_only"));
assert_semantic_tokens_nonempty(&mut harness, MAIN);
}
#[test]
fn o17_membership_addition_makes_new_dependency_visible_to_graph_apis() {
let initial = local_source("local_only");
let updated_main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &initial), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.fs_insert(MAIN, &updated_main);
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "alpha", DEP);
assert_main_label_references_current_file(&mut harness);
}
#[test]
fn o18_shadow_open_keeps_memory_source_until_close_then_uses_filesystem() {
let disk = local_source("disk_symbol");
let memory = local_source("memory_symbol");
let filesystem = local_source("filesystem_symbol");
let mut harness = LspHarness::new(&[(MAIN, &disk)]);
harness.open_source(MAIN, &memory);
assert!(harness
.document_symbol_names(MAIN)
.contains("memory_symbol"));
let memory_tokens = harness.semantic_full(MAIN);
harness.fs_insert(MAIN, &filesystem);
assert!(harness
.document_symbol_names(MAIN)
.contains("memory_symbol"));
assert!(!harness
.document_symbol_names(MAIN)
.contains("filesystem_symbol"));
assert_eq!(harness.semantic_full(MAIN).data, memory_tokens.data);
harness.close_source(MAIN);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
assert_definition_resolves(&mut harness, MAIN, "filesystem_symbol", MAIN);
}
#[test]
fn o19_symlink_like_read_result_refreshes_graph_apis_for_link_path() {
let main = main_source("link.typ", "linked");
let mut harness = LspHarness::new(&[(MAIN, &main)]);
harness.compile_primary();
harness.assert_latest_diagnostics_present();
let link_kind = create_link_fixture(harness.root.path(), "target.typ", "link.typ");
#[cfg(unix)]
assert_eq!(link_kind, LinkFixtureKind::Symlink);
#[cfg(not(unix))]
assert_eq!(link_kind, LinkFixtureKind::PlainFileFallback);
harness.fs_insert("link.typ", "#let linked = 19\n");
harness.assert_latest_diagnostics_empty();
assert_graph_resolves(&mut harness, MAIN, "linked", "link.typ");
}
#[test]
fn o20_mixed_batch_exercises_all_focus_api_groups_from_one_fresh_case() {
let main = main_source_with_local(DEP, "alpha", "before_symbol");
let updated_main = format!(
"#set heading(numbering: \"1.\")\n#import \"{RENAMED_DEP}\": alpha\n#import \"{OTHER_DEP}\": beta\n= Main <main-label>\n@main-label\n#let after = 20\n#alpha\n#beta\n#after\n"
);
let mut harness = LspHarness::new(&[
(MAIN, &main),
(DEP, "#let alpha = 1\n"),
("stale.typ", "#let stale = 1\n"),
]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.open_source(MAIN, &main);
let before_tokens = harness.semantic_full(MAIN);
let before_id = before_tokens
.result_id
.clone()
.expect("expected semantic token result id");
harness.close_source(MAIN);
assert_file_rename_assistance(&mut harness, DEP, RENAMED_DEP, "renamed");
harness.fs_batch(
&[
(RENAMED_DEP, "#let alpha = 20\n"),
(OTHER_DEP, "#let beta = 20\n"),
(MAIN, &updated_main),
],
&[DEP, "stale.typ"],
false,
);
harness.assert_latest_diagnostics_empty();
harness.open_source(MAIN, &updated_main);
assert_graph_resolves(&mut harness, MAIN, "alpha", RENAMED_DEP);
assert_graph_resolves(&mut harness, MAIN, "beta", OTHER_DEP);
assert_workspace_symbol_excludes(&mut harness, "alpha", DEP);
assert_source_symbol_changed(
&mut harness,
MAIN,
"before_symbol",
"after",
before_tokens,
before_id,
);
assert_main_label_references_current_file(&mut harness);
}
#[test]
#[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
fn real_fs_dependency_content_change_publishes_diagnostics_without_lsp_fs_change() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.real_fs_insert(DEP, "");
harness.assert_real_fs_diagnostics_present();
harness.real_fs_insert(DEP, "#let alpha = 2\n");
harness.assert_real_fs_diagnostics_empty();
}
#[test]
#[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
fn real_fs_dependency_remove_and_recreate_publishes_diagnostics_without_lsp_fs_change() {
let main = main_source(DEP, "alpha");
let mut harness = LspHarness::new(&[(MAIN, &main), (DEP, "#let alpha = 1\n")]);
harness.compile_primary();
harness.assert_latest_diagnostics_empty();
harness.real_fs_remove(DEP);
harness.assert_real_fs_diagnostics_present();
harness.real_fs_insert(DEP, "#let alpha = 2\n");
harness.assert_real_fs_diagnostics_empty();
}
fn assert_graph_resolves(harness: &mut LspHarness, rel: &str, symbol: &str, expected_rel: &str) {
let usage = format!("#{symbol}");
let position = harness.position_of(rel, &usage, 0, 2);
let expected_uri = harness.uri(expected_rel);
let current_uri = harness.uri(rel);
let definitions = harness.goto_definition_uris(rel, position);
assert!(
definitions.contains(&expected_uri),
"expected definition of {symbol} in {rel} to include {expected_uri}, got {definitions:?}"
);
let references = harness.reference_uris(rel, position);
assert!(
references.contains(&expected_uri) || references.contains(¤t_uri),
"expected references for {symbol} to include {expected_uri} or {current_uri}, got {references:?}"
);
let hover = harness.hover_text(rel, position);
assert!(
hover.as_ref().is_some_and(|text| !text.is_empty()),
"expected non-empty hover for {symbol}, got {hover:?}"
);
let completions = harness.completion_labels(rel, position);
assert!(
completions.contains(symbol),
"expected completion labels at {symbol} to contain {symbol}, got {completions:?}"
);
let workspace_symbols = harness.workspace_symbol_uris(symbol);
assert!(
workspace_symbols.contains(&expected_uri),
"expected workspace symbols for {symbol} to contain {expected_uri}, got {workspace_symbols:?}"
);
}
fn assert_definition_resolves(
harness: &mut LspHarness,
rel: &str,
symbol: &str,
expected_rel: &str,
) {
let usage = format!("#{symbol}");
let position = harness.position_of(rel, &usage, 0, 2);
let expected_uri = harness.uri(expected_rel);
let definitions = harness.goto_definition_uris(rel, position);
assert!(
definitions.contains(&expected_uri),
"expected definition of {symbol} in {rel} to include {expected_uri}, got {definitions:?}"
);
}
fn assert_graph_does_not_resolve_to(
harness: &mut LspHarness,
rel: &str,
symbol: &str,
retired_rel: &str,
) {
let usage = format!("#{symbol}");
let position = harness.position_of(rel, &usage, 0, 2);
let retired_uri = harness.uri(retired_rel);
let definitions = harness.goto_definition_uris(rel, position);
assert!(
!definitions.contains(&retired_uri),
"expected definition of {symbol} not to retain {retired_uri}, got {definitions:?}"
);
let references = harness.reference_uris(rel, position);
assert!(
!references.contains(&retired_uri),
"expected references for {symbol} not to retain {retired_uri}, got {references:?}"
);
let hover = harness.hover_text(rel, position);
assert!(
hover
.as_ref()
.is_none_or(|text| !text.contains(retired_rel)),
"expected hover for {symbol} not to mention retired path {retired_rel}, got {hover:?}"
);
let _ = harness.completion_labels(rel, position);
}
fn assert_main_label_references_current_file(harness: &mut LspHarness) {
let label_pos = harness.position_of(MAIN, "@main-label", 0, 2);
let references = harness.reference_uris(MAIN, label_pos);
assert!(
references.contains(&harness.uri(MAIN)),
"expected label references to include current main file, got {references:?}"
);
}
fn assert_workspace_symbol_excludes(harness: &mut LspHarness, symbol: &str, rel: &str) {
let uri = harness.uri(rel);
let workspace_symbols = harness.workspace_symbol_uris(symbol);
assert!(
!workspace_symbols.contains(&uri),
"expected workspace symbols for {symbol} not to contain {uri}, got {workspace_symbols:?}"
);
}
fn assert_semantic_tokens_nonempty(harness: &mut LspHarness, rel: &str) {
let tokens = harness.semantic_full(rel);
assert!(
!tokens.data.is_empty(),
"expected non-empty semantic tokens for {rel}, got {tokens:?}"
);
assert!(
tokens.result_id.is_some(),
"expected semantic tokens for {rel} to carry a result id"
);
}
fn assert_source_symbol_changed(
harness: &mut LspHarness,
rel: &str,
old_symbol: &str,
new_symbol: &str,
before_tokens: SemanticTokens,
before_id: String,
) {
let symbols = harness.document_symbol_names(rel);
assert!(
!symbols.contains(old_symbol),
"expected {rel} document symbols to drop {old_symbol}, got {symbols:?}"
);
assert!(
symbols.contains(new_symbol),
"expected {rel} document symbols to include {new_symbol}, got {symbols:?}"
);
let after_tokens = harness.semantic_full(rel);
assert_ne!(
after_tokens.data, before_tokens.data,
"expected semantic token data to change for {rel}"
);
assert_ne!(
after_tokens.result_id,
Some(before_id.clone()),
"expected semantic token result id to change for {rel}"
);
let delta_id = harness.semantic_delta_result_id(rel, before_id.clone());
assert_ne!(
delta_id,
Some(before_id),
"expected semantic token delta to avoid reusing the old result id for {rel}"
);
}
fn assert_file_rename_assistance(
harness: &mut LspHarness,
old_rel: &str,
new_rel: &str,
fallback_new_name: &str,
) {
let edit = harness
.will_rename_edit(old_rel, new_rel)
.filter(|edit| workspace_edit_carries_rename_assistance(harness, edit, new_rel))
.unwrap_or_else(|| {
let include_path_pos = harness.position_of(MAIN, old_rel, 0, 1);
harness.rename_edit(MAIN, include_path_pos, fallback_new_name)
});
assert_workspace_edit_carries_rename_assistance(harness, &edit, new_rel);
}
fn assert_directory_rename_assistance(harness: &mut LspHarness, old_rel: &str, new_rel: &str) {
if let Some(edit) = harness.will_rename_edit(old_rel, new_rel) {
assert_workspace_edit_carries_rename_assistance(harness, &edit, new_rel);
}
}
fn workspace_edit_carries_rename_assistance(
harness: &LspHarness,
edit: &WorkspaceEdit,
expected_rel: &str,
) -> bool {
let expected_uri = harness.uri(expected_rel);
applied_workspace_edit_to_main(harness, edit)
.is_some_and(|after| after.contains(&format!("\"{expected_rel}\"")))
|| workspace_edit_resource_renames(edit).contains(&expected_uri)
}
fn assert_workspace_edit_carries_rename_assistance(
harness: &LspHarness,
edit: &WorkspaceEdit,
expected_rel: &str,
) {
assert!(
workspace_edit_carries_rename_assistance(harness, edit, expected_rel),
"workspace edit should either update main imports or rename a resource to {expected_rel}: {edit:?}"
);
}
fn workspace_edit_resource_renames(edit: &WorkspaceEdit) -> Vec<Url> {
let mut renames = Vec::new();
let Some(DocumentChanges::Operations(operations)) = &edit.document_changes else {
return renames;
};
for operation in operations {
if let DocumentChangeOperation::Op(ResourceOp::Rename(rename)) = operation {
renames.push(rename.new_uri.clone());
}
}
renames
}
fn applied_workspace_edit_to_main(harness: &LspHarness, edit: &WorkspaceEdit) -> Option<String> {
let main_uri = harness.uri(MAIN);
let edits = workspace_edit_text_edits(edit, &main_uri);
if edits.is_empty() {
return None;
}
let before = fs::read_to_string(harness.path(MAIN)).expect("failed to read main fixture");
let after = apply_text_edits(&before, &edits);
if after == before {
return None;
}
Some(after)
}
fn workspace_edit_text_edits(edit: &WorkspaceEdit, uri: &Url) -> Vec<TextEdit> {
let mut edits = Vec::new();
if let Some(changes) = &edit.changes {
if let Some(uri_edits) = changes.get(uri) {
edits.extend(uri_edits.iter().cloned());
}
}
if let Some(document_changes) = &edit.document_changes {
match document_changes {
DocumentChanges::Edits(document_edits) => {
for document_edit in document_edits {
collect_text_document_edit(uri, document_edit, &mut edits);
}
}
DocumentChanges::Operations(operations) => {
for operation in operations {
if let DocumentChangeOperation::Edit(document_edit) = operation {
collect_text_document_edit(uri, document_edit, &mut edits);
}
}
}
}
}
edits
}
fn collect_text_document_edit(
uri: &Url,
document_edit: &TextDocumentEdit,
edits: &mut Vec<TextEdit>,
) {
if document_edit.text_document.uri != *uri {
return;
}
for edit in &document_edit.edits {
match edit {
OneOf::Left(edit) => edits.push(edit.clone()),
OneOf::Right(edit) => edits.push(edit.text_edit.clone()),
}
}
}
fn apply_text_edits(source: &str, edits: &[TextEdit]) -> String {
let mut replacements = edits
.iter()
.map(|edit| {
(
byte_offset(source, edit.range.start),
byte_offset(source, edit.range.end),
edit.new_text.clone(),
)
})
.collect::<Vec<_>>();
replacements.sort_by_key(|(start, end, _)| (*start, *end));
let mut edited = source.to_owned();
for (start, end, new_text) in replacements.into_iter().rev() {
edited.replace_range(start..end, &new_text);
}
edited
}
fn byte_offset(source: &str, position: Position) -> usize {
let mut line = 0;
let mut line_start = 0;
for (idx, ch) in source.char_indices() {
if line == position.line {
break;
}
if ch == '\n' {
line += 1;
line_start = idx + ch.len_utf8();
}
}
assert_eq!(
line, position.line,
"position line {} is outside source {source:?}",
position.line
);
let line_end = source[line_start..]
.find('\n')
.map_or(source.len(), |offset| line_start + offset);
let line_text = &source[line_start..line_end];
let mut character = 0;
for (idx, ch) in line_text.char_indices() {
if character == position.character {
return line_start + idx;
}
character += 1;
if character == position.character {
return line_start + idx + ch.len_utf8();
}
}
assert_eq!(
character, position.character,
"position character {} is outside source line {line_text:?}",
position.character
);
line_end
}
fn write_fixture(root: &Path, rel: &str, source: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("failed to create fixture directory");
}
fs::write(path, source).expect("failed to write fixture");
}
fn remove_existing_fixture(root: &Path, rel: &str) {
let path = root.join(rel);
let metadata = fs::symlink_metadata(&path)
.unwrap_or_else(|err| panic!("expected fixture {path:?} to exist before removal: {err}"));
if metadata.file_type().is_dir() {
fs::remove_dir_all(path).expect("failed to remove fixture directory");
} else {
fs::remove_file(path).expect("failed to remove fixture file");
}
}
fn position_in(source: &str, needle: &str, occurrence: usize, offset: usize) -> Position {
let mut start = 0;
let mut found = None;
for _ in 0..=occurrence {
let relative = source[start..]
.find(needle)
.unwrap_or_else(|| panic!("needle {needle:?} not found in {source:?}"));
let absolute = start + relative;
found = Some(absolute);
start = absolute + needle.len();
}
let target = found.expect("needle should be found") + offset;
let prefix = &source[..target];
let line = prefix.bytes().filter(|byte| *byte == b'\n').count() as u32;
let character = prefix
.rsplit_once('\n')
.map_or(prefix, |(_, last_line)| last_line)
.chars()
.count() as u32;
Position { line, character }
}
fn flatten_document_symbol(symbol: DocumentSymbol) -> Vec<String> {
let mut names = vec![symbol.name];
for child in symbol.children.unwrap_or_default() {
names.extend(flatten_document_symbol(child));
}
names
}
fn main_source(dep: &str, symbol: &str) -> String {
format!(
"#set heading(numbering: \"1.\")\n#import \"{dep}\": {symbol}\n= Main <main-label>\n@main-label\n#{symbol}\n"
)
}
fn main_source_with_local(dep: &str, symbol: &str, local: &str) -> String {
format!(
"#set heading(numbering: \"1.\")\n#import \"{dep}\": {symbol}\n= Main <main-label>\n@main-label\n#let {local} = 1\n#{symbol}\n#{local}\n"
)
}
fn local_source(symbol: &str) -> String {
format!("#let {symbol} = 1\n#{symbol}\n")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinkFixtureKind {
#[cfg(unix)]
Symlink,
#[cfg(not(unix))]
PlainFileFallback,
}
fn create_link_fixture(root: &Path, target: &str, link: &str) -> LinkFixtureKind {
write_fixture(root, target, "#let linked = 0\n");
#[cfg(unix)]
{
let link_path = root.join(link);
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent).expect("failed to create symlink fixture directory");
}
std::os::unix::fs::symlink(target, link_path).expect("failed to create symlink fixture");
LinkFixtureKind::Symlink
}
#[cfg(not(unix))]
{
write_fixture(root, link, "#let linked = 0\n");
LinkFixtureKind::PlainFileFallback
}
}