herbst 0.0.1 → 0.1.0

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/herbst/cli.rb ADDED
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Herbst
6
+ # The `herbst` command.
7
+ #
8
+ # herbst convert app/views/posts/show.html.haml # prints the ERB
9
+ # herbst convert app/views # writes show.html.erb next to show.html.haml or .slim
10
+ #
11
+ # A single file is printed to stdout unless `--write` is given. Directories and multiple
12
+ # files are written next to their sources, replacing the `.haml` or `.slim` extension with `.erb`.
13
+ # Existing ERB files are skipped unless `--force` is given. A file without a format gets
14
+ # `html`, so `show.haml` becomes `show.html.erb`, while `welcome.text.haml` keeps its format.
15
+ #
16
+ class CLI
17
+ class Conversion
18
+ attr_reader :path #: String
19
+ attr_reader :warnings #: Array[String]
20
+ attr_reader :error #: String?
21
+ attr_reader :difference #: String?
22
+ attr_accessor :erb #: String?
23
+
24
+ #: (String, ?erb: String?, ?warnings: Array[String], ?error: String?, ?difference: String?) -> void
25
+ def initialize(path, erb: nil, warnings: [], error: nil, difference: nil)
26
+ @path = path
27
+ @erb = erb
28
+ @warnings = warnings
29
+ @error = error
30
+ @difference = difference
31
+ end
32
+
33
+ #: () -> bool
34
+ def failed?
35
+ !error.nil? || !difference.nil?
36
+ end
37
+ end
38
+
39
+ # @rbs @arguments: Array[String]
40
+ # @rbs @stdout: IO
41
+ # @rbs @stderr: IO
42
+ # @rbs @format: bool
43
+ # @rbs @verify: bool
44
+ # @rbs @write: bool
45
+ # @rbs @force: bool
46
+ # @rbs @default_format: String?
47
+ # @rbs @help: bool
48
+ # @rbs @version: bool
49
+ # @rbs @option_parser: OptionParser?
50
+
51
+ #: (Array[String], ?stdout: IO, ?stderr: IO) -> void
52
+ def initialize(arguments, stdout: $stdout, stderr: $stderr)
53
+ @arguments = arguments
54
+ @stdout = stdout
55
+ @stderr = stderr
56
+ @format = true
57
+ @verify = true
58
+ @write = false
59
+ @force = false
60
+ @default_format = "html"
61
+ @help = false
62
+ @version = false
63
+ end
64
+
65
+ #: () -> Integer
66
+ def run
67
+ command, *paths = option_parser.parse(@arguments)
68
+
69
+ return help if @help
70
+ return version if @version
71
+
72
+ case command
73
+ when "convert"
74
+ paths.empty? ? help(1, "Missing path: pass the Haml or Slim files, or directories, to convert.") : convert(paths)
75
+ when "help"
76
+ help
77
+ when "version"
78
+ version
79
+ when String
80
+ help(1, "Unknown command: '#{command}'")
81
+ else
82
+ help(1)
83
+ end
84
+ rescue OptionParser::ParseError => e
85
+ help(1, e.message)
86
+ end
87
+
88
+ private
89
+
90
+ #: (Array[String]) -> Integer
91
+ def convert(paths)
92
+ missing = paths.reject { |path| File.exist?(path) }
93
+ return fail_with("#{missing.join(", ")}: No such file or directory") unless missing.empty?
94
+
95
+ files = paths.flat_map { |path| File.directory?(path) ? Dir[File.join(path, "**", "*.{haml,slim}")] : [path] }.sort
96
+ return fail_with("No .haml or .slim files found in #{paths.join(", ")}") if files.empty?
97
+
98
+ conversions = files.map { |file| convert_file(file) }
99
+ format(conversions) if @format
100
+
101
+ write = @write || paths.size > 1 || paths.any? { |path| File.directory?(path) }
102
+ write ? write_files(conversions) : print_file(conversions.fetch(0))
103
+ end
104
+
105
+ #: () -> OptionParser
106
+ def option_parser
107
+ @option_parser ||= OptionParser.new do |parser|
108
+ parser.banner = ""
109
+
110
+ parser.on_tail("-v", "--version", "Show the version") { @version = true }
111
+ parser.on_tail("-h", "--help", "Show this message") { @help = true }
112
+
113
+ parser.on("--force", "Overwrite existing ERB files (for convert command)") { @force = true }
114
+ parser.on("--no-format", "Don't run the Herb formatter on the output (for convert command)") { @format = false }
115
+
116
+ parser.on("--no-verify", "Don't check the ERB parses back into the converted tree (for convert command)") do
117
+ @verify = false
118
+ end
119
+
120
+ parser.on("--write", "Write a single file next to its source instead of printing it (for convert command)") do
121
+ @write = true
122
+ end
123
+
124
+ parser.on("--default-format FORMAT", "Format for files without one (default: html) (for convert command)") do |format|
125
+ @default_format = format
126
+ end
127
+
128
+ parser.on("--no-default-format", "Don't add a format to files without one, as in show.erb (for convert command)") do
129
+ @default_format = nil
130
+ end
131
+ end
132
+ end
133
+
134
+ #: (?Integer, ?String?) -> Integer
135
+ def help(exit_code = 0, message = nil)
136
+ io = exit_code.zero? ? @stdout : @stderr
137
+
138
+ io.puts message, "" if message
139
+ io.puts <<~HELP
140
+ Herbst 🍂 Unified syntax tree for HTML templating languages.
141
+
142
+ Usage:
143
+ bundle exec herbst [command] [options]
144
+
145
+ Commands:
146
+ bundle exec herbst convert [paths] Convert Haml and Slim files, or every one in a directory, to ERB.
147
+ bundle exec herbst help Show this message.
148
+ bundle exec herbst version Prints the versions of Herbst, Herb, Haml and Slim.
149
+
150
+ Converting:
151
+ A single file is printed to stdout, unless `--write` is passed:
152
+ bundle exec herbst convert app/views/posts/show.html.haml
153
+
154
+ Directories, and several files at once, are written next to their sources:
155
+ bundle exec herbst convert app/views
156
+
157
+ Existing ERB files are skipped unless `--force` is passed. The Haml and Slim files are kept.
158
+
159
+ A file without a format gets html, so show.haml becomes show.html.erb. Pass
160
+ `--default-format FORMAT` for another format, or `--no-default-format` to write show.erb.
161
+
162
+ Options:
163
+ HELP
164
+ io.puts option_parser.to_s.delete_prefix("\n").gsub(/^ /, " "), ""
165
+
166
+ exit_code
167
+ end
168
+
169
+ #: () -> Integer
170
+ def version
171
+ versions = ["herbst gem v#{VERSION}", "herb gem v#{Herb::VERSION}"]
172
+
173
+ ["haml", "slim"].each do |name|
174
+ version = Gem::Specification.find_all_by_name(name).map(&:version).max # steep:ignore
175
+ versions << "#{name} gem v#{version}" if version
176
+ end
177
+
178
+ @stdout.puts versions.join(", ")
179
+
180
+ 0
181
+ end
182
+
183
+ #: (String) -> Conversion
184
+ def convert_file(file)
185
+ converter = converter_for(file)
186
+ document = converter.convert(File.read(file, encoding: "UTF-8"))
187
+ erb = IdentityPrinter.print(document)
188
+ difference = RoundTrip.difference(document, erb) if @verify
189
+
190
+ Conversion.new(file, erb: erb, warnings: converter.warnings, difference: difference)
191
+ rescue Herbst::Error => e
192
+ Conversion.new(file, error: e.message)
193
+ end
194
+
195
+ #: (String) -> Herbst::Converter
196
+ def converter_for(path)
197
+ File.extname(path) == ".slim" ? Slim::Converter.new : Haml::Converter.new
198
+ end
199
+
200
+ #: (Array[Conversion]) -> void
201
+ def format(conversions)
202
+ converted = conversions.select(&:erb)
203
+ formatted = Printer.format_all(converted.map { |conversion| conversion.erb.to_s })
204
+
205
+ converted.zip(formatted) { |conversion, erb| conversion.erb = erb }
206
+ end
207
+
208
+ #: (Conversion) -> Integer
209
+ def print_file(conversion)
210
+ report(conversion)
211
+ @stdout.print(conversion.erb) if conversion.erb
212
+
213
+ conversion.failed? ? 1 : 0
214
+ end
215
+
216
+ #: (Array[Conversion]) -> Integer
217
+ def write_files(conversions)
218
+ written = 0
219
+ skipped = 0
220
+
221
+ conversions.each do |conversion|
222
+ report(conversion)
223
+
224
+ erb = conversion.erb
225
+ next unless erb
226
+
227
+ target = target_path(conversion.path)
228
+
229
+ if File.exist?(target) && !@force
230
+ @stderr.puts "#{conversion.path}: #{target} already exists, skipped (use --force to overwrite)"
231
+ skipped += 1
232
+ else
233
+ File.write(target, erb)
234
+ written += 1
235
+ end
236
+ end
237
+
238
+ failed = conversions.count(&:failed?)
239
+ @stderr.puts summary(conversions.size, written, skipped, failed)
240
+
241
+ failed.positive? ? 1 : 0
242
+ end
243
+
244
+ #: (String) -> String
245
+ def target_path(path)
246
+ base = path.sub(/\.(haml|slim)\z/, "")
247
+ base = "#{base}.#{@default_format}" if @default_format && !File.basename(base).include?(".")
248
+
249
+ "#{base}.erb"
250
+ end
251
+
252
+ #: (Conversion) -> void
253
+ def report(conversion)
254
+ conversion.warnings.each { |warning| @stderr.puts "#{conversion.path}: #{warning}" }
255
+ @stderr.puts "#{conversion.path}: #{conversion.error}" if conversion.error
256
+ @stderr.puts "#{conversion.path}: #{conversion.difference}" if conversion.difference
257
+ end
258
+
259
+ #: (Integer, Integer, Integer, Integer) -> String
260
+ def summary(total, written, skipped, failed)
261
+ parts = ["Converted #{written} of #{total} #{total == 1 ? "file" : "files"}"]
262
+
263
+ parts << "#{skipped} skipped" if skipped.positive?
264
+ parts << "#{failed} failed" if failed.positive?
265
+
266
+ "#{parts.join(", ")}."
267
+ end
268
+
269
+ #: (String) -> Integer
270
+ def fail_with(message)
271
+ @stderr.puts message
272
+
273
+ 1
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herbst
4
+ class Converter
5
+ include Builder
6
+
7
+ INDENT = " " #: String
8
+
9
+ # @rbs!
10
+ # type clause = [String, String?, Array[Herb::AST::Node], Integer?]
11
+
12
+ attr_reader :warnings #: Array[String]
13
+
14
+ # @rbs @markup: bool
15
+
16
+ #: () -> void
17
+ def initialize
18
+ @warnings = []
19
+ @markup = false
20
+ end
21
+
22
+ #: (String) -> Herb::AST::DocumentNode
23
+ def convert(source)
24
+ raise NotImplementedError, "#{self.class} must implement `convert`"
25
+ end
26
+
27
+ #: (String) -> Array[Herb::AST::Node]
28
+ def text_nodes(value)
29
+ @markup = true if value.match?(%r{<[\p{L}/!?]})
30
+
31
+ value.split(/(<%|%>)/).reject(&:empty?).map do |part|
32
+ case part
33
+ when "<%"
34
+ erb('"<%"', opening: "<%==")
35
+ when "%>"
36
+ erb('"%\>"', opening: "<%==")
37
+ else
38
+ text(part)
39
+ end
40
+ end
41
+ end
42
+
43
+ #: (Integer?, String) -> void
44
+ def warn(line, message)
45
+ @warnings << (line ? "line #{line}: #{message}" : message)
46
+ end
47
+
48
+ private
49
+
50
+ #: (Herb::AST::DocumentNode) -> Herb::AST::DocumentNode
51
+ def finish(document)
52
+ document = merge_texts!(document)
53
+ return document unless @markup
54
+
55
+ Herb.parse(IdentityPrinter.print(document), track_whitespace: true).value
56
+ end
57
+
58
+ #: (Integer) -> String
59
+ def newline(depth)
60
+ "\n#{INDENT * depth}"
61
+ end
62
+
63
+ #: (String) -> String?
64
+ def keyword(code)
65
+ code[/\A\s*(if|unless|case|begin|while|until|for|elsif|else|when|in|rescue|ensure)\b/, 1]
66
+ end
67
+
68
+ #: (String, String?, Array[Herb::AST::Node], Array[clause], Integer, ?line: Integer?) -> Herb::AST::Node
69
+ def control(code, keyword, body, clauses, depth, line: nil)
70
+ case keyword
71
+ when "if"
72
+ control_if(code, body, clauses)
73
+ when "unless"
74
+ control_unless(code, body, clauses)
75
+ when "case"
76
+ control_case(code, body, clauses, depth, line)
77
+ when "begin"
78
+ control_begin(code, body, clauses)
79
+ when "while"
80
+ clauses.empty? ? erb_while(code, body) : control_block(code, body, clauses)
81
+ when "until"
82
+ clauses.empty? ? erb_until(code, body) : control_block(code, body, clauses)
83
+ when "for"
84
+ clauses.empty? ? erb_for(code, body) : control_block(code, body, clauses)
85
+ else
86
+ control_block(code, body, clauses)
87
+ end
88
+ end
89
+
90
+ #: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBIfNode
91
+ def control_if(code, body, clauses)
92
+ subsequent = nil #: (Herb::AST::ERBIfNode | Herb::AST::ERBElseNode)?
93
+
94
+ clauses.reverse_each do |clause_code, clause_keyword, clause_body, clause_line|
95
+ subsequent =
96
+ case clause_keyword
97
+ when "else"
98
+ erb_else(clause_body)
99
+ when "elsif"
100
+ erb_if(clause_code, clause_body, subsequent: subsequent, end_node: nil)
101
+ else
102
+ unsupported_clause(clause_keyword, "if", clause_line)
103
+ end
104
+ end
105
+
106
+ erb_if(code, body, subsequent: subsequent)
107
+ end
108
+
109
+ #: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBUnlessNode
110
+ def control_unless(code, body, clauses)
111
+ clauses.each do |_code, clause_keyword, _body, clause_line|
112
+ unsupported_clause(clause_keyword, "unless", clause_line) unless clause_keyword == "else"
113
+ end
114
+
115
+ else_clause = clauses.find { |clause| clause[1] == "else" }
116
+
117
+ erb_unless(code, body, else_clause: else_clause && erb_else(else_clause[2]))
118
+ end
119
+
120
+ #: (String, Array[Herb::AST::Node], Array[clause], Integer, Integer?) -> Herb::AST::ERBCaseNode
121
+ def control_case(code, body, clauses, depth, line)
122
+ unless body.all? { |node| node.is_a?(Herb::AST::HTMLTextNode) && node.content.to_s.strip.empty? }
123
+ warn(line, "content between `case` and the first `when` was dropped")
124
+ end
125
+
126
+ conditions = clauses.filter_map do |clause_code, clause_keyword, clause_body, clause_line|
127
+ case clause_keyword
128
+ when "when"
129
+ erb_when(clause_code, clause_body)
130
+ when "else"
131
+ nil
132
+ else
133
+ unsupported_clause(clause_keyword, "case", clause_line)
134
+ end
135
+ end
136
+
137
+ else_clause = clauses.find { |clause| clause[1] == "else" }
138
+ node = erb_case(code, conditions, else_clause: else_clause && erb_else(else_clause[2]))
139
+ first = conditions.first&.statements&.first
140
+
141
+ node.children << text(newline(depth)) if first.is_a?(Herb::AST::HTMLTextNode) && first.content.to_s.start_with?("\n")
142
+
143
+ node
144
+ end
145
+
146
+ #: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBBeginNode
147
+ def control_begin(code, body, clauses)
148
+ else_clause = clauses.find { |clause| clause[1] == "else" }
149
+ ensure_clause = clauses.find { |clause| clause[1] == "ensure" }
150
+
151
+ rescue_clause = nil #: Herb::AST::ERBRescueNode?
152
+
153
+ clauses.select { |clause| clause[1] == "rescue" }.reverse_each do |clause_code, _keyword, clause_body, _line|
154
+ rescue_clause = erb_rescue(clause_code, clause_body, subsequent: rescue_clause)
155
+ end
156
+
157
+ erb_begin(
158
+ code, body,
159
+ rescue_clause: rescue_clause,
160
+ else_clause: else_clause && erb_else(else_clause[2]),
161
+ ensure_clause: ensure_clause && erb_ensure(ensure_clause[2])
162
+ )
163
+ end
164
+
165
+ #: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBBlockNode
166
+ def control_block(code, body, clauses)
167
+ body = body.dup
168
+
169
+ clauses.each do |clause_code, _keyword, clause_body, _line|
170
+ body << erb(clause_code, opening: "<%")
171
+ body.concat(clause_body)
172
+ end
173
+
174
+ erb_block(code, body)
175
+ end
176
+
177
+ #: (String?, String, Integer?) -> bot
178
+ def unsupported_clause(clause_keyword, keyword, line)
179
+ raise Herbst::UnsupportedError.new("`#{clause_keyword}` is not supported inside `#{keyword}`", line)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "temple"
5
+
6
+ module Herbst
7
+ module Haml
8
+ class Attributes
9
+ include Builder
10
+
11
+ # @rbs @warnings: Array[String]
12
+
13
+ #: (Array[String]) -> void
14
+ def initialize(warnings)
15
+ @warnings = warnings
16
+ end
17
+
18
+ #: (::Haml::Parser::ParseNode) -> Array[Herb::AST::Node]
19
+ def compile(node)
20
+ value = node.value
21
+ dynamic = value[:dynamic_attributes]
22
+ sources = [dynamic.new, dynamic.old].compact
23
+
24
+ if value[:object_ref] != :nil
25
+ warn(node, "object references (`[#{value[:object_ref]}]`) are not supported and were dropped")
26
+ end
27
+
28
+ hashes = sources.filter_map { |source| ::Haml::AttributeParser.parse(source) }
29
+ return fallback(node, sources) if hashes.size < sources.size || hashes.any? { |hash| hash.any? { |_key, code| code.empty? } }
30
+
31
+ values = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[[Symbol, untyped]]]
32
+ value[:attributes].each { |key, static| values[key.to_s] << [:static, static] }
33
+ hashes.each { |hash| hash.each { |key, code| values[key.to_s] << [:dynamic, code] } }
34
+
35
+ values.keys.sort.flat_map { |key| compile_attribute(key, values[key]) }
36
+ end
37
+
38
+ private
39
+
40
+ #: (String, Array[[Symbol, untyped]]) -> Array[Herb::AST::Node]
41
+ def compile_attribute(key, values)
42
+ # Literal-only Ruby (checked by Temple::StaticAnalyzer) is folded into static values.
43
+ values = values.map do |type, value|
44
+ type == :dynamic && literal?(value) ? [:static, eval(value)] : [type, value] # rubocop:disable Security/Eval
45
+ end #: Array[[Symbol, untyped]]
46
+
47
+ case key
48
+ when "class"
49
+ compile_joined(key, values, " ") do |code|
50
+ array_literal?(code) ? "#{code}.flatten.select(&:itself).join(\" \")" : code
51
+ end
52
+ when "id"
53
+ compile_joined(key, values, "_") do |code|
54
+ array_literal?(code) ? "#{code}.flatten.select(&:itself).join(\"_\")" : code
55
+ end
56
+ when "data", "aria"
57
+ compile_hash(key, values.fetch(-1))
58
+ else
59
+ compile_single(key, values.fetch(-1))
60
+ end
61
+ end
62
+
63
+ #: (String, Array[[Symbol, untyped]], String) { (String) -> String } -> Array[Herb::AST::Node]
64
+ def compile_joined(key, values, separator)
65
+ statics = values.filter_map { |type, value| value if type == :static }
66
+ statics = statics.flat_map { |value| Array(value).flatten }.select(&:itself).map(&:to_s)
67
+ statics = statics.flat_map { |value| value.split(separator) } if separator == " "
68
+ dynamics = values.filter_map { |type, code| yield(code) if type == :dynamic }
69
+
70
+ return [] if statics.empty? && dynamics.empty?
71
+
72
+ children = [] #: Array[Herb::AST::Node]
73
+ children << literal(escape(statics.uniq.join(separator))) unless statics.empty?
74
+ dynamics.each do |code|
75
+ children << literal(separator) unless children.empty?
76
+ children << erb(code)
77
+ end
78
+
79
+ [attribute(key, children)]
80
+ end
81
+
82
+ #: (String, [Symbol, untyped]) -> Array[Herb::AST::Node]
83
+ def compile_hash(prefix, pair)
84
+ type, value = pair
85
+
86
+ hash = type == :static ? value : ::Haml::AttributeParser.parse(value)
87
+
88
+ unless hash.is_a?(Hash)
89
+ return compile_single(prefix, [type, value]) unless type == :dynamic
90
+
91
+ return [erb("tag.attributes(#{prefix}: #{value})")]
92
+ end
93
+
94
+ hash.sort_by { |key, _| key.to_s }.flat_map do |key, nested|
95
+ name = "#{prefix}-#{key.to_s.tr("_", "-")}"
96
+ nested_type = type == :static || literal?(nested) ? :static : :dynamic
97
+ nested_value = type == :dynamic && nested_type == :static ? eval(nested) : nested # rubocop:disable Security/Eval
98
+
99
+ if nested_value.is_a?(Hash) || (nested_type == :dynamic && ::Haml::AttributeParser.parse(nested).is_a?(Hash))
100
+ compile_hash(name, [nested_type, nested_value])
101
+ else
102
+ compile_single(name, [nested_type, nested_value])
103
+ end
104
+ end
105
+ end
106
+
107
+ #: (String, [Symbol, untyped]) -> Array[Herb::AST::Node]
108
+ def compile_single(key, pair)
109
+ type, value = pair
110
+
111
+ if type == :static
112
+ case value
113
+ when true
114
+ [attribute(key)]
115
+ when false, nil
116
+ []
117
+ else
118
+ [attribute(key, [literal(escape(value.to_s))])]
119
+ end
120
+ elsif boolean?(key)
121
+ [erb("#{key.inspect} if #{value}")]
122
+ else
123
+ [attribute(key, [erb(value)])]
124
+ end
125
+ end
126
+
127
+ #: (::Haml::Parser::ParseNode, Array[String]) -> Array[Herb::AST::Node]
128
+ def fallback(node, sources)
129
+ arguments = sources.map { |source| source.strip.delete_prefix("{").delete_suffix("}").strip.delete_suffix(",") }
130
+ warn(node, "dynamic attributes `#{sources.join(" ")}` were converted to `tag.attributes`, which requires Rails 7.1+")
131
+
132
+ node.value[:attributes].sort.flat_map { |key, value| compile_single(key, [:static, value]) } + [erb("tag.attributes(#{arguments.join(", ")})")]
133
+ end
134
+
135
+ #: (String) -> bool
136
+ def literal?(code)
137
+ Temple::StaticAnalyzer.static?(code)
138
+ end
139
+
140
+ #: (String) -> bool
141
+ def array_literal?(code)
142
+ code.strip.start_with?("[")
143
+ end
144
+
145
+ #: (String) -> bool
146
+ def boolean?(key)
147
+ ::Haml::BOOLEAN_ATTRIBUTES.include?(key)
148
+ end
149
+
150
+ #: (String) -> String
151
+ def escape(value)
152
+ CGI.escapeHTML(value).gsub("<%", "&lt;%")
153
+ end
154
+
155
+ #: (::Haml::Parser::ParseNode, String) -> void
156
+ def warn(node, message)
157
+ @warnings << "line #{node.line}: #{message}"
158
+ end
159
+ end
160
+ end
161
+ end