Skip to content

Instantly share code, notes, and snippets.

@MatthiasPortzel
Last active May 31, 2025 14:53
Show Gist options
  • Save MatthiasPortzel/4978c1188537d9bf280ad41a30a86b7a to your computer and use it in GitHub Desktop.
Save MatthiasPortzel/4978c1188537d9bf280ad41a30a86b7a to your computer and use it in GitHub Desktop.
A Sublime Text Package management script
#!/usr/bin/env ruby
# Some dependencies are not available on PiPy (like mdpopups, sublime_lib, and lsp_utils).
# These can be installed by supporting a custom dependency format that is compatible with Package Control's,
# but right now I have code for that commented-out and I'm trying to upstream a `pyproject.toml` file for these dependencies,
# which allows them to be installed by pip with a `git+` URL.
# # Package control uses naming from https://github.com/packagecontrol/channel/blob/main/repository.json
# AVAILABLE_DEP_PACKAGES = {
# # TODO: These should really be installed in Lib/ not Packages/
# # TODO: We need the subpath to be at the top level. It makes things less elegant but I think we need to support it
# # Or create a "loader" package, which I think is worse
# # I'm going to go with option 3, which is trying to upstream pyproject.toml files
# # mdpopups: { url: "https://github.com/facelessuser/sublime-markdown-popups", name: "mdpopups", subpaths: ["st3/mdpopups"] },
# # sublime_lib: { url: "https://github.com/SublimeText/sublime_lib", name: "sublime_lib", subpaths: ["st3/sublime_lib"] },
# # lsp_utils: { url: "https://github.com/MatthiasPortzel/lsp_utils", name: "lsp_utils" },
# # mdpopups: { url: "git+https://github.com/MatthiasPortzel/sublime-markdown-popups", name: "mdpopups" },
# # sublime_lib: { url: "https://github.com/SublimeText/sublime_lib", name: "sublime_lib", subpaths: ["st3/sublime_lib"] },
# # lsp_utils: { url: "https://github.com/sublimelsp/lsp_utils", name: "lsp_utils", subpaths: ["st4_py38/lsp_utils"] },
# }.freeze
packages = [
{ url: "https://github.com/colinta/ApacheConf.tmLanguage", name: "ApacheConf" },
{ url: "https://github.com/apiaryio/api-blueprint-sublime-plugin" },
# Deps are installed by pip and use names pip recognizes
{ url: "https://github.com/jfcherng-sublime/ST-AutoSetSyntax", name: "AutoSetSyntax", deps: ["more-itertools", "pydantic", "eval_type_backport"]},
{ url: "https://github.com/tvi/Sublime-ARM-Assembly" },
{ url: "https://github.com/dougmasten/sublime-assembly-6809" },
{ url: "https://github.com/JohnNilsson/awk-sublime" },
{ url: "https://github.com/tyrone-sudeium/st3-binaryplist" },
{ url: "https://github.com/zyxar/Sublime-CMakeLists", name: "CMake" },
{ url: "https://github.com/SublimeText/CoffeeScript" },
{ url: "https://github.com/appleton/demain", name: "Demain Color Scheme" },
{ url: "https://github.com/Sublime-Instincts/BetterJinja" },
{ url: "https://github.com/elixir-editors/elixir-sublime-syntax" },
{ url: "https://github.com/asbjornenge/Docker.tmbundle", name: "Dockerfile Syntax Highlighting" },
{ url: "https://github.com/braver/FileIcons" },
{ url: "https://github.com/Phidica/sublime-fish", deps: ["PyYAML"], name: "fish" },
{ url: "https://github.com/bitst0rm-pub/Formatter" },
{ url: "https://github.com/printfn/gemini-sublime", name: "Gemini" },
{ url: "https://github.com/skozlovf/Sublime-GenericConfig" },
{ url: "https://github.com/mauroreisvieira/github-sublime-theme" },
{ url: "https://github.com/timbrel/GitSavvy" },
{ url: "https://github.com/kingofmalkier/sublime-gradle" },
{ url: "https://github.com/daaain/Handlebars" },
{ url: "https://github.com/jwortmann/ini-syntax" },
{ url: "https://github.com/nk9/just_sublime" },
{ url: "https://github.com/vkostyukov/kotlin-sublime-package" },
{ url: "https://github.com/whitequark/LLVM.tmBundle" },
{ url: "https://github.com/whitequark/LLVM-TableGen.tmBundle" },
# sublime-package+ prefix mirrors git+ prefix, except that instead of being handled by pip, it's handled by us and looked up in AVAILABLE_DEP_PACAKGES
{ url: "https://github.com/sublimelsp/LSP", deps: ["wcmatch", "orjson", "typing_extensions", "bracex", "git+https://github.com/MatthiasPortzel/sublime-markdown-popups"] },
{ url: "https://github.com/sublimelsp/LSP-file-watcher-chokidar", deps: ["git+https://github.com/MatthiasPortzel/lsp_utils"] },
# { url: "https://github.com/sublimelsp/LSP-json", deps: ["git+https://github.com/MatthiasPortzel/lsp_utils", "sublime-package+sublime_lib"] },
{ url: "https://github.com/sublimelsp/LSP-rust-analyzer" },
# { url: "https://github.com/sublimelsp/LSP-typescript", deps: ["git+https://github.com/MatthiasPortzel/lsp_utils", "sublime-package+sublime_lib"] },
{ url: "https://github.com/SublimeText-Markdown/MarkdownEditing" },
{ url: "https://github.com/euler0/sublime-glsl" },
# { url: "https://github.com/SublimeText/PackageDev", deps: ["sublime-package+sublime_lib"] },
{ url: "https://github.com/skuroda/PackageResourceViewer" },
{ url: "https://github.com/Sublime-Instincts/PrismaHighlight" },
{ url: "https://github.com/follesoe/sublime-racket" },
{ url: "https://github.com/mechatroner/sublime_rainbow_csv", name: "rainbow_csv" },
{ url: "https://github.com/SublimeText/Sass" },
{ url: "https://github.com/AmjadHD/sublime_one_theme" },
{ url: "https://github.com/SublimeText/Spacegray" },
{ url: "https://github.com/jasonwilliams/sublime_toml_highlighting" },
{ url: "https://github.com/pro711/sublime-verilog" },
{ url: "https://github.com/SublimeText/Vue" },
{ url: "https://github.com/bathos/wast-sublime-syntax" },
{ url: "https://github.com/braver/XcodeColors" },
{ url: "https://github.com/aidenfoxivey/sublime-zig-unofficial" },
{ url: "https://github.com/ihdavids/orgextended", name: "OrgExtended", deps: ["python-dateutil", "pyyaml", "requests", "regex"] }, # Not sure what "websocket" means when only "websockets" is registered
{ url: "https://github.com/randy3k/RemoteSubl", ignore: true },
{ url: "https://github.com/absop/ST-Scheme" },
{ url: "https://github.com/sublimehq/Packages", subpaths: %w[ShellScript Lisp] },
{ url: "https://github.com/robballou/sublimetext-sshconfig" },
{ url: "https://github.com/fnando/sublime-soda" },
{ url: "https://github.com/fnando/sublime-close-others" },
{ name: "catppuccin-sublime-text", ignore: true },
{ name: "run_spec", ignore: true },
]
# ----- EDIT PACAKGE DATA ABOVE THIS LINE -----
# -- Setup and Validation -- #
error "Unsupported Ruby; I'm sorry `it` in blocks is addicting and it's a Ruby 3.4 feature." unless RUBY_VERSION >= "3.4"
require "json"
require "optparse"
require "open3"
def error str
STDERR.puts str
exit 1
end
error "Unsupported git; we require git 2.25 or later." unless `git --version`.match(/^git version (.*)$/).captures.first > "2.25.0"
error "uv not found; uv with python 3.8 is required to install python 3.8 dependencies" unless `uv --version`.include?("uv")
# See comment at start of file
# # "dependency packages" are dependencies that are distributed and installed like packages (not through Pypi like most deps)
# # This is currently "mdpopups", "sublime_lib", lsp_utils
# # They need to be pushed to the package list
# packages.each do |package|
# next unless package[:deps]
# package_deps = package[:deps].filter { it.start_with?("sublime-package+") }
# package_deps.each do |dep|
# dep_name = dep.delete_prefix("sublime-package+").to_sym
# error "Can't locate dependency #{dep}" unless AVAILABLE_DEP_PACKAGES[dep_name]
# packages.push AVAILABLE_DEP_PACKAGES[dep_name]
# # And remove from deps
# package[:deps].delete dep
# end
# end
packages.each do |package|
# The package name is the name of the package's directory
package[:name] ||= package[:url].split("/").last
end
def parse_sublime_json str
return if str.nil?
# Remove single-line comments, multi-line comments, and trailing commas
# TODO: This sucks because if you have a URL in your settings, it's recognized as a comment
str.gsub!(/\/\/[^\n]*\n/, '')
str.gsub!(/\/\*.*?\*\//m, '')
str.gsub!(/,(\s*[}\]])/, '\1')
return JSON.parse str
end
# cd's into a directory, runs a block, and then pops afterwords
def in_directory directory
original_dir = Dir.pwd
Dir.chdir directory
yield
ensure
Dir.chdir(original_dir)
end
safe_mode = false
OptionParser.new { |opt| opt.on("--safe-mode") { safe_mode = true } }.parse!
sublime_directory = Dir.pwd
error "Please run from Sublime Text directory" unless sublime_directory.split("/").last == "Sublime Text"
if safe_mode
Dir.chdir ".."
SAFE_MODE_PATH = "Sublime Text (Safe Mode)"
begin
Dir.chdir SAFE_MODE_PATH
rescue
error "Couldn't find Sublime Text safe-mode directory at #{File.join(Dir.cwd, SAFE_MODE_PATH)}"
end
end
sublime_version = `subl --version`.match(/\d+/).to_s.to_i
error "Only Sublime Text 4 versions that support disabling the 3.3 plugin host are supported" unless sublime_version > 4194
user_settings_file = begin
File.open("Packages/User/Preferences.sublime-settings", "r")
rescue
nil
end
user_settings = parse_sublime_json user_settings_file&.read
unless user_settings&.dig("disable_plugin_host_3.3")
error "Only the Python 3.8 plugin runtime is supported. Please add `\"disable_plugin_host_3.3\": true` to your Sublime Text settings"
end
# -- Package Control Migration and uninstall-- #
installed_packages = Dir["Installed Packages/*.sublime-package"]
if installed_packages.any? { it.match /Package Control/ }
error "Please uninstall Package Control"
end
# Code below this assumes that all packages in "Installed Packages" were installed by Package Control
# Manually installing packages into "Installed Packages" isn't supported
# Packages, like MarkdownEditing, which install packages into "Installed Package" need an exception here
if packages.any? { it[:name] == "MarkdownEditing" }
installed_packages.delete "Installed Packages/Markdown.sublime-package"
end
unless installed_packages.empty?
package_control_settings_file = File.open("Packages/User/Package Control.sublime-settings")
package_control_settings = parse_sublime_json package_control_settings_file.read
pc_installed_packages = package_control_settings["installed_packages"]
pc_installed_packages.each do |package|
begin
f = File.open("Installed Packages/#{package}.sublime-package")
rescue
# TODO: Why is this an error?
# What are you supposed to do? Re-install Package Control and re-install it?
STDERR.puts "Package which package control thinks should be installed (#{package} -- listed in package control settings) doesn't exist"
next
end
STDERR.puts "Deleting Package Control installed package #{package}"
File.delete f
# TODO: Only
STDERR.puts "Please re-install by adding a listing to the Working Package Manager"
end
installed_packages = Dir["Installed Packages/*.sublime-package"]
unless installed_packages.empty?
error "Please manually uninstall and remove installed packages unrelated to Package Control:\n#{installed_packages.join(" ")}"
end
end
# -- Existing Package Validation -- #
already_installed_packages = Dir["Packages/*"]
already_installed_packages.map { File.open(it) }.filter { it.stat.directory? }
package_names = packages.map { it[:name] }
already_installed_package_names = already_installed_packages.map { File.basename(it) } - package_names - ["User"]
unless already_installed_package_names.empty?
STDERR.puts "Found packages in Packages not managed by Working Package Manager"
STDERR.puts "Please add them to Working Package Manager or delete them"
STDERR.puts already_installed_package_names.join(" ")
exit 1
end
already_installed_packages.clear # "free" the file handles
# -- Dependency installation -- #
# TODO: Handle uninstalling dependencies
packages.filter_map { it[:deps] }.flatten.each do |dependency|
# For now, we're just going to ask the user to install uv since we already ask them to install ruby and git
STDERR.puts "Handling dependency #{dependency} with pip"
# Some packages may normally compile from source. In order to do this, we need a version of python that matches the target
# We use uv to emulate pip with python 3.8 since python 3.8 is EOL
stdout, stderr, status = Open3.capture3("uv pip install --python 3.8 --python-version 3.8 --upgrade --target Lib/python38 #{dependency}")
if stderr.include?("No interpreter found for Python 3.8")
error "Please install python 3.8 with uv: `uv python install 3.8`"
end
if !status.success?
puts "Error while installing dependency #{dependency}"
error stderr
end
end
# -- Package Installation -- #
in_directory "Packages" do
packages.each do |package|
next if package[:ignore]
if File.exist?(package[:name])
in_directory package[:name] do
# We don't use the result of this git-status call, we're just trying to figure out if we're in a git directory or not
`git status --porcelain`
unless $?.success?
# Not a git directory.
`git init`
`git remote add origin #{package[:url]}`
git_status = `git status --porcelain`
unless $?.success?
error "Sorry. #{package[:name]}'s git is screwed the h*ck up"
end
end
STDERR.puts "Fetching for #{package[:name]}"
`git fetch`
git_status = `git status --porcelain -b`.split("\n")
branch_status = git_status.unshift
dirty_files = git_status
if branch_status.include?("behind")
if dirty_files.empty? && !branch_status.include?("ahead")
# Then we're good to just merge
STDERR.puts "Updating #{package[:name]}"
`git merge`
else
puts "Package #{package[:name]} needs update but has a dirty git status; skipping"
end
end
end
else
# We need to fetch it fresh
if package[:subpaths]
STDERR.puts "Performing sparse-checkout of #{package[:name]}"
`git clone --depth=1 --no-checkout -- "#{package[:url]}" "#{package[:name]}"`
in_directory package[:name] do
# cone is simpler but requires that the top-level paths are included
# This is designed for monorepos where your top-level files are README and LICENSE
# It's too easy for me to image a package with a top-level file that is important to avoid
# In no-cone mode, the arguments are glob patterns matching files, like gitignore
`git sparse-checkout set --no-cone -- #{package[:subpaths].join(" ")}`
`git checkout`
end
else
STDERR.puts "Cloning #{package[:name]}"
`git clone --depth=1 -- "#{package[:url]}" "#{package[:name]}"`
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment