use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tower_lsp_server::ls_types::{Position, Range, TextEdit, Uri};
use crate::osv::VulnerabilityMap;
use crate::{Dependency, EcosystemId, InvalidPackageName, PackageName, VersionReq};
mod code_actions;
mod code_lenses;
mod diagnostics;
mod hover;
mod in_use_version;
mod inlay_hints;
#[cfg(test)]
mod test_support;
pub use code_actions::generate_code_actions;
pub use code_lenses::{collect_update_all_edits, generate_code_lenses};
pub use diagnostics::{
DiagnosticSeverities, UNSATISFIABLE_DIAGNOSTIC_CODE, compile_requirement_unless,
generate_diagnostics, generate_diagnostics_from_cache, requirement_is_unsatisfiable,
};
pub use hover::generate_hover;
pub use in_use_version::{concrete_pin_version, in_use_version};
pub use inlay_hints::generate_inlay_hints;
pub const HOVER_RECENT_VERSIONS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageVersions {
pub latest: String,
pub available: Arc<[String]>,
pub yanked: Arc<[String]>,
pub published_at: Option<crate::freshness::PublishTime>,
}
impl PackageVersions {
pub fn latest_only(latest: impl Into<String>) -> 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<String>) -> Self {
Self {
latest: latest.into(),
available: Arc::from(Vec::new()),
yanked: Arc::from(Vec::new()),
published_at: None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct VersionData<'a> {
pub cached: &'a HashMap<PackageName, PackageVersions>,
pub resolved: &'a HashMap<PackageName, String>,
pub vulnerabilities: Option<&'a VulnerabilityMap>,
pub yanked: Option<&'a HashMap<String, String>>,
pub fetch_failed: Option<&'a HashSet<String>>,
pub ecosystem: Option<EcosystemId>,
}
impl<'a> VersionData<'a> {
pub fn new(
cached: &'a HashMap<PackageName, PackageVersions>,
resolved: &'a HashMap<PackageName, String>,
) -> Self {
Self {
cached,
resolved,
vulnerabilities: None,
yanked: None,
fetch_failed: None,
ecosystem: None,
}
}
#[must_use]
pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
self.vulnerabilities = Some(vulnerabilities);
self
}
#[must_use]
pub fn with_yanked(mut self, yanked: &'a HashMap<String, String>) -> Self {
self.yanked = Some(yanked);
self
}
#[must_use]
pub fn with_fetch_failed(mut self, fetch_failed: &'a HashSet<String>) -> Self {
self.fetch_failed = Some(fetch_failed);
self
}
#[must_use]
pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
self.ecosystem = Some(ecosystem);
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>,
}
impl LineOffsetTable {
pub fn new(content: &str) -> Self {
let mut line_starts = vec![0];
for (i, c) in content.char_indices() {
if c == '\n' {
line_starts.push(i + 1);
}
}
Self { line_starts }
}
pub fn line_start(&self, line: usize) -> Option<usize> {
self.line_starts.get(line).copied()
}
pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
let offset = offset.min(content.len());
let mut offset = offset;
while offset > 0 && !content.is_char_boundary(offset) {
offset -= 1;
}
let line = self
.line_starts
.partition_point(|&start| start <= offset)
.saturating_sub(1);
let line_start = self.line_starts[line];
let character = content[line_start..offset]
.chars()
.map(|c| c.len_utf16() as u32)
.sum();
Position::new(line as u32, character)
}
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: &str) -> Option<bool>;
}
pub trait EcosystemFormatter: Send + Sync {
fn normalize_package_name(&self, name: &PackageName) -> String {
name.to_string()
}
fn validate_package_name(&self, _name: &str) -> Result<(), InvalidPackageName> {
Ok(())
}
fn format_version_for_text_edit(&self, version: &str) -> String;
fn format_version_replacing(&self, version: &str, _current: &str) -> String {
self.format_version_for_text_edit(version)
}
fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool {
if let Some(req) = requirement.strip_prefix('^') {
let req_parts: Vec<&str> = req.split('.').collect();
let ver_parts: Vec<&str> = version.split('.').collect();
if req_parts.first() != ver_parts.first() {
return false;
}
if req_parts.first().is_some_and(|m| *m != "0") {
return true;
}
if req_parts.len() >= 2 && ver_parts.len() >= 2 {
return req_parts[1] == ver_parts[1];
}
return true;
}
if let Some(req) = requirement.strip_prefix('~') {
return is_same_major_minor(req, version);
}
let req_parts: Vec<&str> = requirement.split('.').collect();
let is_partial_version = req_parts.len() <= 2;
version == requirement
|| (is_partial_version && is_same_major_minor(requirement, version))
|| (is_partial_version && version.starts_with(requirement))
}
fn is_requirement_up_to_date(&self, requirement: &VersionReq, latest: &str) -> bool {
self.version_satisfies_requirement(latest, requirement.as_str())
}
fn requirement_is_unresolved(&self, _requirement: &VersionReq) -> bool {
false
}
fn requirement_status(&self, requirement: &VersionReq, latest: &str) -> RequirementStatus {
if self.requirement_is_unresolved(requirement) {
return RequirementStatus::Unresolved;
}
if self.is_requirement_up_to_date(requirement, latest) {
RequirementStatus::UpToDate
} else {
RequirementStatus::Outdated
}
}
fn compile_requirement(
&self,
_requirement: &VersionReq,
) -> Option<Box<dyn RequirementMatcher>> {
None
}
fn requirement_is_undecidable_given_available(
&self,
_requirement: &VersionReq,
_available: &[String],
) -> bool {
false
}
fn strict_semver_prerelease_exclusion(&self) -> bool {
false
}
fn package_url(&self, name: &PackageName) -> String;
fn yanked_message(&self) -> &'static str {
"This version has been yanked"
}
fn yanked_label(&self) -> &'static str {
"*(yanked)*"
}
fn yanked_diagnostic_applies_to(&self, _requirement: &VersionReq) -> bool {
true
}
fn is_position_on_dependency(&self, dep: &dyn Dependency, position: Position) -> bool {
dep.version_range()
.is_some_and(|r| position_in_range(position, r))
}
fn osv_package_name(&self, dep: &dyn Dependency) -> Option<String> {
Some(dep.name().to_string())
}
fn osv_version_to_native(&self, version: &str) -> String {
version.to_string()
}
fn osv_version(&self, version: &str) -> String {
version.to_string()
}
fn manifest_requirement_is_resolved_version(&self, dep: &dyn Dependency) -> bool {
let _ = dep;
false
}
}
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 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"
);
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::PackageName;
use crate::lsp_helpers::test_support::*;
#[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_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_ecosystem_formatter_version_satisfies() {
let formatter = MockFormatter;
assert!(formatter.version_satisfies_requirement("1.2.3", "1.2.3"));
assert!(formatter.version_satisfies_requirement("1.2.3", "^1.2"));
assert!(formatter.version_satisfies_requirement("1.2.3", "~1.2"));
assert!(formatter.version_satisfies_requirement("1.2.3", "1"));
assert!(formatter.version_satisfies_requirement("1.2.3", "1.2"));
assert!(!formatter.version_satisfies_requirement("1.2.3", "2.0.0"));
assert!(!formatter.version_satisfies_requirement("1.2.3", "1.3"));
}
#[test]
fn test_ecosystem_formatter_custom_normalize() {
struct PyPIFormatter;
impl EcosystemFormatter for PyPIFormatter {
fn normalize_package_name(&self, name: &PackageName) -> String {
name.as_str().to_lowercase().replace('-', "_")
}
fn format_version_for_text_edit(&self, version: &str) -> String {
format!(
">={},<{}",
version,
version.split('.').next().unwrap_or("0")
)
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://pypi.org/project/{}", name)
}
}
let formatter = PyPIFormatter;
assert_eq!(
formatter.normalize_package_name(&pkg("Test-Package")),
"test_package"
);
assert_eq!(
formatter.format_version_for_text_edit("1.2.3"),
">=1.2.3,<1"
);
assert_eq!(
formatter.package_url(&pkg("requests")),
"https://pypi.org/project/requests"
);
}
}