1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
|
# frozen_string_literal: true
require 'open3'
require 'shellwords'
require_relative 'keyword'
module RCM
# Stores a named shell command template for prompt-time expansion.
class CommandDefinition < Keyword
Reference = Struct.new(:name, keyword_init: true)
attr_reader :name
class InvalidName < StandardError; end
def self.id_for(name) = super(normalize_name(name))
def self.reference(name) = Reference.new(name: normalize_name(name))
def self.normalize_name(name)
normalized = name.to_s.strip.gsub(/\s+/, ' ')
raise InvalidName, 'Command name must not be empty' if normalized.empty?
normalized
end
def initialize(name)
@name = self.class.normalize_name(name)
super(@name)
end
def template(text = nil)
return @template if text.nil?
@template = text.to_s
end
end
# Shared append/prepend-from-command behavior for prompt and file content.
module CommandComposable
def initialize_command_composition!
@command_composition = []
end
def append(value = nil)
compose_command(:append, value)
nil
end
def prepend(value = nil)
compose_command(:prepend, value)
nil
end
def command(name = nil)
CommandDefinition.reference(name)
end
private
def render_composed_content(base_text, file_path)
prepended = render_command_sections(:prepend, file_path)
appended = render_command_sections(:append, file_path)
join_command_sections(*prepended, base_text.to_s, *appended)
end
def compose_command(position, value)
invalid_command_composition!(position) unless value.is_a?(CommandDefinition::Reference)
@command_composition << { position:, name: value.name }
end
def render_command_sections(position, file_path)
@command_composition
.select { |entry| entry[:position] == position }
.map { |entry| execute_composed_command(entry[:name], file_path) }
end
def execute_composed_command(name, file_path)
command = resolved_command(name, file_path)
return dry_run_command(name, file_path) if option :dry
stdout, stderr, status = Open3.capture3(command)
return stdout if status.success?
raise command_failure_class, command_failure_message(name, file_path, status.exitstatus, stderr)
end
def resolved_command(name, file_path)
command_definition = dsl.object!(
CommandDefinition,
name,
error_class: DSL::NoSuchCommandDefinition,
kind: 'command'
)
render_command_template(command_definition.template.to_s, file_path)
end
def render_command_template(template, file_path)
command = template.dup
command.gsub!(/\bFILE_PATH\b/, Shellwords.escape(file_path))
command
end
def dry_run_command(name, file_path)
info "Running command #{name} for #{command_composition_subject} on #{file_path} - dry run!"
''
end
def command_failure_message(name, file_path, exit_status, stderr)
message = stderr.to_s.strip
message = 'no stderr output' if message.empty?
"Command #{name} failed while rendering #{command_composition_subject} for #{file_path} " \
"(exit #{exit_status}): #{message}"
end
def join_command_sections(*sections)
sections.each_with_object(+'') do |section, result|
next if section.nil? || section.empty?
result << "\n" if !result.empty? && !result.end_with?("\n") && !section.start_with?("\n")
result << section
end
end
end
# Adds the `command` definition keyword to the top-level DSL.
class DSL
def command(name = nil, &block)
return name if name.nil?
return unless @conds_met
definition = CommandDefinition.new(name)
definition.dsl = self
definition.template(definition.instance_eval(&block)) if block
register(definition, schedule: false, duplicate_error: DuplicateDefinition)
end
end
end
|