refactoring load-config and Config.struct

This commit is contained in:
Jorge Morante 2023-10-26 13:01:44 +02:00
parent c9c6d8e79b
commit 5cf5765160
10 changed files with 433 additions and 183 deletions

125
spec/lib/config_spec.cr Normal file
View File

@ -0,0 +1,125 @@
require "../spec_helper"
require "../../src/fingers/config"
describe Fingers::Config do
describe "errors" do
it "should valid when there are noerrors" do
conf = Fingers::Config.build
conf.valid?.should eq(true)
end
it "should not be valid when there are errors" do
conf = Fingers::Config.build
conf.errors << "shit"
conf.valid?.should eq(false)
end
it "errors should not be serialized" do
conf = Fingers::Config.build
has_errors = !!conf.to_json.match(/errors/)
has_errors.should eq(false)
end
end
describe "keyboard-layout" do
it "is valid for known layouts" do
conf = Fingers::Config.build
conf.keyboard_layout = "qwerty"
conf.valid?.should eq(true)
end
it "is should not include disallowed chars" do
conf = Fingers::Config.build
conf.keyboard_layout = "qwerty"
conf.alphabet.includes?("c").should eq(false)
conf.alphabet.includes?("i").should eq(false)
conf.alphabet.includes?("m").should eq(false)
conf.alphabet.includes?("q").should eq(false)
conf.alphabet.includes?("n").should eq(false)
end
it "is not valid for unknown layouts" do
conf = Fingers::Config.build
conf.keyboard_layout = "potato"
conf.valid?.should eq(false)
end
it "is qwerty by default" do
conf = Fingers::Config.build
conf.keyboard_layout.should eq("qwerty")
end
it "populates alphabet" do
conf = Fingers::Config.build
conf.alphabet.empty?.should eq(false)
end
end
describe "patterns" do
it "is valid for correct regexp" do
conf = Fingers::Config.build
conf.patterns = ["(foo|bar)"]
conf.valid?.should eq(true)
conf.patterns.size.should be > 0
end
it "is not valid for incorrect regexps" do
conf = Fingers::Config.build
conf.patterns = ["(unbalanced"]
conf.valid?.should eq(false)
end
it "is empty by default" do
conf = Fingers::Config.build
conf.patterns.size.should eq(0)
end
end
describe "styles" do
it "is valid for correct style" do
conf = Fingers::Config.build
conf.highlight_style = "fg=blue"
conf.valid?.should eq(true)
end
it "is not valid for incorrect style" do
conf = Fingers::Config.build
conf.highlight_style = "fg=shit"
conf.valid?.should eq(false)
end
end
describe "hint_position" do
it "is valid for correct value" do
conf = Fingers::Config.build
conf.hint_position = "left"
conf.valid?.should eq(true)
end
it "is not valid for incorrect value" do
conf = Fingers::Config.build
conf.hint_position = "behind"
conf.valid?.should eq(false)
end
end
describe "set_option" do
it "can set known options" do
conf = Fingers::Config.build
conf.set_option("keyboard_layout", "qwerty")
conf.valid?.should eq(true)
end
it "can set known options with invalid values" do
conf = Fingers::Config.build
conf.set_option("keyboard_layout", "caca")
conf.valid?.should eq(false)
end
it "is invalid when setting wrong option names" do
conf = Fingers::Config.build
conf.set_option("potato", "tomato")
conf.valid?.should eq(false)
end
end
end

View File

