diff --git a/Cargo.lock b/Cargo.lock index 1b42fbe..44eb435 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1885,9 +1885,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ecab64a98bbac9f8e02990a9ea5e3c974a7d49b95f2bd70ad94ad22fa6b48c" +checksum = "3d63f9e28b59ddeb1a1eb9e5cf986a9222b5d484947445edbc20473939cc7fd0" dependencies = [ "bstr", "gix-error", @@ -1958,9 +1958,9 @@ dependencies = [ [[package]] name = "gix-hashtable" -version = "0.15.1" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e30b93eea8718baf7d8153fcb938e2926175bbf18097c09f1c01b6f0be0563" +checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e" dependencies = [ "gix-hash", "hashbrown 0.17.1", @@ -2043,9 +2043,9 @@ dependencies = [ [[package]] name = "gix-tempfile" -version = "23.0.1" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27850097e1ff9515f46a0dad0f5f9c9d020e972727772dabab9450690c4adb22" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" dependencies = [ "gix-fs", "libc", @@ -4208,9 +4208,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -4228,9 +4228,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", @@ -4264,9 +4264,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -7238,9 +7238,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" [[package]] name = "zmij" diff --git a/src/lintian_overrides/completion.rs b/src/lintian_overrides/completion.rs index d93c196..cf78616 100644 --- a/src/lintian_overrides/completion.rs +++ b/src/lintian_overrides/completion.rs @@ -1,82 +1,377 @@ -use crate::position::Source; -use lintian_overrides::{LintianOverrides, Parse, SyntaxKind}; +use rowan::TextSize; use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position}; -/// Get completion items for a lintian overrides file. `parsed` is -/// the salsa-cached parse — its tree is always usable even on -/// malformed input, so we don't bail on parse errors. +use crate::position::Source; +use lintian_overrides::{ + AstNode, LintianOverrides, OverrideLine, PackageSpec, Parse, PACKAGE_TYPES, +}; + +/// Get completion items for a lintian-overrides file. +/// +/// Override grammar: +/// `[[][ ][ ]: ][[*][*]]` pub fn get_completions( parsed: &Parse, src: Source<'_>, position: Position, tags: &[(String, String)], + packages: &[String], + architectures: &[String], ) -> Vec { - let offset = match src.try_position_to_offset(position) { - Some(o) => o, - None => return Vec::new(), + let current_line = src.text.lines().nth(position.line as usize).unwrap_or(""); + let col = (position.character as usize).min(current_line.len()); + let before_cursor = ¤t_line[..col]; + + let offset = TextSize::from(offset_at(src.text, position) as u32); + let tree = parsed.tree(); + + let Some(line) = tree.lines().find(|l| { + let r = l.syntax().text_range(); + r.start() <= offset && offset <= r.end() + }) else { + return Vec::new(); }; - let root = parsed.syntax(); - - // Find the token at the cursor position - let token = root.token_at_offset(offset).right_biased(); - - // Determine context: are we on a tag, package spec, or starting a new line? - let on_tag = token.as_ref().is_some_and(|t| t.kind() == SyntaxKind::TAG); - let on_package_name = token - .as_ref() - .is_some_and(|t| t.kind() == SyntaxKind::PACKAGE_NAME); - - // If on a tag or at the start of a line (where a tag would go), suggest known tags - if on_tag || on_package_name { - return tags - .iter() - .map(|(tag, description)| CompletionItem { - label: tag.clone(), - kind: Some(CompletionItemKind::VALUE), - detail: if description.is_empty() { - None - } else { - Some(description.clone()) - }, - ..Default::default() - }) - .collect(); - } - - Vec::new() + if line.is_comment() { + return Vec::new(); + } + + let spec = line.package_spec(); + + if spec.is_none() { + let (committed, pending) = split_committed(before_cursor); + if committed.is_empty() { + let mut out = package_items(packages, pending); + out.extend(tag_items(pending, tags)); + return out; + } + return Vec::new(); + } + + match spec { + Some(spec) if offset < spec.syntax().text_range().end() => { + // Inside the spec: architecture list or type slot. + spec_region_completions(&spec, offset, before_cursor, architectures) + } + _ => { + // At or past the colon: the lintian tag, then free-form context. + tag_region_completions(&line, offset, before_cursor, tags) + } + } +} + +/// Completions after the spec colon: the lintian tag, then free-form context. +fn tag_region_completions( + line: &OverrideLine, + offset: TextSize, + before_cursor: &str, + tags: &[(String, String)], +) -> Vec { + match line.tag() { + // Cursor is on (or right at the end of) the tag word -> filter tags. + Some(tag) if offset <= tag.text_range().end() => { + tag_items(pending_word(before_cursor), tags) + } + // Cursor is past the tag, in the context -> nothing to suggest. + Some(_) => Vec::new(), + // Colon but no tag yet -> offer the full tag list. + None => tag_items(pending_word(before_cursor), tags), + } +} + +/// Completions inside a package spec: architecture names within the bracket +/// list, or the opening `[` / type keywords elsewhere. +fn spec_region_completions( + spec: &PackageSpec, + offset: TextSize, + before_cursor: &str, + architectures: &[String], +) -> Vec { + if spec.arch_list_contains_offset(offset) { + return arch_items(architectures, pending_arch(before_cursor)); + } + + let pending = pending_word(before_cursor); + let mut out = Vec::new(); + if pending.is_empty() && bracket_allowed(spec, offset) { + out.push(punct("[", "Architecture restriction list")); + } + out.extend(type_items(pending)); + out +} + +/// Whether `[` can be inserted at `offset` without reordering components +/// already committed in `spec`: no arch-list yet, and not past a type +/// keyword that's already there (archlist must precede type). +fn bracket_allowed(spec: &PackageSpec, offset: TextSize) -> bool { + if spec.has_arch_list() { + return false; + } + match spec.package_type_range() { + Some(r) => offset <= r.start(), + None => true, + } +} + +/// Build tag items filtered by `prefix`. +fn tag_items(prefix: &str, tags: &[(String, String)]) -> Vec { + let p = prefix.to_ascii_lowercase(); + tags.iter() + .filter(|(tag, _)| tag.to_ascii_lowercase().starts_with(&p)) + .map(|(tag, description)| CompletionItem { + label: tag.clone(), + kind: Some(CompletionItemKind::VALUE), + detail: (!description.is_empty()).then(|| description.clone()), + ..Default::default() + }) + .collect() +} + +/// Build package-name items (source and binary packages from `debian/control`) +/// filtered by `prefix`. +fn package_items(packages: &[String], prefix: &str) -> Vec { + let p = prefix.to_ascii_lowercase(); + packages + .iter() + .filter(|name| name.to_ascii_lowercase().starts_with(&p)) + .map(|name| CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::MODULE), + detail: Some("Package".to_string()), + ..Default::default() + }) + .collect() +} + +/// Build architecture items filtered by `prefix`. +fn arch_items(architectures: &[String], prefix: &str) -> Vec { + architectures + .iter() + .filter(|arch| arch.as_str().starts_with(prefix)) + .map(|arch| CompletionItem { + label: arch.clone(), + kind: Some(CompletionItemKind::VALUE), + ..Default::default() + }) + .collect() +} + +/// Build type-keyword items (`source`, `binary`, `udeb`) filtered by `prefix`. +fn type_items(prefix: &str) -> Vec { + let p = prefix.to_ascii_lowercase(); + PACKAGE_TYPES + .iter() + .filter(|t| t.starts_with(&p)) + .map(|t| CompletionItem { + label: (*t).to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("Package type".to_string()), + ..Default::default() + }) + .collect() +} + +/// Build a punctuation completion item (`[`). +fn punct(label: &str, detail: &str) -> CompletionItem { + CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::OPERATOR), + detail: Some(detail.to_string()), + insert_text: Some(label.to_string()), + ..Default::default() + } +} + +/// The partial word immediately before the cursor. Used only to filter +/// candidates, never to derive structure. +fn pending_word(before_cursor: &str) -> &str { + before_cursor + .rsplit(char::is_whitespace) + .next() + .unwrap_or("") +} + +/// Like [`pending_word`] but strips the leading `[` / `!` that precede an +/// architecture inside a bracket list. +fn pending_arch(before_cursor: &str) -> &str { + pending_word(before_cursor) + .trim_start_matches('[') + .trim_start_matches('!') +} + +/// Split `before_cursor` into the already-committed text and the word currently +/// being typed. +fn split_committed(before_cursor: &str) -> (&str, &str) { + let trimmed = before_cursor.trim_start(); + match trimmed.rfind(char::is_whitespace) { + Some(i) => (trimmed[..i].trim_end(), trimmed[i..].trim_start()), + None => ("", trimmed), + } +} + +/// Byte offset into `text` for an LSP position. Columns are treated as byte +/// offsets (lintian-overrides content is ASCII). +fn offset_at(text: &str, position: Position) -> usize { + let mut offset = 0; + for (row, line) in text.split_inclusive('\n').enumerate() { + if row as u32 == position.line { + return offset + (position.character as usize).min(line.len()); + } + offset += line.len(); + } + offset + position.character as usize } #[cfg(test)] mod tests { use super::*; + use crate::position::LineIndex; + + fn tags() -> Vec<(String, String)> { + vec![ + ("missing-systemd-service".to_string(), "d".to_string()), + ("missing-build-dependency".to_string(), "d".to_string()), + ("hardening-no-pie".to_string(), "d".to_string()), + ] + } + + fn pkgs() -> Vec { + vec!["libcurl4".to_string(), "libfoo-dev".to_string()] + } + + fn archs() -> Vec { + vec![ + "amd64".to_string(), + "arm64".to_string(), + "armhf".to_string(), + "i386".to_string(), + "any".to_string(), + "all".to_string(), + "linux-any".to_string(), + ] + } + + fn complete(line: &str, ch: u32) -> Vec { + complete_with(line, ch, &[]) + } + + fn complete_with(line: &str, ch: u32, packages: &[String]) -> Vec { + let idx = LineIndex::new(line); + let src = Source::new(line, &idx); + let parsed = LintianOverrides::parse(line); + get_completions( + &parsed, + src, + Position::new(0, ch), + &tags(), + packages, + &archs(), + ) + } + + fn labels(items: &[CompletionItem]) -> Vec<&str> { + items.iter().map(|c| c.label.as_str()).collect() + } + + #[test] + fn first_token_offers_packages_and_tags() { + let items = complete_with("lib", 3, &pkgs()); + let l = labels(&items); + assert!(l.contains(&"libcurl4")); + assert!(l.contains(&"libfoo-dev")); + } + + #[test] + fn first_token_filters_tags() { + let items = complete("missing-", 8); + let l = labels(&items); + assert!(l.contains(&"missing-systemd-service")); + assert!(l.contains(&"missing-build-dependency")); + assert!(!l.contains(&"hardening-no-pie")); + } + + #[test] + fn second_token_without_colon_offers_nothing() { + assert!(complete_with("libcurl4 ", 9, &pkgs()).is_empty()); + } - fn run(text: &str, position: Position, tags: &[(String, String)]) -> Vec { - let parsed = LintianOverrides::parse(text); - let idx = crate::position::LineIndex::new(text); - get_completions(&parsed, Source::new(text, &idx), position, tags) + #[test] + fn after_colon_offers_tags() { + let items = complete("foo: ", 5); + let l = labels(&items); + assert!(l.contains(&"missing-systemd-service")); + assert!(l.contains(&"hardening-no-pie")); + } + + #[test] + fn after_colon_filters_tags() { + let items = complete("foo: missing-", 13); + let l = labels(&items); + assert!(l.contains(&"missing-systemd-service")); + assert!(!l.contains(&"hardening-no-pie")); + } + + #[test] + fn context_after_tag_offers_nothing() { + assert!(complete("foo: some-tag ", 14).is_empty()); + } + + #[test] + fn inside_brackets_offers_archs() { + let items = complete("libcurl4 []: hardening-no-pie", 10); + let l = labels(&items); + assert!(l.contains(&"amd64")); + assert!(l.contains(&"arm64")); + } + + #[test] + fn inside_brackets_filters_archs() { + let items = complete("libcurl4 [arm]: hardening-no-pie", 13); + let l = labels(&items); + assert!(l.contains(&"arm64")); + assert!(l.contains(&"armhf")); + assert!(!l.contains(&"amd64")); + } + + #[test] + fn type_slot_offers_types() { + let items = complete("foo binary: x", 4); + let l = labels(&items); + assert!(l.contains(&"binary")); + assert!(l.contains(&"source")); + assert!(l.contains(&"udeb")); + } + + #[test] + fn bracket_offered_after_package() { + let items = complete("foo binary: x", 4); + let l = labels(&items); + assert!(l.contains(&"[")); + } + + #[test] + fn bracket_not_offered_with_existing_arch_list() { + let items = complete("foo [amd64] binary: x", 12); + let l = labels(&items); + assert!(!l.contains(&"[")); + assert!(l.contains(&"binary")); } #[test] - fn test_completions_empty_file() { - assert!(run("", Position::new(0, 0), &[]).is_empty()); + fn bracket_not_offered_past_type() { + let items = complete("foo binary : x", 11); + let l = labels(&items); + assert!(!l.contains(&"[")); } #[test] - fn test_completions_on_tag() { - let tags = vec![ - ("some-tag".to_string(), "A test tag".to_string()), - ("other-tag".to_string(), "Another tag".to_string()), - ]; - // Position at start of line, on the tag token - let completions = run("some-tag\n", Position::new(0, 0), &tags); - assert_eq!(completions.len(), 2); - assert!(completions.iter().any(|c| c.label == "some-tag")); - assert!(completions.iter().any(|c| c.label == "other-tag")); + fn comment_offers_nothing() { + assert!(complete("# a comment", 11).is_empty()); } #[test] - fn test_completions_no_tags_available() { - assert!(run("some-tag\n", Position::new(0, 0), &[]).is_empty()); + fn after_colon_no_tag_offers_all_tags() { + let items = complete("foo: ", 5); + assert_eq!(labels(&items).len(), 3); // the full tag list } } diff --git a/src/lintian_overrides/hover.rs b/src/lintian_overrides/hover.rs new file mode 100644 index 0000000..00f0130 --- /dev/null +++ b/src/lintian_overrides/hover.rs @@ -0,0 +1,141 @@ +use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind}; + +use lintian_overrides::SyntaxKind; + +/// Get hover information for a lintian-overrides file at the given cursor position. +pub fn get_hover( + kind: SyntaxKind, + text: &str, + tags: &[(String, String)], + packages: &[String], +) -> Option { + let value = match kind { + SyntaxKind::TAG => { + let description = tags + .iter() + .find(|(tag, _)| tag == text) + .map(|(_, desc)| desc.as_str())?; + if description.is_empty() { + return None; + } + format!("**{}**\n\nLintian tag\n\n{}", text, description) + } + SyntaxKind::PACKAGE_NAME => { + if packages.iter().any(|p| p == text) { + format!("**{}**\n\nPackage defined in `debian/control`", text) + } else { + format!("**{}**\n\nPackage, not found in `debian/control`", text) + } + } + SyntaxKind::ARCH => { + let bare = text.trim_start_matches('!'); + if text.starts_with('!') { + format!( + "**{}**\n\nArchitecture restriction (excludes `{}`)", + text, bare + ) + } else { + format!("**{}**\n\nArchitecture restriction", text) + } + } + SyntaxKind::PACKAGE_TYPE => { + let desc = match text { + "source" => "Applies to the source package.", + "binary" => "Applies to binary packages.", + "udeb" => "Applies to udeb (micro-deb) packages.", + _ => return None, + }; + format!("**{}**\n\nPackage type\n\n{}", text, desc) + } + _ => return None, + }; + + Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + range: None, + }) +} +#[cfg(test)] +mod tests { + use super::*; + + fn tags() -> Vec<(String, String)> { + vec![ + ( + "hardening-no-pie".to_string(), + "Binary is not built with PIE.".to_string(), + ), + ("empty-desc".to_string(), String::new()), + ] + } + + fn packages() -> Vec { + vec!["libcurl4".to_string(), "libcurl".to_string()] + } + + fn md(h: &Hover) -> &str { + match &h.contents { + HoverContents::Markup(m) => &m.value, + _ => panic!("expected markup"), + } + } + + #[test] + fn test_hover_tag() { + let h = get_hover(SyntaxKind::TAG, "hardening-no-pie", &tags(), &packages()) + .expect("should have hover"); + assert!(md(&h).contains("Lintian tag")); + assert!(md(&h).contains("Binary is not built with PIE.")); + } + + #[test] + fn test_hover_unknown_tag() { + assert!(get_hover(SyntaxKind::TAG, "nope", &tags(), &packages()).is_none()); + } + + #[test] + fn test_hover_tag_empty_description() { + assert!(get_hover(SyntaxKind::TAG, "empty-desc", &tags(), &packages()).is_none()); + } + + #[test] + fn test_hover_known_package() { + let h = get_hover(SyntaxKind::PACKAGE_NAME, "libcurl4", &tags(), &packages()).unwrap(); + assert!(md(&h).contains("debian/control")); + assert!(!md(&h).contains("not found")); + } + + #[test] + fn test_hover_unknown_package() { + let h = get_hover(SyntaxKind::PACKAGE_NAME, "ghost", &tags(), &packages()).unwrap(); + assert!(md(&h).contains("not found")); + } + + #[test] + fn test_hover_arch() { + let h = get_hover(SyntaxKind::ARCH, "amd64", &tags(), &packages()).unwrap(); + assert!(md(&h).contains("Architecture restriction")); + assert!(!md(&h).contains("excludes")); + } + + #[test] + fn test_hover_arch_negated() { + let h = get_hover(SyntaxKind::ARCH, "!amd64", &tags(), &packages()).unwrap(); + assert!(md(&h).contains("excludes")); + assert!(md(&h).contains("amd64")); + } + + #[test] + fn test_hover_type_binary() { + let h = get_hover(SyntaxKind::PACKAGE_TYPE, "binary", &tags(), &packages()).unwrap(); + assert!(md(&h).contains("binary packages")); + } + + #[test] + fn test_hover_type_unknown_returns_none() { + assert!(get_hover(SyntaxKind::PACKAGE_TYPE, "weird", &tags(), &packages()).is_none()); + } +} diff --git a/src/lintian_overrides/mod.rs b/src/lintian_overrides/mod.rs index 3b22d1f..ce485d9 100644 --- a/src/lintian_overrides/mod.rs +++ b/src/lintian_overrides/mod.rs @@ -1,6 +1,8 @@ pub mod completion; pub use definition::goto_definition; pub mod definition; +pub mod hover; +pub use hover::get_hover; pub mod detection; pub mod semantic; pub mod tags; diff --git a/src/main.rs b/src/main.rs index 24b8838..3393852 100644 --- a/src/main.rs +++ b/src/main.rs @@ -753,6 +753,24 @@ impl Backend { } } + /// Read package names (Source: and Package:) from the control file + /// for the debian directory containing the given URI. + /// Checks open files first, falls back to reading from disk. + fn get_local_package_names( + uri: &Uri, + files: &HashMap, + workspace: &mut Workspace, + ) -> Vec { + let Some((control_sf, _)) = Self::get_control_for_uri(uri, files, workspace) else { + return Vec::new(); + }; + // Lintian override specs name binary packages, so complete from the + // binary paragraphs via the high-level control API rather than the + // source name or an ad-hoc reparse. + let parsed = workspace.get_parsed_control(control_sf); + parsed.tree().binaries().filter_map(|b| b.name()).collect() + } + /// Get or load the control source file for the debian directory /// containing the given URI. fn get_control_for_uri( @@ -1299,6 +1317,7 @@ impl LanguageServer for Backend { let file_info = files .get(&uri) .map(|info| (info.file_type, info.source_file)); + let files_snapshot = files.clone(); drop(files); // Release the lock let completions = match file_info { @@ -1435,14 +1454,26 @@ impl LanguageServer for Backend { Some((FileType::SourceFormat, _)) => source_format::get_completions(&uri, position), Some((FileType::LintianOverrides, source_file)) => { let workspace = self.workspace_clone().await; + let packages = + Self::get_local_package_names(&uri, &files_snapshot, &mut workspace.clone()); + let parsed = workspace.get_parsed_lintian_overrides(source_file); let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); - let parsed = workspace.get_parsed_lintian_overrides(source_file); drop(workspace); - let mut tag_cache = self.lintian_tag_cache.write().await; - let tags = tag_cache.get_tags().await; - lintian_overrides::get_completions(&parsed, src, position, tags) + let tags: Vec<(String, String)> = { + let mut tag_cache = self.lintian_tag_cache.write().await; + tag_cache.get_tags().await.to_vec() + }; + let architectures = self.architecture_list.read().await; + lintian_overrides::get_completions( + &parsed, + src, + position, + &tags, + &packages, + &architectures, + ) } Some((FileType::SourceOptions, source_file)) => { let workspace = self.workspace_clone().await; @@ -2703,6 +2734,7 @@ impl LanguageServer for Backend { Some(f) => *f, None => return Ok(None), }; + let files_snapshot = files.clone(); drop(files); let workspace = self.workspace_clone().await; @@ -2736,6 +2768,40 @@ impl LanguageServer for Backend { } } } + FileType::LintianOverrides => { + let parsed = workspace.get_parsed_lintian_overrides(file.source_file); + let token = src.try_position_to_offset(position).and_then(|offset| { + use ::lintian_overrides::{AstNode, SyntaxKind}; + // The cursor's package-name / arch / type / tag token, if any. + let line = parsed.tree().line_at_offset(offset)?; + line.syntax() + .descendants_with_tokens() + .filter_map(|it| it.into_token()) + .find(|tok| { + matches!( + tok.kind(), + SyntaxKind::PACKAGE_NAME + | SyntaxKind::ARCH + | SyntaxKind::PACKAGE_TYPE + | SyntaxKind::TAG + ) && tok.text_range().contains(offset) + }) + .map(|tok| (tok.kind(), tok.text().to_string())) + }); + let packages = + Self::get_local_package_names(uri, &files_snapshot, &mut workspace.clone()); + drop(workspace); + + let Some((kind, text)) = token else { + return Ok(None); + }; + + let tags: Vec<(String, String)> = { + let mut tag_cache = self.lintian_tag_cache.write().await; + tag_cache.get_tags().await.to_vec() + }; + Ok(lintian_overrides::get_hover(kind, &text, &tags, &packages)) + } FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); drop(workspace);