summaryrefslogtreecommitdiff
path: root/lib/dslkeywords/command.rb
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-11 08:55:47 +0300
committerPaul Buetow <paul@buetow.org>2026-05-11 08:55:47 +0300
commit52cd829388e0313a79def8eabd77b60f01a42260 (patch)
tree9293723bf55355ab7882d8d0cec9d52a7d32846c /lib/dslkeywords/command.rb
parentd57d57fa6cf99db8447380202b7f091827728ef5 (diff)
add commandsdevelop
Diffstat (limited to 'lib/dslkeywords/command.rb')
-rw-r--r--lib/dslkeywords/command.rb140
1 files changed, 140 insertions, 0 deletions
diff --git a/lib/dslkeywords/command.rb b/lib/dslkeywords/command.rb
new file mode 100644
index 0000000..54a7f4b
--- /dev/null
+++ b/lib/dslkeywords/command.rb
@@ -0,0 +1,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