@ -0,0 +1,77 @@
require "../../../spec_helper.cr"
require "../../../../src/fingers/commands/load_config"
class FakeShell < Shell
@known_cmds = {} of String => String
def exec(cmd) : String
output = @known_cmds[cmd]?
return "" if cmd =~ /bind-key.*send-input/
if output.nil?
puts "Unknown cmd #{cmd}"
""
else
output
end
end
def expect(cmd, output)
@known_cmds[cmd] = output
end
def clear!
@known_cmds = {} of String => String
end
end
describe Fingers::Commands::LoadConfig do
it "can be instantiated" do
cmd = Fingers::Commands::LoadConfig.new(FakeShell.new)
end
it "can run" do
shell = FakeShell.new
cmd = Fingers::Commands::LoadConfig.new(shell: shell, executable_path: "/path/to/fingers", log_path: "/tmp/log_path")
shell.expect("tmux show-options -g | grep ^@fingers", "@fingers-key")
shell.expect("tmux show-option -gv @fingers-key", "F")
shell.expect("tmux -V", "3.3a")
shell.expect(%(tmux bind-key F run-shell -b "/path/to/fingers start '\#{pane_id}' self >>/tmp/log_path 2>&1"), "")
cmd.run
end
it "assigns options to config struct" do
shell = FakeShell.new
cmd = Fingers::Commands::LoadConfig.new(shell: shell, executable_path: "/path/to/fingers", log_path: "/tmp/log_path")
shell.expect("tmux show-options -g | grep ^@fingers", "@fingers-key")
shell.expect("tmux show-option -gv @fingers-key", "A")
shell.expect("tmux -V", "3.3a")
shell.expect(%(tmux bind-key A run-shell -b "/path/to/fingers start '\#{pane_id}' self >>/tmp/log_path 2>&1"), "")
cmd.run
Fingers.config.key.should eq("A")
end
it "propagates config errors" do
shell = FakeShell.new
output = IO::Memory.new
cmd = Fingers::Commands::LoadConfig.new(shell: shell, executable_path: "/path/to/fingers", log_path: "/tmp/log_path", output: output)
shell.expect("tmux show-options -g | grep ^@fingers", "@fingers-hint-style")
shell.expect("tmux show-option -gv @fingers-hint-style", "fg=caca")
shell.expect("tmux -V", "3.3a")
shell.expect(%(tmux bind-key F run-shell -b "/path/to/fingers start '\#{pane_id}' self >>/tmp/log_path 2>&1"), "")
shell.expect(%(tmux set-option -ug @fingers-hint-style), "")
cmd.run
output.rewind
cmd.errors.empty?.should eq(false)
(output.gets || "").size.should be > 0
end
end

View File

@ -1,7 +1,7 @@
require "spec"
require "../../src/tmux_style_printer"
class FakeShell < TmuxStylePrinter::Shell
class FakeShell < Shell
def exec(cmd)
"$(#{cmd})"
end

View File

@ -11,7 +11,7 @@ module Fingers
when "start"
Fingers::Commands::Start.new(args)
when "load-config"
Fingers::Commands::LoadConfig.new(args)
Fingers::Commands::LoadConfig.new
when "send-input"
Fingers::Commands::SendInput.new(args)
when "version"

View File

