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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tmux
4
+ class Error < StandardError; end
5
+ end
@@ -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