4 changed files with 263 additions and 238 deletions
@ -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 |
||||||
@ -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