diff --git a/grammar/terraform_plan.treetop b/grammar/terraform_plan.treetop index 6692ea8..39e8cc0 100644 --- a/grammar/terraform_plan.treetop +++ b/grammar/terraform_plan.treetop @@ -24,6 +24,14 @@ grammar TerraformPlan end rule resource + # Modern format with resource block + header:resource_header "\n" ws? change:('~' / '-/+' / '-' / '+' / '<=')? ws? 'resource' ws '"' rtype:[^"]+ '"' ws '"' rname:[^"]+ '"' ws '{' "\n" attrs:modern_attribute_list ws? '}' { + def to_ast + header.to_ast.merge(attributes: attrs.to_ast) + end + } + / + # Old format header:resource_header "\n" attrs:attribute_list { def to_ast header.to_ast.merge(attributes: attrs.to_ast) @@ -38,6 +46,25 @@ grammar TerraformPlan end rule resource_header + # Modern Terraform format: # module.foo.aws_instance.bar will be updated in-place + ws? '#' ws path:([\w.-]+ '.')* type:[a-zA-Z0-9_-]+ '.' name:[\S]+ ws 'will be' ws action:[^'\n']+ { + def to_ast + change_map = { + 'updated in-place' => '~', + 'created' => '+', + 'destroyed' => '-', + 'replaced' => '-/+', + 'read during apply' => '<=' + } + { + change: change_map[action.text_value.strip].to_sym, + resource_type: type.text_value, + resource_name: (path.text_value + name.text_value).chomp('.'), + } + end + } + / + # Old Terraform 0.11 format with reasons ws? change:('~' / '-/+' / '-' / '+' / '<=') ws type:[a-zA-Z0-9_-]+ '.' name:[\S]+ ws '(' reason1:[^)]+ ')' ws '(' reason2:[^)]+ ')' { def to_ast { @@ -86,6 +113,70 @@ grammar TerraformPlan } end + rule modern_attribute_list + item:modern_attribute attrs:modern_attribute_list { + def to_ast + item.to_ast.merge(attrs.to_ast) + end + } + / + item:modern_attribute { + def to_ast + item.to_ast + end + } + / + '' { + def to_ast + {} + end + } + end + + rule modern_attribute + # Skip comment lines + ws? '#' [^\n]* "\n" { + def to_ast + {} + end + } + / + # Changed attribute: ~ name = old => new + ws? '~' ws name:[^ =]+ ws? '=' ws? old_val:(!'=>' .)* ws? '=>' ws? new_val:[^\n]+ "\n" { + def to_ast + { name.text_value => { value: "#{old_val.text_value.strip} => #{new_val.text_value.strip}" } } + end + } + / + # Added attribute: + name = value + ws? '+' ws name:[^ =]+ ws? '=' ws? value:[^\n]+ "\n" { + def to_ast + { name.text_value => { value: "=> #{value.text_value.strip}" } } + end + } + / + # Removed attribute: - name = value + ws? '-' ws name:[^ =]+ ws? '=' ws? value:[^\n]+ "\n" { + def to_ast + { name.text_value => { value: "#{value.text_value.strip} =>" } } + end + } + / + # Unchanged attribute: name = value + ws name:[^ =]+ ws? '=' ws? value:[^\n]+ "\n" { + def to_ast + { name.text_value => { value: value.text_value.strip } } + end + } + / + # Skip empty lines + ws? "\n" { + def to_ast + {} + end + } + end + rule attribute attribute_name:(!': ' .)* ':' ws? attribute_value:[^\n]+ { def to_ast diff --git a/lib/terraform_landscape/printer.rb b/lib/terraform_landscape/printer.rb index c301daa..6854de0 100644 --- a/lib/terraform_landscape/printer.rb +++ b/lib/terraform_landscape/printer.rb @@ -76,6 +76,113 @@ def process_string(plan_output) # rubocop:disable Metrics/MethodLength end end + # Check if this is modern Terraform output (1.0+) + is_modern = scrubbed_output =~ /^Terraform will perform the following actions:/ && + (scrubbed_output =~ /^\s*#\s*\S+.*(will|must) be/ || scrubbed_output =~ /^\s*[\+\-~]\/?\+?\s+resource\s+"/) + + if is_modern + # Process modern Terraform format inline + lines = scrubbed_output.split("\n") + current_resource = nil + in_resource = false + + lines.each do |line| + # Skip empty lines and headers + next if line.strip.empty? + next if line =~ /^Terraform will perform/ + next if line =~ /^Resource actions are indicated/ + + # Resource header: # module.foo.aws_instance.bar will be created + if line =~ /^\s*#\s+(.+?)\s+(will be|must be)\s+(.+)$/ + resource_path = $1 + verb = $2 + action = $3 + + # Map actions to symbols + action_symbol = case action + when 'created' then '+' + when 'destroyed' then '-' + when 'updated in-place' then '~' + when 'replaced' then '-/+' + else action + end + + # Extract resource type and name + parts = resource_path.split('.') + if parts.size >= 2 + resource_type = parts[-2] + resource_name = parts[-1] + full_name = parts.size > 2 ? parts[0..-3].join('.') + '.' + resource_name : resource_name + else + resource_type = 'unknown' + resource_name = resource_path + full_name = resource_name + end + + # Output resource header + @output.puts format_resource_header(action_symbol, resource_type, full_name) + in_resource = true + current_resource = resource_path + + # Resource type line: ~ resource "aws_instance" "example" { + elsif line =~ /^\s*(~|\+|-|[\-\+]\/[\-\+])\s+resource\s+"([^"]+)"\s+"([^"]+)"\s+{/ + change = $1 + resource_type = $2 + resource_name = $3 + + # For replacements without header, output the resource header + if change == '-/+' && !current_resource + @output.puts format_resource_header(change, resource_type, resource_name) + in_resource = true + end + + # Handle comments about hidden attributes/blocks + elsif in_resource && line =~ /^\s*#\s*\((\d+)\s+unchanged\s+(attributes?|blocks?)\s+hidden\)/ + # Skip these lines + + # Attribute changes inside resource block + elsif in_resource && line =~ /^\s*(~|\+|-)\s+(\S+)\s*=\s*(.*)$/ + change = $1 + attr = $2 + value = $3 + + # Handle different change types + case change + when '~' + # Changed attribute - look for => on this or next line + if value =~ /^(.*?)\s*(?:->|=>\s*)\s*(.*)$/ + old_val = $1.strip + new_val = $2.strip + @output.puts format_attribute(attr, "#{old_val} => #{new_val}", change) + else + @output.puts format_attribute(attr, value, change) + end + when '+' + @output.puts format_attribute(attr, "=> #{value}", change) + when '-' + @output.puts format_attribute(attr, "#{value} =>", change) + end + + # Unchanged attributes + elsif in_resource && line =~ /^\s+(\S+)\s*=\s*(.*)$/ + attr = $1 + value = $2 + @output.puts format_attribute(attr, value) + + # End of resource block + elsif line =~ /^\s*}/ + in_resource = false + current_resource = nil + @output.puts "" + + # Plan summary + elsif line =~ /^Plan:/ + @output.puts "\n#{line.colorize(:cyan)}" + end + end + return + end + # Remove preface if (match = scrubbed_output.match(/^Path:[^\n]+/)) scrubbed_output = scrubbed_output[match.end(0)..-1] @@ -107,6 +214,56 @@ def strip_ansi(string) string.gsub(/\e\[\d+m/, '') end + + def format_resource_header(change, type, name) + color = case change + when '+' then :green + when '-' then :red + when '~' then :yellow + when '-/+' then :red + else :white + end + + "#{change.colorize(color)} #{type}.#{name}".colorize(color) + end + + def format_attribute(name, value, change = nil) + # Indent attributes + line = " #{name.colorize(:light_black)}: " + + if change + color = case change + when '+' then :green + when '-' then :red + when '~' then :yellow + else :white + end + + # Handle => for changes + if value.include?('=>') + parts = value.split('=>', 2) + old_val = parts[0].strip + new_val = parts[1].strip + + formatted_value = if old_val.empty? + "#{new_val}".colorize(:green) + elsif new_val.empty? + "#{old_val}".colorize(:red) + else + "#{old_val.colorize(:red)}#{' => '.colorize(:light_black)}#{new_val.colorize(:green)}" + end + + line += formatted_value + else + line += value.colorize(color) + end + else + line += value + end + + line + end + def apply_prompt(output) return unless output =~ /Enter a value:\s+$/ output[/Do you want to perform these actions.*$/m, 0]