diff --git a/src/debhelper/links/completion.rs b/src/debhelper/links/completion.rs new file mode 100644 index 0000000..cde4c72 --- /dev/null +++ b/src/debhelper/links/completion.rs @@ -0,0 +1,122 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position}; + +use crate::debhelper::completion; + +/// Completions for a debian/links file at the given cursor position. +pub fn get_completions( + text: &str, + position: Position, + package_dir: Option<&Path>, +) -> Vec { + completion::get_completions(text, position, |_, prefix| match package_dir { + Some(dir) => package_candidates(dir, prefix), + None => Vec::new(), + }) +} + +/// Paths inside the package staging directory for a link token. +fn package_candidates(package_dir: &Path, prefix: &str) -> Vec { + let dir = match prefix.rfind('/') { + Some(i) => &prefix[..=i], + None => "", + }; + let mut found: BTreeMap = BTreeMap::new(); + if let Ok(entries) = std::fs::read_dir(package_dir.join(dir)) { + for entry in entries.flatten() { + let name = entry.file_name(); + let candidate = format!("{dir}{}", name.to_string_lossy()); + if candidate.starts_with(prefix) { + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + found.insert(candidate, is_dir); + } + } + } + found + .into_iter() + .map(|(path, is_dir)| { + let (label, kind) = if is_dir { + (format!("{path}/"), CompletionItemKind::FOLDER) + } else { + (path, CompletionItemKind::FILE) + }; + CompletionItem { + label, + kind: Some(kind), + ..Default::default() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn staging(files: &[&str]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for rel in files { + let path = dir.path().join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "").unwrap(); + } + dir + } + + fn labels(items: &[CompletionItem]) -> Vec { + items.iter().map(|i| i.label.clone()).collect() + } + + #[test] + fn completes_the_target_token() { + let pkg = staging(&["usr/bin/prog"]); + let items = get_completions("usr/\n", Position::new(0, 4), Some(pkg.path())); + assert!(labels(&items).contains(&"usr/bin/".to_string())); + } + + #[test] + fn completes_the_link_token() { + let pkg = staging(&["usr/bin/prog"]); + let items = get_completions( + "usr/share/foo usr/\n", + Position::new(0, 18), + Some(pkg.path()), + ); + assert!(labels(&items).iter().any(|l| l.starts_with("usr/"))); + } + + #[test] + fn a_later_token_still_completes() { + let pkg = staging(&["usr/bin/prog"]); + let items = get_completions("a b usr/\n", Position::new(0, 8), Some(pkg.path())); + assert!(labels(&items).iter().any(|l| l.starts_with("usr/"))); + } + + #[test] + fn directories_end_with_a_slash() { + let pkg = staging(&["usr/bin/prog"]); + let items = get_completions("usr", Position::new(0, 3), Some(pkg.path())); + let usr = items.iter().find(|i| i.label == "usr/").unwrap(); + assert_eq!(usr.kind, Some(CompletionItemKind::FOLDER)); + } + + #[test] + fn nothing_without_a_package_dir() { + let items = get_completions("usr/\n", Position::new(0, 4), None); + assert!(items.is_empty()); + } + + #[test] + fn dollar_offers_substitution_vars() { + let items = get_completions("usr/lib/$\n", Position::new(0, 9), None); + assert!(items.iter().any(|i| i.label == "${DEB_HOST_MULTIARCH}")); + } + + #[test] + fn no_completion_in_comment() { + let items = get_completions("# usr/share/foo\n", Position::new(0, 15), None); + assert!(items.is_empty()); + } +} diff --git a/src/debhelper/links/detection.rs b/src/debhelper/links/detection.rs new file mode 100644 index 0000000..3111950 --- /dev/null +++ b/src/debhelper/links/detection.rs @@ -0,0 +1,61 @@ +use std::path::{Path, PathBuf}; + +use tower_lsp_server::ls_types::Uri; + +use crate::debhelper::detection::is_debhelper_file; + +/// Whether the URI is a debian/links or debian/.links file. +pub fn is_links_file(uri: &Uri) -> bool { + is_debhelper_file(uri, "links") +} + +/// The staging directory whose files a links file refers to: debian/ +/// for a debian/.links file, else debian/tmp for a plain debian/links. +pub fn package_dir(debian_dir: &Path, uri: &Uri) -> PathBuf { + match package_name(uri) { + Some(pkg) => debian_dir.join(pkg), + None => debian_dir.join("tmp"), + } +} + +/// The part of a debian/.links filename, if any. +fn package_name(uri: &Uri) -> Option { + let file = uri.as_str().rsplit('/').next()?; + let stem = file.strip_suffix(".links")?; + (!stem.is_empty()).then(|| stem.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uri(s: &str) -> Uri { + s.parse().unwrap() + } + + #[test] + fn detects_qualified_and_unqualified() { + assert!(is_links_file(&uri("file:///p/debian/links"))); + assert!(is_links_file(&uri("file:///p/debian/mypkg.links"))); + } + + #[test] + fn package_dir_uses_the_package_name() { + let debian = Path::new("/p/debian"); + assert_eq!( + package_dir(debian, &uri("file:///p/debian/mypkg.links")), + debian.join("mypkg") + ); + assert_eq!( + package_dir(debian, &uri("file:///p/debian/links")), + debian.join("tmp") + ); + } + + #[test] + fn rejects_other_files() { + assert!(!is_links_file(&uri("file:///p/debian/control"))); + assert!(!is_links_file(&uri("file:///p/links"))); + assert!(!is_links_file(&uri("file:///p/debian/links.bak"))); + } +} diff --git a/src/debhelper/links/mod.rs b/src/debhelper/links/mod.rs new file mode 100644 index 0000000..b283cf4 --- /dev/null +++ b/src/debhelper/links/mod.rs @@ -0,0 +1,7 @@ +//! Support for debian/links and debian/.links files. + +pub mod completion; +pub mod detection; + +pub use completion::get_completions; +pub use detection::{is_links_file, package_dir}; diff --git a/src/debhelper/mod.rs b/src/debhelper/mod.rs index e20cc51..33c0e6a 100644 --- a/src/debhelper/mod.rs +++ b/src/debhelper/mod.rs @@ -7,6 +7,7 @@ pub mod docs; pub mod examples; pub mod info; pub mod install; +pub mod links; pub mod manpages; pub mod not_installed; pub mod parser; diff --git a/src/main.rs b/src/main.rs index 24b8838..0ebe028 100644 --- a/src/main.rs +++ b/src/main.rs @@ -152,6 +152,8 @@ enum FileType { Install, /// debian/not-installed or debian/.not-installed file NotInstalled, + /// debian/links or debian/.links file + Links, } impl FileType { @@ -203,6 +205,8 @@ impl FileType { Some(Self::Install) } else if debhelper::not_installed::is_not_installed_file(uri) { Some(Self::NotInstalled) + } else if debhelper::links::is_links_file(uri) { + Some(Self::Links) } else { None } @@ -505,7 +509,8 @@ impl Backend { | FileType::Info | FileType::Manpages | FileType::Install - | FileType::NotInstalled => None, + | FileType::NotInstalled + | FileType::Links => None, } } @@ -572,7 +577,8 @@ impl Backend { | FileType::Info | FileType::Manpages | FileType::Install - | FileType::NotInstalled => Vec::new(), + | FileType::NotInstalled + | FileType::Links => Vec::new(), } } @@ -1553,6 +1559,13 @@ impl LanguageServer for Backend { debian_dir.as_deref(), ) } + Some((FileType::Links, source_file)) => { + let workspace = self.workspace_clone().await; + let source_text = workspace.source_text(source_file); + let package_dir = + Self::find_debian_dir(&uri).map(|d| debhelper::links::package_dir(&d, &uri)); + debhelper::links::get_completions(&source_text, position, package_dir.as_deref()) + } None => Vec::new(), }; @@ -2169,7 +2182,8 @@ impl LanguageServer for Backend { | FileType::Info | FileType::Manpages | FileType::Install - | FileType::NotInstalled => debhelper::semantic::generate_semantic_tokens(src), + | FileType::NotInstalled + | FileType::Links => debhelper::semantic::generate_semantic_tokens(src), FileType::Triggers => triggers::generate_semantic_tokens(src), };