use crate::runtime_state::RuntimeState;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
mod cache;
#[cfg(test)]
mod tests;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MasonRegistry {
lspconfigs: BTreeMap<String, MasonPackage>,
package_names: BTreeMap<String, MasonPackage>,
binaries: BTreeMap<String, String>,
}
impl MasonRegistry {
pub fn load(state: &RuntimeState) -> Result<Self, String> {
state.ensure_dirs()?;
let registry_json_path = state.registry_json_path();
match cache::ensure_registry_cache(state) {
Ok(()) => Self::from_registry_json_path(®istry_json_path),
Err(error) if registry_json_path.is_file() => {
eprintln!("warning: failed to refresh Mason registry, using cached data: {error}");
Self::from_registry_json_path(®istry_json_path)
}
Err(error) => Err(error),
}
}
#[must_use]
pub fn load_cached(state: &RuntimeState) -> Option<Self> {
let path = state.registry_json_path();
path.is_file()
.then_some(())
.and_then(|()| Self::from_registry_json_path(&path).ok())
}
pub fn package_for_lspconfig(&self, lspconfig: &str) -> Option<&MasonPackage> {
self.lspconfigs.get(lspconfig)
}
pub fn package_for_detected(
&self,
config_id: &str,
server: &str,
program: &str,
) -> Option<&MasonPackage> {
self.package_for_lspconfig(config_id)
.or_else(|| {
mapping_override(config_id).and_then(|target| self.package_for_override(target))
})
.or_else(|| self.package_for_name(config_id))
.or_else(|| self.package_for_name(server))
.or_else(|| self.package_for_bin(program))
}
fn package_for_name(&self, package_name: &str) -> Option<&MasonPackage> {
self.package_names.get(package_name)
}
fn package_for_bin(&self, program: &str) -> Option<&MasonPackage> {
self.binaries
.get(program)
.and_then(|package_name| self.package_for_name(package_name))
}
fn package_for_override(&self, target: MappingOverride) -> Option<&MasonPackage> {
match target {
MappingOverride::Lspconfig(lspconfig) => self.package_for_lspconfig(lspconfig),
MappingOverride::Package(package_name) => self.package_for_name(package_name),
}
}
fn from_registry_json_path(path: &Path) -> Result<Self, String> {
let contents = fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let package_values = serde_json::from_str::<Vec<serde_json::Value>>(&contents)
.map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
let mut packages = Vec::new();
for value in package_values.into_iter().filter(is_lsp_package_value) {
if let Ok(mut package) = serde_json::from_value::<MasonPackage>(value) {
let _ = package.apply_source_version_overrides();
packages.push(package);
}
}
if packages.is_empty() {
return Err(format!(
"failed to parse any Mason LSP packages from {}",
path.display()
));
}
Ok(Self::from_packages(packages))
}
fn from_packages(packages: Vec<MasonPackage>) -> Self {
let mut lspconfigs = BTreeMap::new();
let mut package_names = BTreeMap::new();
let mut binaries = BTreeMap::new();
let mut ambiguous_bins = std::collections::BTreeSet::new();
for mut package in packages {
if !package.is_lsp() {
continue;
}
let _ = package.apply_source_version_overrides();
let package_name = package.name.clone();
for binary in package.bin.keys() {
if let Some(existing) = binaries.get(binary) {
if existing != &package_name {
ambiguous_bins.insert(binary.clone());
}
} else {
binaries.insert(binary.clone(), package_name.clone());
}
}
package_names.insert(package_name, package.clone());
let Some(lspconfig) = package.neovim.lspconfig.clone() else {
continue;
};
lspconfigs.insert(lspconfig, package);
}
for binary in ambiguous_bins {
binaries.remove(&binary);
}
Self {
lspconfigs,
package_names,
binaries,
}
}
}
#[derive(Clone, Copy)]
enum MappingOverride {
Lspconfig(&'static str),
Package(&'static str),
}
fn mapping_override(config_id: &str) -> Option<MappingOverride> {
match config_id {
"sumneko_lua" => Some(MappingOverride::Lspconfig("lua_ls")),
"tsserver" => Some(MappingOverride::Lspconfig("ts_ls")),
"typescript_language_server" => {
Some(MappingOverride::Package("typescript-language-server"))
}
"volar" => Some(MappingOverride::Lspconfig("vue_ls")),
_ => None,
}
}
fn is_lsp_package_value(value: &serde_json::Value) -> bool {
value
.get("categories")
.and_then(serde_json::Value::as_array)
.is_some_and(|categories| {
categories
.iter()
.any(|category| category.as_str() == Some("LSP"))
})
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct MasonPackage {
pub name: String,
#[serde(default)]
pub categories: Vec<String>,
pub source: MasonSource,
#[serde(default)]
pub bin: BTreeMap<String, String>,
#[serde(default)]
pub share: BTreeMap<String, String>,
#[serde(default)]
pub neovim: MasonNeovim,
}
impl MasonPackage {
fn is_lsp(&self) -> bool {
self.categories.iter().any(|category| category == "LSP")
}
fn apply_source_version_overrides(&mut self) -> Result<(), String> {
self.source.apply_version_overrides()
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct MasonSource {
pub id: String,
#[serde(default)]
pub extra_packages: Vec<String>,
#[serde(default)]
pub asset: Option<OneOrMany<MasonAsset>>,
#[serde(default)]
pub download: Option<OneOrMany<MasonDownload>>,
#[serde(default)]
pub version_overrides: Vec<MasonVersionOverride>,
}
impl MasonSource {
#[must_use]
pub fn assets(&self) -> &[MasonAsset] {
self.asset.as_ref().map_or(&[], OneOrMany::as_slice)
}
#[must_use]
pub fn downloads(&self) -> &[MasonDownload] {
self.download.as_ref().map_or(&[], OneOrMany::as_slice)
}
fn apply_version_overrides(&mut self) -> Result<(), String> {
let version = source_id_version(&self.id)?;
let override_ = self
.version_overrides
.iter()
.filter(|override_| version_matches_constraint(version, &override_.constraint))
.min_by(|left, right| {
compare_versions(
constraint_upper_bound(&left.constraint),
constraint_upper_bound(&right.constraint),
)
})
.cloned();
let Some(override_) = override_ else {
return Ok(());
};
self.id = override_.id;
if let Some(extra_packages) = override_.extra_packages {
self.extra_packages = extra_packages;
}
if let Some(asset) = override_.asset {
self.asset = Some(asset);
}
if let Some(download) = override_.download {
self.download = Some(download);
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct MasonVersionOverride {
pub constraint: String,
pub id: String,
#[serde(default)]
pub extra_packages: Option<Vec<String>>,
#[serde(default)]
pub asset: Option<OneOrMany<MasonAsset>>,
#[serde(default)]
pub download: Option<OneOrMany<MasonDownload>>,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
impl<T> OneOrMany<T> {
#[must_use]
pub fn as_slice(&self) -> &[T] {
match self {
Self::One(value) => std::slice::from_ref(value),
Self::Many(values) => values,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct MasonAsset {
#[serde(default)]
pub target: Option<OneOrMany<String>>,
pub file: OneOrMany<String>,
#[serde(default)]
pub bin: Option<MasonAssetBin>,
#[serde(default)]
pub ext: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum MasonAssetBin {
One(String),
Many(BTreeMap<String, String>),
}
impl MasonAssetBin {
#[must_use]
pub fn as_single(&self) -> Option<&str> {
match self {
Self::One(value) => Some(value),
Self::Many(_) => None,
}
}
#[must_use]
pub fn as_map(&self) -> Option<&BTreeMap<String, String>> {
match self {
Self::One(_) => None,
Self::Many(values) => Some(values),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct MasonDownload {
#[serde(default)]
pub target: Option<OneOrMany<String>>,
#[serde(default)]
pub files: BTreeMap<String, String>,
pub bin: Option<String>,
pub config: Option<String>,
#[serde(default)]
pub man: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct MasonNeovim {
pub lspconfig: Option<String>,
}
fn source_id_version(source_id: &str) -> Result<&str, String> {
source_id
.strip_prefix("pkg:")
.and_then(|value| value.rsplit_once('@').map(|(_, version)| version))
.ok_or_else(|| format!("unsupported Mason package source {source_id}"))
}
fn version_matches_constraint(version: &str, constraint: &str) -> bool {
let Some(upper_bound) = constraint_upper_bound(constraint) else {
return false;
};
compare_versions(Some(version), Some(upper_bound)).is_le()
}
fn constraint_upper_bound(constraint: &str) -> Option<&str> {
constraint
.strip_prefix("semver:")
.and_then(|value| value.strip_prefix("<="))
}
fn compare_versions(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
match (left, right) {
(Some(left), Some(right)) => compare_version_strings(left, right),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
}
}
fn compare_version_strings(left: &str, right: &str) -> std::cmp::Ordering {
let left = tokenize_version(left);
let right = tokenize_version(right);
let max_len = left.len().max(right.len());
for index in 0..max_len {
let ordering = compare_version_part(left.get(index), right.get(index));
if ordering != std::cmp::Ordering::Equal {
return ordering;
}
}
std::cmp::Ordering::Equal
}
fn tokenize_version(version: &str) -> Vec<VersionPart> {
let trimmed = version.trim_start_matches('v');
let mut parts = Vec::new();
let mut current = String::new();
let mut current_is_digit = None;
for ch in trimmed.chars() {
if !ch.is_ascii_alphanumeric() {
push_version_part(&mut parts, &mut current, &mut current_is_digit);
continue;
}
let is_digit = ch.is_ascii_digit();
if current_is_digit.is_some_and(|value| value != is_digit) {
push_version_part(&mut parts, &mut current, &mut current_is_digit);
}
current_is_digit = Some(is_digit);
current.push(ch);
}
push_version_part(&mut parts, &mut current, &mut current_is_digit);
parts
}
fn push_version_part(
parts: &mut Vec<VersionPart>,
current: &mut String,
current_is_digit: &mut Option<bool>,
) {
if current.is_empty() {
*current_is_digit = None;
return;
}
if current_is_digit.unwrap_or(false) {
parts.push(VersionPart::Number(
current.parse::<u64>().unwrap_or(u64::MAX),
));
} else {
parts.push(VersionPart::Text(current.to_ascii_lowercase()));
}
current.clear();
*current_is_digit = None;
}
#[derive(Debug)]
enum VersionPart {
Number(u64),
Text(String),
}
fn compare_version_part(
left: Option<&VersionPart>,
right: Option<&VersionPart>,
) -> std::cmp::Ordering {
match (left, right) {
(Some(VersionPart::Number(left)), Some(VersionPart::Number(right))) => left.cmp(right),
(Some(VersionPart::Text(left)), Some(VersionPart::Text(right))) => left.cmp(right),
(Some(VersionPart::Number(left)), Some(VersionPart::Text(_))) => {
if *left == 0 {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
}
}
(Some(VersionPart::Text(_)), Some(VersionPart::Number(right))) => {
if *right == 0 {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Less
}
}
(Some(part), None) => compare_version_part(Some(part), Some(&VersionPart::Number(0))),
(None, Some(part)) => compare_version_part(Some(&VersionPart::Number(0)), Some(part)),
(None, None) => std::cmp::Ordering::Equal,
}
}