diff --git a/lib/squib/deck.rb b/lib/squib/deck.rb index 14de164..51f686c 100644 --- a/lib/squib/deck.rb +++ b/lib/squib/deck.rb @@ -5,6 +5,7 @@ require 'squib/card' require 'squib/progress' require 'squib/input_helpers' require 'squib/constants' +require 'squib/layout_parser' # The project module # @@ -65,7 +66,7 @@ module Squib cards.times{ @cards << Squib::Card.new(self, width, height) } show_info(config, layout) load_config(config) - load_layout(layout) + @layout = LayoutParser.load_layout(layout) if block_given? instance_eval(&block) end @@ -106,69 +107,6 @@ module Squib 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 # :nodoc: # @api private diff --git a/lib/squib/layout_parser.rb b/lib/squib/layout_parser.rb new file mode 100644 index 0000000..3f4a446 --- /dev/null +++ b/lib/squib/layout_parser.rb @@ -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 \ No newline at end of file diff --git a/spec/deck_spec.rb b/spec/deck_spec.rb index 938756c..e2bf0b1 100644 --- a/spec/deck_spec.rb +++ b/spec/deck_spec.rb @@ -45,180 +45,16 @@ describe Squib::Deck do end end - context '#load_layout' do - - it 'loads a normal layout with no extends' do - d = Squib::Deck.new(layout: layout_file('no-extends.yml')) - expect(d.layout).to \ - eq({'frame' => { - 'x' => 38, - 'valign' => :middle, - 'str' => 'blah', - '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 - + it 'loads a normal layout with no extends' do + d = Squib::Deck.new(layout: layout_file('no-extends.yml')) + expect(d.layout).to eq({ + 'frame' => { + 'x' => 38, + 'valign' => :middle, + 'str' => 'blah', + 'font' => 'Mr. Font', + } + }) end end diff --git a/spec/layout_parser_spec.rb b/spec/layout_parser_spec.rb new file mode 100644 index 0000000..93f7c78 --- /dev/null +++ b/spec/layout_parser_spec.rb @@ -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 \ No newline at end of file