giter8 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +18 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +39 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/giter8.gemspec +35 -0
- data/lib/giter8/ast.rb +67 -0
- data/lib/giter8/conditional.rb +57 -0
- data/lib/giter8/error.rb +21 -0
- data/lib/giter8/fs/fs.rb +75 -0
- data/lib/giter8/literal.rb +36 -0
- data/lib/giter8/pair.rb +29 -0
- data/lib/giter8/pairs.rb +80 -0
- data/lib/giter8/parsers/pairs_parser.rb +128 -0
- data/lib/giter8/parsers/template_parser.rb +563 -0
- data/lib/giter8/renderer/executor.rb +134 -0
- data/lib/giter8/renderer/utils.rb +104 -0
- data/lib/giter8/template.rb +19 -0
- data/lib/giter8/version.rb +5 -0
- data/lib/giter8.rb +91 -0
- metadata +93 -0
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
|
data/lib/giter8/error.rb
ADDED
@@ -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
|
data/lib/giter8/fs/fs.rb
ADDED
@@ -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
|
data/lib/giter8/pair.rb
ADDED
@@ -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
|
data/lib/giter8/pairs.rb
ADDED
@@ -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
|