Skip to content

Instantly share code, notes, and snippets.

@estum
Last active November 11, 2020 04:32
Show Gist options
  • Save estum/8d91605966e393adb0d64ec4e426cb37 to your computer and use it in GitHub Desktop.
Save estum/8d91605966e393adb0d64ec4e426cb37 to your computer and use it in GitHub Desktop.
Menu utility for LES (Live Enhancement Suite)
#!/usr/bin/env ruby
# frozen_string_literal: true
# = Lesmenu: Menu utility for Live Enhancement Suite (https://enhancementsuite.me)
#
# The utility creates a YAML-formatted copy of LES's `menuconfig.ini`,
# which is (IMHO) a better format for a human-readable and -editable structure.
#
# First, you should generate '~/.hammerspoon/menuconfig.yml':
#
# $ lesmenu yaml -e # converts & opens editor
#
# Than, edit menu & save it back to INI:
#
# $ lesmenu ini # writes back to menuconfig.ini
# $ lesmenu ini - # outputs to stdout without writing the file
#
# You also can inspect generated structure in one of 3 forms: yaml, ruby hash or struct
#
# $ lesmenu inspect yaml --as struct
#
# Commands:
# lesmenu ini [TARGET] # Convert YML back to menuconfig.ini
# lesmenu yaml [TARGET] # Convert menuconfig.ini to yaml
# lesmenu inspect [SUBCOMMAND]
# lesmenu version # Print version
require "bundler/inline"
gemfile do
source 'https://rubygems.org'
gem 'dry-cli'
gem 'dry-struct'
gem 'dry-types'
gem 'awesome_print'
end
require "yaml"
module Lesmenu
Types = Module.new { include Dry.Types }
SEP = "--"
SUB_END = ".."
SUB_CHR = ?/
SEP_ATTRS = { label: SEP }.freeze
INI_TAIL = [
"; --",
";V DONT REMOVE THIS OR THE PROGRAM WILL NOT WORK V",
"; Readme",
"End"
]
class MenuWalker
attr_reader :items
def initialize
@items = []
@_path = []
end
def call(line)
case line.chr
when "/"; submenu(line)
when "."; exit_submenu
when "-"; current_items << SEP_ATTRS
when ""; close_item if @_item
when /[\w\"\']/; item(line)
end
end
def current_items
@_path.size > 0 ? @items.dig(*current_path) : @items
end
def current_path
@_path.size > 0 ? [*@_path, :items] : @_path
end
def current_depth
@_path.size > 0 ? @items.dig(*@_path, :depth) : 0
end
def submenu(line)
depth = line[/^\/+/].size
sub = { depth: depth, label: line[depth..-1], items: []}
exit_submenu if depth == current_depth
current_items << sub
@_path = [*current_path, current_items.index(sub)]
end
def item(line)
return @_item = { label: line } unless @_item
if line[0] == ?" && line[-1] == ?"
line = line[1...-1]
end
@_item[:query] = line
close_item
end
def close_item
@_item[:query] ||= @_item[:label]
current_items << @_item
@_item = nil
end
def exit_submenu
@_path.pop(2)
end
end
YAMLWalker = -> (item, depth: 0) {
case item
when SEP
SEP_ATTRS
when String
{ label: item, query: item }
when Hash
item.flat_map do |label, sub|
case sub
when Array
depth = depth.next
{ label: label, items: sub.flat_map { |v| YAMLWalker[v, depth: depth] }, depth: depth }
when String
{ label: label, query: sub }
end
end[0]
else
item
end
}
class MenuConfig < Dry::Struct
MenuItem = Dry::Struct(label: Types::String) do
abstract
def encode_with(coder)
coder.represent_scalar(nil, label)
end
end
Separator = Class.new(MenuItem).attributes(label: Types::String.optional.default(SEP).constrained(eql: SEP))
Item = Class.new(MenuItem).attributes(query: Types::String)
Submenu = Class.new(MenuItem).attributes(depth: Types::Integer.optional.default(0))
ItemsList = Types.Array(Types.Constructor(Separator) | Types.Constructor(Item) | Types.Constructor(Submenu))
class Separator
def to_ini(*)
SEP
end
end
class Item
def encode_with(coder)
label == query ? super : coder.represent_map(nil, label => query)
end
def to_ini(*)
[label, query].uniq.join("\n")
end
end
class Submenu
attribute :items, ItemsList.default {[]}
def to_ini(depth: 0)
depth = depth.next
lines = ["#{SUB_CHR * depth}#{label}", *items.flat_map { |v| v.to_ini(depth: depth) }]
lines << SUB_END unless lines[-1] == SUB_END
lines
end
def encode_with(coder)
coder.represent_map(nil, label => items)
end
end
Sep = Separator.new
attribute :items, ItemsList
def self.load_ini(source)
walker = MenuWalker.new
File.open(source, "r") { |io| io.each_line(chomp: true, &walker.method(:call).to_proc) }
new(items: walker.items)
end
def self.load_yaml(source)
unparsed = YAML.load(File.read(source))
items = unparsed.map(&YAMLWalker)
new(items: items)
end
def encode_with(coder)
coder.represent_seq nil, [*items]
end
def to_ini
lines = items.map(&:to_ini).flatten
while lines[-1] == SUB_END
lines.pop
end
lines.concat(INI_TAIL)
lines.join("\n\n")
end
end
module CLI
module Commands
extend Dry::CLI::Registry
class Version < Dry::CLI::Command
desc "Print version"
def call(*)
puts "1.0.0"
end
end
class ConvertCommand < Dry::CLI::Command
def call(source:, target:, **options)
source, target = expand_paths(source, target)
menu = load_menu_from(source)
File.write(target, serialize_menu(menu), mode: "w")
spawn_editor(target) if options[:edit] && target != "/dev/stdout"
end
private
def load_menu_from(source)
raise NotImplementedError
end
def serialize_menu(menu)
raise NotImplementedError
end
def spawn_editor(target)
editor = Shellwords.split(ENV['EDITOR'] || "/usr/bin/open -e")
cmd = Shellwords.join([*editor, target])
warn "$ #{cmd}"
pid = spawn(cmd)
Process.detach(pid)
end
def expand_paths(source, target)
[File.expand_path(source),
(target == ?- ? "/dev/stdout" : File.expand_path(target))]
end
end
class ToYAML < ConvertCommand
desc "Convert menuconfig.ini to yaml"
argument :target, default: "~/.hammerspoon/menuconfig.yml", desc: "Path to menuconfig.yml or - to stdout"
option :source, aliases: %w(-i), default: "~/.hammerspoon/menuconfig.ini", desc: "Path to menuconfig.ini"
option :edit, type: :boolean,
default: false,
aliases: %w(-e),
desc: %(Convert and open .yml file in editor (respecting EDITOR environment variable))
private
def load_menu_from(source)
MenuConfig.load_ini(source)
end
def serialize_menu(menu)
menu.to_yaml
end
end
class ToINI < Dry::CLI::Command
desc "Convert YML back to menuconfig.ini"
argument :target, default: "~/.hammerspoon/menuconfig.ini", desc: "Path to menuconfig.ini or - to stdout"
option :source, aliases: %w(-i), default: "~/.hammerspoon/menuconfig.yml", desc: "Path to menuconfig.yml"
option :edit, type: :boolean,
default: false,
aliases: %w(-e),
desc: %(Convert and open .yml file in editor (respecting EDITOR environment variable))
def load_menu_from(source)
MenuConfig.load_yaml(source)
end
def serialize_menu(menu)
menu.to_ini
end
end
register "version", Version, aliases: %w(v -v --version)
register "yaml", ToYAML, aliases: %w(yml y -y)
register "ini", ToINI, aliases: %w(i -i)
module Inspect
class INI < Dry::CLI::Command
desc "Inspect unparsed menuconfig.ini"
option :from, desc: "Path to menuconfig.ini", default: "~/.hammerspoon/menuconfig.ini"
option :struct, type: :boolean, default: false, desc: "Will generate struct?"
def call(from:, struct:, **)
menu = MenuConfig.load_ini(File.expand_path(from))
if struct
ap menu, raw: true
else
ap ::YAML.load(menu.to_yaml), raw: true
end
end
end
class YAML < Dry::CLI::Command
DigArgs = Types::Params::Integer | Types::Params::Symbol
desc "Inspect unparsed menuconfig.yml"
option :from, default: "~/.hammerspoon/menuconfig.yml", desc: "Path to menuconfig.yml"
option :format, default: "yml", values: %w(yml yaml struct hash attrs), aliases: %w(-f --as), desc: "Output format"
def call(from:, format:, args: [], **)
yaml = ::YAML.load_file(File.expand_path(from))
formatted =
case format
when "yaml", "yml"
yaml
when "hash", "attrs", "struct"
hashed = yaml.map(&YAMLWalker)
format == "struct" ? MenuConfig.new(items: hashed) : hashed
end
formatted = formatted.dig(*args.map(&DigArgs)) if args.size > 0
ap formatted, raw: true
end
end
end
register "inspect", aliases: %w(p -p) do |prefix|
prefix.register "ini", Inspect::INI, aliases: %w(i -i)
prefix.register "yaml", Inspect::YAML, aliases: %w(yml y -y)
end
end
end
end
Dry::CLI.new(Lesmenu::CLI::Commands).call
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment