Refactoring out LayoutParser
parent
04783ef8de
commit
31e5d75f59
|
|
@ -5,6 +5,7 @@ require 'squib/card'
|
||||||
require 'squib/progress'
|
require 'squib/progress'
|
||||||
require 'squib/input_helpers'
|
require 'squib/input_helpers'
|
||||||
require 'squib/constants'
|
require 'squib/constants'
|
||||||
|
require 'squib/layout_parser'
|
||||||
|
|
||||||
# The project module
|
# The project module
|
||||||
#
|
#
|
||||||
|
|
@ -65,7 +66,7 @@ module Squib
|
||||||
cards.times{ @cards << Squib::Card.new(self, width, height) }
|
cards.times{ @cards << Squib::Card.new(self, width, height) }
|
||||||
show_info(config, layout)
|
show_info(config, layout)
|
||||||
load_config(config)
|
load_config(config)
|
||||||
load_layout(layout)
|
@layout = LayoutParser.load_layout(layout)
|
||||||
if block_given?
|
if block_given?
|
||||||
instance_eval(&block)
|
instance_eval(&block)
|
||||||
end
|
end
|
||||||
|
|
@ -106,69 +107,6 @@ module Squib
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load the layout configuration file, if exists
|
|
||||||
# @api private
|
|
||||||
def load_layout(files)
|
|
||||||
@layout = {}
|
|
||||||
Squib::logger.info { " using layout(s): #{files}" }
|
|
||||||
Array(files).each do |file|
|
|
||||||
thefile = file
|
|
||||||
thefile = "#{File.dirname(__FILE__)}/layouts/#{file}" unless File.exists?(file)
|
|
||||||
if File.exists? thefile
|
|
||||||
yml = @layout.merge(YAML.load_file(thefile) || {}) #load_file returns false on empty file
|
|
||||||
yml.each do |key, value|
|
|
||||||
@layout[key] = recurse_extends(yml, key, {})
|
|
||||||
end
|
|
||||||
else
|
|
||||||
puts "the file: #{thefile}"
|
|
||||||
Squib::logger.error { "Layout file not found: #{file}" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Process the extends recursively
|
|
||||||
# :nodoc:
|
|
||||||
# @api private
|
|
||||||
def recurse_extends(yml, key, visited )
|
|
||||||
assert_not_visited(key, visited)
|
|
||||||
return yml[key] unless has_extends?(yml, key)
|
|
||||||
visited[key] = key
|
|
||||||
parent_keys = [yml[key]['extends']].flatten
|
|
||||||
h = {}
|
|
||||||
parent_keys.each do |parent_key|
|
|
||||||
from_extends = yml[key].merge(recurse_extends(yml, parent_key, visited)) do |key, child_val, parent_val|
|
|
||||||
if child_val.to_s.strip.start_with?('+=')
|
|
||||||
parent_val + child_val.sub('+=','').strip.to_f
|
|
||||||
elsif child_val.to_s.strip.start_with?('-=')
|
|
||||||
parent_val - child_val.sub('-=','').strip.to_f
|
|
||||||
else
|
|
||||||
child_val #child overrides parent when merging, no +=
|
|
||||||
end
|
|
||||||
end
|
|
||||||
h = h.merge(from_extends) do |key, older_sibling, younger_sibling|
|
|
||||||
younger_sibling #when two siblings have the same entry, the "younger" (lower one) overrides
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return h
|
|
||||||
end
|
|
||||||
|
|
||||||
# Does this layout entry have an extends field?
|
|
||||||
# i.e. is it a base-case or will it need recursion?
|
|
||||||
# :nodoc:
|
|
||||||
# @api private
|
|
||||||
def has_extends?(yml, key)
|
|
||||||
!!yml[key] && yml[key].key?('extends')
|
|
||||||
end
|
|
||||||
|
|
||||||
# Safeguard against malformed circular extends
|
|
||||||
# :nodoc:
|
|
||||||
# @api private
|
|
||||||
def assert_not_visited(key, visited)
|
|
||||||
if visited.key? key
|
|
||||||
raise "Invalid layout: circular extends with '#{key}'"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Use Logger to show more detail on the run
|
# Use Logger to show more detail on the run
|
||||||
# :nodoc:
|
# :nodoc:
|
||||||
# @api private
|
# @api private
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
module Squib
|
||||||
|
# Internal class for handling layouts
|
||||||
|
#@api private
|
||||||
|
class LayoutParser
|
||||||
|
|
||||||
|
# Load the layout file(s), if exists
|
||||||
|
# @api private
|
||||||
|
def self.load_layout(files)
|
||||||
|
layout = {}
|
||||||
|
Squib::logger.info { " using layout(s): #{files}" }
|
||||||
|
Array(files).each do |file|
|
||||||
|
thefile = file
|
||||||
|
thefile = builtin(file) unless File.exists?(file)
|
||||||
|
if File.exists? thefile
|
||||||
|
yml = layout.merge(YAML.load_file(thefile) || {}) #load_file returns false on empty file
|
||||||
|
yml.each do |key, value|
|
||||||
|
layout[key] = recurse_extends(yml, key, {})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Squib::logger.error { "Layout file not found: #{file}. Skipping..." }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return layout
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine the file path of the built-in layout file
|
||||||
|
def self.builtin(file)
|
||||||
|
"#{File.dirname(__FILE__)}/layouts/#{file}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process the extends recursively
|
||||||
|
# :nodoc:
|
||||||
|
# @api private
|
||||||
|
def self.recurse_extends(yml, key, visited )
|
||||||
|
assert_not_visited(key, visited)
|
||||||
|
return yml[key] unless has_extends?(yml, key)
|
||||||
|
visited[key] = key
|
||||||
|
parent_keys = [yml[key]['extends']].flatten
|
||||||
|
h = {}
|
||||||
|
parent_keys.each do |parent_key|
|
||||||
|
from_extends = yml[key].merge(recurse_extends(yml, parent_key, visited)) do |key, child_val, parent_val|
|
||||||
|
if child_val.to_s.strip.start_with?('+=')
|
||||||
|
parent_val + child_val.sub('+=','').strip.to_f
|
||||||
|
elsif child_val.to_s.strip.start_with?('-=')
|
||||||
|
parent_val - child_val.sub('-=','').strip.to_f
|
||||||
|
else
|
||||||
|
child_val #child overrides parent when merging, no +=
|
||||||
|
end
|
||||||
|
end
|
||||||
|
h = h.merge(from_extends) do |key, older_sibling, younger_sibling|
|
||||||
|
younger_sibling #when two siblings have the same entry, the "younger" (lower one) overrides
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return h
|
||||||
|
end
|
||||||
|
|
||||||
|
# Does this layout entry have an extends field?
|
||||||
|
# i.e. is it a base-case or will it need recursion?
|
||||||
|
# :nodoc:
|
||||||
|
# @api private
|
||||||
|
def self.has_extends?(yml, key)
|
||||||
|
!!yml[key] && yml[key].key?('extends')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Safeguard against malformed circular extends
|
||||||
|
# :nodoc:
|
||||||
|
# @api private
|
||||||
|
def self.assert_not_visited(key, visited)
|
||||||
|
if visited.key? key
|
||||||
|
raise "Invalid layout: circular extends with '#{key}'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -45,180 +45,16 @@ describe Squib::Deck do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context '#load_layout' do
|
|
||||||
|
|
||||||
it 'loads a normal layout with no extends' do
|
it 'loads a normal layout with no extends' do
|
||||||
d = Squib::Deck.new(layout: layout_file('no-extends.yml'))
|
d = Squib::Deck.new(layout: layout_file('no-extends.yml'))
|
||||||
expect(d.layout).to \
|
expect(d.layout).to eq({
|
||||||
eq({'frame' => {
|
'frame' => {
|
||||||
'x' => 38,
|
'x' => 38,
|
||||||
'valign' => :middle,
|
'valign' => :middle,
|
||||||
'str' => 'blah',
|
'str' => 'blah',
|
||||||
'font' => 'Mr. Font',
|
'font' => 'Mr. Font',
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'loads with a single extends' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('single-extends.yml'))
|
|
||||||
expect(d.layout).to \
|
|
||||||
eq({'frame' => {
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 38,
|
|
||||||
},
|
|
||||||
'title' => {
|
|
||||||
'extends' => 'frame',
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 50,
|
|
||||||
'width' => 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'applies the extends regardless of order' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('pre-extends.yml'))
|
|
||||||
expect(d.layout).to \
|
|
||||||
eq({'frame' => {
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 38,
|
|
||||||
},
|
|
||||||
'title' => {
|
|
||||||
'extends' => 'frame',
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 50,
|
|
||||||
'width' => 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'applies the single-level extends multiple times' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('single-level-multi-extends.yml'))
|
|
||||||
expect(d.layout).to \
|
|
||||||
eq({'frame' => {
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 38,
|
|
||||||
},
|
|
||||||
'title' => {
|
|
||||||
'extends' => 'frame',
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 50,
|
|
||||||
'width' => 100,
|
|
||||||
},
|
|
||||||
'title2' => {
|
|
||||||
'extends' => 'frame',
|
|
||||||
'x' => 75,
|
|
||||||
'y' => 150,
|
|
||||||
'width' => 150,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'applies multiple extends in a single rule' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('multi-extends-single-entry.yml'))
|
|
||||||
expect(d.layout).to \
|
|
||||||
eq({'aunt' => {
|
|
||||||
'a' => 101,
|
|
||||||
'b' => 102,
|
|
||||||
'c' => 103,
|
|
||||||
},
|
|
||||||
'uncle' => {
|
|
||||||
'x' => 104,
|
|
||||||
'y' => 105,
|
|
||||||
'b' => 106,
|
|
||||||
},
|
|
||||||
'child' => {
|
|
||||||
'extends' => ['uncle','aunt'],
|
|
||||||
'a' => 107, # my own
|
|
||||||
'b' => 102, # from the younger aunt
|
|
||||||
'c' => 103, # from aunt
|
|
||||||
'x' => 108, # my own
|
|
||||||
'y' => 105, # from uncle
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'applies multi-level extends' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('multi-level-extends.yml'))
|
|
||||||
expect(d.layout).to \
|
|
||||||
eq({'frame' => {
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 38,
|
|
||||||
},
|
|
||||||
'title' => {
|
|
||||||
'extends' => 'frame',
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 50,
|
|
||||||
'width' => 100,
|
|
||||||
},
|
|
||||||
'subtitle' => {
|
|
||||||
'extends' => 'title',
|
|
||||||
'x' => 38,
|
|
||||||
'y' => 150,
|
|
||||||
'width' => 100,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'fails on a self-circular extends' do
|
|
||||||
file = layout_file('self-circular-extends.yml')
|
|
||||||
expect { Squib::Deck.new(layout: file) }.to \
|
|
||||||
raise_error(RuntimeError, 'Invalid layout: circular extends with \'a\'')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'fails on a easy-circular extends' do
|
|
||||||
file = layout_file('easy-circular-extends.yml')
|
|
||||||
expect { Squib::Deck.new(layout: file) }.to \
|
|
||||||
raise_error(RuntimeError, 'Invalid layout: circular extends with \'a\'')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'hard on a easy-circular extends' do
|
|
||||||
file = layout_file('hard-circular-extends.yml')
|
|
||||||
expect { Squib::Deck.new(layout: file) }.to \
|
|
||||||
raise_error(RuntimeError, 'Invalid layout: circular extends with \'a\'')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'redefines keys on multiple layouts' do
|
|
||||||
a = layout_file('multifile-a.yml')
|
|
||||||
b = layout_file('multifile-b.yml')
|
|
||||||
d = Squib::Deck.new(layout: [a, b])
|
|
||||||
expect(d.layout).to eq({
|
|
||||||
'title' => { 'x' => 300 },
|
|
||||||
'subtitle' => { 'x' => 200 },
|
|
||||||
'desc' => { 'x' => 400 }
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'evaluates extends on each file first' do
|
|
||||||
a = layout_file('multifile-extends-a.yml')
|
|
||||||
b = layout_file('multifile-extends-b.yml')
|
|
||||||
d = Squib::Deck.new(layout: [a, b])
|
|
||||||
expect(d.layout).to eq({
|
|
||||||
'grandparent' => { 'x' => 100 },
|
|
||||||
'parent_a' => { 'x' => 110, 'extends' => 'grandparent' },
|
|
||||||
'parent_b' => { 'x' => 130, 'extends' => 'grandparent' },
|
|
||||||
'child_a' => { 'x' => 113, 'extends' => 'parent_a' },
|
|
||||||
'child_b' => { 'x' => 133, 'extends' => 'parent_b' }
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'loads nothing on an empty layout file' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('empty.yml'))
|
|
||||||
expect(d.layout).to eq({})
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles extends on a rule with no args' do
|
|
||||||
d = Squib::Deck.new(layout: layout_file('empty-rule.yml'))
|
|
||||||
expect(d.layout).to eq({
|
|
||||||
'empty' => nil
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Squib::LayoutParser do
|
||||||
|
|
||||||
|
it 'loads a normal layout with no extends' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('no-extends.yml'))
|
||||||
|
expect(layout).to eq({'frame' => {
|
||||||
|
'x' => 38,
|
||||||
|
'valign' => :middle,
|
||||||
|
'str' => 'blah',
|
||||||
|
'font' => 'Mr. Font',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'loads with a single extends' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('single-extends.yml'))
|
||||||
|
expect(layout).to eq({'frame' => {
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 38,
|
||||||
|
},
|
||||||
|
'title' => {
|
||||||
|
'extends' => 'frame',
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 50,
|
||||||
|
'width' => 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'applies the extends regardless of order' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('pre-extends.yml'))
|
||||||
|
expect(layout).to eq({'frame' => {
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 38,
|
||||||
|
},
|
||||||
|
'title' => {
|
||||||
|
'extends' => 'frame',
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 50,
|
||||||
|
'width' => 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'applies the single-level extends multiple times' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('single-level-multi-extends.yml'))
|
||||||
|
expect(layout).to eq({'frame' => {
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 38,
|
||||||
|
},
|
||||||
|
'title' => {
|
||||||
|
'extends' => 'frame',
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 50,
|
||||||
|
'width' => 100,
|
||||||
|
},
|
||||||
|
'title2' => {
|
||||||
|
'extends' => 'frame',
|
||||||
|
'x' => 75,
|
||||||
|
'y' => 150,
|
||||||
|
'width' => 150,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'applies multiple extends in a single rule' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('multi-extends-single-entry.yml'))
|
||||||
|
expect(layout).to eq({'aunt' => {
|
||||||
|
'a' => 101,
|
||||||
|
'b' => 102,
|
||||||
|
'c' => 103,
|
||||||
|
},
|
||||||
|
'uncle' => {
|
||||||
|
'x' => 104,
|
||||||
|
'y' => 105,
|
||||||
|
'b' => 106,
|
||||||
|
},
|
||||||
|
'child' => {
|
||||||
|
'extends' => ['uncle','aunt'],
|
||||||
|
'a' => 107, # my own
|
||||||
|
'b' => 102, # from the younger aunt
|
||||||
|
'c' => 103, # from aunt
|
||||||
|
'x' => 108, # my own
|
||||||
|
'y' => 105, # from uncle
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'applies multi-level extends' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('multi-level-extends.yml'))
|
||||||
|
expect(layout).to eq({'frame' => {
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 38,
|
||||||
|
},
|
||||||
|
'title' => {
|
||||||
|
'extends' => 'frame',
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 50,
|
||||||
|
'width' => 100,
|
||||||
|
},
|
||||||
|
'subtitle' => {
|
||||||
|
'extends' => 'title',
|
||||||
|
'x' => 38,
|
||||||
|
'y' => 150,
|
||||||
|
'width' => 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails on a self-circular extends' do
|
||||||
|
file = layout_file('self-circular-extends.yml')
|
||||||
|
expect { Squib::LayoutParser.load_layout(file) }
|
||||||
|
.to raise_error(RuntimeError, 'Invalid layout: circular extends with \'a\'')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails on a easy-circular extends' do
|
||||||
|
file = layout_file('easy-circular-extends.yml')
|
||||||
|
expect { Squib::LayoutParser.load_layout(file) }
|
||||||
|
.to raise_error(RuntimeError, 'Invalid layout: circular extends with \'a\'')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hard on a easy-circular extends' do
|
||||||
|
file = layout_file('hard-circular-extends.yml')
|
||||||
|
expect { Squib::LayoutParser.load_layout(file) }
|
||||||
|
.to raise_error(RuntimeError, 'Invalid layout: circular extends with \'a\'')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redefines keys on multiple layouts' do
|
||||||
|
a = layout_file('multifile-a.yml')
|
||||||
|
b = layout_file('multifile-b.yml')
|
||||||
|
layout = Squib::LayoutParser.load_layout([a, b])
|
||||||
|
expect(layout).to eq({
|
||||||
|
'title' => { 'x' => 300 },
|
||||||
|
'subtitle' => { 'x' => 200 },
|
||||||
|
'desc' => { 'x' => 400 }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'evaluates extends on each file first' do
|
||||||
|
a = layout_file('multifile-extends-a.yml')
|
||||||
|
b = layout_file('multifile-extends-b.yml')
|
||||||
|
layout = Squib::LayoutParser.load_layout([a, b])
|
||||||
|
expect(layout).to eq({
|
||||||
|
'grandparent' => { 'x' => 100 },
|
||||||
|
'parent_a' => { 'x' => 110, 'extends' => 'grandparent' },
|
||||||
|
'parent_b' => { 'x' => 130, 'extends' => 'grandparent' },
|
||||||
|
'child_a' => { 'x' => 113, 'extends' => 'parent_a' },
|
||||||
|
'child_b' => { 'x' => 133, 'extends' => 'parent_b' }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'loads nothing on an empty layout file' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('empty.yml'))
|
||||||
|
expect(layout).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles extends on a rule with no args' do
|
||||||
|
layout = Squib::LayoutParser.load_layout(layout_file('empty-rule.yml'))
|
||||||
|
expect(layout).to eq({
|
||||||
|
'empty' => nil
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs an error when a file is not found' do
|
||||||
|
expect(Squib.logger).to receive(:error).once
|
||||||
|
Squib::LayoutParser.load_layout('yeti')
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue