Skip to content

Commit 69862d6

Browse files
authored
feat: add support for removal of packages from a registry (#2052)
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.
1 parent 57e5a8a commit 69862d6

File tree

10 files changed

+164
-16
lines changed

10 files changed

+164
-16
lines changed

lua/mason-core/installer/InstallHandle.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function InstallHandleSpawnHandle:__tostring()
4343
end
4444

4545
---@class InstallHandle : EventEmitter
46-
---@field package AbstractPackage
46+
---@field public package AbstractPackage
4747
---@field state InstallHandleState
4848
---@field stdio_sink BufferedSink
4949
---@field is_terminated boolean

lua/mason-core/installer/context/init.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ local receipt = require "mason-core.receipt"
1717
---@field location InstallLocation
1818
---@field spawn InstallContextSpawn
1919
---@field handle InstallHandle
20-
---@field package AbstractPackage
20+
---@field public package AbstractPackage
2121
---@field cwd InstallContextCwd
2222
---@field opts PackageInstallOpts
2323
---@field stdio_sink StdioSink

lua/mason-core/package/AbstractPackage.lua

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ local Result = require "mason-core.result"
66
local _ = require "mason-core.functional"
77
local fs = require "mason-core.fs"
88
local log = require "mason-core.log"
9-
local path = require "mason-core.path"
109
local settings = require "mason.settings"
1110
local Semaphore = require("mason-core.async.control").Semaphore
1211

@@ -166,15 +165,10 @@ end
166165
---@return string?
167166
function AbstractPackage:get_installed_version(location)
168167
return self:get_receipt(location)
169-
:and_then(
168+
:map(
170169
---@param receipt InstallReceipt
171170
function(receipt)
172-
local source = receipt:get_source()
173-
if source.id then
174-
return Purl.parse(source.id):map(_.prop "version"):ok()
175-
else
176-
return Optional.empty()
177-
end
171+
return receipt:get_installed_package_version()
178172
end
179173
)
180174
:or_else(nil)

lua/mason-core/receipt.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
local Optional = require "mason-core.optional"
2+
local Purl = require "mason-core.purl"
3+
local _ = require "mason-core.functional"
4+
15
local M = {}
26

37
---@alias InstallReceiptSchemaVersion
@@ -41,6 +45,14 @@ function InstallReceipt:get_name()
4145
return self.name
4246
end
4347

48+
---@return string?
49+
function InstallReceipt:get_installed_package_version()
50+
local source = self:get_source()
51+
if source.id then
52+
return Purl.parse(source.id):map(_.prop "version"):get_or_nil()
53+
end
54+
end
55+
4456
function InstallReceipt:get_schema_version()
4557
return self.schema_version
4658
end

lua/mason-registry/init.lua

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ function Registry.get_installed_package_names()
4949
directories[#directories + 1] = entry.name
5050
end
5151
end
52-
-- TODO: validate that entry is a mason package
5352
return directories
5453
end
5554

@@ -68,7 +67,10 @@ function Registry.get_all_package_names()
6867
end
6968

7069
function Registry.get_all_packages()
71-
return vim.tbl_map(Registry.get_package, Registry.get_all_package_names())
70+
local _ = require "mason-core.functional"
71+
local packages =
72+
_.uniq_by(_.identity, _.concat(Registry.get_all_package_names(), Registry.get_installed_package_names()))
73+
return vim.tbl_map(Registry.get_package, packages)
7274
end
7375

7476
function Registry.get_all_package_specs()

lua/mason-registry/sources/github.lua

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
local InstallLocation = require "mason-core.installer.InstallLocation"
2-
local Optional = require "mason-core.optional"
32
local Result = require "mason-core.result"
43
local _ = require "mason-core.functional"
54
local fetch = require "mason-core.fetch"

lua/mason-registry/sources/init.lua

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ local log = require "mason-core.log"
1111
---@field serialize fun(self: RegistrySource): InstallReceiptRegistry
1212
---@field is_same_location fun(self: RegistrySource, other: RegistrySource): boolean
1313

14-
---@alias RegistrySourceType '"github"' | '"lua"' | '"file"'
14+
---@alias RegistrySourceType '"github"' | '"lua"' | '"file"' | '"synthesized"'
1515

1616
---@class LazySource
1717
---@field type RegistrySourceType
@@ -54,6 +54,11 @@ function LazySource.File(id)
5454
}
5555
end
5656

57+
function LazySource.Synthesized()
58+
local SynthesizedSource = require "mason-registry.sources.synthesized"
59+
return SynthesizedSource:new()
60+
end
61+
5762
---@param type RegistrySourceType
5863
---@param id string
5964
---@param init fun(id: string): RegistrySource
@@ -115,6 +120,7 @@ end
115120

116121
---@class LazySourceCollection
117122
---@field list LazySource[]
123+
---@field synthesized LazySource
118124
local LazySourceCollection = {}
119125
LazySourceCollection.__index = LazySourceCollection
120126

@@ -123,6 +129,7 @@ function LazySourceCollection:new()
123129
local instance = {}
124130
setmetatable(instance, self)
125131
instance.list = {}
132+
instance.synthesized = LazySource:new("synthesized", "synthesized", LazySource.Synthesized)
126133
return instance
127134
end
128135

@@ -184,7 +191,7 @@ function LazySourceCollection:checksum()
184191
return vim.fn.sha256(table.concat(registry_ids, ""))
185192
end
186193

187-
---@param opts? { include_uninstalled?: boolean }
194+
---@param opts? { include_uninstalled?: boolean, include_synthesized?: boolean }
188195
function LazySourceCollection:iterate(opts)
189196
opts = opts or {}
190197

@@ -197,6 +204,12 @@ function LazySourceCollection:iterate(opts)
197204
return source
198205
end
199206
end
207+
208+
-- We've exhausted the true registry sources, fall back to the synthesized registry source.
209+
if idx == #self.list + 1 and opts.include_synthesized ~= false then
210+
idx = idx + 1
211+
return self.synthesized:get()
212+
end
200213
end
201214
end
202215

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
local Package = require "mason-core.package"
2+
local Result = require "mason-core.result"
3+
local _ = require "mason-core.functional"
4+
local InstallReceipt = require("mason-core.receipt").InstallReceipt
5+
local InstallLocation = require "mason-core.installer.InstallLocation"
6+
local fs = require "mason-core.fs"
7+
local log = require "mason-core.log"
8+
9+
---@class SynthesizedRegistrySource : RegistrySource
10+
---@field buffer table<string, Package>
11+
local SynthesizedRegistrySource = {}
12+
SynthesizedRegistrySource.__index = SynthesizedRegistrySource
13+
14+
function SynthesizedRegistrySource:new()
15+
---@type SynthesizedRegistrySource
16+
local instance = {}
17+
setmetatable(instance, self)
18+
instance.buffer = {}
19+
return instance
20+
end
21+
22+
function SynthesizedRegistrySource:is_installed()
23+
return true
24+
end
25+
26+
---@return RegistryPackageSpec[]
27+
function SynthesizedRegistrySource:get_all_package_specs()
28+
return {}
29+
end
30+
31+
---@param pkg_name string
32+
---@param receipt InstallReceipt
33+
---@return Package
34+
function SynthesizedRegistrySource:load_package(pkg_name, receipt)
35+
local installed_version = receipt:get_installed_package_version()
36+
local source = {
37+
id = ("pkg:mason/%s@%s"):format(pkg_name, installed_version or "N%2FA"), -- N%2FA = N/A
38+
install = function()
39+
error("This package can no longer be installed because it has been removed from the registry.", 0)
40+
end,
41+
}
42+
---@type RegistryPackageSpec
43+
local spec = {
44+
schema = "registry+v1",
45+
name = pkg_name,
46+
description = "",
47+
categories = {},
48+
languages = {},
49+
homepage = "",
50+
licenses = {},
51+
deprecation = {
52+
since = receipt:get_installed_package_version() or "N/A",
53+
message = "This package has been removed from the registry.",
54+
},
55+
source = source,
56+
}
57+
local existing_pkg = self.buffer[pkg_name]
58+
if existing_pkg then
59+
existing_pkg:update(spec, self)
60+
return existing_pkg
61+
else
62+
local pkg = Package:new(spec, self)
63+
self.buffer[pkg_name] = pkg
64+
return pkg
65+
end
66+
end
67+
68+
---@param pkg_name string
69+
---@return Package?
70+
function SynthesizedRegistrySource:get_package(pkg_name)
71+
local receipt_path = InstallLocation.global():receipt(pkg_name)
72+
if fs.sync.file_exists(receipt_path) then
73+
local ok, receipt_json = pcall(vim.json.decode, fs.sync.read_file(receipt_path))
74+
if ok then
75+
local receipt = InstallReceipt.from_json(receipt_json)
76+
return self:load_package(pkg_name, receipt)
77+
else
78+
log.error("Failed to decode package receipt", pkg_name, receipt_json)
79+
end
80+
end
81+
end
82+
83+
function SynthesizedRegistrySource:get_all_package_names()
84+
return vim.tbl_keys(self.buffer)
85+
end
86+
87+
---@async
88+
function SynthesizedRegistrySource:install()
89+
return Result.success()
90+
end
91+
92+
function SynthesizedRegistrySource:get_display_name()
93+
return "SynthesizedRegistrySource"
94+
end
95+
96+
function SynthesizedRegistrySource:serialize()
97+
return {}
98+
end
99+
100+
---@param other SynthesizedRegistrySource
101+
function SynthesizedRegistrySource:is_same_location(other)
102+
return true
103+
end
104+
105+
function SynthesizedRegistrySource:__tostring()
106+
return "SynthesizedRegistrySource"
107+
end
108+
109+
return SynthesizedRegistrySource

lua/mason/ui/instance.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@ end
660660

661661
local function update_registry_info()
662662
local registries = {}
663-
for source in registry.sources:iterate { include_uninstalled = true } do
663+
for source in registry.sources:iterate { include_uninstalled = true, include_synthesized = false } do
664664
table.insert(registries, {
665665
name = source:get_display_name(),
666666
is_installed = source:is_installed(),

tests/mason-registry/sources/collection_spec.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local LazySourceCollection = require "mason-registry.sources"
2+
local SynthesizedSource = require "mason-registry.sources.synthesized"
23

34
describe("LazySourceCollection", function()
45
it("should dedupe registries on append/prepend", function()
@@ -18,4 +19,22 @@ describe("LazySourceCollection", function()
1819
assert.same("github:mason-org/mason-registry@2025-05-16", coll:get(3):get_full_id())
1920
assert.same("file:~/registry", coll:get(4):get_full_id())
2021
end)
22+
23+
it("should fall back to synthesized source", function()
24+
local coll = LazySourceCollection:new()
25+
26+
for source in coll:iterate() do
27+
assert.is_true(getmetatable(source) == SynthesizedSource)
28+
return
29+
end
30+
error "Did not fall back to synthesized source"
31+
end)
32+
33+
it("should exclude synthesized source", function()
34+
local coll = LazySourceCollection:new()
35+
36+
for source in coll:iterate { include_synthesized = false } do
37+
error "Should not iterate."
38+
end
39+
end)
2140
end)

0 commit comments

Comments
 (0)