use lsp_types::{ClientCapabilities, ServerCapabilities, TextDocumentIdentifier, Uri};
use tokio_util::sync::CancellationToken;
use super::RegisterCapabilities;
use crate::context::{RequestOutcome, ServerContextSnapshot, snapshot_query};
use crate::util::{path_to_uri, uri_to_file_path};
pub enum SwitchSourceHeader {}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SwitchSourceHeaderParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
text_document: Option<TextDocumentIdentifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
uri: Option<Uri>,
}
impl SwitchSourceHeaderParams {
pub fn the_uri(&self) -> Option<&Uri> {
self.text_document
.as_ref()
.map(|document| &document.uri)
.or(self.uri.as_ref())
}
}
impl lsp_types::request::Request for SwitchSourceHeader {
type Params = SwitchSourceHeaderParams;
type Result = Option<Uri>;
const METHOD: &'static str = "textDocument/switchSourceHeader";
}
pub async fn on_switch_source_header(
context: ServerContextSnapshot,
params: SwitchSourceHeaderParams,
cancel_token: CancellationToken,
) -> RequestOutcome<Option<Uri>> {
let Some(uri) = params.the_uri().cloned() else {
return RequestOutcome::Ready(None);
};
if let Some(path) = uri_to_file_path(&uri) {
context.analysis().prepare(&path).await;
context.analysis().catch_up(&path).await;
}
snapshot_query(context.analysis(), cancel_token, move |session| {
let path = uri_to_file_path(&uri)?;
Some(match the_counterpart_of(session, &path) {
Some(counterpart) => path_to_uri(&counterpart),
None => None,
})
})
.await
}
pub struct SwitchSourceHeaderCapabilities;
impl RegisterCapabilities for SwitchSourceHeaderCapabilities {
fn register_capabilities(_: &mut ServerCapabilities, _: &ClientCapabilities) {}
}
const SOURCE_EXTENSIONS: &[&str] = &["cpp", "cc", "cxx", "c++", "c", "m", "mm"];
const HEADER_EXTENSIONS: &[&str] = &["h", "hh", "hpp", "hxx", "h++", "inl", "ipp", "tcc"];
const MODULE_INTERFACE_EXTENSIONS: &[&str] = &["cppm", "ixx", "mpp", "ccm", "cxxm"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Source,
Header,
ModuleInterface,
}
impl Kind {
fn of(extension: &str) -> Option<Self> {
if SOURCE_EXTENSIONS.contains(&extension) {
Some(Kind::Source)
} else if HEADER_EXTENSIONS.contains(&extension) {
Some(Kind::Header)
} else if MODULE_INTERFACE_EXTENSIONS.contains(&extension) {
Some(Kind::ModuleInterface)
} else {
None
}
}
fn counterpart_extensions(
self,
family: Option<cpp_parser::features::Family>,
) -> Vec<&'static str> {
use cpp_parser::features::Family;
let sources: Vec<&'static str> = match family {
Some(Family::C) => vec!["c", "m", "mm", "cpp", "cc", "cxx", "c++"],
_ => vec!["cpp", "cc", "cxx", "c++", "c", "m", "mm"],
};
match self {
Kind::Source => HEADER_EXTENSIONS
.iter()
.chain(MODULE_INTERFACE_EXTENSIONS)
.copied()
.collect(),
Kind::Header => sources
.into_iter()
.chain(MODULE_INTERFACE_EXTENSIONS.iter().copied())
.collect(),
Kind::ModuleInterface => sources,
}
}
fn expected_counterpart(
self,
family: Option<cpp_parser::features::Family>,
) -> Option<&'static str> {
use cpp_parser::features::Family;
match self {
Kind::Header if family == Some(Family::C) => Some("c"),
Kind::Header => Some("cpp"),
Kind::ModuleInterface => Some("cpp"),
Kind::Source => None,
}
}
}
fn the_counterpart_of<F>(
session: &cpp_code_analysis::Session<F>,
path: &std::path::Path,
) -> Option<std::path::PathBuf>
where
F: cpp_code_analysis::FileProvider + Clone,
{
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
let kind = Kind::of(&extension)?;
let family = session.config_for(path).language_for(path).family();
let counterparts = kind.counterpart_extensions(family);
let directory = path.parent()?;
let stem = path.file_stem()?.to_str()?;
for extension in &counterparts {
let candidate = directory.join(format!("{stem}.{extension}"));
if exists(session, &candidate) {
return Some(candidate);
}
}
let listed = session.project_files();
for candidate in listed {
let Some(candidate_extension) = candidate
.extension()
.and_then(|extension| extension.to_str())
else {
continue;
};
let candidate_extension = candidate_extension.to_ascii_lowercase();
if !counterparts.contains(&candidate_extension.as_str()) {
continue;
}
if includes(session, candidate, path) || includes(session, path, candidate) {
return Some(candidate.clone());
}
}
for candidate in listed {
let Some(candidate_stem) = candidate.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
if candidate_stem != stem {
continue;
}
let Some(candidate_extension) = candidate
.extension()
.and_then(|extension| extension.to_str())
else {
continue;
};
let candidate_extension = candidate_extension.to_ascii_lowercase();
if counterparts.contains(&candidate_extension.as_str()) {
return Some(candidate.clone());
}
}
let expected = kind.expected_counterpart(family)?;
Some(directory.join(format!("{stem}.{expected}")))
}
fn exists<F>(session: &cpp_code_analysis::Session<F>, path: &std::path::Path) -> bool
where
F: cpp_code_analysis::FileProvider + Clone,
{
if path.exists() {
return true;
}
let wanted = normalized(path);
session
.project_files()
.iter()
.any(|listed| normalized(listed) == wanted)
}
fn includes<F>(
session: &cpp_code_analysis::Session<F>,
file: &std::path::Path,
target: &std::path::Path,
) -> bool
where
F: cpp_code_analysis::FileProvider + Clone,
{
let Some(summary) = session.directive_summary(file) else {
return false;
};
let target = normalized(target);
summary.includes.iter().any(|include| {
include
.resolved
.as_deref()
.is_some_and(|resolved| normalized(resolved) == target)
})
}
fn normalized(path: &std::path::Path) -> String {
path.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::the_counterpart_of;
use cpp_code_analysis::{
CompilerConfig, MemoryFiles, OpenDocuments, Session, SessionFiles, WatchFilter,
};
fn session_with(files: &[(&str, &str)]) -> Session<MemoryFiles> {
let mut memory = MemoryFiles::new();
for (path, source) in files {
memory = memory.with_file(*path, *source);
}
let providers = SessionFiles::new(OpenDocuments::new(), memory);
let mut session = Session::with_config(
"/p",
providers,
WatchFilter::new("/p"),
CompilerConfig::default(),
);
session.add_project_files(
files
.iter()
.map(|(path, _)| std::path::PathBuf::from(*path)),
);
session
}
#[test]
fn the_twin_is_found_in_both_directions() {
let session = session_with(&[
("/p/foo.cpp", "#include \"foo.h\"\n"),
("/p/foo.h", "int f();\n"),
]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/foo.cpp")),
Some(std::path::PathBuf::from("/p/foo.h")),
"a source's header"
);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/foo.h")),
Some(std::path::PathBuf::from("/p/foo.cpp")),
"a header's source"
);
}
#[test]
fn an_include_finds_a_pair_that_is_not_named_alike() {
let session = session_with(&[
("/p/detail.cpp", "#include \"all.h\"\n"),
("/p/all.h", "int f();\n"),
]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/detail.cpp")),
Some(std::path::PathBuf::from("/p/all.h"))
);
}
#[test]
fn a_file_with_no_pair_answers_nothing() {
let session = session_with(&[("/p/lonely.cpp", "int main() { return 0; }\n")]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/lonely.cpp")),
None
);
let session = session_with(&[("/p/notes.txt", "notes\n"), ("/p/notes.h", "int f();\n")]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/notes.txt")),
None
);
}
#[test]
fn a_header_answers_the_source_it_would_have() {
let session = session_with(&[("/p/api.h", "int f();\n")]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/api.h")),
Some(std::path::PathBuf::from("/p/api.cpp"))
);
let c_implementation = session_with(&[
("/p/api.h", "int f(void);\n"),
("/p/api.c", "int f(void);\n"),
]);
assert_eq!(
the_counterpart_of(&c_implementation, std::path::Path::new("/p/api.h")),
Some(std::path::PathBuf::from("/p/api.c"))
);
}
#[test]
fn a_module_interface_pairs_with_its_implementation() {
let session = session_with(&[
("/p/widget.cppm", "export module widget;\n"),
("/p/widget.cpp", "import widget;\n"),
]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/widget.cppm")),
Some(std::path::PathBuf::from("/p/widget.cpp")),
"an interface's implementation unit"
);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/widget.cpp")),
Some(std::path::PathBuf::from("/p/widget.cppm")),
"and the implementation unit's interface, when there is no header"
);
let alone = session_with(&[("/p/widget.cppm", "export module widget;\n")]);
assert_eq!(
the_counterpart_of(&alone, std::path::Path::new("/p/widget.cppm")),
Some(std::path::PathBuf::from("/p/widget.cpp"))
);
}
#[test]
fn a_source_with_both_answers_the_header() {
let session = session_with(&[
("/p/widget.h", "int f();\n"),
("/p/widget.cppm", "export module widget;\n"),
("/p/widget.cpp", "#include \"widget.h\"\n"),
]);
assert_eq!(
the_counterpart_of(&session, std::path::Path::new("/p/widget.cpp")),
Some(std::path::PathBuf::from("/p/widget.h"))
);
}
}
#[cfg(test)]
mod params_tests {
use super::SwitchSourceHeaderParams;
#[test]
fn both_param_shapes_name_the_document() {
let bare: SwitchSourceHeaderParams = serde_json::from_str(r#"{"uri":"file:///p/foo.cpp"}"#)
.expect("a bare `uri` is a `TextDocumentIdentifier`");
assert_eq!(
bare.the_uri().map(|uri| uri.as_str()),
Some("file:///p/foo.cpp")
);
let nested: SwitchSourceHeaderParams =
serde_json::from_str(r#"{"textDocument":{"uri":"file:///p/foo.h"}}"#)
.expect("the LSP-shaped `textDocument` is what a VS Code client sends");
assert_eq!(
nested.the_uri().map(|uri| uri.as_str()),
Some("file:///p/foo.h")
);
let empty: SwitchSourceHeaderParams = serde_json::from_str("{}").expect("an empty object");
assert_eq!(empty.the_uri(), None);
}
}