use std::collections::HashMap;
use std::sync::Arc;
use tower_lsp_server::ls_types::{Position, Range, TextEdit, Uri};
use crate::error::DepsError;
use crate::licenses::LicensePolicy;
use crate::osv::VulnerabilityMap;
use crate::{
ConcreteVersion, Dependency, Deprecation, DepsDevClient, EcosystemId, FetchFailure,
LicenseSource, PackageName, RemovalStatus,
};
mod code_actions;
mod code_lenses;
mod diagnostics;
mod formatter;
mod git_ref;
mod hover;
mod in_use_version;
mod inlay_hints;
#[cfg(test)]
pub(crate) mod test_support;
pub use code_actions::generate_code_actions;
pub use code_lenses::{
PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
dedup_overlapping_edits, generate_code_lenses,
};
pub use diagnostics::{
DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
UNSATISFIABLE_DIAGNOSTIC_CODE, compile_requirement_unless, generate_diagnostics_from_cache,
requirement_is_unsatisfiable, truncate_for_diagnostic,
};
pub use formatter::{
DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
PackageRendering, RequirementResolution, SourcePolicy,
};
pub use git_ref::{
MAX_FALLBACK_SCAN_BYTES, MarkedScalar, byte_span_to_range, is_full_sha,
is_partial_semver_shaped, is_tag_shaped, locate_value_span, marker_byte_offset,
match_v_prefix_style,
};
pub use hover::{CMD_DOT_FOOTER, generate_hover};
pub use in_use_version::{concrete_pin_version, is_full_semver_shape, resolve_in_use_version};
pub use inlay_hints::generate_inlay_hints;
pub const HOVER_RECENT_VERSIONS: usize = 8;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageVersions {
pub latest: ConcreteVersion,
pub available: Arc<[ConcreteVersion]>,
pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
pub published_at: Option<crate::freshness::PublishTime>,
}
impl PackageVersions {
#[must_use]
pub fn new(latest: ConcreteVersion, available: Arc<[ConcreteVersion]>) -> Self {
Self {
latest,
available,
yanked: Arc::from(Vec::new()),
published_at: None,
}
}
#[must_use]
pub fn with_yanked(mut self, yanked: Arc<[(ConcreteVersion, RemovalStatus)]>) -> Self {
self.yanked = yanked;
self
}
#[must_use]
pub const fn with_published_at(mut self, published_at: crate::freshness::PublishTime) -> Self {
self.published_at = Some(published_at);
self
}
pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
let latest = latest.into();
let available = Arc::from(vec![latest.clone()]);
Self {
latest,
available,
yanked: Arc::from(Vec::new()),
published_at: None,
}
}
pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
Self {
latest: latest.into(),
available: Arc::from(Vec::new()),
yanked: Arc::from(Vec::new()),
published_at: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DependencyOutcome {
pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
pub deprecation: Option<Deprecation>,
pub fetch_failure: Option<FetchFailure>,
pub no_comparable_versions: bool,
}
impl DependencyOutcome {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.yanked.is_none()
&& self.deprecation.is_none()
&& self.fetch_failure.is_none()
&& !self.no_comparable_versions
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
impl DependencyOutcomes {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
self.0.get(name)
}
#[must_use]
pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
self.0.get(name)?.yanked.as_ref()
}
#[must_use]
pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
self.0.get(name)?.deprecation.as_ref()
}
#[must_use]
pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
self.0.get(name)?.fetch_failure.as_ref()
}
#[must_use]
pub fn no_comparable_versions(&self, name: &str) -> bool {
self.0.get(name).is_some_and(|o| o.no_comparable_versions)
}
pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
self.0.entry(name).or_default().yanked = Some(yanked);
}
pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
self.0.entry(name).or_default().deprecation = Some(deprecation);
}
pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
self.0.entry(name).or_default().fetch_failure = Some(failure);
}
pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
self.0
.entry(name)
.or_default()
.fetch_failure
.get_or_insert(failure);
}
pub fn set_no_comparable_versions(&mut self, name: String) {
self.0.entry(name).or_default().no_comparable_versions = true;
}
pub fn clear_no_comparable_versions(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.no_comparable_versions = false;
self.prune(name);
}
}
pub fn clear_yanked(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.yanked = None;
self.prune(name);
}
}
pub fn clear_deprecation(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.deprecation = None;
self.prune(name);
}
}
pub fn clear_fetch_failure(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.fetch_failure = None;
self.prune(name);
}
}
pub fn remove(&mut self, name: &str) {
self.0.remove(name);
}
pub fn clear_all_fetch_failures(&mut self) {
let names: Vec<String> = self.0.keys().cloned().collect();
for name in names {
self.clear_fetch_failure(&name);
}
}
fn prune(&mut self, name: &str) {
if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
self.0.remove(name);
}
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn yanked_count(&self) -> usize {
self.0.values().filter(|o| o.yanked.is_some()).count()
}
#[must_use]
pub fn deprecation_count(&self) -> usize {
self.0.values().filter(|o| o.deprecation.is_some()).count()
}
#[must_use]
pub fn fetch_failure_count(&self) -> usize {
self.0
.values()
.filter(|o| o.fetch_failure.is_some())
.count()
}
#[must_use]
pub fn with_yanked(
mut self,
name: impl Into<String>,
yanked: (ConcreteVersion, RemovalStatus),
) -> Self {
self.set_yanked(name.into(), yanked);
self
}
#[must_use]
pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
self.set_deprecation(name.into(), deprecation);
self
}
#[must_use]
pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
self.set_fetch_failure(name.into(), failure);
self
}
#[must_use]
pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
self.set_no_comparable_versions(name.into());
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub struct VersionData<'a> {
pub cached: &'a HashMap<PackageName, PackageVersions>,
pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
pub resolved_version_candidates: Option<&'a HashMap<PackageName, Vec<ConcreteVersion>>>,
pub vulnerabilities: Option<&'a VulnerabilityMap>,
pub outcomes: Option<&'a DependencyOutcomes>,
pub ecosystem: Option<EcosystemId>,
pub offline: bool,
pub trust: Option<&'a Arc<DepsDevClient>>,
pub license_prefetch: Option<&'a HashMap<PackageName, Vec<String>>>,
pub license_source: Option<LicenseSource>,
pub license_policy: Option<&'a LicensePolicy>,
}
impl<'a> VersionData<'a> {
pub fn new(
cached: &'a HashMap<PackageName, PackageVersions>,
resolved: &'a HashMap<PackageName, ConcreteVersion>,
) -> Self {
Self {
cached,
resolved,
resolved_version_candidates: None,
vulnerabilities: None,
outcomes: None,
ecosystem: None,
offline: false,
trust: None,
license_prefetch: None,
license_source: None,
license_policy: None,
}
}
#[must_use]
pub const fn with_resolved_version_candidates(
mut self,
candidates: &'a HashMap<PackageName, Vec<ConcreteVersion>>,
) -> Self {
self.resolved_version_candidates = Some(candidates);
self
}
#[must_use]
pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
self.vulnerabilities = Some(vulnerabilities);
self
}
#[must_use]
pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
self.outcomes = Some(outcomes);
self
}
#[must_use]
pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
self.ecosystem = Some(ecosystem);
self
}
#[must_use]
pub const fn with_offline(mut self, offline: bool) -> Self {
self.offline = offline;
self
}
#[must_use]
pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
self.trust = Some(client);
self
}
#[must_use]
pub const fn with_license_prefetch(
mut self,
licenses: &'a HashMap<PackageName, Vec<String>>,
) -> Self {
self.license_prefetch = Some(licenses);
self
}
#[must_use]
pub const fn with_license_source(mut self, source: LicenseSource) -> Self {
self.license_source = Some(source);
self
}
#[must_use]
pub const fn with_license_policy(mut self, policy: &'a LicensePolicy) -> Self {
self.license_policy = Some(policy);
self
}
}
pub fn position_in_range(pos: Position, range: Range) -> bool {
if pos.line < range.start.line || pos.line > range.end.line {
return false;
}
if pos.line == range.start.line && pos.character < range.start.character {
return false;
}
if pos.line == range.end.line && pos.character > range.end.character {
return false;
}
true
}
pub struct LineOffsetTable {
line_starts: Vec<usize>,
line_is_ascii: Vec<bool>,
non_ascii_line_index_cache:
std::cell::RefCell<std::collections::HashMap<usize, NonAsciiLineIndex>>,
}
struct NonAsciiLineIndex {
entries: Vec<(u32, u32)>,
}
impl NonAsciiLineIndex {
fn build(line_text: &str) -> Self {
let mut entries = Vec::with_capacity(line_text.len() + 1);
let mut utf16_units = 0u32;
for (byte_offset, c) in line_text.char_indices() {
entries.push((u32::try_from(byte_offset).unwrap_or(u32::MAX), utf16_units));
utf16_units = utf16_units.saturating_add(u32::try_from(c.len_utf16()).unwrap_or(2));
}
entries.push((
u32::try_from(line_text.len()).unwrap_or(u32::MAX),
utf16_units,
));
entries.shrink_to_fit();
Self { entries }
}
}
impl LineOffsetTable {
pub fn new(content: &str) -> Self {
let mut line_starts = vec![0];
let mut line_is_ascii = Vec::new();
let mut current_ascii = true;
for (i, c) in content.char_indices() {
if !c.is_ascii() {
current_ascii = false;
}
if c == '\n' {
line_starts.push(i + 1);
line_is_ascii.push(current_ascii);
current_ascii = true;
}
}
line_is_ascii.push(current_ascii);
debug_assert_eq!(
line_starts.len(),
line_is_ascii.len(),
"line_starts and line_is_ascii must stay in lockstep — one entry per line"
);
Self {
line_starts,
line_is_ascii,
non_ascii_line_index_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
}
}
#[allow(clippy::expect_used)]
fn with_non_ascii_line_index<R>(
&self,
line0: usize,
line_text: &str,
f: impl FnOnce(&NonAsciiLineIndex) -> R,
) -> R {
self.non_ascii_line_index_cache
.borrow_mut()
.entry(line0)
.or_insert_with(|| NonAsciiLineIndex::build(line_text));
let cache = self.non_ascii_line_index_cache.borrow();
let index = cache
.get(&line0)
.expect("just inserted above if it was missing");
f(index)
}
fn non_ascii_char_byte_offset(&self, line0: usize, line_text: &str, col: usize) -> usize {
self.with_non_ascii_line_index(line0, line_text, |index| {
index
.entries
.get(col)
.map_or(line_text.len(), |&(byte_offset, _)| byte_offset as usize)
})
}
fn non_ascii_utf16_units_before(
&self,
line0: usize,
line_text: &str,
byte_offset: usize,
) -> u32 {
self.with_non_ascii_line_index(line0, line_text, |index| {
let byte_offset = u32::try_from(byte_offset).unwrap_or(u32::MAX);
let found = index
.entries
.binary_search_by_key(&byte_offset, |&(b, _)| b);
let i = found.unwrap_or_else(|i| i.saturating_sub(1));
index.entries.get(i).map_or(0, |&(_, units)| units)
})
}
pub fn line_start(&self, line: usize) -> Option<usize> {
self.line_starts.get(line).copied()
}
#[allow(clippy::string_slice)]
pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
let offset = offset.min(content.len());
let offset = content.floor_char_boundary(offset);
let line = self
.line_starts
.partition_point(|&start| start <= offset)
.saturating_sub(1);
#[allow(clippy::indexing_slicing)]
let line_start = self.line_starts[line];
let character = if self.line_is_ascii.get(line).copied().unwrap_or(false) {
u32::try_from(offset - line_start).unwrap_or(u32::MAX)
} else {
let line_end = self
.line_starts
.get(line + 1)
.copied()
.unwrap_or(content.len());
let line_text = content.get(line_start..line_end).unwrap_or_default();
self.non_ascii_utf16_units_before(line, line_text, offset - line_start)
};
Position::new(u32::try_from(line).unwrap_or(u32::MAX), character)
}
#[allow(clippy::string_slice)]
pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
let Some(&line_start) = self.line_starts.get(position.line as usize) else {
return content.len();
};
let line_end = self
.line_starts
.get(position.line as usize + 1)
.copied()
.unwrap_or(content.len());
let line = &content[line_start..line_end];
crate::completion::utf16_to_byte_offset(line, position.character)
.map_or(line_end, |offset| line_start + offset)
.min(content.len())
}
}
pub fn escape_markdown(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
if c.is_control() {
escaped.push(' ');
continue;
}
if c.is_ascii_punctuation() {
escaped.push('\\');
}
escaped.push(c);
}
escaped
}
pub fn markdown_code_span(content: &str) -> String {
let sanitized: String = content
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let max_backtick_run = sanitized
.split(|c| c != '`')
.map(str::len)
.max()
.unwrap_or(0);
let fence = "`".repeat(max_backtick_run + 1);
if sanitized.is_empty() {
format!("{fence} {fence}")
} else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
format!("{fence} {sanitized} {fence}")
} else {
format!("{fence}{sanitized}{fence}")
}
}
pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
if v1.is_empty() || v2.is_empty() {
return false;
}
let mut parts1 = v1.split('.');
let mut parts2 = v2.split('.');
if parts1.next() != parts2.next() {
return false;
}
match (parts1.next(), parts2.next()) {
(Some(m1), Some(m2)) => m1 == m2,
_ => true,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequirementStatus {
UpToDate,
Outdated,
Unresolved,
}
pub trait RequirementMatcher: Send + Sync {
fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
}
pub fn is_dot_segment(segment: &str) -> bool {
segment == "." || segment == ".."
}
pub fn is_safe_version_string(version: &str) -> bool {
!version.is_empty()
&& version.len() <= 64
&& version.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
})
}
pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment.len() <= 128
&& !is_dot_segment(segment)
&& segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
pub fn maven_coordinate_path(group_id: &str, artifact_id: &str) -> Option<String> {
if !is_safe_maven_coordinate_segment(group_id) || !is_safe_maven_coordinate_segment(artifact_id)
{
return None;
}
let group_segments: Vec<&str> = group_id.split('.').collect();
if group_segments
.iter()
.any(|segment| !is_safe_maven_coordinate_segment(segment))
{
return None;
}
Some(format!("{}/{artifact_id}", group_segments.join("/")))
}
pub fn is_safe_registry_url(url: &str) -> bool {
!url.is_empty()
&& url.len() <= 2048
&& url.starts_with("https://")
&& url.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.'
| '_'
| '~'
| ':'
| '/'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
}
pub fn is_safe_package_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 256
&& name.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
})
}
pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
tracing::warn!(
gate,
context,
len = value.len(),
"rejected unsafe value before manifest/registry sink"
);
}
#[must_use]
pub fn dot_segment_rejection_error(
gate: &str,
context: &str,
name: &str,
registry: &'static str,
) -> DepsError {
warn_rejected_value(gate, context, name);
DepsError::PackageNotFound {
package: name.to_string(),
registry,
}
}
fn single_file_edit(uri: &Uri, range: Range, new_text: String) -> HashMap<Uri, Vec<TextEdit>> {
let mut edits = HashMap::new();
edits.insert(uri.clone(), vec![TextEdit { range, new_text }]);
edits
}
fn strip_whitespace(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
fn slice_for_range<'a>(content: &'a str, table: &LineOffsetTable, range: Range) -> &'a str {
let start = table.position_to_byte_offset(content, range.start);
let end = table.position_to_byte_offset(content, range.end);
if start > end {
return "";
}
content.get(start..end).unwrap_or("")
}
fn literal_span_matches(slice: &str, requirement: &str) -> bool {
let norm_slice = strip_whitespace(slice);
let norm_req = strip_whitespace(requirement);
norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
}
#[must_use]
pub fn dependency_version_range_is_literal(
dep: &dyn Dependency,
content: &str,
version_range: Range,
) -> bool {
let Some(version_req) = dep.version_requirement() else {
return false;
};
let table = LineOffsetTable::new(content);
let slice = slice_for_range(content, &table, version_range);
let trimmed = slice.trim();
if trimmed.contains('$')
|| (trimmed.starts_with('*') && !crate::is_existence_wildcard_str(trimmed))
{
return false;
}
let literal_target = dep
.version_literal()
.unwrap_or_else(|| version_req.as_str());
literal_span_matches(slice, literal_target)
}
#[cfg(test)]
#[allow(clippy::string_slice)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::{PackageName, VersionReq};
#[test]
fn test_dependency_version_range_is_literal_plain_literal_admitted() {
let content = r#"serde = "1.0.0""#;
let dep = MockDep {
name: PackageName::new("serde"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
assert!(dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_property_interpolation() {
let content = r"<version>${slf4j.version}</version>";
let dep = MockDep {
name: PackageName::new("slf4j-api"),
version_req: VersionReq::new("2.0.16"), version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_yaml_alias() {
let content = "ref: *pin";
let dep = MockDep {
name: PackageName::new("job"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 9)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_admits_empty_requirement_with_empty_slice() {
let content = r#"serde = """#;
let dep = MockDep {
name: PackageName::new("serde"),
version_req: VersionReq::new(""),
version_range: Range::new(Position::new(0, 9), Position::new(0, 9)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
assert!(dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_empty_requirement_with_mismatched_slice() {
let content = r#"x = "1.0""#;
let dep = MockDep {
name: PackageName::new("x"),
version_req: VersionReq::new(""),
version_range: Range::new(Position::new(0, 5), Position::new(0, 8)), name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_unresolved_maven_property() {
let content = r"<version>${slf4j.version}</version>";
let dep = MockDep {
name: PackageName::new("slf4j-api"),
version_req: VersionReq::new("${slf4j.version}"), version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_unresolved_gradle_variable() {
let content = "implementation 'com.example:lib:$libVersion'";
let dep = MockDep {
name: PackageName::new("com.example:lib"),
version_req: VersionReq::new("$libVersion"), version_range: Range::new(Position::new(0, 32), Position::new(0, 43)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_embedded_interpolation() {
let content = r#"x = "1.0-${suffix}""#;
let dep = MockDep {
name: PackageName::new("x"),
version_req: VersionReq::new("1.0-${suffix}"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 18)), name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_admits_existence_wildcard() {
let content = r#"x = "*""#;
let dep = MockDep {
name: PackageName::new("x"),
version_req: VersionReq::new("*"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 6)), name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
};
assert!(dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_multi_char_alias_despite_wildcard_exemption()
{
let content = "ref: *pinned-anchor";
let dep = MockDep {
name: PackageName::new("job"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 19)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_gradle_mid_string_variable() {
let content = r#"implementation "com.example:lib:1.0.$patch""#;
let dep = MockDep {
name: PackageName::new("com.example:lib"),
version_req: VersionReq::new("1.0.$patch"), version_range: Range::new(Position::new(0, 32), Position::new(0, 42)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
let mut outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
.with_deprecation(
"pkg",
Deprecation {
reason: None,
replacement: None,
},
)
.with_fetch_failure("pkg", FetchFailure::Transient);
assert_eq!(outcomes.len(), 1);
outcomes.clear_yanked("pkg");
assert!(
outcomes.get("pkg").is_some(),
"entry must survive while other channels are still set"
);
outcomes.clear_deprecation("pkg");
assert!(
outcomes.get("pkg").is_some(),
"entry must survive while the fetch-failure channel is still set"
);
outcomes.clear_fetch_failure("pkg");
assert!(
outcomes.is_empty(),
"entry must be pruned once its last channel is cleared, not left as a vacuous Some"
);
assert_eq!(outcomes.len(), 0);
}
#[test]
fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
let mut outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
outcomes.clear_deprecation("pkg");
outcomes.clear_fetch_failure("pkg");
assert!(
outcomes.yanked("pkg").is_some(),
"clearing unset channels must not touch the still-set yanked channel"
);
assert_eq!(outcomes.len(), 1);
}
#[test]
fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
let mut outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
.with_fetch_failure("pkg", FetchFailure::Transient);
outcomes.remove("pkg");
assert!(outcomes.get("pkg").is_none());
assert!(outcomes.is_empty());
}
#[test]
fn test_dependency_outcomes_clear_all_fetch_failures() {
let mut outcomes = DependencyOutcomes::new()
.with_fetch_failure("only-failure", FetchFailure::Transient)
.with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
.with_fetch_failure("mixed", FetchFailure::Transient);
outcomes.clear_all_fetch_failures();
assert!(
outcomes.get("only-failure").is_none(),
"an entry whose only channel was fetch-failure must be pruned"
);
assert!(outcomes.fetch_failure("mixed").is_none());
assert!(
outcomes.yanked("mixed").is_some(),
"clearing fetch-failure must not touch a mixed entry's other channels"
);
}
#[test]
fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
let mut outcomes = DependencyOutcomes::new();
outcomes.clear_all_fetch_failures();
assert!(outcomes.is_empty());
}
#[test]
fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
"pkg",
FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
);
outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
assert_eq!(
outcomes.fetch_failure("pkg"),
Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
"an existing genuine failure must survive a collided-name NotAttempted marker"
);
}
#[test]
fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
let mut outcomes = DependencyOutcomes::new();
outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
assert_eq!(
outcomes.fetch_failure("pkg"),
Some(&FetchFailure::NotAttempted)
);
}
#[test]
fn test_line_offset_table_line_start_crlf() {
let table = LineOffsetTable::new("a\r\nbb\r\nc");
assert_eq!(table.line_start(0), Some(0));
assert_eq!(table.line_start(1), Some(3));
assert_eq!(table.line_start(2), Some(7));
assert_eq!(table.line_start(3), None);
}
#[test]
fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
let content = "é";
let table = LineOffsetTable::new(content);
let pos = table.byte_offset_to_position(content, 1);
assert_eq!(pos, Position::new(0, 0));
}
#[test]
fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
let content = "ab é cd";
let table = LineOffsetTable::new(content);
assert!(content.is_char_boundary(3));
assert!(!content.is_char_boundary(4));
let pos = table.byte_offset_to_position(content, 4);
assert_eq!(pos, table.byte_offset_to_position(content, 3));
}
#[test]
fn test_byte_offset_to_position_ascii_and_non_ascii_lines_agree() {
let content = "abcde\nhéllo 😀\nab café end";
let table = LineOffsetTable::new(content);
assert_eq!(
table.byte_offset_to_position(content, 3),
Position::new(0, 3)
);
assert_eq!(
table.byte_offset_to_position(content, 5),
Position::new(0, 5)
);
assert_eq!(
table.byte_offset_to_position(content, 6 + 3),
Position::new(1, 2)
);
assert_eq!(
table.byte_offset_to_position(content, 6 + 11),
Position::new(1, 8)
);
assert_eq!(
table.byte_offset_to_position(content, 18 + 8),
Position::new(2, 7)
);
}
#[test]
fn test_byte_offset_to_position_minified_line_stays_fast() {
let mut content = String::from("{\"dependencies\":{");
for i in 0..8000 {
content.push_str(&format!("\"dep{i}\":\"1.0.{i}\","));
}
content.push_str("}}");
let table = LineOffsetTable::new(&content);
let step = (content.len() / 8000).max(1);
let start = std::time::Instant::now();
for offset in (0..content.len()).step_by(step) {
std::hint::black_box(table.byte_offset_to_position(&content, offset));
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(100),
"8000 byte_offset_to_position calls on a minified line took {elapsed:?}, expected < 100ms"
);
}
#[test]
fn test_byte_offset_to_position_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
let filler = "x".repeat(200 * 1024);
let content = format!("{filler}\u{2014}{filler}");
let table = LineOffsetTable::new(&content);
let step = (content.len() / 5000).max(1);
let start = std::time::Instant::now();
for offset in (0..content.len()).step_by(step) {
std::hint::black_box(table.byte_offset_to_position(&content, offset));
}
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"byte_offset_to_position took {:?} for ~5000 lookups on a huge non-ASCII line, \
expected well under 1s",
start.elapsed()
);
}
#[test]
fn test_byte_offset_to_position_non_ascii_cache_matches_char_sum_result_across_lines() {
fn slow_path(content: &str, line_start: usize, offset: usize) -> u32 {
u32::try_from(
content[line_start..offset]
.chars()
.map(char::len_utf16)
.sum::<usize>(),
)
.unwrap_or(u32::MAX)
}
let content = "\u{1f680}rocket \u{2014} dash\nascii line\n\u{3000}ideographic \u{e9}nd";
let table = LineOffsetTable::new(content);
let line_starts = [
table.line_start(0).unwrap(),
table.line_start(1).unwrap(),
table.line_start(2).unwrap(),
];
let line_ends = [line_starts[1] - 1, line_starts[2] - 1, content.len()];
for (line, (&line_start, &line_end)) in line_starts.iter().zip(&line_ends).enumerate() {
let line_text = &content[line_start..line_end];
for (byte_offset, _) in line_text
.char_indices()
.chain(std::iter::once((line_text.len(), '\0')))
{
let offset = line_start + byte_offset;
assert_eq!(
table.byte_offset_to_position(content, offset).character,
slow_path(content, line_start, offset),
"line {line} byte offset {byte_offset} desynced from the char-sum walk"
);
}
}
}
#[test]
fn test_position_in_range_inside() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 15);
assert!(position_in_range(position, range));
}
#[test]
fn test_position_in_range_at_start() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 10);
assert!(position_in_range(position, range));
}
#[test]
fn test_position_in_range_at_end() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 20);
assert!(position_in_range(position, range));
}
#[test]
fn test_position_in_range_before() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 5);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_after() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 25);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_different_line_before() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(4, 15);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_different_line_after() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(6, 15);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_multiline() {
let range = Range::new(Position::new(5, 10), Position::new(7, 5));
let position = Position::new(6, 0);
assert!(position_in_range(position, range));
}
#[test]
fn test_escape_markdown_link_breakout_payload() {
let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
let escaped = escape_markdown(payload);
assert_eq!(
escaped,
r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
);
assert!(!escaped.contains("]("));
}
#[test]
fn test_escape_markdown_backslash_and_backtick() {
assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
}
#[test]
fn test_escape_markdown_autolink_angle_brackets() {
let escaped = escape_markdown("pkg <https://evil.example>");
assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
assert!(!escaped.contains('<') || escaped.contains(r"\<"));
}
#[test]
fn test_escape_markdown_control_chars_become_spaces() {
assert_eq!(escape_markdown("a\nb"), "a b");
assert_eq!(escape_markdown("a\r\nb"), "a b");
assert_eq!(escape_markdown("a\tb"), "a b");
assert_eq!(escape_markdown("a\0b"), "a b");
}
#[test]
fn test_escape_markdown_newline_cannot_break_out_of_heading() {
let escaped = escape_markdown("react\n# [fake](https://evil.example)");
assert!(!escaped.contains('\n'));
}
#[test]
fn test_escape_markdown_hyphenated_name_round_trips_visually() {
assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
}
#[test]
fn test_markdown_code_span_plain_content() {
assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
}
#[test]
fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
assert_eq!(markdown_code_span("a`b"), "``a`b``");
assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
}
#[test]
fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
let span = markdown_code_span("`leading");
assert!(span.starts_with("`` `"));
}
#[test]
fn test_markdown_code_span_replaces_control_chars() {
let span = markdown_code_span("1.0\n[evil](https://evil.example)");
assert!(!span.contains('\n'));
}
#[test]
fn test_markdown_code_span_empty_content() {
assert_eq!(markdown_code_span(""), "` `");
}
#[test]
fn test_markdown_code_span_backtick_payload_cannot_break_span() {
let payload = "1.0` <https://evil.example>` more";
let span = markdown_code_span(payload);
let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
let inner = &span[opening_fence_len..span.len() - opening_fence_len];
assert!(
!inner.contains(&"`".repeat(opening_fence_len)),
"content contains a run of backticks as long as the fence: {span}"
);
}
#[test]
fn test_is_same_major_minor_full_match() {
assert!(is_same_major_minor("1.2.3", "1.2.9"));
}
#[test]
fn test_is_same_major_minor_exact_match() {
assert!(is_same_major_minor("1.2.3", "1.2.3"));
}
#[test]
fn test_is_same_major_minor_major_only_match() {
assert!(is_same_major_minor("1", "1.2.3"));
assert!(is_same_major_minor("1.2.3", "1"));
}
#[test]
fn test_is_same_major_minor_no_match_different_minor() {
assert!(!is_same_major_minor("1.2.3", "1.3.0"));
}
#[test]
fn test_is_same_major_minor_no_match_different_major() {
assert!(!is_same_major_minor("1.2.3", "2.2.3"));
}
#[test]
fn test_is_same_major_minor_empty_strings() {
assert!(!is_same_major_minor("", ""));
assert!(!is_same_major_minor("1.2.3", ""));
assert!(!is_same_major_minor("", "1.2.3"));
}
#[test]
fn test_is_safe_version_string_accepts_ordinary_versions() {
assert!(is_safe_version_string("1.2.3"));
assert!(is_safe_version_string("1.2.3-beta.1+build"));
assert!(is_safe_version_string("v1.2.3"));
}
#[test]
fn test_is_safe_version_string_rejects_empty_or_whitespace() {
assert!(!is_safe_version_string(""));
assert!(!is_safe_version_string(" "));
assert!(!is_safe_version_string("\t\n"));
}
#[test]
fn test_is_safe_version_string_rejects_control_and_structural_characters() {
for bad in [
"1.2.3\n",
"1.2.3\t",
"1.2.3\"",
"1.2.3'",
"1.2.3<",
"1.2.3>",
"1.2.3&",
"1.2.3\\",
"1.0.0\", \"malicious\": \"true",
] {
assert!(
!is_safe_version_string(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
assert!(
!is_safe_version_string(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_version_string_rejects_invisible_unicode() {
for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
assert!(
!is_safe_version_string(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_version_string_accepts_pep440_epoch() {
assert!(is_safe_version_string("1!2.0"));
}
#[test]
fn test_is_safe_version_string_length_cap() {
assert!(is_safe_version_string(&"1".repeat(64)));
assert!(!is_safe_version_string(&"1".repeat(65)));
}
#[test]
fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
assert!(is_safe_maven_coordinate_segment("commons-lang3"));
assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
}
#[test]
fn test_is_safe_maven_coordinate_segment_rejects_empty() {
assert!(!is_safe_maven_coordinate_segment(""));
}
#[test]
fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
for bad in [
"commons</artifactId><parent>",
"commons\"",
"commons'",
"commons&",
"commons\nlang3",
"commons\tlang3",
] {
assert!(
!is_safe_maven_coordinate_segment(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
assert!(!is_safe_maven_coordinate_segment(
"org.apache.commons:commons-lang3"
));
}
#[test]
fn test_is_safe_maven_coordinate_segment_length_cap() {
assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
}
#[test]
fn test_is_safe_registry_url_accepts_real_urls() {
assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
assert!(is_safe_registry_url(
"https://github.com/apple/swift-nio.git"
));
assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
}
#[test]
fn test_is_safe_registry_url_rejects_non_https_scheme() {
assert!(!is_safe_registry_url("http://example.com/repo"));
assert!(!is_safe_registry_url("file:///etc/passwd"));
assert!(!is_safe_registry_url("javascript:alert(1)"));
assert!(!is_safe_registry_url("ftp://example.com/repo"));
assert!(!is_safe_registry_url(""));
}
#[test]
fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
for bad in [
"https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
"https://evil.example\\",
"https://evil.example\nlet x = 1",
"https://evil.example`echo`",
"https://evil.example<script>",
] {
assert!(
!is_safe_registry_url(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_registry_url_length_cap() {
let prefix = "https://example.com/";
let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
assert_eq!(at_cap.len(), 2048);
assert!(is_safe_registry_url(&at_cap));
let over_cap = format!("{at_cap}a");
assert_eq!(over_cap.len(), 2049);
assert!(!is_safe_registry_url(&over_cap));
}
#[test]
fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
for good in [
"serde", "requests", "@scope/name", "monolog/monolog", "github.com/org/repo", "path", "org.apache.commons:commons-lang3", "Newtonsoft.Json", "rails", "npm:react", "jsr:@std/fs", "github.com/foo/bar~compat", ] {
assert!(
is_safe_package_name(good),
"expected {good:?} to be accepted"
);
}
}
#[test]
fn test_is_safe_package_name_rejects_empty() {
assert!(!is_safe_package_name(""));
}
#[test]
fn test_is_safe_package_name_rejects_non_ascii() {
assert!(!is_safe_package_name("café"));
assert!(!is_safe_package_name("пакет"));
}
#[test]
fn test_is_safe_package_name_accepts_dot_dot_shapes() {
assert!(is_safe_package_name(".."));
assert!(is_safe_package_name("../.."));
}
#[test]
fn test_is_safe_package_name_rejects_structural_breakout_characters() {
for bad in [
"evil\"\nbackdoor = \"9.9.9",
"evil\", git = \"https://evil",
"evil\\",
"evil'",
"evil<script>",
"evil`echo`",
"evil\ninjected = true",
"evil\tname",
] {
assert!(
!is_safe_package_name(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_package_name_length_cap() {
assert!(is_safe_package_name(&"a".repeat(256)));
assert!(!is_safe_package_name(&"a".repeat(257)));
}
#[test]
fn test_is_same_major_minor_partial_versions() {
assert!(is_same_major_minor("1.2", "1.2.3"));
assert!(is_same_major_minor("1.2.3", "1.2"));
}
#[test]
fn test_ecosystem_formatter_defaults() {
let formatter = MockFormatter;
assert_eq!(
formatter.normalize_package_name(&pkg("test-pkg")),
"test-pkg"
);
assert_eq!(formatter.yanked_message(), "This version has been yanked");
assert_eq!(formatter.yanked_label(), "*(yanked)*");
}
#[test]
fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
let formatter = MockFormatter;
let dep = MockDep {
name: pkg("test-pkg"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::default(),
name_range: Range::default(),
};
assert_eq!(
formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
);
}
#[test]
fn test_ecosystem_formatter_version_satisfies() {
let formatter = MockFormatter;
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.3"));
}
#[test]
fn test_ecosystem_formatter_custom_normalize() {
struct PyPIFormatter;
impl PackageNaming for PyPIFormatter {
fn normalize_package_name(&self, name: &PackageName) -> String {
name.as_str().to_lowercase().replace('-', "_")
}
}
impl PackageRendering for PyPIFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
format!(
">={},<{}",
version,
version.as_str().split('.').next().unwrap_or("0")
)
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://pypi.org/project/{}", name)
}
}
impl RequirementResolution for PyPIFormatter {}
impl DiagnosticMessages for PyPIFormatter {}
impl DiagnosticPolicy for PyPIFormatter {}
impl SourcePolicy for PyPIFormatter {}
impl OsvNaming for PyPIFormatter {}
let formatter = PyPIFormatter;
assert_eq!(
formatter.normalize_package_name(&pkg("Test-Package")),
"test_package"
);
assert_eq!(
formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
">=1.2.3,<1"
);
assert_eq!(
formatter.package_url(&pkg("requests")),
"https://pypi.org/project/requests"
);
}
}