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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
# The context of a Tmux.configure block. Every DSL method returns the
|
|
5
|
+
# builder (conditionals return a handle for `.else`), so calls can be chained.
|
|
6
|
+
class ConfigBuilder
|
|
7
|
+
include Evaluation
|
|
8
|
+
|
|
9
|
+
attr_reader :target
|
|
10
|
+
|
|
11
|
+
def initialize(target: Target.current)
|
|
12
|
+
@target = target
|
|
13
|
+
@nodes = []
|
|
14
|
+
@else_filled = {}
|
|
15
|
+
@conditional_states = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def switch(cases) = Tmux.switch(cases)
|
|
19
|
+
def shell(command, background: false) = Tmux.shell(command, background:)
|
|
20
|
+
|
|
21
|
+
def blank
|
|
22
|
+
@nodes << ConfigNode::Blank.new
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Verbatim tmux commands, one node per line. A line ending in "\\" keeps
|
|
27
|
+
# the next line in the same command, matching tmux's line continuation.
|
|
28
|
+
def raw(command)
|
|
29
|
+
pending = nil
|
|
30
|
+
command.to_s.each_line(chomp: true) do |line|
|
|
31
|
+
pending = pending ? "#{pending}\n#{line}" : line
|
|
32
|
+
next if pending[/\\*\z/].length.odd?
|
|
33
|
+
|
|
34
|
+
@nodes << (pending.empty? ? ConfigNode::Blank.new : ConfigNode::Command.new(pending))
|
|
35
|
+
pending = nil
|
|
36
|
+
end
|
|
37
|
+
@nodes << ConfigNode::Command.new(pending) if pending
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Scoped options are generated from the selected target's complete catalog.
|
|
42
|
+
def global = option_root(:global)
|
|
43
|
+
def server = option_root(:server)
|
|
44
|
+
def window = option_root(:window)
|
|
45
|
+
|
|
46
|
+
def environment(name, value)
|
|
47
|
+
raw "set-environment -g #{ConfigCompiler.quote(name)} #{ConfigCompiler.quote(value)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Build a reusable tmux command sequence. This makes ordinary Ruby helper
|
|
51
|
+
# methods composable with bind, e.g. `bind "r", reload_config`.
|
|
52
|
+
def commands(&block)
|
|
53
|
+
builder = CommandBuilder.new
|
|
54
|
+
Evaluation.run(builder, block)
|
|
55
|
+
builder.build
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A reusable command sequence that recompiles and loads a Ruby config, with
|
|
59
|
+
# an optional success message. Intended to be returned from ordinary Ruby
|
|
60
|
+
# helper methods and passed directly to #bind.
|
|
61
|
+
def load_config(path, executable: "tmux.rb", message: nil)
|
|
62
|
+
commands do
|
|
63
|
+
load_config(path, executable:)
|
|
64
|
+
display_message message if message
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unbind(key, table: nil, no_prefix: false)
|
|
69
|
+
raise Error, "unbind cannot combine table: and no_prefix:" if table && no_prefix
|
|
70
|
+
|
|
71
|
+
raw [
|
|
72
|
+
"unbind-key",
|
|
73
|
+
("-n" if no_prefix),
|
|
74
|
+
("-T #{ConfigCompiler.quote(table)}" if table),
|
|
75
|
+
ConfigCompiler.quote(key)
|
|
76
|
+
].compact.join(" ")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def bind(key, command = nil, table: nil, repeat: false, no_prefix: false, note: nil, &block)
|
|
80
|
+
raise Error, "bind cannot combine table: and no_prefix:" if table && no_prefix
|
|
81
|
+
raise Error, "bind accepts either a command or a block, not both" if command && block
|
|
82
|
+
|
|
83
|
+
command = commands(&block) if block
|
|
84
|
+
raise Error, "bind requires a command or block" unless command
|
|
85
|
+
|
|
86
|
+
parts = ["bind-key"]
|
|
87
|
+
parts << "-r" if repeat
|
|
88
|
+
parts << "-n" if no_prefix
|
|
89
|
+
parts.concat(["-N", ConfigCompiler.quote(note)]) if note
|
|
90
|
+
parts.concat(["-T", ConfigCompiler.quote(table)]) if table
|
|
91
|
+
parts << ConfigCompiler.quote(key)
|
|
92
|
+
parts << command
|
|
93
|
+
raw parts.join(" ")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# source-file; `quiet: true` (-q) skips a missing file without an error.
|
|
97
|
+
def source(path, quiet: false) = raw("source-file#{' -q' if quiet} #{ConfigCompiler.quote(path)}")
|
|
98
|
+
|
|
99
|
+
def run_shell(command, background: false)
|
|
100
|
+
raw "run-shell#{' -b' if background} #{ConfigCompiler.quote(command)}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Runtime configuration branches, using either a tmux expression or a
|
|
104
|
+
# deferred shell check. Branches remain native tmux command blocks.
|
|
105
|
+
def if?(condition, background: false, &block)
|
|
106
|
+
node = conditional_branch(condition, block, background:)
|
|
107
|
+
index = @nodes.size
|
|
108
|
+
@nodes << node
|
|
109
|
+
@conditional_states[index] = [node]
|
|
110
|
+
@else_filled[index] = false
|
|
111
|
+
ConfigConditional.new(self, index)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fill_elsif(index, condition, block, background: false)
|
|
115
|
+
open_conditional(index, ".elsif?")
|
|
116
|
+
branch = conditional_branch(condition, block, background:)
|
|
117
|
+
@conditional_states.fetch(index) << branch
|
|
118
|
+
rebuild_conditional(index, [])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fill_else(index, block)
|
|
122
|
+
node = @nodes[index]
|
|
123
|
+
unless (node.is_a?(ConfigNode::IfShell) || node.is_a?(ConfigNode::Condition)) && @else_filled.key?(index)
|
|
124
|
+
raise Error, ".else must directly follow the if?, tmux_from, or tmux_below it belongs to"
|
|
125
|
+
end
|
|
126
|
+
raise Error, "this conditional already has an else branch" if @else_filled[index]
|
|
127
|
+
|
|
128
|
+
otherwise = capture(block)
|
|
129
|
+
@else_filled[index] = true
|
|
130
|
+
if node.is_a?(ConfigNode::IfShell)
|
|
131
|
+
rebuild_conditional(index, otherwise)
|
|
132
|
+
else
|
|
133
|
+
@nodes[index] = node.with(otherwise: otherwise)
|
|
134
|
+
end
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Version-gated config: the block applies only when the running tmux server
|
|
139
|
+
# is older than / at least `version` ("major.minor"). Compiles to a %if on
|
|
140
|
+
# tmux's own #{version}, so no shell runs and no tmux binary on PATH is
|
|
141
|
+
# consulted.
|
|
142
|
+
def tmux_below(version, &block) = version_gate(:below, version, block)
|
|
143
|
+
def tmux_from(version, &block) = version_gate(:from, version, block)
|
|
144
|
+
|
|
145
|
+
def append(config)
|
|
146
|
+
@nodes.concat(config.nodes)
|
|
147
|
+
self
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Import another tmux config object into this config. `component` is anything
|
|
151
|
+
# implementing #to_config (returning a Tmux::Config) — e.g. a plugin's Ruby
|
|
152
|
+
# package. Splices its nodes in call order.
|
|
153
|
+
def use(component) = append(component.to_config)
|
|
154
|
+
|
|
155
|
+
def build = Config.new(@nodes, target: target).validate!
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def conditional_branch(condition, block, background:)
|
|
160
|
+
if condition.is_a?(ShellCondition)
|
|
161
|
+
test = condition.command
|
|
162
|
+
background ||= condition.background
|
|
163
|
+
format = false
|
|
164
|
+
else
|
|
165
|
+
test = Tmux.expression(condition).source
|
|
166
|
+
format = true
|
|
167
|
+
end
|
|
168
|
+
ConfigNode::IfShell.new(test, capture(block), [], format, background)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def open_conditional(index, method)
|
|
172
|
+
unless @nodes[index].is_a?(ConfigNode::IfShell) && @conditional_states.key?(index)
|
|
173
|
+
raise Error, "#{method} must follow the if? it belongs to"
|
|
174
|
+
end
|
|
175
|
+
raise Error, "this conditional already has an else branch" if @else_filled[index]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def rebuild_conditional(index, otherwise)
|
|
179
|
+
@nodes[index] = @conditional_states.fetch(index).reverse_each.reduce(otherwise) do |failure, branch|
|
|
180
|
+
[branch.with(failure:)]
|
|
181
|
+
end.first
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def write_option(name, value, scope:, append: false, expand: true)
|
|
185
|
+
@nodes << ConfigNode::SetOption.new(normalize_option(name), value, scope, append, expand)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def remove_option(name, scope:)
|
|
189
|
+
@nodes << ConfigNode::UnsetOption.new(normalize_option(name), scope)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def option_root(scope)
|
|
193
|
+
@option_roots ||= {}
|
|
194
|
+
@option_roots[scope] ||= OptionNode.root(self, scope)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Runs a branch block on this same builder with a fresh node buffer, so
|
|
198
|
+
# helper methods and instance variables stay visible in nested blocks.
|
|
199
|
+
def capture(block)
|
|
200
|
+
return [] unless block
|
|
201
|
+
|
|
202
|
+
saved = [@nodes, @else_filled, @conditional_states]
|
|
203
|
+
@nodes = []
|
|
204
|
+
@else_filled = {}
|
|
205
|
+
@conditional_states = {}
|
|
206
|
+
evaluate(block)
|
|
207
|
+
@nodes
|
|
208
|
+
ensure
|
|
209
|
+
@nodes, @else_filled, @conditional_states = saved if saved
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def normalize_option(name)
|
|
213
|
+
value = name.to_s
|
|
214
|
+
value.start_with?("@") ? value : value.tr("_", "-")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def version_gate(direction, version, block)
|
|
218
|
+
@nodes << ConfigNode::Condition.new(version_condition(direction, version), capture(block), [], version, direction)
|
|
219
|
+
@else_filled[@nodes.size - 1] = false
|
|
220
|
+
Else.new(self, @nodes.size - 1)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# #{version} as major * 1000 + minor, so 3.10 sorts after 3.9 and "3.5a",
|
|
224
|
+
# "next-3.6", or "openbsd-7.5" compare by their numbers. A version without
|
|
225
|
+
# a number (a "master" build) counts as newest.
|
|
226
|
+
VERSION_NUMBER = '#{?#{m/r:[0-9]+[.][0-9]+,#{version}},' \
|
|
227
|
+
'#{e|+:#{e|*:#{s/^[^0-9]*([0-9]+)[.]([0-9]+).*$/\1/:version},1000},' \
|
|
228
|
+
'#{s/^[^0-9]*([0-9]+)[.]([0-9]+).*$/\2/:version}},999999}'
|
|
229
|
+
|
|
230
|
+
# Thresholds from 3.2 compare numerically with #{e|...}. tmux before 3.2
|
|
231
|
+
# expands that to empty, which reads as "not at least", so "below" is
|
|
232
|
+
# written as its negation and stays correct there. Older thresholds compare
|
|
233
|
+
# the version string, which orders every 3.x and 4.x release correctly.
|
|
234
|
+
def version_condition(direction, version)
|
|
235
|
+
match = version.to_s.match(/\A(\d+)\.(\d+)\z/)
|
|
236
|
+
raise Error, "invalid tmux version: #{version.inspect} (expected \"major.minor\", e.g. \"3.2\")" unless match
|
|
237
|
+
|
|
238
|
+
major, minor = match.captures.map(&:to_i)
|
|
239
|
+
if major > 3 || (major == 3 && minor >= 2)
|
|
240
|
+
at_least = "#{'#{'}e|>=:#{VERSION_NUMBER},#{major * 1000 + minor}}"
|
|
241
|
+
direction == :from ? at_least : "#{'#{'}?#{at_least},0,1}"
|
|
242
|
+
else
|
|
243
|
+
"#{'#{'}#{direction == :from ? '>=' : '<'}:#{'#{'}version},#{major}.#{minor}}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
class ConfigCompiler
|
|
5
|
+
INDENT = " "
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def quote(value, expand: true)
|
|
9
|
+
return value.to_s if value.is_a?(Numeric)
|
|
10
|
+
return "on" if value.equal?(true)
|
|
11
|
+
return "off" if value.equal?(false)
|
|
12
|
+
escaped = value.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }
|
|
13
|
+
escaped = escaped.gsub("$", "\\$").sub(/\A~/, "\\~") unless expand
|
|
14
|
+
"\"#{escaped}\""
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compile(nodes)
|
|
19
|
+
lines(nodes).join("\n") + "\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def lines(nodes)
|
|
25
|
+
nodes.flat_map do |node|
|
|
26
|
+
text = compile_node(node)
|
|
27
|
+
text.empty? ? [""] : text.split("\n", -1)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def compile_node(node)
|
|
32
|
+
case node
|
|
33
|
+
when ConfigNode::Blank then ""
|
|
34
|
+
when ConfigNode::Command then node.text
|
|
35
|
+
when ConfigNode::SetOption then compile_option(node)
|
|
36
|
+
when ConfigNode::UnsetOption then compile_unset(node)
|
|
37
|
+
when ConfigNode::IfShell then compile_if_shell(node)
|
|
38
|
+
when ConfigNode::Condition then compile_condition(node)
|
|
39
|
+
else raise Error, "unknown config node: #{node.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scope_flags(scope) = {global: "-g", window: "-gw", server: "-s"}.fetch(scope)
|
|
44
|
+
|
|
45
|
+
def compile_option(node)
|
|
46
|
+
flags = scope_flags(node.scope)
|
|
47
|
+
flags += "a" if node.append
|
|
48
|
+
value = node.value.is_a?(Format) ? node.value.to_tmux : node.value
|
|
49
|
+
"set-option #{flags} #{node.name} #{self.class.quote(value, expand: node.expand)}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def compile_unset(node)
|
|
53
|
+
"set-option #{scope_flags(node.scope)}u #{node.name}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# if-shell with { } command blocks, one command per line.
|
|
57
|
+
def compile_if_shell(node)
|
|
58
|
+
parts = ["if-shell"]
|
|
59
|
+
parts << "-b" if node.background
|
|
60
|
+
parts << "-F" if node.format
|
|
61
|
+
parts << self.class.quote(node.test)
|
|
62
|
+
text = "#{parts.join(' ')} #{block(node.success)}"
|
|
63
|
+
text += " #{block(node.failure)}" unless node.failure.empty?
|
|
64
|
+
text
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def block(nodes)
|
|
68
|
+
body = lines(nodes).map { |line| line.empty? ? line : "#{INDENT}#{line}" }
|
|
69
|
+
["{", *body, "}"].join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The condition is generated by tmux.rb and never contains a single quote;
|
|
73
|
+
# single quotes keep tmux from treating its backslashes as escapes.
|
|
74
|
+
def compile_condition(node)
|
|
75
|
+
text = ["%if '#{node.condition}'", *lines(node.body)]
|
|
76
|
+
text.concat(["%else", *lines(node.otherwise)]) unless node.otherwise.empty?
|
|
77
|
+
text << "%endif"
|
|
78
|
+
text.join("\n")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
private_constant :ConfigCompiler
|
|
82
|
+
end
|
data/lib/tmux/dsl.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
# Builds a Tmux::Format. The block runs on a FormatBuilder; give it one
|
|
5
|
+
# parameter (`do |f| ... end`) to keep Ruby's normal scoping instead.
|
|
6
|
+
def self.format(&block)
|
|
7
|
+
builder = FormatBuilder.new
|
|
8
|
+
Evaluation.run(builder, block)
|
|
9
|
+
builder.build
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# A reusable conditional format, with the same elsif? / else chain as if?
|
|
13
|
+
# inside a format block.
|
|
14
|
+
def self.if?(condition, &block)
|
|
15
|
+
builder = FormatBuilder.new
|
|
16
|
+
ConditionalFormat.new(builder, builder.if?(condition, &block))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# left/centre/right zones assembled into one aligned format (see ZoneBuilder).
|
|
20
|
+
def self.pane_border(&block)
|
|
21
|
+
builder = ZoneBuilder.new
|
|
22
|
+
Evaluation.run(builder, block)
|
|
23
|
+
builder.to_format
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Builds a Tmux::Config. The block runs on a ConfigBuilder; give it one
|
|
27
|
+
# parameter (`do |c| ... end`) to keep Ruby's normal scoping instead.
|
|
28
|
+
def self.configure(tmux: nil, &block)
|
|
29
|
+
target = tmux ? Target.for_executable(tmux) : Target.current
|
|
30
|
+
Target.with(target) do
|
|
31
|
+
builder = ConfigBuilder.new(target: target)
|
|
32
|
+
Evaluation.run(builder, block)
|
|
33
|
+
builder.build
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Coerces tmux format source: an Expression, or a String, Symbol, or Numeric
|
|
38
|
+
# used verbatim. Ruby booleans and nil cannot represent runtime conditions.
|
|
39
|
+
def self.expression(value)
|
|
40
|
+
case value
|
|
41
|
+
when Expression then value
|
|
42
|
+
when String, Symbol, Numeric then raw(value)
|
|
43
|
+
when true, false, nil
|
|
44
|
+
raise Error, "expected a tmux expression, got #{value.inspect}; " \
|
|
45
|
+
"use a tmux variable or expression comparison"
|
|
46
|
+
else
|
|
47
|
+
raise Error, "expected a tmux expression, String, Symbol, or Numeric, got #{value.class}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# An Expression from raw tmux format source, e.g. Tmux.raw("#{pane_id}").
|
|
52
|
+
def self.raw(source) = Expression.new(source.to_s)
|
|
53
|
+
|
|
54
|
+
# A deferred shell exit-status check for configuration conditionals.
|
|
55
|
+
def self.shell(command, background: false)
|
|
56
|
+
ShellCondition.new(command.to_s, background)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A runtime conditional color/value, e.g.
|
|
60
|
+
# switch(prefix => colors.orange, copy_mode => colors.green, else: colors.tab)
|
|
61
|
+
# compiles to #{?client_prefix,#ff8800,#{?pane_in_mode,#6a9955,#2d2d30}} — a
|
|
62
|
+
# single expression usable anywhere a color is (fg:/bg:), so one mapping can
|
|
63
|
+
# drive several attributes. The first case is the outermost condition; `else:`
|
|
64
|
+
# is the fallback and is required. Values are tmux format source (colors,
|
|
65
|
+
# expressions, or #{...} strings); stray "," and "}" are escaped, and so is
|
|
66
|
+
# the "#" of an upper-case hex color, which tmux would otherwise read as a
|
|
67
|
+
# variable like #F or #D once the branch is chosen.
|
|
68
|
+
def self.switch(cases)
|
|
69
|
+
default = cases.fetch(:else) { raise Error, "switch needs an else: value" }
|
|
70
|
+
branches = cases.reject { |condition, _| condition.equal?(:else) }
|
|
71
|
+
source = branches.reverse_each.reduce(switch_value(default)) do |rest, (condition, value)|
|
|
72
|
+
"#{'#{'}?#{expression(condition).condition_source},#{switch_value(value)},#{rest}}"
|
|
73
|
+
end
|
|
74
|
+
raw(source)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.switch_value(value)
|
|
78
|
+
source = Escape.structural(expression(value).source)
|
|
79
|
+
source.gsub(/(?<!#)#(?=[A-F][0-9A-Fa-f]{5}(?![0-9A-Za-z]))/, "##")
|
|
80
|
+
end
|
|
81
|
+
private_class_method :switch_value
|
|
82
|
+
|
|
83
|
+
def self.variable(name, shorthand: nil) = Variable.new(name, shorthand:)
|
|
84
|
+
|
|
85
|
+
# Built-in tmux format variables, exposed as readers on the Tmux module so
|
|
86
|
+
# any config can reach them directly (e.g. Tmux.pane_current_path). Keyed by
|
|
87
|
+
# tmux's own variable name; the value is a shorthand (#S) where tmux has one.
|
|
88
|
+
STANDARD_VARIABLES = {
|
|
89
|
+
client_prefix: nil,
|
|
90
|
+
pane_in_mode: nil,
|
|
91
|
+
pane_input_off: nil,
|
|
92
|
+
pane_mode: nil,
|
|
93
|
+
pane_active: nil,
|
|
94
|
+
pane_index: nil,
|
|
95
|
+
pane_current_path: nil,
|
|
96
|
+
pane_current_command: nil,
|
|
97
|
+
pane_synchronized: nil,
|
|
98
|
+
window_zoomed_flag: nil,
|
|
99
|
+
window_index: "#I",
|
|
100
|
+
window_name: "#W",
|
|
101
|
+
session_name: "#S",
|
|
102
|
+
host: "#H",
|
|
103
|
+
host_short: "#h"
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
STANDARD_VARIABLES.each do |variable_name, shorthand|
|
|
107
|
+
define_singleton_method(variable_name) { variable(variable_name, shorthand:) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.user_option(name)
|
|
111
|
+
option = normalize_user_option(name)
|
|
112
|
+
Expression.new("#{'#{'}#{option}}", condition_source: option)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.normalize_user_option(name)
|
|
116
|
+
"@#{name.to_s.delete_prefix('@')}"
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/tmux/errors.rb
ADDED
data/lib/tmux/escape.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
# tmux format escaping. "#" is tmux's escape character: "##", "#," and "#}"
|
|
5
|
+
# stand for a literal "#", "," and "}". Inside a conditional branch (or an
|
|
6
|
+
# operator argument) an unescaped "," or "}" ends that branch, so content
|
|
7
|
+
# placed there needs all three escaped. Escapes do not compound with nesting:
|
|
8
|
+
# tmux unescapes each branch once, when it expands the branch it chose.
|
|
9
|
+
module Escape
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Literal text that tmux must not interpret at all.
|
|
13
|
+
def literal(value, branch: false)
|
|
14
|
+
escaped = value.to_s.gsub("#", "##")
|
|
15
|
+
branch ? escaped.gsub(",", "#,").gsub("}", "#}") : escaped
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Format source (#{...}, #[...], #(...) written on purpose) placed inside
|
|
19
|
+
# a branch: only the "," and "}" outside any #{...} would end the branch,
|
|
20
|
+
# so only those are escaped. Existing "#x" escapes are left alone.
|
|
21
|
+
def structural(source)
|
|
22
|
+
result = +""
|
|
23
|
+
each_top_level(source) do |token, top_level|
|
|
24
|
+
# A lone trailing "#" would escape whatever follows the branch.
|
|
25
|
+
result << (top_level && [",", "}", "#"].include?(token) ? "##{token}" : token)
|
|
26
|
+
end
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# tmux matches parentheses inside #(...), so "$(cmd)" is fine, but an
|
|
31
|
+
# unmatched ")" ends the command early (and "(" swallows the rest of the
|
|
32
|
+
# format). tmux counts them everywhere, even inside #{...}, and neither can
|
|
33
|
+
# be escaped.
|
|
34
|
+
def unbalanced_parens?(source)
|
|
35
|
+
depth = 0
|
|
36
|
+
source.to_s.each_char do |char|
|
|
37
|
+
depth += 1 if char == "("
|
|
38
|
+
depth -= 1 if char == ")"
|
|
39
|
+
return true if depth.negative?
|
|
40
|
+
end
|
|
41
|
+
!depth.zero?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Yields each character (or two-character "#x" sequence) with whether it
|
|
45
|
+
# sits outside every #{...}.
|
|
46
|
+
def each_top_level(source)
|
|
47
|
+
chars = source.to_s.chars
|
|
48
|
+
depth = 0
|
|
49
|
+
index = 0
|
|
50
|
+
while index < chars.length
|
|
51
|
+
if chars[index] == "#" && index + 1 < chars.length
|
|
52
|
+
token = chars[index, 2].join
|
|
53
|
+
yield token, depth.zero?
|
|
54
|
+
depth += 1 if token == '#{'
|
|
55
|
+
index += 2
|
|
56
|
+
elsif chars[index] == "}" && depth.positive?
|
|
57
|
+
depth -= 1
|
|
58
|
+
yield "}", false
|
|
59
|
+
index += 1
|
|
60
|
+
else
|
|
61
|
+
yield chars[index], depth.zero?
|
|
62
|
+
index += 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_constant :Escape
|
|
68
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
# How DSL blocks run. A block without parameters is instance_exec'd on the
|
|
5
|
+
# builder, so builder methods can be called bare. A block that takes one
|
|
6
|
+
# parameter (`do |c| ... end`) is called with the builder instead, keeping
|
|
7
|
+
# Ruby's normal scoping for self, instance variables, and methods.
|
|
8
|
+
module Evaluation
|
|
9
|
+
def self.run(target, block)
|
|
10
|
+
block.arity == 1 ? block.call(target) : target.instance_exec(&block)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def evaluate(block) = Evaluation.run(self, block)
|
|
16
|
+
end
|
|
17
|
+
private_constant :Evaluation
|
|
18
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tmux
|
|
4
|
+
class Expression
|
|
5
|
+
attr_reader :source, :condition_source
|
|
6
|
+
|
|
7
|
+
def initialize(source, condition_source: source)
|
|
8
|
+
@source = source
|
|
9
|
+
@condition_source = condition_source
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The tmux source, so expressions interpolate into text, binding commands,
|
|
13
|
+
# and `append` source: "#{Tmux.session_name}" is "#{session_name}".
|
|
14
|
+
def to_s = source
|
|
15
|
+
|
|
16
|
+
# Logical negation. Written as #{?expr,0,1} because tmux's own #{!:...}
|
|
17
|
+
# only exists from tmux 3.6.
|
|
18
|
+
def ! = Expression.new("#{'#{'}?#{condition_source},0,1}")
|
|
19
|
+
|
|
20
|
+
# String comparisons build tmux expressions, rather than Ruby booleans.
|
|
21
|
+
def ==(other) = compare("==", other)
|
|
22
|
+
def !=(other) = compare("!=", other)
|
|
23
|
+
alias_method :eq, :==
|
|
24
|
+
alias_method :not_eq, :!=
|
|
25
|
+
|
|
26
|
+
# Structural equality for hashes and the library's format/style snapshots.
|
|
27
|
+
def eql?(other)
|
|
28
|
+
other.is_a?(Expression) && source.eql?(other.source) && condition_source.eql?(other.condition_source)
|
|
29
|
+
end
|
|
30
|
+
def hash = [Expression, source, condition_source].hash
|
|
31
|
+
|
|
32
|
+
# Numeric comparisons (#{e|>:...}, tmux 3.2+); tmux's plain #{>:...}
|
|
33
|
+
# compares strings, so 10 would sort before 9.
|
|
34
|
+
def >(other) = compare("e|>", other)
|
|
35
|
+
def <(other) = compare("e|<", other)
|
|
36
|
+
def >=(other) = compare("e|>=", other)
|
|
37
|
+
def <=(other) = compare("e|<=", other)
|
|
38
|
+
|
|
39
|
+
# Regex match. A String pattern is literal regex text; the compiler escapes
|
|
40
|
+
# the characters tmux itself would interpret.
|
|
41
|
+
def matches(pattern)
|
|
42
|
+
Expression.new("#{'#{'}m/r:#{argument(pattern)},#{source}}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Regex substitution. The delimiter is chosen automatically from characters
|
|
46
|
+
# absent in the pattern and replacement, since tmux cannot escape it. A ":"
|
|
47
|
+
# cannot appear in either: tmux ends the modifier there.
|
|
48
|
+
def substitute(pattern, replacement)
|
|
49
|
+
pattern = argument(pattern)
|
|
50
|
+
replacement = argument(replacement)
|
|
51
|
+
if pattern.include?(":") || replacement.include?(":")
|
|
52
|
+
raise Error, "tmux's substitute modifier cannot contain \":\" (got #{pattern.inspect} -> #{replacement.inspect})"
|
|
53
|
+
end
|
|
54
|
+
delimiter = SUBSTITUTE_DELIMITERS.find do |candidate|
|
|
55
|
+
!pattern.include?(candidate) && !replacement.include?(candidate)
|
|
56
|
+
end
|
|
57
|
+
raise Error, "substitute pattern and replacement use every delimiter (#{SUBSTITUTE_DELIMITERS.join})" unless delimiter
|
|
58
|
+
|
|
59
|
+
d = delimiter
|
|
60
|
+
Expression.new("#{'#{'}s#{d}#{pattern}#{d}#{replacement}#{d}:#{subject_source}}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
SUBSTITUTE_DELIMITERS = %w[| / ! @ ~ + = ^ &].freeze
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# The text after a modifier's ":" — a bare variable name when possible,
|
|
68
|
+
# otherwise the full nested format (which tmux expands).
|
|
69
|
+
def subject_source
|
|
70
|
+
source.match?(/\A#\{[^{}:]+\}\z/) ? source[2...-1] : source
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def argument(value)
|
|
74
|
+
return value.source if value.is_a?(Expression)
|
|
75
|
+
return Escape.literal(value, branch: true) if value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric)
|
|
76
|
+
|
|
77
|
+
raise Error, "expected an expression, String, Symbol, or Numeric operand, got #{value.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def operation(operator, other)
|
|
81
|
+
Expression.new("#{'#{'}#{operator}:#{source},#{Tmux.expression(other).source}}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Comparison operands that aren't expressions are literal values.
|
|
85
|
+
def compare(operator, other)
|
|
86
|
+
Expression.new("#{'#{'}#{operator}:#{source},#{argument(other)}}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A single built-in tmux format variable, e.g. #{pane_current_path}. Behaves
|
|
91
|
+
# as an Expression, so comparisons, #substitute and #matches all work on it.
|
|
92
|
+
class Variable < Expression
|
|
93
|
+
attr_reader :name, :shorthand
|
|
94
|
+
|
|
95
|
+
def initialize(name, shorthand: nil)
|
|
96
|
+
@name = name
|
|
97
|
+
@shorthand = shorthand
|
|
98
|
+
super(shorthand || "#{'#{'}#{name}}", condition_source: name.to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Always the long form, which is unambiguous wherever it is embedded and
|
|
102
|
+
# which FormatBuilder#text recognizes as a dynamic part.
|
|
103
|
+
def to_s = "#{'#{'}#{name}}"
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def subject_source = name.to_s
|
|
108
|
+
end
|
|
109
|
+
end
|