Last active
May 31, 2025 14:53
-
-
Save MatthiasPortzel/4978c1188537d9bf280ad41a30a86b7a to your computer and use it in GitHub Desktop.
A Sublime Text Package management script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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