erbf 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fa38a8ddf489f16a90c48c63039d9509210f13764d1cbe7590f8b094de30d9c8
4
+ data.tar.gz: ec92348e114992ffef85120d0467cb53df0cd55fe7b55eff8e2fd9446e37abee
5
+ SHA512:
6
+ metadata.gz: c14b1941af80e13905fea704dace9d92f633370ba5d18e8c3ab5a2159b8139120681aa9bc239018d7bb1b327c9cb2b686b367ac8716012963f41cdad79dadc6d
7
+ data.tar.gz: 061c828ee94a80c20ff91a2944924a4e73bb884490c1653f21a58727f7bb5bc0e08192c364b9fbbad324534d09e29a3e80b97a850f953a400ccbac7569c4f823
data/.rubocop.yml ADDED
@@ -0,0 +1,40 @@
1
+ inherit_gem:
2
+ syntax_tree: config/rubocop.yml
3
+
4
+ AllCops:
5
+ DisplayCopNames: true
6
+ DisplayStyleGuide: true
7
+ NewCops: enable
8
+ SuggestExtensions: false
9
+ TargetRubyVersion: 3.0
10
+
11
+ Metrics:
12
+ Enabled: false
13
+
14
+ Style/Documentation:
15
+ Enabled: false
16
+
17
+ Naming/MethodName:
18
+ Enabled: false
19
+
20
+ Naming/MethodParameterName:
21
+ Enabled: false
22
+
23
+ Layout/LineLength:
24
+ Enabled: false
25
+
26
+ Style/GuardClause:
27
+ Enabled: false
28
+
29
+ Style/IfUnlessModifier:
30
+ Enabled: false
31
+
32
+ Style/DocumentDynamicEvalDefinition:
33
+ Exclude:
34
+ - test/fixtures/**/*.rb
35
+
36
+ Style/FormatString:
37
+ Enabled: false
38
+
39
+ Style/ClassAndModuleChildren:
40
+ EnforcedStyle: compact
data/.streerc ADDED
@@ -0,0 +1 @@
1
+ --print-width=100
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Tomasz Szczęśniak-Szlagowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # erbf
2
+
3
+ A formatter for your `.html.erb` files.
4
+
5
+ > [!CAUTION]
6
+ > erbf is still in alpha stages of development. Use at your own risk.
7
+
8
+ ## Features
9
+
10
+ #### Style similar to Prettier's
11
+
12
+ Many teams use prettier and it'd be strange if `.html.erb` files were formatted
13
+ differently than `.html` files in the same project.
14
+
15
+ If some HTML (without ERB tags) gets formatted differently than Prettier with
16
+ default settings would have done it, that's a bug.
17
+
18
+ Support for `--html-whitespace-sensitivity` may be added in the future.
19
+
20
+ #### Formats Ruby code
21
+
22
+ SyntaxTree will be used if it's available. If you don't want your Ruby code
23
+ reformatted, you can disable it in the config file.
24
+
25
+ #### Formats other embedded languages
26
+
27
+ The code within `<script>` and `<style>` tags will be formatted if `prettier`
28
+ is installed under the `node_modules` directory.
29
+
30
+ You can also specify any other formatter that has a CLI which can format STDIN.
31
+
32
+ #### CLI
33
+
34
+ Format/check/write specified files (or STDIN)
35
+
36
+ #### Planned
37
+
38
+ - Some kind of "no-format" comment support
39
+ - Support for any other Ruby formatter
40
+ - Ruby LSP Plugin
41
+ - SyntaxTree Plugin
42
+ - Rake tasks
43
+ - Website with examples of: formatting, configuration, integration with other tools and IDEs
44
+
45
+ ## Installation
46
+
47
+ Install the gem, either as your project's dependency or globally:
48
+
49
+ ```sh
50
+ bundle add erbf --group "development, test"
51
+ # or
52
+ gem install erbf
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```sh
58
+ # format all *.html.erb files in a directory
59
+ erbf directory
60
+
61
+ # check if all files are formatted
62
+ erbf -c directory
63
+
64
+ # auto-format all files
65
+ erbf -w directory
66
+
67
+ # auto-format files with a different extension
68
+ erbf 'directory/**/*.erb'
69
+
70
+ # format stdin
71
+ erbf < file.erb
72
+ ```
73
+
74
+ You can configure it via a config file in your repo:
75
+
76
+ ```yaml
77
+ # .erbf.yml or config/erbf.yml
78
+ line_length: 80
79
+ ruby:
80
+ formatter: syntax_tree
81
+ syntax_tree_plugins:
82
+ - plugin/single_quotes
83
+ embedded:
84
+ - types:
85
+ - text/javascript
86
+ - module
87
+ command: prettier --stdin-filepath file.js --print-width %<line_length>d
88
+ - types:
89
+ - text/css
90
+ command: prettier --stdin-filepath file.css --print-width %<line_length>d
91
+ ```
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ # Install dependencies
97
+ bin/setup
98
+
99
+ # Run the linter, formatter and tests
100
+ bundle exec rake
101
+
102
+ # Run a REPL
103
+ bin/console
104
+
105
+ # Install the gem locally
106
+ bundle exec rake install
107
+
108
+ # Release a new version (after updating version.rb)
109
+ bundle exec rake release
110
+ ```
111
+
112
+ ## Contributing
113
+
114
+ Bug reports and pull requests are welcome on GitHub at https://github.com/spect88/erbf.
115
+
116
+ ## License
117
+
118
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/erbf ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/erbf"
5
+ require_relative "../lib/erbf/cli"
6
+
7
+ exit(Erbf::CLI.call(ARGV, $stdin))
data/lib/erbf/cli.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ class Erbf::CLI
6
+ class Error < StandardError
7
+ end
8
+
9
+ def self.call(...)
10
+ new.call(...)
11
+ end
12
+
13
+ def initialize
14
+ @command = :format
15
+ @files = []
16
+ @input = nil
17
+ @config_path = nil
18
+ end
19
+
20
+ def parse!(argv, stdin)
21
+ argv_left = argv.dup
22
+
23
+ begin
24
+ options.parse!(argv_left)
25
+ rescue OptionParser::MissingArgument, OptionParser::InvalidOption => e
26
+ raise Error, e.message
27
+ end
28
+
29
+ if @command == :format
30
+ @input = stdin.read if (argv_left.empty? && !stdin.tty?) || argv_left.include?("-")
31
+ argv_left -= ["-"]
32
+ end
33
+
34
+ @files =
35
+ argv_left
36
+ .flat_map do |path|
37
+ paths = Dir[path]
38
+ raise Error, "invalid file/directory/glob: #{path}" if paths.empty?
39
+
40
+ dirs, files = paths.partition { |p| File.directory?(p) }
41
+ files + dirs.flat_map { |d| Dir["#{d}/**/*.html.erb"] }
42
+ end
43
+ .uniq
44
+
45
+ if @input.nil? && @files.empty? && %i[format check write].include?(@command)
46
+ raise Error, "no file/directory/glob specified"
47
+ end
48
+ end
49
+
50
+ def execute(stdout = $stdout, stderr = $stderr)
51
+ erbf = Erbf.new(config_file: @config_path)
52
+ case @command
53
+ when :help
54
+ stdout.puts options
55
+ true
56
+ when :version
57
+ stdout.puts Erbf::VERSION
58
+ true
59
+ when :format
60
+ @files.each do |path|
61
+ content = File.read(path)
62
+ stdout.puts erbf.format_code(content)
63
+ end
64
+ stdout.puts erbf.format_code(@input) if @input
65
+ true
66
+ when :check
67
+ success = true
68
+ @files.each do |path|
69
+ original = File.read(path)
70
+ formatted = erbf.format_code(original)
71
+ formatted = "#{formatted}\n"
72
+ next if original == formatted
73
+
74
+ stderr.puts "#{path} needs to be formatted"
75
+ success = false
76
+ end
77
+ success
78
+ when :write
79
+ @files.each do |path|
80
+ original = File.read(path)
81
+ formatted = erbf.format_code(original)
82
+ formatted = "#{formatted}\n"
83
+ next if original == formatted
84
+
85
+ File.write(path, formatted)
86
+ stdout.puts "Formatted: #{path}"
87
+ end
88
+ true
89
+ end
90
+ end
91
+
92
+ def call(argv, stdin, stdout = $stdout, stderr = $stderr)
93
+ parse!(argv, stdin)
94
+ execute(stdout, stderr) ? 0 : 1
95
+ rescue Error => e
96
+ stderr.puts "#{e.message}\n\n"
97
+ stderr.puts options
98
+ 1
99
+ end
100
+
101
+ def options
102
+ @options ||=
103
+ OptionParser.new do |opts|
104
+ opts.banner = <<~USAGE
105
+ Usage: erbf [options] [files/directories/glob]
106
+
107
+ By default the output is written to stdout
108
+
109
+ Output options:
110
+
111
+ USAGE
112
+ opts.on("-c", "--check", "Check if the files are formatted") do
113
+ raise Error, "incompatible options: --#{@command} and --check" if @command != :format
114
+ @command = :check
115
+ end
116
+ opts.on("-w", "--write", "Format the files in-place") do
117
+ raise Error, "incompatible options: --#{@command} and --write" if @command != :format
118
+ @command = :write
119
+ end
120
+ opts.separator <<~USAGE
121
+
122
+ Other options:
123
+
124
+ USAGE
125
+ opts.on("--config PATH", String, "Use a config file at a different location") do |path|
126
+ @config_path = path
127
+ end
128
+ opts.on("-h", "--help", "Show this help") do
129
+ raise Error, "incompatible options: --#{@command} and --help" if @command != :format
130
+ @command = :help
131
+ end
132
+ opts.on("-v", "--version", "Show erbf version") do
133
+ raise Error, "incompatible options: --#{@command} and --version" if @command != :format
134
+ @command = :version
135
+ end
136
+ opts.separator ""
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "yaml"
5
+
6
+ class Erbf::Config
7
+ DEFAULT_FILEPATHS = %w[config/erbf.yml .erbf.yml].freeze
8
+
9
+ attr_reader :line_length, :logger, :embedded, :ruby
10
+
11
+ def initialize(line_length:, logger:, debug:, embedded:, ruby:)
12
+ @line_length = line_length
13
+ @logger = logger
14
+ @debug = debug
15
+ @embedded = embedded
16
+ @ruby = ruby
17
+ end
18
+
19
+ def debug? = @debug
20
+
21
+ class << self
22
+ def load(config_file: nil, **options)
23
+ opts =
24
+ default_options.merge(
25
+ config_file.nil? ? options_from_default_file : options_from_file(config_file),
26
+ options
27
+ )
28
+ new(**opts)
29
+ end
30
+
31
+ def default_embedded
32
+ prettier = "node_modules/.bin/prettier"
33
+ if File.exist?(prettier)
34
+ [
35
+ {
36
+ types: %w[text/javascript module],
37
+ command: "#{prettier} --stdin-filepath file.js --print-width %<line_length>d"
38
+ },
39
+ {
40
+ types: ["text/css"],
41
+ command: "#{prettier} --stdin-filepath file.css --print-width %<line_length>d"
42
+ },
43
+ {
44
+ types: %w[importmap application/json application/ld+json],
45
+ command: "#{prettier} --stdin-filepath file.json --print-width %<line_length>d"
46
+ }
47
+ ]
48
+ else
49
+ []
50
+ end
51
+ end
52
+
53
+ def default_ruby
54
+ { formatter: "syntax_tree", syntax_tree_plugins: [] }
55
+ end
56
+
57
+ def default_options
58
+ {
59
+ line_length: 80,
60
+ logger: Logger.new($stderr, level: Logger::WARN),
61
+ debug: false,
62
+ embedded: default_embedded,
63
+ ruby: default_ruby
64
+ }
65
+ end
66
+
67
+ def options_from_file(path)
68
+ yaml = YAML.safe_load_file(path, symbolize_names: true)
69
+ { line_length: yaml[:line_length], embedded: yaml[:embedded], ruby: yaml[:ruby] }.compact
70
+ end
71
+
72
+ def options_from_default_file
73
+ DEFAULT_FILEPATHS.each do |path|
74
+ next unless File.exist?(path)
75
+
76
+ return options_from_file(path)
77
+ end
78
+ {}
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ class Erbf::Formatter::EmbeddedLanguageFormatter
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def supported?(type)
11
+ !find(type).nil?
12
+ end
13
+
14
+ def format(type, content, line_length)
15
+ formatter = find(type)
16
+ return content.chomp if formatter.nil?
17
+
18
+ command = sprintf(formatter[:command], line_length: line_length)
19
+
20
+ logger.debug(self.class.to_s) { "Formatting #{type}: #{command}" }
21
+ stdout, stderr, status = Open3.capture3({}, command, { stdin_data: content })
22
+
23
+ if status.exitstatus != 0
24
+ logger.error(self.class.to_s) { "[#{command}] exit status: #{status.exitstatus}" }
25
+ logger.warn(self.class.to_s) { "[#{command}] stderr output:\n#{stderr}" } unless stderr.empty?
26
+ logger.debug(self.class.to_s) { "[#{command}] input was:\n#{content}" }
27
+ end
28
+
29
+ status.exitstatus.zero? ? stdout.chomp : content.chomp
30
+ end
31
+
32
+ private
33
+
34
+ def find(type)
35
+ @config.embedded.find { |e| e[:types].include?(type) }
36
+ end
37
+
38
+ def logger
39
+ @config.logger
40
+ end
41
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Erbf::Formatter::HtmlHelper
4
+ CASE_INSENSITIVE_ATTRIBUTES = %w[id class].to_set.freeze
5
+ INLINE_TAGS = %w[
6
+ a
7
+ abbr
8
+ acronym
9
+ b
10
+ bdo
11
+ big
12
+ br
13
+ button
14
+ cite
15
+ code
16
+ dfn
17
+ em
18
+ i
19
+ img
20
+ input
21
+ kbd
22
+ label
23
+ map
24
+ object
25
+ output
26
+ q
27
+ samp
28
+ select
29
+ small
30
+ span
31
+ strong
32
+ sub
33
+ sup
34
+ textarea
35
+ time
36
+ tt
37
+ var
38
+ ].to_set.freeze
39
+
40
+ private
41
+
42
+ def inline?(node)
43
+ node.is_a?(Herb::AST::HTMLTextNode) || inline_tag?(node)
44
+ end
45
+
46
+ def inline_tag?(node)
47
+ node.is_a?(Herb::AST::HTMLElementNode) &&
48
+ INLINE_TAGS.include?(node.open_tag.tag_name.value.downcase)
49
+ end
50
+
51
+ def pre_tag?(node)
52
+ node.is_a?(Herb::AST::HTMLElementNode) && node.open_tag.tag_name.value.downcase == "pre"
53
+ end
54
+
55
+ def tag_attribute(node, name)
56
+ attribute =
57
+ node.open_tag.children.find do |child|
58
+ child.is_a?(Herb::AST::HTMLAttributeNode) && child.name.name.value.downcase == name.downcase
59
+ end
60
+ return nil if attribute.nil?
61
+
62
+ value_children = attribute.value.children
63
+
64
+ if value_children.size == 1 && value_children.first.is_a?(Herb::AST::LiteralNode)
65
+ value_children.first.content
66
+ else
67
+ :dynamic
68
+ end
69
+ end
70
+
71
+ def case_insensitive_attribute_name?(node)
72
+ CASE_INSENSITIVE_ATTRIBUTES.include?(node.name.value.downcase)
73
+ end
74
+
75
+ def br_tag?(node)
76
+ node.is_a?(Herb::AST::HTMLElementNode) && node.open_tag.tag_name.value.downcase == "br"
77
+ end
78
+
79
+ def starts_with_whitespace?(node)
80
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\A\s/
81
+ end
82
+
83
+ def ends_with_whitespace?(node)
84
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\s\z/
85
+ end
86
+
87
+ def ends_with_double_newline?(node)
88
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\n\s*\n\z/
89
+ end
90
+
91
+ def ends_with_newline?(node)
92
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\n\z/
93
+ end
94
+
95
+ def begins_with_double_newline?(node)
96
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\A\n\s*\n/
97
+ end
98
+
99
+ def begins_with_newline?(node)
100
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\A\n/
101
+ end
102
+
103
+ def blank_node?(node)
104
+ node.is_a?(Herb::AST::HTMLTextNode) && node.content =~ /\A\s+\z/
105
+ end
106
+
107
+ def text_node?(node)
108
+ node.is_a?(Herb::AST::HTMLTextNode)
109
+ end
110
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Erbf::Formatter::PrettierPrintHelper
4
+ private
5
+
6
+ def current_indent_level
7
+ queue = [[0, q.groups.first]]
8
+ while (indent, node = queue.shift)
9
+ next_indent = indent
10
+
11
+ case node
12
+ when PrettierPrint::Indent
13
+ next_indent += 2
14
+ when PrettierPrint::Align
15
+ next_indent += node.indent
16
+ end
17
+
18
+ case node
19
+ when PrettierPrint::Indent, PrettierPrint::Align, PrettierPrint::Group,
20
+ PrettierPrint::LineSuffix
21
+ return indent if node.contents.equal?(q.target)
22
+
23
+ queue += node.contents.map { |child| [next_indent, child] }
24
+ when PrettierPrint::IfBreak
25
+ return indent if node.flat_contents.equal?(q.target) || node.break_contents.equal?(q.target)
26
+
27
+ queue += (node.flat_contents + node.break_contents).map { |child| [next_indent, child] }
28
+ end
29
+ end
30
+
31
+ nil
32
+ end
33
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Erbf::Formatter::RubyFormatter
4
+ def initialize(config)
5
+ @config = config
6
+ @loaded = false
7
+ end
8
+
9
+ def format(code, line_length)
10
+ formatter.call(code, line_length)
11
+ end
12
+
13
+ def format_incomplete(code, line_length)
14
+ code = code.strip
15
+ keyword = code[/\A[a-z]+/]
16
+ case keyword
17
+ when "if", "unless", "while", "until", "for", "begin"
18
+ format([code, "end"].join("\n"), line_length).delete_suffix("end").rstrip
19
+ when "elsif"
20
+ format(["if a", code, "end"].join("\n"), line_length)
21
+ .delete_prefix("if a")
22
+ .delete_suffix("end")
23
+ .strip
24
+ when "case"
25
+ format([code, "when true", "end"].join("\n"), line_length)
26
+ .delete_suffix("end")
27
+ .rstrip
28
+ .delete_suffix("when true")
29
+ .rstrip
30
+ when "when", "in"
31
+ format(["case a", code, "end"].join("\n"), line_length)
32
+ .delete_prefix("case a")
33
+ .delete_suffix("end")
34
+ .strip
35
+ when "rescue", "ensure"
36
+ format(["begin", code, "end"].join("\n"), line_length)
37
+ .delete_prefix("begin")
38
+ .delete_suffix("end")
39
+ .strip
40
+ else
41
+ if code =~ /(do|{)\s*(\|[\s,()\w-]+\|)?\z/
42
+ if Regexp.last_match(1) == "{"
43
+ format([code, "}"].join("\n"), line_length).delete_suffix("}").rstrip
44
+ else
45
+ # Formatter may convert short `do end` into `{}`, so let's add content
46
+ format([code, "a" * line_length, "end"].join("\n"), line_length)
47
+ .delete_suffix("end")
48
+ .rstrip
49
+ .delete_suffix("a" * line_length)
50
+ .rstrip
51
+ end
52
+ else
53
+ @config.logger.warn(self.class.to_s) { "Can't handle incomplete ruby: #{code}" }
54
+ code
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def formatter
62
+ @formatter ||=
63
+ case @config.ruby[:formatter]
64
+ when nil
65
+ @config.logger.debug(self.class.to_s) { "Using null formatter" }
66
+ method(:null_format)
67
+ when "syntax_tree"
68
+ begin
69
+ require "syntax_tree"
70
+ (@config.ruby[:syntax_tree_plugins] || []).each do |plugin|
71
+ require "syntax_tree/#{plugin}"
72
+ end
73
+ @config.logger.debug(self.class.to_s) { "Using syntax_tree formatter" }
74
+ method(:syntax_tree_format)
75
+ rescue LoadError => e
76
+ @config.logger.error(self.class.to_s) { e.to_s }
77
+ method(:null_format)
78
+ end
79
+ else
80
+ @config
81
+ .logger
82
+ .error(self.class.to_s) { "Unsupported Ruby formatter: #{@config.ruby[:formatter]}" }
83
+ method(:null_format)
84
+ end
85
+ end
86
+
87
+ def null_format(code, _line_length)
88
+ code.strip
89
+ end
90
+
91
+ def syntax_tree_format(code, line_length)
92
+ SyntaxTree.format(code, line_length).chomp
93
+ end
94
+ end
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Erbf::Formatter
4
+ autoload :HtmlHelper, "erbf/formatter/html_helper"
5
+ autoload :PrettierPrintHelper, "erbf/formatter/prettier_print_helper"
6
+ autoload :EmbeddedLanguageFormatter, "erbf/formatter/embedded_language_formatter"
7
+ autoload :RubyFormatter, "erbf/formatter/ruby_formatter"
8
+
9
+ include HtmlHelper
10
+ include PrettierPrintHelper
11
+
12
+ def initialize(q, config)
13
+ @q = q
14
+ @context = []
15
+ @config = config
16
+ @embedded_language = EmbeddedLanguageFormatter.new(config)
17
+ @ruby = RubyFormatter.new(config)
18
+ end
19
+
20
+ def visit(node)
21
+ debug { "visiting #{node.class}" }
22
+ case node
23
+ when Herb::AST::DocumentNode
24
+ visit_document(node)
25
+ when Herb::AST::WhitespaceNode
26
+ visit_whitespace(node)
27
+ when Herb::AST::LiteralNode
28
+ visit_literal(node)
29
+ when Herb::AST::HTMLDoctypeNode
30
+ visit_html_doctype(node)
31
+ when Herb::AST::HTMLCommentNode
32
+ visit_html_comment(node)
33
+ when Herb::AST::HTMLElementNode
34
+ visit_html_element(node)
35
+ when Herb::AST::HTMLOpenTagNode
36
+ visit_html_open_tag(node)
37
+ when Herb::AST::HTMLCloseTagNode
38
+ visit_html_close_tag(node)
39
+ when Herb::AST::HTMLSelfCloseTagNode
40
+ visit_html_self_close_tag(node)
41
+ when Herb::AST::HTMLAttributeNode
42
+ visit_html_attribute(node)
43
+ when Herb::AST::HTMLAttributeNameNode
44
+ visit_html_attribute_name(node)
45
+ when Herb::AST::HTMLAttributeValueNode
46
+ visit_html_attribute_value(node)
47
+ when Herb::AST::HTMLTextNode
48
+ visit_html_text(node)
49
+ when Herb::AST::ERBIfNode
50
+ visit_erb_if(node)
51
+ when Herb::AST::ERBUnlessNode
52
+ visit_erb_unless(node)
53
+ when Herb::AST::ERBElseNode
54
+ visit_erb_else(node)
55
+ when Herb::AST::ERBCaseNode
56
+ visit_erb_case(node)
57
+ when Herb::AST::ERBWhenNode
58
+ visit_erb_when(node)
59
+ when Herb::AST::ERBWhileNode
60
+ visit_erb_while(node)
61
+ when Herb::AST::ERBUntilNode
62
+ visit_erb_until(node)
63
+ when Herb::AST::ERBBlockNode
64
+ visit_erb_block(node)
65
+ when Herb::AST::ERBForNode
66
+ visit_erb_for(node)
67
+ when Herb::AST::ERBRescueNode
68
+ visit_erb_rescue(node)
69
+ when Herb::AST::ERBBeginNode
70
+ visit_erb_begin(node)
71
+ when Herb::AST::ERBEnsureNode
72
+ visit_erb_ensure(node)
73
+ when Herb::AST::ERBContentNode
74
+ visit_erb_content(node)
75
+ when Herb::AST::ERBEndNode
76
+ visit_erb_end(node)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :q
83
+
84
+ def visit_document(node)
85
+ visit_elements(node.children)
86
+ end
87
+
88
+ def visit_html_doctype(node)
89
+ q.text(node.tag_opening.value.downcase)
90
+ node.children.each(&method(:visit))
91
+ q.text(node.tag_closing.value)
92
+ end
93
+
94
+ def visit_html_element(node)
95
+ if node.is_void
96
+ debug { "<#{node.open_tag.tag_name.value}> void" }
97
+ q.group do
98
+ visit(node.open_tag)
99
+ q.breakable(" ")
100
+ q.text("/>")
101
+ end
102
+ elsif inline_tag?(node)
103
+ debug { "<#{node.open_tag.tag_name.value}> inline" }
104
+ q.group do
105
+ visit(node.open_tag)
106
+ q.indent do
107
+ q.breakable("")
108
+ q.text(">")
109
+ visit_elements(node.body)
110
+ visit(node.close_tag) if node.close_tag
111
+ end
112
+ if node.close_tag
113
+ q.breakable("")
114
+ q.text(">")
115
+ end
116
+ end
117
+ else
118
+ debug { "<#{node.open_tag.tag_name.value}> block" }
119
+ q.group do
120
+ q.group do
121
+ visit(node.open_tag)
122
+ q.breakable("") if node.body.any?
123
+ q.text(">")
124
+ end
125
+ if node.body.any?
126
+ if pre_tag?(node)
127
+ debug { "pre" }
128
+ q.breakable("", indent: false)
129
+ with_context(:preserve_whitespace) { node.body.each { |child| visit(child) } }
130
+ elsif (language = embedded_language(node))
131
+ debug { "embedded language: #{language}" }
132
+ if @embedded_language.supported?(language) && node.body.size == 1 &&
133
+ text_node?(node.body.first)
134
+ debug { "formatting" }
135
+ q.indent do
136
+ q.breakable("")
137
+ lines =
138
+ @embedded_language.format(
139
+ language,
140
+ node.body.first.content,
141
+ [@config.line_length - current_indent_level, 1].max
142
+ ).lines
143
+ print_formatted_lines(lines)
144
+ end
145
+ else
146
+ debug { "unsupported or more than 1 child node" }
147
+ q.indent do
148
+ q.breakable("")
149
+ with_context(:preserve_whitespace) { node.body.each { |child| visit(child) } }
150
+ end
151
+ end
152
+ else
153
+ debug { "normal block" }
154
+ q.indent do
155
+ q.breakable("")
156
+ visit_elements(node.body)
157
+ end
158
+ end
159
+ end
160
+ if node.close_tag
161
+ q.breakable("") unless pre_tag?(node)
162
+ visit(node.close_tag)
163
+ q.text(">")
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ def visit_html_open_tag(node)
170
+ q.group do
171
+ q.text("<")
172
+ q.text(node.tag_name.value.downcase)
173
+ if node.children.any?
174
+ q.indent do
175
+ q.breakable(" ")
176
+ q.seplist(node.children, -> { q.breakable(" ") }) { |child| visit(child) }
177
+ end
178
+ end
179
+ end
180
+ # The > is handled in #visit_html_element
181
+ end
182
+
183
+ def visit_html_close_tag(node)
184
+ q.text("</")
185
+ q.text(node.tag_name.value.downcase)
186
+ # The > is handled in #visit_html_element
187
+ end
188
+
189
+ def visit_html_attribute(node)
190
+ visit(node.name)
191
+ q.text("=") if node.value
192
+ visit(node.value)
193
+ end
194
+
195
+ def visit_html_attribute_name(node)
196
+ q.text(case_insensitive_attribute_name?(node) ? node.name.value.downcase : node.name.value)
197
+ end
198
+
199
+ def visit_html_attribute_value(node)
200
+ if node.children.size == 1 && node.children.first.is_a?(Herb::AST::LiteralNode)
201
+ # The value is a literal, so we can process it to find the optimal way to quote it
202
+ value = node.children.first.content
203
+ quotes_count = value.scan(/"|&quot;|&#34;/i).count
204
+ apostrophes_count = value.scan(/'|&apos;|&#39;/i).count
205
+ if quotes_count > apostrophes_count
206
+ q.text("'")
207
+ q.text(value.gsub(/"|&quot;|&#34/i, '"').gsub(/'|&apos;|&#39;/i, "&apos;"))
208
+ q.text("'")
209
+ else
210
+ q.text('"')
211
+ q.text(value.gsub(/"|&quot;|&#34/i, "&quot;").gsub(/'|&apos;|&#39;/i, "'"))
212
+ q.text('"')
213
+ end
214
+ else
215
+ q.text(node.open_quote.value) if node.quoted
216
+ node.children.each(&method(:visit))
217
+ q.text(node.close_quote.value) if node.quoted
218
+ end
219
+ end
220
+
221
+ def visit_literal(node)
222
+ q.text(node.content)
223
+ end
224
+
225
+ def visit_html_text(node)
226
+ if context?(:preserve_whitespace)
227
+ q.group { q.text(node.content) }
228
+ return
229
+ end
230
+
231
+ content = node.content.gsub(/\s+/, " ").strip
232
+
233
+ q.group do
234
+ content.split.each.with_index do |word, index|
235
+ q.fill_breakable(" ") if index.positive?
236
+ q.text(word)
237
+ end
238
+ end
239
+ end
240
+
241
+ def visit_html_comment(node)
242
+ q.text("<!--")
243
+ node.children.each(&method(:visit))
244
+ q.text("-->")
245
+ end
246
+
247
+ def visit_erb_content(node)
248
+ q.text(node.tag_opening.value)
249
+
250
+ # Don't format comments
251
+ if node.tag_opening.value == "<%#"
252
+ q.text(node.content.value)
253
+ q.text(node.tag_closing.value)
254
+ return
255
+ end
256
+
257
+ q.indent do
258
+ q.breakable(" ")
259
+
260
+ lines = format_ruby(node.content.value)
261
+ print_formatted_lines(lines)
262
+ end
263
+
264
+ q.breakable(" ")
265
+ q.text(node.tag_closing.value)
266
+ end
267
+
268
+ def visit_erb_if(node)
269
+ visit_erb_keyword(node) do
270
+ if node.subsequent
271
+ q.breakable("")
272
+ visit(node.subsequent)
273
+ end
274
+ end
275
+ end
276
+
277
+ def visit_erb_unless(node)
278
+ visit_erb_keyword(node) do
279
+ if node.else_clause
280
+ q.breakable("")
281
+ visit(node.else_clause)
282
+ end
283
+ end
284
+ end
285
+
286
+ def visit_erb_case(node)
287
+ visit_erb_keyword(node, can_have_statements: false) do
288
+ # "children" are the thing between "case condition" and "when value"
289
+ # Valid values are basically whitespace and ERB comments
290
+ if node.children.any?
291
+ q.breakable("")
292
+ visit_elements(node.children)
293
+ end
294
+
295
+ if node.conditions.any?
296
+ q.breakable("")
297
+ visit_elements(node.conditions)
298
+ end
299
+
300
+ if node.else_clause
301
+ q.breakable("")
302
+ visit(node.else_clause)
303
+ end
304
+ end
305
+ end
306
+
307
+ def visit_erb_when(node)
308
+ visit_erb_keyword(node, can_have_end: false)
309
+ end
310
+
311
+ def visit_erb_else(node)
312
+ q.group do
313
+ q.text(node.tag_opening.value)
314
+ q.breakable(" ")
315
+ q.text(node.content.value.strip)
316
+ q.breakable(" ")
317
+ q.text(node.tag_closing.value)
318
+ end
319
+
320
+ if node.statements.any?
321
+ q.indent do
322
+ q.breakable("")
323
+ visit_elements(node.statements)
324
+ end
325
+ end
326
+ end
327
+
328
+ def visit_erb_while(node)
329
+ visit_erb_keyword(node)
330
+ end
331
+
332
+ def visit_erb_until(node)
333
+ visit_erb_keyword(node)
334
+ end
335
+
336
+ def visit_erb_for(node)
337
+ visit_erb_keyword(node)
338
+ end
339
+
340
+ def visit_erb_begin(node)
341
+ visit_erb_keyword(node) do
342
+ if node.rescue_clause
343
+ q.breakable("")
344
+ visit(node.rescue_clause)
345
+ end
346
+
347
+ if node.else_clause
348
+ q.breakable("")
349
+ visit(node.else_clause)
350
+ end
351
+
352
+ if node.ensure_clause
353
+ q.breakable("")
354
+ visit(node.ensure_clause)
355
+ end
356
+ end
357
+ end
358
+
359
+ def visit_erb_rescue(node)
360
+ visit_erb_keyword(node, can_have_end: false) do
361
+ if node.subsequent
362
+ q.breakable("")
363
+ visit(node.subsequent)
364
+ end
365
+ end
366
+ end
367
+
368
+ def visit_erb_ensure(node)
369
+ visit_erb_keyword(node, can_have_end: false)
370
+ end
371
+
372
+ def visit_erb_block(node)
373
+ visit_erb_keyword(node, can_have_statements: false) do
374
+ if node.body.any?
375
+ q.indent do
376
+ q.breakable("")
377
+ visit_elements(node.body)
378
+ end
379
+ end
380
+ end
381
+ end
382
+
383
+ def visit_erb_end(node)
384
+ q.group do
385
+ q.text(node.tag_opening.value)
386
+ q.breakable(" ")
387
+ q.text(node.content.value.strip)
388
+ q.breakable(" ")
389
+ q.text(node.tag_closing.value)
390
+ end
391
+ end
392
+
393
+ def visit_erb_keyword(node, can_have_statements: true, can_have_end: true, &block)
394
+ q.group do
395
+ q.text(node.tag_opening.value)
396
+
397
+ q.indent do
398
+ q.breakable(" ")
399
+
400
+ lines = format_incomplete_ruby(node.content.value)
401
+ print_formatted_lines(lines)
402
+ end
403
+
404
+ q.breakable(" ")
405
+ q.text(node.tag_closing.value)
406
+ end
407
+
408
+ if can_have_statements && node.statements.any?
409
+ q.indent do
410
+ q.breakable("")
411
+ visit_elements(node.statements)
412
+ end
413
+ end
414
+
415
+ block.call if block_given?
416
+
417
+ if can_have_end && node.end_node
418
+ q.breakable("")
419
+ visit(node.end_node)
420
+ end
421
+ end
422
+
423
+ def visit_elements(children)
424
+ if children.size == 1
425
+ visit(children.first)
426
+ return
427
+ end
428
+
429
+ groups =
430
+ children
431
+ .slice_when do |prev_child, next_child|
432
+ ends_with_double_newline?(prev_child) || begins_with_double_newline?(next_child)
433
+ end
434
+ .map do |group|
435
+ group.drop_while(&method(:blank_node?)).reverse.drop_while(&method(:blank_node?)).reverse
436
+ end
437
+ .reject(&:empty?)
438
+
439
+ break_next = false
440
+
441
+ (groups + [nil]).each_cons(2) do |group, next_group|
442
+ q.group do
443
+ (group + [nil]).each_cons(2) do |child, next_child|
444
+ next if blank_node?(child)
445
+
446
+ if break_next
447
+ q.group do
448
+ q.breakable(" ")
449
+ visit(child)
450
+ end
451
+ break_next = false
452
+ else
453
+ visit(child)
454
+ end
455
+
456
+ next if next_child.nil?
457
+
458
+ if br_tag?(child) && starts_with_whitespace?(next_child)
459
+ debug { "breakable(force) after <br> tag" }
460
+ q.breakable(force: true)
461
+ elsif inline?(child) && inline?(next_child)
462
+ if starts_with_whitespace?(next_child)
463
+ debug { "adding a break before whitespace" }
464
+ q.with_target(q.target.last.contents) { q.breakable(" ") }
465
+ elsif ends_with_whitespace?(child)
466
+ debug { "will add a break after whitespace" }
467
+ break_next = true
468
+ else
469
+ debug { "fill_breakable('') between inline/text without separating whitespace" }
470
+ q.fill_breakable("")
471
+ end
472
+ else
473
+ q.breakable(force: true)
474
+ end
475
+ end
476
+ end
477
+ next if next_group.nil?
478
+
479
+ q.breakable(force: true)
480
+ q.breakable(force: true)
481
+ end
482
+ end
483
+
484
+ def with_context(context)
485
+ @context.push(context)
486
+ yield
487
+ ensure
488
+ @context.pop
489
+ end
490
+
491
+ def context?(value)
492
+ @context.last == value
493
+ end
494
+
495
+ def embedded_language(node)
496
+ return nil unless node.is_a?(Herb::AST::HTMLElementNode)
497
+
498
+ case node.open_tag.tag_name.value.downcase
499
+ when "script"
500
+ normalize_type(tag_attribute(node, "type"), "text/javascript")
501
+ when "style"
502
+ normalize_type(tag_attribute(node, "type"), "text/css")
503
+ end
504
+ end
505
+
506
+ def normalize_type(value, default)
507
+ case value
508
+ when String
509
+ value.downcase
510
+ when :dynamic
511
+ "unknown"
512
+ else
513
+ default
514
+ end
515
+ end
516
+
517
+ def format_ruby(code)
518
+ @ruby.format(code, [@config.line_length - current_indent_level, 1].max).lines
519
+ end
520
+
521
+ def format_incomplete_ruby(code)
522
+ @ruby.format_incomplete(code, [@config.line_length - current_indent_level, 1].max).lines
523
+ end
524
+
525
+ def print_formatted_lines(lines)
526
+ (lines + [nil]).each_cons(2) do |line, next_line|
527
+ break if line.nil?
528
+
529
+ q.text(line.chomp)
530
+ q.breakable(force: true) unless next_line.nil?
531
+ end
532
+ end
533
+
534
+ def debug(&block)
535
+ @config.logger.debug(self.class.to_s, &block)
536
+ end
537
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Erbf
4
+ VERSION = "0.1.0"
5
+ end
data/lib/erbf.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+ require "prettier_print"
5
+ require "pp"
6
+
7
+ class Erbf
8
+ autoload :Config, "erbf/config"
9
+ autoload :Formatter, "erbf/formatter"
10
+ autoload :CLI, "erbf/cli"
11
+ autoload :VERSION, "erbf/version"
12
+
13
+ def initialize(config_or_options = {})
14
+ @config =
15
+ (config_or_options.is_a?(Config) ? config_or_options : Config.load(**config_or_options))
16
+ @logger = @config.logger
17
+ @logger.debug! if @config.debug?
18
+ end
19
+
20
+ def format_code(input)
21
+ result = Herb.parse(input)
22
+ # TODO: Handle errors
23
+ format_ast(result.value)
24
+ end
25
+
26
+ def format_ast(ast_node)
27
+ PrettierPrint.format(+"", @config.line_length) do |q|
28
+ @logger.debug(to_s) { "AST:\n#{PP.pp(ast_node, +"", 80)}" }
29
+ # TODO: Use Herb::Visitor
30
+ Formatter.new(q, @config).visit(ast_node)
31
+ @logger.debug(to_s) { "Formatted:\n#{PP.pp(q.target, +"", 80)}" }
32
+ end
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: erbf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomasz Szczęśniak-Szlagowski
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: herb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: prettier_print
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ description: It breaks longs lines and integrates with Ruby and JS/CSS formatters
41
+ email:
42
+ - spect88@gmail.com
43
+ executables:
44
+ - erbf
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rubocop.yml"
49
+ - ".streerc"
50
+ - LICENSE.txt
51
+ - README.md
52
+ - exe/erbf
53
+ - lib/erbf.rb
54
+ - lib/erbf/cli.rb
55
+ - lib/erbf/config.rb
56
+ - lib/erbf/formatter.rb
57
+ - lib/erbf/formatter/embedded_language_formatter.rb
58
+ - lib/erbf/formatter/html_helper.rb
59
+ - lib/erbf/formatter/prettier_print_helper.rb
60
+ - lib/erbf/formatter/ruby_formatter.rb
61
+ - lib/erbf/version.rb
62
+ homepage: https://github.com/spect88/erbf
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://github.com/spect88/erbf
67
+ source_code_uri: https://github.com/spect88/erbf
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.0.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.6.7
84
+ specification_version: 4
85
+ summary: A formatter for .html.erb files
86
+ test_files: []