giter8 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/giter8/ast.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ # AST represents a set of nodes of a template file
5
+ class AST
6
+ extend Forwardable
7
+
8
+ LEADING_LINEBREAK_REGEXP = /^\r?\n/.freeze
9
+
10
+ def initialize
11
+ @nodes = []
12
+ end
13
+
14
+ def_delegators :@nodes, :push, :<<, :each, :each_with_index, :empty?,
15
+ :length, :[], :last, :first, :map!, :map, :find, :shift,
16
+ :unshift
17
+
18
+ # Returns whether this AST node is composed exclusively by Literals
19
+ def pure_literal?
20
+ all? { |v| v.is_a? Literal }
21
+ end
22
+
23
+ # Cleans up this AST's nodes in-place.
24
+ def clean!
25
+ @nodes = clean.instance_variable_get(:@nodes)
26
+ end
27
+
28
+ # Cleans leading linebreaks from the provided node
29
+ def clean_conditional_ast(node)
30
+ return node if node.empty? || !node.first.is_a?(Literal)
31
+
32
+ cond = node.first
33
+ cond.value.sub!(LEADING_LINEBREAK_REGEXP, "")
34
+ node.shift
35
+ node.unshift(cond) unless cond.value.empty?
36
+ node
37
+ end
38
+
39
+ # clean_node attempts to sanitise a provide node under a given index
40
+ # in this AST
41
+ def clean_node(node, idx)
42
+ if node.is_a?(Conditional)
43
+ # Remove leading linebreak from ASTs inside conditionals
44
+ node.cond_then = clean_conditional_ast(node.cond_then.clean)
45
+ node.cond_else = clean_conditional_ast(node.cond_else.clean)
46
+
47
+ # cond_else_if contains a list of Condition objects, which
48
+ # need special handling.
49
+ node.cond_else_if.clean!
50
+ end
51
+
52
+ if node.is_a?(Literal) && idx.positive? && self[idx - 1].is_a?(Conditional)
53
+ # Remove leading linebreak from Literals following conditionals
54
+ node.value.sub!(LEADING_LINEBREAK_REGEXP, "")
55
+ return if node.value.empty?
56
+ end
57
+ node
58
+ end
59
+
60
+ # Returns a new AST node containing this node's after cleaning them.
61
+ def clean
62
+ ast = AST.new
63
+ ast.push(*each_with_index.map(&method(:clean_node)).reject(&:nil?))
64
+ ast
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ # Represents a conditional structure in an AST
5
+ class Conditional
6
+ attr_accessor :source, :line, :column, :property, :helper, :cond_then,
7
+ :cond_else_if, :cond_else, :parent
8
+
9
+ TRUTHY_VALUES = %w[yes y true].freeze
10
+
11
+ # Returns whether a provided value is considered as "truthy". Giter8
12
+ # assumes the values "yes", "y", and "true" as true. Any other value
13
+ # is assumed to be false.
14
+ def self.truthy?(value)
15
+ if value.nil?
16
+ nil
17
+ elsif value.is_a? Literal
18
+ truthy?(value.value)
19
+ elsif value.is_a? String
20
+ TRUTHY_VALUES.any? { |e| e.casecmp(value).zero? }
21
+ end
22
+ end
23
+
24
+ def initialize(property, helper, parent, source, line, column)
25
+ @source = source
26
+ @line = line
27
+ @column = column
28
+ @property = property
29
+ @helper = helper
30
+ @parent = parent
31
+
32
+ @cond_then = AST.new
33
+ @cond_else_if = AST.new
34
+ @cond_else = AST.new
35
+ end
36
+
37
+ # Cleans this Conditional's branches by calling AST#clean, and returns a
38
+ # copy of this instance.
39
+ def clean
40
+ cond = Conditional.new(@property, @helper, @parent, @source, @line, @column)
41
+
42
+ cond.cond_then = @cond_then.clean
43
+ cond.cond_else = @cond_else.clean
44
+ cond.cond_else_if = @cond_else_if.clean
45
+
46
+ cond
47
+ end
48
+
49
+ # clean! executes the same operation as #clean, but updates this instance
50
+ # instead of returning a copy.
51
+ def clean!
52
+ @cond_then = @cond_then.clean
53
+ @cond_else = @cond_else.clean
54
+ @cond_else_if = @cond_else_if.clean
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ class Error < StandardError; end
5
+
6
+ # PropertyNotFoundError indicates that a template referenced a variable not
7
+ # defined by the property pairs provided to the renderer.
8
+ class PropertyNotFoundError < Error
9
+ def initialize(prop_name, source, line, column)
10
+ super("Property `#{prop_name}' is not defined at #{source}:#{line}:#{column}")
11
+ end
12
+ end
13
+
14
+ # FormatterNotFoundError indicates that a template variable referenced a
15
+ # formatter not known by the renderer.
16
+ class FormatterNotFoundError < Error
17
+ def initialize(name, source, line, column)
18
+ super("Formatter `#{name}' is not defined at #{source}:#{line}:#{column}")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ # FS implements filesystem-related methods for handling template directories
5
+ class FS
6
+ # Returns whether the provided path must be copied verbatim by the renderer
7
+ # instead of being handled by parsers.
8
+ def self.verbatim?(path, ignore_patterns)
9
+ return false if ignore_patterns.empty?
10
+
11
+ ignore_patterns.any? do |pattern|
12
+ File.fnmatch? pattern, path
13
+ end
14
+ end
15
+
16
+ # Returns whether a given path contains a binary file.
17
+ def self.binary?(path)
18
+ File.binary?(path)
19
+ end
20
+
21
+ # Recursively enumerate paths inside a given path and returns a list
22
+ # of files, except "default.properties".
23
+ def self.enumerate(path)
24
+ original_path = path
25
+ path = "#{path}/**/*" unless path.match?(%r{/\*})
26
+ Dir.glob(path)
27
+ .select { |e| File.stat(e).file? }
28
+ .reject { |e| File.basename(e) == "default.properties" }
29
+ .collect { |e| e[original_path.length + 1..] }
30
+ end
31
+
32
+ def self.handle_file_copy(from, to)
33
+ FileUtils.cp(from, to)
34
+ end
35
+
36
+ def self.handle_file_render(props, source, destination)
37
+ f = File.open(source)
38
+ rendered = Giter8.render_template(f, props)
39
+ f.close
40
+
41
+ File.write(destination, rendered, 0, mode: "w")
42
+ end
43
+
44
+ # Optimistically attempt to render a file name. Returns the name verbatim
45
+ # in case parsing or rendering fails.
46
+ def self.render_file_name(name, props)
47
+ ast = Giter8.parse_template(name)
48
+ Giter8.render_template(ast, props)
49
+ rescue Giter8::Error
50
+ name
51
+ end
52
+
53
+ # Renders the contents of a given input directory into a destination,
54
+ # creating directories as required, using provided opts to render templates.
55
+ def self.render(props, input, output)
56
+ FileUtils.mkdir_p output
57
+ props = Giter8.parse_props(props)
58
+
59
+ verbatim = []
60
+ verbatim = props.fetch(:verbatim).split if props.key? :verbatim
61
+
62
+ enumerate(input).each do |file|
63
+ source = File.absolute_path(File.join(input, file))
64
+ output_file_name = render_file_name(file, props)
65
+ destination = File.absolute_path(File.join(output, output_file_name))
66
+ FileUtils.mkdir_p File.dirname(destination)
67
+ if verbatim?(source, verbatim)
68
+ handle_file_copy(source, destination)
69
+ else
70
+ handle_file_render(props, source, destination)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ # Literal represents a sequence of one or more characters not separated by
5
+ # either a Template or Condition node.
6
+ class Literal
7
+ extend Forwardable
8
+ attr_accessor :source, :line, :column, :value, :parent
9
+
10
+ def initialize(value, parent, source, line, column)
11
+ @source = source
12
+ @line = line
13
+ @column = column
14
+ @value = value
15
+ @parent = parent
16
+ end
17
+
18
+ def_delegators :@value, :empty?
19
+ def_delegators :@value, :start_with?
20
+
21
+ # Returns whether this node's value is comprised solely of a linebreak
22
+ def linebreak?
23
+ ["\r\n", "\n"].include? @value
24
+ end
25
+
26
+ def inspect
27
+ parent = @parent
28
+ parent = if parent.nil?
29
+ "nil"
30
+ else
31
+ "#<#{@parent.class.name}:#{format("%08x", (@parent.object_id * 2))}>"
32
+ end
33
+ "#<#{self.class.name}:#{format("%08x", (object_id * 2))} line=#{@line} value=#{@value.inspect} parent=#{parent}>"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ # Pair represent a key-value property pair
5
+ class Pair
6
+ PLAIN_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/.freeze
7
+
8
+ attr_accessor :key, :value
9
+
10
+ def initialize(key, value)
11
+ key = key.to_sym if !key.is_a?(Symbol) && PLAIN_KEY.match?(key)
12
+
13
+ @key = key
14
+ @value = value
15
+ end
16
+
17
+ # Determines whether the Pair's value contains a truthy value.
18
+ # See Conditional.truthy?
19
+ def truthy?
20
+ Conditional.truthy? @value
21
+ end
22
+
23
+ def ==(other)
24
+ same_pair = other.is_a?(Pair) && other.key == @key && other.value == @value
25
+ same_hash = other.is_a?(Hash) && other == { @key => @value }
26
+ same_hash || same_pair
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ # Pairs represents a set of property pairs
5
+ class Pairs
6
+ # Creates a new Pairs instance, optionally with a given map.
7
+ def initialize(map = {})
8
+ @pairs = map.map do |e|
9
+ if e.is_a? Pair
10
+ e
11
+ else
12
+ Pair.new(*e)
13
+ end
14
+ end
15
+ end
16
+
17
+ # Attempts to find a Pair instance with a given name among all propertines
18
+ # in the current set.
19
+ # Returns a Pair object, or nil, if the provided name does not match any
20
+ # Pair.
21
+ def find(name)
22
+ v = find_pair(name.to_sym)
23
+ return nil if v.nil?
24
+
25
+ v.value
26
+ end
27
+
28
+ # Returns the value associated with a given name, or an optional default
29
+ # argument. In case no default is provided, nil is returned.
30
+ def fetch(name, default = nil)
31
+ find(name) || default
32
+ end
33
+
34
+ # When a block is provided, invokes the block once for each Pair included in
35
+ # this set. Otherwise, returns an Enumerator for this instance.
36
+ def each(&block)
37
+ @pairs.each(&block)
38
+ end
39
+
40
+ # Invokes a given block once for each element in this set, providing the
41
+ # element's key and value as arguments, respectively.
42
+ def each_pair
43
+ each do |p|
44
+ yield p.key, p.value
45
+ end
46
+ end
47
+
48
+ # Returns a Pair value with a given name, or nil, in case no Pair matches
49
+ # the provided name.
50
+ def find_pair(name)
51
+ @pairs.find { |e| e.key == name.to_sym }
52
+ end
53
+
54
+ # Returns the current props represented as a Hash
55
+ def to_h
56
+ @pairs.map { |e| [e.key, e.value] }.to_h
57
+ end
58
+
59
+ # Returns whether a Pair with the provided name exists in the set
60
+ def key?(name)
61
+ !find(name).nil?
62
+ end
63
+
64
+ # Merges a provided Hash or Pairs instance into the current set
65
+ def merge(pairs)
66
+ pairs = Pairs.new(pairs) if pairs.is_a? Hash
67
+
68
+ pairs.each_pair do |k, v|
69
+ pair = find_pair(k)
70
+ if pair.nil?
71
+ @pairs << Pair.new(k, v)
72
+ else
73
+ idx = @pairs.index(pair)
74
+ pair.value = v
75
+ @pairs[idx] = pair
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giter8
4
+ module Parsers
5
+ # PairsParser implements an FSM for parsing a key-value string containing
6
+ # property pairs for rendering.
7
+ class PairsParser
8
+ STATE_KEY = 0
9
+ STATE_VALUE = 1
10
+ STATE_COMMENT = 2
11
+
12
+ CHR_SPACE = " "
13
+ CHR_TAB = "\t"
14
+ CHR_CARRIAGE_RETURN = "\r"
15
+ CHR_NEWLINE = "\n"
16
+ CHR_HASH = "#"
17
+ CHR_EQUAL = "="
18
+ WHITE_CHARS = [CHR_SPACE, CHR_TAB, CHR_CARRIAGE_RETURN, CHR_NEWLINE].freeze
19
+ ALPHA_REGEXP = /[[:alpha:]]/.freeze
20
+
21
+ # Parses a given key-value pair list within a string with provided options.
22
+ # Options is a hash that currently only supports the :source key, which
23
+ # must be the name of the file being parsed. This key is used to identify
24
+ # any errors whilst parsing the contents and will be provided on any
25
+ # raised errors.
26
+ # Returns an Pairs object with the read properties.
27
+ def self.parse(input, opts = {})
28
+ new(opts).parse(input)
29
+ end
30
+
31
+ # Initialises a new PairsParser instance.
32
+ # See also: PairsParser.parse
33
+ def initialize(opts = {})
34
+ @pairs = []
35
+ @state = STATE_KEY
36
+ @tmp_key = []
37
+ @tmp_val = []
38
+ @source = opts[:source] || "unknown"
39
+ @column = 0
40
+ @line = 1
41
+ end
42
+
43
+ # Parses a given input string into key-value Pair objects.
44
+ # Returns an Pairs object of identified keys and values.
45
+ def parse(input)
46
+ input.chars.each do |chr|
47
+ chr = chr.chr
48
+ case @state
49
+ when STATE_KEY
50
+ parse_key(chr)
51
+
52
+ when STATE_COMMENT
53
+ @state = STATE_KEY if chr == CHR_NEWLINE
54
+
55
+ when STATE_VALUE
56
+ parse_value(chr)
57
+ end
58
+
59
+ @column += 1
60
+ if chr == CHR_NEWLINE
61
+ @line += 1
62
+ @column = 0
63
+ end
64
+ end
65
+
66
+ finish_parse
67
+ end
68
+
69
+ private
70
+
71
+ # Returns the current FSM's location as a string representation in the
72
+ # format SOURCE_FILE_NAME:LINE:COLUMN
73
+ def location
74
+ "#{@source}:#{line}:#{column}"
75
+ end
76
+
77
+ # Parses a given character into the current key's key property.
78
+ # Raises an error in case the character is not accepted as a valid
79
+ # candidate for a key identifier.
80
+ def parse_key(chr)
81
+ if @tmp_key.empty? && WHITE_CHARS.include?(chr)
82
+ nil
83
+ elsif @tmp_key.empty? && chr == CHR_HASH
84
+ @state = STATE_COMMENT
85
+ elsif @tmp_key.empty? && !ALPHA_REGEXP.match?(chr)
86
+ raise Giter8::Error, "unexpected char #{chr} at #{location}"
87
+ elsif chr == CHR_EQUAL
88
+ @state = STATE_VALUE
89
+ else
90
+ @tmp_key << chr
91
+ end
92
+ end
93
+
94
+ # Consumes provided characters until a newline is reached.
95
+ def parse_value(chr)
96
+ if chr != CHR_NEWLINE
97
+ @tmp_val << chr
98
+ return
99
+ end
100
+
101
+ push_result
102
+ @state = STATE_KEY
103
+ end
104
+
105
+ def reset_tmp
106
+ @tmp_key = []
107
+ @tmp_val = []
108
+ end
109
+
110
+ def push_result
111
+ @pairs << Pair.new(@tmp_key.join.strip, @tmp_val.join.strip)
112
+ reset_tmp
113
+ end
114
+
115
+ def finish_parse
116
+ raise Giter8::Error, "unexpected end of input at #{location}" if @state == STATE_KEY && !@tmp_key.empty?
117
+
118
+ push_result if @state == STATE_VALUE
119
+
120
+ result = Pairs.new(@pairs)
121
+ reset_tmp
122
+ @state = STATE_KEY
123
+ @pairs = []
124
+ result
125
+ end
126
+ end
127
+ end
128
+ end