aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWilliam Boman <william@redwill.se>2026-01-07 14:39:35 +0100
committerWilliam Boman <william@redwill.se>2026-01-07 14:39:35 +0100
commitad903f1c1f18fd229d67e523418b3aa6c9f1c862 (patch)
treed52cca80e062b3df9b8daeba20435aa83c34c7a2
parentfix(installer): update cwd after uv_fs_rename() was successful (#2033) (diff)
downloadmason-fix/support-removed-packages.tar
mason-fix/support-removed-packages.tar.gz
mason-fix/support-removed-packages.tar.bz2
mason-fix/support-removed-packages.tar.lz
mason-fix/support-removed-packages.tar.xz
mason-fix/support-removed-packages.tar.zst
mason-fix/support-removed-packages.zip
feat: add support for removal of packages from a registryfix/support-removed-packages
This adds support for removal of packages from any given registry. Currently mason.nvim doesn't support this at all and throws an error when trying to interact with the registry in any way while having a removed package installed locally. This ensures that removed packages are available both in the `:Mason` UI as well as the public Lua APIs. These "synthesized" packages only supports uninstallation, and metadata such as licenses, categories, homepage, etc is not available.
-rw-r--r--lua/mason-core/installer/InstallHandle.lua2
-rw-r--r--lua/mason-core/installer/context/init.lua2
-rw-r--r--lua/mason-core/package/AbstractPackage.lua10
-rw-r--r--lua/mason-core/receipt.lua12
-rw-r--r--lua/mason-registry/init.lua6
-rw-r--r--lua/mason-registry/sources/github.lua1
-rw-r--r--lua/mason-registry/sources/init.lua17
-rw-r--r--lua/mason-registry/sources/synthesized.lua109
-rw-r--r--lua/mason/ui/instance.lua2
-rw-r--r--tests/mason-registry/sources/collection_spec.lua19
10 files changed, 164 insertions, 16 deletions
diff --git a/lua/mason-core/installer/InstallHandle.lua b/lua/mason-core/installer/InstallHandle.lua
index d8b8941f..3846659e 100644
--- a/lua/mason-core/installer/InstallHandle.lua
+++ b/lua/mason-core/installer/InstallHandle.lua
@@ -43,7 +43,7 @@ function InstallHandleSpawnHandle:__tostring()
end
---@class InstallHandle : EventEmitter
----@field package AbstractPackage
+---@field public package AbstractPackage
---@field state InstallHandleState
---@field stdio_sink BufferedSink
---@field is_terminated boolean
diff --git a/lua/mason-core/installer/context/init.lua b/lua/mason-core/installer/context/init.lua
index 9af95f80..ae96f986 100644
--- a/lua/mason-core/installer/context/init.lua
+++ b/lua/mason-core/installer/context/init.lua
@@ -17,7 +17,7 @@ local receipt = require "mason-core.receipt"
---@field location InstallLocation
---@field spawn InstallContextSpawn
---@field handle InstallHandle
----@field package AbstractPackage
+---@field public package AbstractPackage
---@field cwd InstallContextCwd
---@field opts PackageInstallOpts
---@field stdio_sink StdioSink
diff --git a/lua/mason-core/package/AbstractPackage.lua b/lua/mason-core/package/AbstractPackage.lua
index d0fde00d..5678f4dd 100644
--- a/lua/mason-core/package/AbstractPackage.lua
+++ b/lua/mason-core/package/AbstractPackage.lua
@@ -6,7 +6,6 @@ local Result = require "mason-core.result"
local _ = require "mason-core.functional"
local fs = require "mason-core.fs"
local log = require "mason-core.log"
-local path = require "mason-core.path"
local settings = require "mason.settings"
local Semaphore = require("mason-core.async.control").Semaphore
@@ -166,15 +165,10 @@ end
---@return string?
function AbstractPackage:get_installed_version(location)
return self:get_receipt(location)
- :and_then(
+ :map(
---@param receipt InstallReceipt
function(receipt)
- local source = receipt:get_source()
- if source.id then
- return Purl.parse(source.id):map(_.prop "version"):ok()
- else
- return Optional.empty()
- end
+ return receipt:get_installed_package_version()
end
)
:or_else(nil)
diff --git a/lua/mason-core/receipt.lua b/lua/mason-core/receipt.lua
index 42a7e882..bdf96254 100644
--- a/lua/mason-core/receipt.lua
+++ b/lua/mason-core/receipt.lua
@@ -1,3 +1,7 @@
+local Optional = require "mason-core.optional"
+local Purl = require "mason-core.purl"
+local _ = require "mason-core.functional"
+
local M = {}
---@alias InstallReceiptSchemaVersion
@@ -41,6 +45,14 @@ function InstallReceipt:get_name()
return self.name
end
+---@return string?
+function InstallReceipt:get_installed_package_version()
+ local source = self:get_source()
+ if source.id then
+ return Purl.parse(source.id):map(_.prop "version"):get_or_nil()
+ end
+end
+
function InstallReceipt:get_schema_version()
return self.schema_version
end
diff --git a/lua/mason-registry/init.lua b/lua/mason-registry/init.lua
index 535e6b57..5806c30a 100644
--- a/lua/mason-registry/init.lua
+++ b/lua/mason-registry/init.lua
@@ -49,7 +49,6 @@ function Registry.get_installed_package_names()
directories[#directories + 1] = entry.name
end
end
- -- TODO: validate that entry is a mason package
return directories
end
@@ -68,7 +67,10 @@ function Registry.get_all_package_names()
end
function Registry.get_all_packages()
- return vim.tbl_map(Registry.get_package, Registry.get_all_package_names())
+ local _ = require "mason-core.functional"
+ local packages =
+ _.uniq_by(_.identity, _.concat(Registry.get_all_package_names(), Registry.get_installed_package_names()))
+ return vim.tbl_map(Registry.get_package, packages)
end
function Registry.get_all_package_specs()
diff --git a/lua/mason-registry/sources/github.lua b/lua/mason-registry/sources/github.lua
index 597e7d84..2b177bdd 100644
--- a/lua/mason-registry/sources/github.lua
+++ b/lua/mason-registry/sources/github.lua
@@ -1,5 +1,4 @@
local InstallLocation = require "mason-core.installer.InstallLocation"
-local Optional = require "mason-core.optional"
local Result = require "mason-core.result"
local _ = require "mason-core.functional"
local fetch = require "mason-core.fetch"
diff --git a/lua/mason-registry/sources/init.lua b/lua/mason-registry/sources/init.lua
index 765d4904..f468a8ef 100644
--- a/lua/mason-registry/sources/init.lua
+++ b/lua/mason-registry/sources/init.lua
@@ -11,7 +11,7 @@ local log = require "mason-core.log"
---@field serialize fun(self: RegistrySource): InstallReceiptRegistry
---@field is_same_location fun(self: RegistrySource, other: RegistrySource): boolean
----@alias RegistrySourceType '"github"' | '"lua"' | '"file"'
+---@alias RegistrySourceType '"github"' | '"lua"' | '"file"' | '"synthesized"'
---@class LazySource
---@field type RegistrySourceType
@@ -54,6 +54,11 @@ function LazySource.File(id)
}
end
+function LazySource.Synthesized()
+ local SynthesizedSource = require "mason-registry.sources.synthesized"
+ return SynthesizedSource:new()
+end
+
---@param type RegistrySourceType
---@param id string
---@param init fun(id: string): RegistrySource
@@ -115,6 +120,7 @@ end
---@class LazySourceCollection
---@field list LazySource[]
+---@field synthesized LazySource
local LazySourceCollection = {}
LazySourceCollection.__index = LazySourceCollection
@@ -123,6 +129,7 @@ function LazySourceCollection:new()
local instance = {}
setmetatable(instance, self)
instance.list = {}
+ instance.synthesized = LazySource:new("synthesized", "synthesized", LazySource.Synthesized)
return instance
end
@@ -184,7 +191,7 @@ function LazySourceCollection:checksum()
return vim.fn.sha256(table.concat(registry_ids, ""))
end
----@param opts? { include_uninstalled?: boolean }
+---@param opts? { include_uninstalled?: boolean, include_synthesized?: boolean }
function LazySourceCollection:iterate(opts)
opts = opts or {}
@@ -197,6 +204,12 @@ function LazySourceCollection:iterate(opts)
return source
end
end
+
+ -- We've exhausted the true registry sources, fall back to the synthesized registry source.
+ if idx == #self.list + 1 and opts.include_synthesized ~= false then
+ idx = idx + 1
+ return self.synthesized:get()
+ end
end
end
diff --git a/lua/mason-registry/sources/synthesized.lua b/lua/mason-registry/sources/synthesized.lua
new file mode 100644
index 00000000..75638cd6
--- /dev/null
+++ b/lua/mason-registry/sources/synthesized.lua
@@ -0,0 +1,109 @@
+local Package = require "mason-core.package"
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local InstallReceipt = require("mason-core.receipt").InstallReceipt
+local InstallLocation = require "mason-core.installer.InstallLocation"
+local fs = require "mason-core.fs"
+local log = require "mason-core.log"
+
+---@class SynthesizedRegistrySource : RegistrySource
+---@field buffer table<string, Package>
+local SynthesizedRegistrySource = {}
+SynthesizedRegistrySource.__index = SynthesizedRegistrySource
+
+function SynthesizedRegistrySource:new()
+ ---@type SynthesizedRegistrySource
+ local instance = {}
+ setmetatable(instance, self)
+ instance.buffer = {}
+ return instance
+end
+
+function SynthesizedRegistrySource:is_installed()
+ return true
+end
+
+---@return RegistryPackageSpec[]
+function SynthesizedRegistrySource:get_all_package_specs()
+ return {}
+end
+
+---@param pkg_name string
+---@param receipt InstallReceipt
+---@return Package
+function SynthesizedRegistrySource:load_package(pkg_name, receipt)
+ local installed_version = receipt:get_installed_package_version()
+ local source = {
+ id = ("pkg:mason/%s@%s"):format(pkg_name, installed_version or "N%2FA"), -- N%2FA = N/A
+ install = function()
+ error("This package can no longer be installed because it has been removed from the registry.", 0)
+ end,
+ }
+ ---@type RegistryPackageSpec
+ local spec = {
+ schema = "registry+v1",
+ name = pkg_name,
+ description = "",
+ categories = {},
+ languages = {},
+ homepage = "",
+ licenses = {},
+ deprecation = {
+ since = receipt:get_installed_package_version() or "N/A",
+ message = "This package has been removed from the registry.",
+ },
+ source = source,
+ }
+ local existing_pkg = self.buffer[pkg_name]
+ if existing_pkg then
+ existing_pkg:update(spec, self)
+ return existing_pkg
+ else
+ local pkg = Package:new(spec, self)
+ self.buffer[pkg_name] = pkg
+ return pkg
+ end
+end
+
+---@param pkg_name string
+---@return Package?
+function SynthesizedRegistrySource:get_package(pkg_name)
+ local receipt_path = InstallLocation.global():receipt(pkg_name)
+ if fs.sync.file_exists(receipt_path) then
+ local ok, receipt_json = pcall(vim.json.decode, fs.sync.read_file(receipt_path))
+ if ok then
+ local receipt = InstallReceipt.from_json(receipt_json)
+ return self:load_package(pkg_name, receipt)
+ else
+ log.error("Failed to decode package receipt", pkg_name, receipt_json)
+ end
+ end
+end
+
+function SynthesizedRegistrySource:get_all_package_names()
+ return vim.tbl_keys(self.buffer)
+end
+
+---@async
+function SynthesizedRegistrySource:install()
+ return Result.success()
+end
+
+function SynthesizedRegistrySource:get_display_name()
+ return "SynthesizedRegistrySource"
+end
+
+function SynthesizedRegistrySource:serialize()
+ return {}
+end
+
+---@param other SynthesizedRegistrySource
+function SynthesizedRegistrySource:is_same_location(other)
+ return true
+end
+
+function SynthesizedRegistrySource:__tostring()
+ return "SynthesizedRegistrySource"
+end
+
+return SynthesizedRegistrySource
diff --git a/lua/mason/ui/instance.lua b/lua/mason/ui/instance.lua
index d0026389..476bdf8c 100644
--- a/lua/mason/ui/instance.lua
+++ b/lua/mason/ui/instance.lua
@@ -660,7 +660,7 @@ end
local function update_registry_info()
local registries = {}
- for source in registry.sources:iterate { include_uninstalled = true } do
+ for source in registry.sources:iterate { include_uninstalled = true, include_synthesized = false } do
table.insert(registries, {
name = source:get_display_name(),
is_installed = source:is_installed(),
diff --git a/tests/mason-registry/sources/collection_spec.lua b/tests/mason-registry/sources/collection_spec.lua
index b603c868..a5253ab5 100644
--- a/tests/mason-registry/sources/collection_spec.lua
+++ b/tests/mason-registry/sources/collection_spec.lua
@@ -1,4 +1,5 @@
local LazySourceCollection = require "mason-registry.sources"
+local SynthesizedSource = require "mason-registry.sources.synthesized"
describe("LazySourceCollection", function()
it("should dedupe registries on append/prepend", function()
@@ -18,4 +19,22 @@ describe("LazySourceCollection", function()
assert.same("github:mason-org/mason-registry@2025-05-16", coll:get(3):get_full_id())
assert.same("file:~/registry", coll:get(4):get_full_id())
end)
+
+ it("should fall back to synthesized source", function()
+ local coll = LazySourceCollection:new()
+
+ for source in coll:iterate() do
+ assert.is_true(getmetatable(source) == SynthesizedSource)
+ return
+ end
+ error "Did not fall back to synthesized source"
+ end)
+
+ it("should exclude synthesized source", function()
+ local coll = LazySourceCollection:new()
+
+ for source in coll:iterate { include_synthesized = false } do
+ error "Should not iterate."
+ end
+ end)
end)