summaryrefslogtreecommitdiff
path: root/lib/dslkeywords/command.rb
blob: 54a7f4bda641c3e77a8359cdcec98cda3fc78c69 (plain)
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