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.
- 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
|