Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/debhelper/links/completion.rs
Original file line number Diff line number Diff line change
@@ -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<CompletionItem> {
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<CompletionItem> {
let dir = match prefix.rfind('/') {
Some(i) => &prefix[..=i],
None => "",
};
let mut found: BTreeMap<String, bool> = 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<String> {
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());
}
}
61 changes: 61 additions & 0 deletions src/debhelper/links/detection.rs
Original file line number Diff line number Diff line change
@@ -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/<package>.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/<package>
/// for a debian/<package>.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 <package> part of a debian/<package>.links filename, if any.
fn package_name(uri: &Uri) -> Option<String> {
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")));
}
}
7 changes: 7 additions & 0 deletions src/debhelper/links/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Support for debian/links and debian/<package>.links files.

pub mod completion;
pub mod detection;

pub use completion::get_completions;
pub use detection::{is_links_file, package_dir};
1 change: 1 addition & 0 deletions src/debhelper/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ enum FileType {
Install,
/// debian/not-installed or debian/<package>.not-installed file
NotInstalled,
/// debian/links or debian/<package>.links file
Links,
}

impl FileType {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -505,7 +509,8 @@ impl Backend {
| FileType::Info
| FileType::Manpages
| FileType::Install
| FileType::NotInstalled => None,
| FileType::NotInstalled
| FileType::Links => None,
}
}

Expand Down Expand Up @@ -572,7 +577,8 @@ impl Backend {
| FileType::Info
| FileType::Manpages
| FileType::Install
| FileType::NotInstalled => Vec::new(),
| FileType::NotInstalled
| FileType::Links => Vec::new(),
}
}

Expand Down Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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),
};

Expand Down
Loading