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.
data/bin/tmux.rb ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "tmux/cli"
5
+
6
+ exit Tmux::CLI.run(ARGV)
data/bin/tmux_rb ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Another name for tmux.rb.
4
+ load File.expand_path("tmux.rb", __dir__)
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
@@ -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