@ -2,22 +2,30 @@ require "file_utils"
require "./base"
require "../dirs"
require "../config"
require "../types"
require "../../tmux"
require "../../persistent_shell"
class Fingers::Commands::LoadConfig < Fingers::Commands::Base
class Fingers::Commands::LoadConfig
@fingers_options_names : Array(String) | Nil
property config : Fingers::Config
property shell : Shell
property log_path : String
property executable_path : String
property errors : Array(String) = [] of String
property output : IO
DISALLOWED_CHARS = /cimqn/
def initialize(*args)
super(*args)
@config = Fingers::Config.new
def initialize(
@shell = PersistentShell.new,
@log_path = Fingers::Dirs::LOG_PATH.to_s,
@executable_path = Process.executable_path.to_s,
@output = STDOUT
)
@config = Fingers::Config.build
end
def run
validate_options!
parse_tmux_conf
setup_bindings
end
@ -31,75 +39,46 @@ class Fingers::Commands::LoadConfig < Fingers::Commands::Base
Fingers.reset_config
config.tmux_version = `tmux -V`.chomp.split(" ").last
config.tmux_version = shell.exec("tmux -V").chomp.split(" ").last
options.each do |option, value|
# TODO generate an enum somehow and use an exhaustive case
case option
when "key"
config.key = value
when "keyboard_layout"
config.keyboard_layout = value
when "main_action"
config.main_action = value
when "ctrl_action"
config.ctrl_action = value
when "alt_action"
config.alt_action = value
when "shift_action"
config.shift_action = value
when "benchmark_mode"
config.benchmark_mode = value
when "hint_position"
config.hint_position = value
when "hint_style"
config.hint_style = tmux.parse_style(value)
when "selected_hint_style"
config.selected_hint_style = tmux.parse_style(value)
when "highlight_style"
config.highlight_style = tmux.parse_style(value)
when "backdrop_style"
config.backdrop_style = tmux.parse_style(value)
when "selected_highlight_style"
config.selected_highlight_style = tmux.parse_style(value)
if option.match(/pattern_[0-9]+/)
user_defined_patterns << value
next
end
if option.match(/pattern/)
check_pattern!(value)
user_defined_patterns.push(value)
config.set_option(option, value)
if !config.valid?
unset_tmux_option!(method_to_option(option))
output.puts "Found errors #{config.errors}"
self.errors = config.errors.clone
end
end
config.patterns = clean_up_patterns([
config.patterns = [
*enabled_default_patterns,
*user_defined_patterns,
])
]
config.alphabet = ::Fingers::Config::ALPHABET_MAP[Fingers.config.keyboard_layout].split("").reject do |char|
char.match(DISALLOWED_CHARS)
if !config.valid?
output.puts "Found errors #{config.errors}"
#exit(1)
end
config.save
Fingers.reset_config
rescue e : TmuxStylePrinter::InvalidFormat
puts "[tmux-fingers] #{e.message}"
exit(1)
end
def clean_up_patterns(patterns)
patterns.reject(&.empty?)
end
def setup_bindings
`tmux bind-key #{Fingers.config.key} run-shell -b "#{cli} start "\#{pane_id}" self >>#{Fingers::Dirs::LOG_PATH} 2>&1"`
`tmux bind-key O run-shell -b "#{cli} start "\#{pane_id}" other >>#{Fingers::Dirs::LOG_PATH} 2>&1"`
shell.exec(%(tmux bind-key #{Fingers.config.key} run-shell -b "#{executable_path} start '\#{pane_id}' self >>#{log_path} 2>&1"))
setup_fingers_mode_bindings
end
def setup_fingers_mode_bindings
("a".."z").to_a.each do |char|
next if char.match(DISALLOWED_CHARS)
next if char.match(Fingers::Config::DISALLOWED_CHARS)
fingers_mode_bind(char, "hint:#{char}:main")
fingers_mode_bind(char.upcase, "hint:#{char}:shift")
@ -124,79 +103,43 @@ class Fingers::Commands::LoadConfig < Fingers::Commands::Base
::Fingers::Config::DEFAULT_PATTERNS.values
end
def to_bool(input)
input == "1"
end
def shell_safe_options
options = {} of String => String
fingers_options_names.each do |option|
option_method = option_to_method(option)
options[option_method] = `tmux show-option -gv #{option}`.chomp
options[option_method] = shell.exec(%(tmux show-option -gv #{option})).chomp
end
options
end
def valid_option?(option)
option_method = option_to_method(option)
@config.members.includes?(option_method) || option_method.match(/pattern_[0-9]+/) || option_method == "skip_wizard"
end
def fingers_options_names
@fingers_options_names ||= `tmux show-options -g | grep ^@fingers`
@fingers_options_names ||= shell.exec(%(tmux show-options -g | grep ^@fingers))
.chomp.split("\n")
.map { |line| line.split(" ")[0] }
.reject { |option| option.empty? }
end
def unset_tmux_option!(option)
`tmux set-option -ug #{option}`
end
def check_pattern!(pattern)
begin
Regex.new(pattern)
rescue e: ArgumentError
puts "[tmux-fingers] Invalid pattern: #{pattern}"
puts "[tmux-fingers] #{e.message}"
exit(1)
end
end
def validate_options!
errors = [] of String
fingers_options_names.each do |option|
unless valid_option?(option)
errors << "'#{option}' is not a valid option"
unset_tmux_option!(option)
end
end
return if errors.empty?
puts "[tmux-fingers] Errors found in tmux.conf:"
errors.each { |error| puts " - #{error}" }
exit(1)
shell.exec(%(tmux set-option -ug #{option}))
end
def option_to_method(option)
option.gsub(/^@fingers-/, "").tr("-", "_")
end
def fingers_mode_bind(key, command)
`tmux bind-key -Tfingers "#{key}" run-shell -b "#{cli} send-input #{command}"`
def method_to_option(method)
"@fingers-#{method.tr("_", "-")}"
end
def cli
Process.executable_path
def fingers_mode_bind(key, command)
shell.exec(%(tmux bind-key -Tfingers "#{key}" run-shell -b "#{executable_path} send-input #{command}"))
end
def tmux
Tmux.new(`tmux -V`.chomp.split(" ").last)
Tmux.new(shell.exec("tmux -V").chomp.split(" ").last)
end
end

View File

@ -1,42 +1,10 @@
require "json"
require "../tmux_style_printer"
module Fingers
struct Config
include JSON::Serializable
property key : String
property keyboard_layout : String
property patterns : Array(String)
property alphabet : Array(String)
property benchmark_mode : String
property main_action : String
property ctrl_action : String
property alt_action : String
property shift_action : String
property hint_position : String
property hint_style : String
property selected_hint_style : String
property highlight_style : String
property selected_highlight_style : String
property backdrop_style : String
property tmux_version : String
FORMAT_PRINTER = TmuxStylePrinter.new
DEFAULT_PATTERNS = {
"ip": "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}",
"uuid": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
"sha": "[0-9a-f]{7,128}",
"digit": "[0-9]{4,}",
"url": "((https?://|git@|git://|ssh://|ftp://|file:///)[^\\s()\"]+)",
"path": "(([.\\w\\-~\\$@]+)?(/[.\\w\\-@]+)+/?)",
"hex": "(0x[0-9a-fA-F]+)",
"kubernetes": "(deployment.app|binding|componentstatuse|configmap|endpoint|event|limitrange|namespace|node|persistentvolumeclaim|persistentvolume|pod|podtemplate|replicationcontroller|resourcequota|secret|serviceaccount|service|mutatingwebhookconfiguration.admissionregistration.k8s.io|validatingwebhookconfiguration.admissionregistration.k8s.io|customresourcedefinition.apiextension.k8s.io|apiservice.apiregistration.k8s.io|controllerrevision.apps|daemonset.apps|deployment.apps|replicaset.apps|statefulset.apps|tokenreview.authentication.k8s.io|localsubjectaccessreview.authorization.k8s.io|selfsubjectaccessreviews.authorization.k8s.io|selfsubjectrulesreview.authorization.k8s.io|subjectaccessreview.authorization.k8s.io|horizontalpodautoscaler.autoscaling|cronjob.batch|job.batch|certificatesigningrequest.certificates.k8s.io|events.events.k8s.io|daemonset.extensions|deployment.extensions|ingress.extensions|networkpolicies.extensions|podsecuritypolicies.extensions|replicaset.extensions|networkpolicie.networking.k8s.io|poddisruptionbudget.policy|clusterrolebinding.rbac.authorization.k8s.io|clusterrole.rbac.authorization.k8s.io|rolebinding.rbac.authorization.k8s.io|role.rbac.authorization.k8s.io|storageclasse.storage.k8s.io)[[:alnum:]_#$%&+=/@-]+",
"git-status": "(modified|deleted|new file): +(?<match>.+)",
"git-status-branch": "Your branch is up to date with '(?<match>.*)'.",
"diff": "(---|\\+\\+\\+) [ab]/(?<match>.*)",
}
ALPHABET_MAP = {
"qwerty": "asdfqwerzxcvjklmiuopghtybn",
"qwerty-homerow": "asdfjklgh",
@ -60,24 +28,154 @@ module Fingers
"colemak-right-hand": "neioluymjhk",
}
def initialize(
@key = "F",
@keyboard_layout = "qwerty",
@alphabet = [] of String,
@patterns = [] of String,
@main_action = ":copy:",
@ctrl_action = ":open:",
@alt_action = "",
@shift_action = ":paste:",
@hint_position = "left",
@hint_style = FORMAT_PRINTER.print("fg=green,bold"),
@highlight_style = FORMAT_PRINTER.print("fg=yellow"),
@selected_hint_style = FORMAT_PRINTER.print("fg=blue,bold"),
@selected_highlight_style = FORMAT_PRINTER.print("fg=blue"),
@backdrop_style = "",
@tmux_version = "",
@benchmark_mode = "0"
)
DISALLOWED_CHARS = /[cimqn]/
DEFAULT_PATTERNS = {
"ip": "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}",
"uuid": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
"sha": "[0-9a-f]{7,128}",
"digit": "[0-9]{4,}",
"url": "((https?://|git@|git://|ssh://|ftp://|file:///)[^\\s()\"]+)",
"path": "(([.\\w\\-~\\$@]+)?(/[.\\w\\-@]+)+/?)",
"hex": "(0x[0-9a-fA-F]+)",
"kubernetes": "(deployment.app|binding|componentstatuse|configmap|endpoint|event|limitrange|namespace|node|persistentvolumeclaim|persistentvolume|pod|podtemplate|replicationcontroller|resourcequota|secret|serviceaccount|service|mutatingwebhookconfiguration.admissionregistration.k8s.io|validatingwebhookconfiguration.admissionregistration.k8s.io|customresourcedefinition.apiextension.k8s.io|apiservice.apiregistration.k8s.io|controllerrevision.apps|daemonset.apps|deployment.apps|replicaset.apps|statefulset.apps|tokenreview.authentication.k8s.io|localsubjectaccessreview.authorization.k8s.io|selfsubjectaccessreviews.authorization.k8s.io|selfsubjectrulesreview.authorization.k8s.io|subjectaccessreview.authorization.k8s.io|horizontalpodautoscaler.autoscaling|cronjob.batch|job.batch|certificatesigningrequest.certificates.k8s.io|events.events.k8s.io|daemonset.extensions|deployment.extensions|ingress.extensions|networkpolicies.extensions|podsecuritypolicies.extensions|replicaset.extensions|networkpolicie.networking.k8s.io|poddisruptionbudget.policy|clusterrolebinding.rbac.authorization.k8s.io|clusterrole.rbac.authorization.k8s.io|rolebinding.rbac.authorization.k8s.io|role.rbac.authorization.k8s.io|storageclasse.storage.k8s.io)[[:alnum:]_#$%&+=/@-]+",
"git-status": "(modified|deleted|new file): +(?<match>.+)",
"git-status-branch": "Your branch is up to date with '(?<match>.*)'.",
"diff": "(---|\\+\\+\\+) [ab]/(?<match>.*)",
}
def self.build
from_json("{}")
end
def self.alphabet_for(layout)
ALPHABET_MAP[layout].split("").reject { |char| char =~ DISALLOWED_CHARS }
end
def self.parse_style(style)
FORMAT_PRINTER.print(style)
end
include JSON::Serializable
property key : String = "F"
getter keyboard_layout : String = "qwerty"
def keyboard_layout=(value)
if !ALPHABET_MAP[value]?
errors << "Invalid layout #{value}"
return
end
@keyboard_layout = value
end
def alphabet
self.class.alphabet_for(keyboard_layout)
end
getter patterns : Array(String) = [] of String
def patterns=(value)
value.each do |pattern|
error = Regex.error?(pattern)
if error
@errors << "Invalid regexp\n\t#{pattern}\n\t#{error}"
return
end
end
@patterns = value
end
getter highlight_style : String = parse_style("fg=green,bold")
def highlight_style=(value)
parsed_style = parse_style!(value)
@highlight_style if parsed_style
end
def parse_style!(style)
begin
self.class.parse_style(style)
rescue TmuxStylePrinter::InvalidFormat
@errors << "Invalid style: #{style}"
end
end
getter highlight_style : String = parse_style("fg=yellow")
def highlight_style=(value)
parsed_style = parse_style!(value)
@highlight_style if parsed_style
end
getter hint_style : String = parse_style("fg=green,bold")
def hint_style=(value)
parsed_style = parse_style!(value)
@hint_style if parsed_style
end
getter selected_highlight_style : String = parse_style("fg=blue")
def highlight_style=(value)
parsed_style = parse_style!(value)
@highlight_style if parsed_style
end
getter selected_hint_style : String = parse_style("fg=blue,bold")
def hint_style=(value)
parsed_style = parse_style!(value)
@hint_style if parsed_style
end
getter backdrop_style : String = ""
def hint_style=(value)
parsed_style = parse_style!(value)
@backdrop_style if parsed_style
end
def parse_style!(style)
begin
self.class.parse_style(style)
rescue TmuxStylePrinter::InvalidFormat
@errors << "Invalid style: #{style}"
end
end
property tmux_version : String = ""
property main_action : String = ":copy:"
property ctrl_action : String = ":open:"
property alt_action : String = ""
property shift_action : String = ":paste: "
getter hint_position : String = "left"
def hint_position=(value)
if !["left", "right"].includes?(value)
@errors << "Invalid hint_position #{value}"
end
@hint_position = value
end
property benchmark_mode : String = "0"
property skip_wizard : String = "0"
@[JSON::Field(ignore: true)]
property errors : Array(String) = [] of String
def valid?
errors.empty?
end
macro define_set_option
def set_option(option : String, value : String | Array(String))
case option
{% for method in @type.methods %}
{% if method.name.split("").last == "=" && method.name != "patterns=" && method.name != "errors=" %}
when "{{method.name.gsub(/=$/, "")}}"
self.{{method.name}} value
{% end %}
{% end %}
else
errors << "#{option} is not a valid option"
end
end
end
def self.load_from_cache
@ -91,12 +189,16 @@ module Fingers
def members : Array(String)
JSON.parse(to_json).as_h.keys
end
macro finished
define_set_option
end
end
def self.config
@@config ||= Config.load_from_cache
rescue
@@config ||= Config.new
@@config ||= Config.build
end
def self.reset_config

View File

@ -8,3 +8,7 @@ module Fingers
abstract def format(hint : String, highlight : String, selected : Bool, offset : Tuple(Int32, Int32) | Nil)
end
end
abstract class Shell
abstract def exec(cmd)
end

27
src/persistent_shell.cr Normal file
View File

@ -0,0 +1,27 @@
require "./fingers/types"
class PersistentShell < Shell
def initialize
@sh = Process.new("/bin/sh", input: :pipe, output: :pipe, error: :close)
end
def exec(cmd)
ch = Channel(String).new
spawn do
output = ""
while line = @sh.output.read_line
break if line == "cmd-end"
output += "#{line}\n"
end
ch.send(output)
end
@sh.input.print("#{cmd}; echo cmd-end\n")
@sh.input.flush
output = ch.receive
output
end
end

View File

@ -39,32 +39,6 @@ end
# rubocop:disable Metrics/ClassLength
class Tmux
class Shell
def initialize
@sh = Process.new("/bin/sh", input: :pipe, output: :pipe, error: :close)
end
def exec(cmd)
ch = Channel(String).new
spawn do
output = ""
while line = @sh.output.read_line
break if line == "cmd-end"
output += "#{line}\n"
end
ch.send(output)
end
@sh.input.print("#{cmd}; echo cmd-end\n")
@sh.input.flush
output = ch.receive
output
end
end
struct Pane
include JSON::Serializable
@ -130,7 +104,7 @@ class Tmux
end
def initialize(version_string)
@sh = Shell.new
@sh = PersistentShell.new
@version = Tmux.tmux_version_to_semver(version_string)
end

View File

@ -1,12 +1,10 @@
require "./fingers/types"
class TmuxStylePrinter
class InvalidFormat < Exception
end
abstract class Shell
abstract def exec(cmd)
end
STYLE_SEPARATOR = /[ ,]+/
COLOR_MAP = {