giter8 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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