Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions grammar/terraform_plan.treetop
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions lib/terraform_landscape/printer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down