tmux 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE +22 -0
- data/README.md +1076 -0
- data/bin/tmux.rb +6 -0
- data/bin/tmux_rb +4 -0
- data/lib/tmux/cli.rb +224 -0
- data/lib/tmux/command_builder.rb +104 -0
- data/lib/tmux/condition_compiler.rb +127 -0
- data/lib/tmux/config.rb +40 -0
- data/lib/tmux/config_builder.rb +247 -0
- data/lib/tmux/config_compiler.rb +82 -0
- data/lib/tmux/dsl.rb +118 -0
- data/lib/tmux/errors.rb +5 -0
- data/lib/tmux/escape.rb +68 -0
- data/lib/tmux/evaluation.rb +18 -0
- data/lib/tmux/expression.rb +109 -0
- data/lib/tmux/format.rb +94 -0
- data/lib/tmux/format_builder.rb +586 -0
- data/lib/tmux/format_compiler.rb +41 -0
- data/lib/tmux/glyph.rb +45 -0
- data/lib/tmux/option_tree.rb +148 -0
- data/lib/tmux/target.rb +200 -0
- data/lib/tmux/version.rb +5 -0
- data/lib/tmux/zone_builder.rb +31 -0
- data/lib/tmux.rb +47 -0
- metadata +92 -0
data/bin/tmux.rb
ADDED
data/bin/tmux_rb
ADDED
data/lib/tmux/cli.rb
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require_relative "../tmux"
|
|
10
|
+
|
|
11
|
+
module Tmux
|
|
12
|
+
# The tmux.rb command (also installed as tmux_rb). #run returns an exit status and never exits the
|
|
13
|
+
# process, so it is safe to call from other Ruby code.
|
|
14
|
+
class CLI
|
|
15
|
+
def self.run(arguments, stdout: $stdout, stderr: $stderr)
|
|
16
|
+
new(stdout:, stderr:).run(arguments)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(stdout:, stderr:)
|
|
20
|
+
@stdout = stdout
|
|
21
|
+
@stderr = stderr
|
|
22
|
+
@tmux_executable = "tmux"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(arguments)
|
|
26
|
+
arguments = arguments.dup
|
|
27
|
+
command = arguments.shift
|
|
28
|
+
case command
|
|
29
|
+
when "build" then build(arguments)
|
|
30
|
+
when "check" then check(arguments)
|
|
31
|
+
when "diff" then diff(arguments)
|
|
32
|
+
when "load" then load_config(arguments)
|
|
33
|
+
when "-v", "--version", "version"
|
|
34
|
+
@stdout.puts version_line
|
|
35
|
+
0
|
|
36
|
+
when "-h", "--help", "help"
|
|
37
|
+
@stdout.puts usage
|
|
38
|
+
0
|
|
39
|
+
else
|
|
40
|
+
@stderr.puts "tmux.rb: unknown command: #{command}" if command
|
|
41
|
+
@stderr.puts usage
|
|
42
|
+
2
|
|
43
|
+
end
|
|
44
|
+
rescue OptionParser::ParseError, Error, SystemCallError => error
|
|
45
|
+
@stderr.puts "tmux.rb: #{error.message}"
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build(arguments)
|
|
52
|
+
output = nil
|
|
53
|
+
return 0 unless parse_options(arguments, "build CONFIG.rb [--output TMUX.conf]") do |opts|
|
|
54
|
+
opts.on("-o", "--output PATH", "Write atomically to PATH instead of stdout") { |path| output = path }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
rendered = render(require_source!(arguments))
|
|
58
|
+
output ? atomic_write(output, rendered) : @stdout.write(rendered)
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def check(arguments)
|
|
63
|
+
return 0 unless parse_options(arguments, "check CONFIG.rb")
|
|
64
|
+
|
|
65
|
+
source = require_source!(arguments)
|
|
66
|
+
object = load_source(source)
|
|
67
|
+
if object.is_a?(Format)
|
|
68
|
+
@stdout.puts "#{source}: compiled to a format; formats are only parsed by tmux when it renders them"
|
|
69
|
+
return 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
validate_with_tmux(object.to_tmux, target: object.target)
|
|
73
|
+
@stdout.puts "#{source}: tmux parsed the generated configuration " \
|
|
74
|
+
"(option names validated; commands and values were not applied)"
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def diff(arguments)
|
|
79
|
+
return 0 unless parse_options(arguments, "diff CONFIG.rb TMUX.conf")
|
|
80
|
+
|
|
81
|
+
source, target = arguments
|
|
82
|
+
raise Error, "usage: tmux.rb diff CONFIG.rb TMUX.conf" unless source && target && arguments.size == 2
|
|
83
|
+
|
|
84
|
+
Tempfile.create(["tmux_rb", ".conf"]) do |file|
|
|
85
|
+
file.write(render(source))
|
|
86
|
+
file.flush
|
|
87
|
+
stdout, stderr, status = Open3.capture3("diff", "-u", target, file.path)
|
|
88
|
+
@stdout.write(stdout)
|
|
89
|
+
@stderr.write(stderr)
|
|
90
|
+
return status.exitstatus || 2
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load_config(arguments)
|
|
95
|
+
target = []
|
|
96
|
+
return 0 unless parse_options(arguments, "load [-S SOCKET | -L NAME] CONFIG.rb") do |opts|
|
|
97
|
+
opts.on("-S", "--socket PATH", "Load into the server at this socket path") { |path| target = ["-S", path] }
|
|
98
|
+
opts.on("-L", "--socket-name NAME", "Load into the server with this socket name") { |name| target = ["-L", name] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
source = require_source!(arguments)
|
|
102
|
+
object = load_source(source)
|
|
103
|
+
raise Error, "#{source} must evaluate to a Tmux::Config" unless object.is_a?(Config)
|
|
104
|
+
|
|
105
|
+
server_version = object.target.run!(*target, "display-message", "-p", '#{version}').strip
|
|
106
|
+
unless server_version == object.target.version
|
|
107
|
+
raise Error, "destination server is tmux #{server_version}, but this config targets tmux #{object.target.version}; " \
|
|
108
|
+
"select a matching executable with --tmux PATH"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Tempfile.create(["tmux_rb-load", ".conf"]) do |file|
|
|
112
|
+
file.write(object.to_tmux)
|
|
113
|
+
file.flush
|
|
114
|
+
stdout, stderr, status = object.target.run(*target, "source-file", file.path)
|
|
115
|
+
raise Error, "tmux rejected generated config:\n#{stderr}#{stdout}" unless status.success?
|
|
116
|
+
end
|
|
117
|
+
0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Parses a subcommand's options. Returns false after printing help or the
|
|
121
|
+
# version, so the caller can return 0 without exiting the process.
|
|
122
|
+
def parse_options(arguments, synopsis)
|
|
123
|
+
early = nil
|
|
124
|
+
parser = OptionParser.new do |opts|
|
|
125
|
+
opts.banner = "usage: tmux.rb #{synopsis}"
|
|
126
|
+
yield opts if block_given?
|
|
127
|
+
opts.on("--tmux PATH", "Target this tmux executable (default: tmux on PATH)") { |path| @tmux_executable = path }
|
|
128
|
+
opts.on("-h", "--help", "Show this help") { early = :help }
|
|
129
|
+
opts.on("--version", "Show the tmux.rb version") { early = :version }
|
|
130
|
+
end
|
|
131
|
+
parser.parse!(arguments)
|
|
132
|
+
@stdout.puts(early == :help ? parser.help : version_line) if early
|
|
133
|
+
early.nil?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def require_source!(arguments)
|
|
137
|
+
source = arguments.shift
|
|
138
|
+
raise Error, "expected exactly one Ruby configuration path" unless source && arguments.empty?
|
|
139
|
+
|
|
140
|
+
source
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def render(path) = load_source(path).to_tmux
|
|
144
|
+
|
|
145
|
+
# Errors raised while the configuration runs are reported with the line
|
|
146
|
+
# in the configuration file that raised them.
|
|
147
|
+
def load_source(path)
|
|
148
|
+
Tmux.load_file(path, tmux: @tmux_executable)
|
|
149
|
+
rescue SyntaxError => error
|
|
150
|
+
raise Error, error.message
|
|
151
|
+
rescue StandardError, ScriptError => error
|
|
152
|
+
location = location_in(error, File.expand_path(path))
|
|
153
|
+
raise if location.nil? && (error.is_a?(Error) || error.is_a?(SystemCallError))
|
|
154
|
+
|
|
155
|
+
raise Error, "#{location || path}: #{error.message} (#{error.class})"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The innermost backtrace frame inside the configuration file itself.
|
|
159
|
+
def location_in(error, path)
|
|
160
|
+
frame = error.backtrace_locations&.find { |location| location.absolute_path == path || location.path == path }
|
|
161
|
+
frame && "#{frame.path}:#{frame.lineno}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Writes beside the real destination, then renames over it, so readers
|
|
165
|
+
# never see a partial file. A symlinked destination (e.g. ~/.tmux.conf
|
|
166
|
+
# pointing into a dotfiles repository) is followed rather than replaced,
|
|
167
|
+
# and an existing file keeps its permissions.
|
|
168
|
+
def atomic_write(path, contents)
|
|
169
|
+
destination = resolve_symlinks(path)
|
|
170
|
+
temporary = nil
|
|
171
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
172
|
+
mode = File.exist?(destination) ? File.stat(destination).mode & 0o7777 : nil
|
|
173
|
+
temporary = "#{destination}.tmp-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
174
|
+
File.open(temporary, "w") do |file|
|
|
175
|
+
file.write(contents)
|
|
176
|
+
file.fsync
|
|
177
|
+
end
|
|
178
|
+
File.chmod(mode, temporary) if mode
|
|
179
|
+
File.rename(temporary, destination)
|
|
180
|
+
ensure
|
|
181
|
+
FileUtils.rm_f(temporary) if temporary
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def resolve_symlinks(path)
|
|
185
|
+
target = File.expand_path(path)
|
|
186
|
+
40.times do
|
|
187
|
+
return target unless File.symlink?(target)
|
|
188
|
+
|
|
189
|
+
target = File.expand_path(File.readlink(target), File.dirname(target))
|
|
190
|
+
end
|
|
191
|
+
raise Error, "too many levels of symbolic links: #{path}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The throwaway server's socket lives in a temporary directory, because
|
|
195
|
+
# tmux leaves its socket file behind when the server is killed, and its
|
|
196
|
+
# session runs `sleep` so the user's shell and rc files never start.
|
|
197
|
+
def validate_with_tmux(contents, target:)
|
|
198
|
+
Dir.mktmpdir("tmux_rb-check") do |directory|
|
|
199
|
+
config = File.join(directory, "check.conf")
|
|
200
|
+
File.write(config, contents)
|
|
201
|
+
target.isolated do |socket|
|
|
202
|
+
stdout, stderr, status = target.run("-S", socket, "source-file", "-n", config)
|
|
203
|
+
raise Error, "tmux rejected generated config:\n#{stderr}#{stdout}" unless status.success?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def version_line = "tmux.rb #{VERSION}"
|
|
209
|
+
|
|
210
|
+
def usage
|
|
211
|
+
<<~TEXT
|
|
212
|
+
usage:
|
|
213
|
+
tmux.rb build CONFIG.rb [--output TMUX.conf]
|
|
214
|
+
tmux.rb check CONFIG.rb
|
|
215
|
+
tmux.rb diff CONFIG.rb TMUX.conf
|
|
216
|
+
tmux.rb load [-S SOCKET | -L NAME] CONFIG.rb
|
|
217
|
+
tmux.rb --version
|
|
218
|
+
|
|
219
|
+
Run `tmux.rb COMMAND --help` for a command's options.
|
|
220
|
+
All commands accept --tmux PATH to select the target tmux executable.
|
|
221
|
+
TEXT
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Tmux
|
|
6
|
+
class MenuBuilder
|
|
7
|
+
def initialize = @entries = []
|
|
8
|
+
|
|
9
|
+
def item(label, key:, command: nil, &block)
|
|
10
|
+
raise Error, "menu item accepts either a command or a block, not both" if command && block
|
|
11
|
+
|
|
12
|
+
if block
|
|
13
|
+
builder = CommandBuilder.new
|
|
14
|
+
Evaluation.run(builder, block)
|
|
15
|
+
command = builder.build
|
|
16
|
+
end
|
|
17
|
+
raise Error, "menu item requires a command or block" unless command
|
|
18
|
+
|
|
19
|
+
@entries << [label.to_s, key.to_s, command.to_s]
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def separator
|
|
24
|
+
@entries << [""]
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def arguments = @entries.flatten
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds the command list attached to a key binding. Commands are kept as
|
|
32
|
+
# separate entries until the binding is compiled, so callers never write tmux
|
|
33
|
+
# command separators or quote command arguments by hand.
|
|
34
|
+
class CommandBuilder
|
|
35
|
+
def initialize = @commands = []
|
|
36
|
+
|
|
37
|
+
# Any tmux command. Arguments are quoted for you; tmux expands $VARIABLES
|
|
38
|
+
# and a leading ~ in them unless `expand: false` is passed.
|
|
39
|
+
def command(name, *arguments, expand: true)
|
|
40
|
+
command_name = normalize(name)
|
|
41
|
+
parts = [command_name, *arguments.map { |argument| ConfigCompiler.quote(argument, expand:) }]
|
|
42
|
+
@commands << parts.join(" ")
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def raw(command)
|
|
47
|
+
@commands << command.to_s
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def select_pane(target: nil)
|
|
52
|
+
parts = ["select-pane"]
|
|
53
|
+
parts.concat(["-t", ConfigCompiler.quote(target)]) if target
|
|
54
|
+
@commands << parts.join(" ")
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Send one copy-mode command (`send-keys -X`) to a pane. `count:` maps to
|
|
59
|
+
# tmux's -N repeat count, used by actions such as scroll-up and scroll-down.
|
|
60
|
+
def copy_mode(action, target: nil, count: nil)
|
|
61
|
+
parts = ["send-keys"]
|
|
62
|
+
parts.concat(["-t", ConfigCompiler.quote(target)]) if target
|
|
63
|
+
parts << "-X"
|
|
64
|
+
parts.concat(["-N", count.to_i]) if count
|
|
65
|
+
parts << normalize(action)
|
|
66
|
+
@commands << parts.join(" ")
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compile and load a Ruby configuration into the tmux server running this
|
|
71
|
+
# command. The CLI renders through a temporary file, so callers only
|
|
72
|
+
# maintain the .rb source. #{socket_path} expands when the binding runs, so
|
|
73
|
+
# the load never follows a $TMUX that names some other (e.g. nested) server.
|
|
74
|
+
def load_config(path, executable: "tmux.rb")
|
|
75
|
+
load = Shellwords.join([executable, "load", "-S"])
|
|
76
|
+
# expand: false: the shell, not tmux's parser, must see any "$" in the path.
|
|
77
|
+
command :run_shell, "#{load} \"\#{socket_path}\" #{Shellwords.escape(path.to_s)}", expand: false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def display_message(message)
|
|
81
|
+
command :display_message, message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def display_menu(title: nil, &block)
|
|
85
|
+
menu = MenuBuilder.new
|
|
86
|
+
Evaluation.run(menu, block)
|
|
87
|
+
parts = ["display-menu"]
|
|
88
|
+
parts.concat(["-T", ConfigCompiler.quote(title)]) if title
|
|
89
|
+
parts.concat(menu.arguments.map { |argument| ConfigCompiler.quote(argument) })
|
|
90
|
+
@commands << parts.join(" ")
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build
|
|
95
|
+
raise Error, "a command block must contain at least one command" if @commands.empty?
|
|
96
|
+
|
|
97
|
+
@commands.join(" \\; ")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def normalize(name) = name.to_s.tr("_", "-")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Tmux
|
|
6
|
+
# Capture Ruby syntax before && / || can discard symbolic operands. This is
|
|
7
|
+
# source translation, not an override of Ruby's truthiness or short circuiting.
|
|
8
|
+
class ConditionCompiler
|
|
9
|
+
EXPRESSION_NODES = %i[
|
|
10
|
+
and_node or_node call_node arguments_node parentheses_node statements_node
|
|
11
|
+
local_variable_read_node instance_variable_read_node constant_read_node
|
|
12
|
+
constant_path_node self_node string_node interpolated_string_node
|
|
13
|
+
embedded_statements_node symbol_node interpolated_symbol_node integer_node
|
|
14
|
+
float_node true_node false_node nil_node array_node hash_node
|
|
15
|
+
keyword_hash_node assoc_node
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.rewrite(source, path)
|
|
19
|
+
compiler = new(source, path)
|
|
20
|
+
edits = compiler.nodes.flat_map do |node|
|
|
21
|
+
compiler.conditions(node).filter_map do |argument|
|
|
22
|
+
next unless compiler.logical?(argument)
|
|
23
|
+
|
|
24
|
+
compiler.validate!(argument)
|
|
25
|
+
[argument.location, compiler.render(argument)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
compiler.replace(nil, edits)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :root
|
|
32
|
+
|
|
33
|
+
def initialize(source, path)
|
|
34
|
+
@source = source
|
|
35
|
+
@path = path
|
|
36
|
+
result = Prism.parse(source, filepath: path)
|
|
37
|
+
unless result.errors.empty?
|
|
38
|
+
error = result.errors.first
|
|
39
|
+
raise Error, "#{path}:#{error.location.start_line}: #{error.message}"
|
|
40
|
+
end
|
|
41
|
+
@root = result.value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def nodes(node = root)
|
|
45
|
+
[node] + node.compact_child_nodes.flat_map { |child| nodes(child) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def logical?(node)
|
|
49
|
+
return false if %i[block_node lambda_node].include?(node.type)
|
|
50
|
+
|
|
51
|
+
%i[and_node or_node].include?(node.type) || node.compact_child_nodes.any? { |child| logical?(child) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def conditions(node)
|
|
55
|
+
return [] unless node.type == :call_node
|
|
56
|
+
|
|
57
|
+
argument = node.arguments&.arguments&.first
|
|
58
|
+
return [] unless argument
|
|
59
|
+
return [argument] if %i[if? elsif?].include?(node.name)
|
|
60
|
+
return [] unless %i[switch switch_style].include?(node.name)
|
|
61
|
+
return [] unless %i[hash_node keyword_hash_node].include?(argument.type)
|
|
62
|
+
|
|
63
|
+
argument.elements.filter_map { |entry| entry.key if entry.type == :assoc_node }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate!(node)
|
|
67
|
+
unless node && EXPRESSION_NODES.include?(node.type)
|
|
68
|
+
fail_at(node, "condition needs a single expression; assignments and control flow are not supported")
|
|
69
|
+
end
|
|
70
|
+
if node.type == :statements_node && node.body.length != 1
|
|
71
|
+
fail_at(node, "condition needs a single expression")
|
|
72
|
+
end
|
|
73
|
+
if node.type == :call_node && (node.block || node.safe_navigation?)
|
|
74
|
+
fail_at(node, "blocks and safe navigation are not supported inside a condition")
|
|
75
|
+
end
|
|
76
|
+
if %i[and_node or_node].include?(node.type) && !%w[&& ||].include?(node.operator_loc.slice)
|
|
77
|
+
fail_at(node, "use && / || rather than and / or in a condition")
|
|
78
|
+
end
|
|
79
|
+
if node.compact_child_nodes.any? { |child| child.location.end_offset > node.location.end_offset }
|
|
80
|
+
fail_at(node, "heredocs are not supported inside a captured condition; use a string or local variable")
|
|
81
|
+
end
|
|
82
|
+
node.compact_child_nodes.each { |child| validate!(child) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render(node)
|
|
86
|
+
if %i[and_node or_node].include?(node.type)
|
|
87
|
+
operator = node.type == :and_node ? "&&" : "||"
|
|
88
|
+
edits = [
|
|
89
|
+
[node.left.location, "::Tmux.expression((#{render(node.left)})).__send__(:operation, #{operator.inspect}, ("],
|
|
90
|
+
[node.operator_loc, ""],
|
|
91
|
+
[node.right.location, "#{render(node.right)}))"]
|
|
92
|
+
]
|
|
93
|
+
return replace(node.location, edits)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
edits = node.compact_child_nodes.map { |child| [child.location, render(child)] }
|
|
97
|
+
replace(node.location, edits)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Prism offsets count bytes, not characters. Keep comments, whitespace and
|
|
101
|
+
# newlines intact so Unicode and error backtraces retain their original lines.
|
|
102
|
+
# An outer edit already contains the rendering of any nested edits.
|
|
103
|
+
def replace(location, edits)
|
|
104
|
+
start_offset = location&.start_offset || 0
|
|
105
|
+
end_offset = location&.end_offset || @source.bytesize
|
|
106
|
+
result = @source.byteslice(start_offset...end_offset).b
|
|
107
|
+
boundary = start_offset
|
|
108
|
+
selected = edits.sort_by { |range, _| [range.start_offset, -range.length] }.select do |range, _|
|
|
109
|
+
next false if range.start_offset < boundary
|
|
110
|
+
|
|
111
|
+
boundary = range.end_offset
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
selected.reverse_each do |range, replacement|
|
|
115
|
+
result[range.start_offset - start_offset, range.length] = replacement.b
|
|
116
|
+
end
|
|
117
|
+
result.force_encoding(@source.encoding)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def fail_at(node, message)
|
|
123
|
+
raise Error, "#{@path}:#{node&.location&.start_line || 1}: #{message}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
private_constant :ConditionCompiler
|
|
127
|
+
end
|
data/lib/tmux/config.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
ShellCondition = Data.define(:command, :background)
|
|
5
|
+
private_constant :ShellCondition
|
|
6
|
+
|
|
7
|
+
module ConfigNode
|
|
8
|
+
Blank = Data.define
|
|
9
|
+
Command = Data.define(:text)
|
|
10
|
+
SetOption = Data.define(:name, :value, :scope, :append, :expand)
|
|
11
|
+
UnsetOption = Data.define(:name, :scope)
|
|
12
|
+
IfShell = Data.define(:test, :success, :failure, :format, :background)
|
|
13
|
+
# A %if/%else/%endif block, evaluated by tmux while it parses the file.
|
|
14
|
+
Condition = Data.define(:condition, :body, :otherwise, :version, :direction)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A compiled configuration. #nodes is the read-only configuration AST.
|
|
18
|
+
class Config
|
|
19
|
+
attr_reader :nodes, :target
|
|
20
|
+
|
|
21
|
+
def initialize(nodes, target: Target.current)
|
|
22
|
+
@nodes = nodes.freeze
|
|
23
|
+
@target = target
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate!
|
|
27
|
+
OptionValidator.new(@target).validate(nodes)
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_tmux
|
|
32
|
+
validate!
|
|
33
|
+
ConfigCompiler.new.compile(nodes)
|
|
34
|
+
end
|
|
35
|
+
alias_method :to_s, :to_tmux
|
|
36
|
+
def ==(other) = other.is_a?(Config) && nodes.eql?(other.nodes)
|
|
37
|
+
alias_method :eql?, :==
|
|
38
|
+
def hash = [self.class, nodes].hash
|
|
39
|
+
end
|
|
40
|
+
end
|