super-slim 3.0.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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter/line_wrapper"
4
+
5
+ module SuperSlim
6
+ # Rewrites a template in its canonical layout. Text blocks shift as a whole, so the whitespace they render is
7
+ # kept; and the formatted template must compile to the same Ruby as the original, or nothing is rewritten.
8
+ # With max_line_length, longer lines are then wrapped, but only if the Ruby differs by nothing but whitespace.
9
+ class Formatter
10
+ INDENT_WIDTH = 2
11
+ CONTROL = /\A-\s*/
12
+ OUTPUT = /\A(==?)([<>]*)\s*/
13
+ TAG_OUTPUT = /\A\s*(==?)(['<>]*)\s*/
14
+ REDUNDANT_DIV = /\Adiv(?=[.#][\p{Word}-])/
15
+
16
+ def self.call(source, **)
17
+ new(**).call(source)
18
+ end
19
+
20
+ def initialize(tailwind_classes: SuperSlim.tailwind_classes, max_line_length: nil, attribute_delimiters: "()")
21
+ @tailwind_classes = tailwind_classes
22
+ @line_wrapper = LineWrapper.new(max_line_length, tailwind_classes:, attribute_delimiters:) if max_line_length
23
+ end
24
+
25
+ def call(source)
26
+ formatted = layout(source)
27
+ unless formatted == source || compiled(formatted) == compiled(source)
28
+ raise UnsafeFormattingError, "Formatting would change how this template renders"
29
+ end
30
+ return formatted unless @line_wrapper
31
+
32
+ wrapped = @line_wrapper.call(formatted)
33
+ return formatted if wrapped == formatted
34
+
35
+ wrapped_tokens = ruby_tokens(wrapped)
36
+ (wrapped_tokens && wrapped_tokens == ruby_tokens(formatted)) ? wrapped : formatted
37
+ end
38
+
39
+ private
40
+
41
+ def layout(source)
42
+ parser = Parser.new(outline: true, tailwind_classes: @tailwind_classes)
43
+ parser.call(source)
44
+ lines = source.delete_prefix("").split(/\r?\n/)
45
+
46
+ formatted = []
47
+ indent_shift_by_lineno = {}
48
+ lines.each.with_index(1) do |line, lineno|
49
+ info = parser.outline[lineno]
50
+ case info.kind
51
+ when :blank
52
+ formatted << "" unless formatted.empty? || formatted.last.empty?
53
+ when :text_blank
54
+ formatted << ""
55
+ when :structure
56
+ indent = info.depth * INDENT_WIDTH
57
+ indent_shift_by_lineno[lineno] = indent - indent_of(line)
58
+ formatted << (" " * indent) + structure(line, info)
59
+ else
60
+ formatted << continuation(line, info, indent_shift_by_lineno.fetch(info.owner, 0))
61
+ end
62
+ end
63
+ formatted.pop while formatted.last&.empty?
64
+ formatted.empty? ? "" : "#{formatted.join("\n")}\n"
65
+ end
66
+
67
+ def structure(line, info)
68
+ content = with_output_spacing(line, info).lstrip
69
+ content = content.rstrip unless info.text
70
+ case content
71
+ when CONTROL then $'.empty? ? "-" : "- #{$'}"
72
+ when OUTPUT then "#{$1}#{$2} #{$'}"
73
+ when REDUNDANT_DIV then $'
74
+ else content
75
+ end
76
+ end
77
+
78
+ def continuation(line, info, indent_shift)
79
+ line = with_output_spacing(expand_tabs(line), info)
80
+ indent = [line[/\A */].size + indent_shift, 0].max
81
+ content = line.lstrip
82
+ content = content.rstrip unless info.kind == :text || info.text
83
+ (" " * indent) + content
84
+ end
85
+
86
+ def with_output_spacing(line, info)
87
+ return line unless info.output_column
88
+
89
+ before = line[0...info.output_column].rstrip
90
+ line[info.output_column..] =~ TAG_OUTPUT
91
+ $'.empty? ? "#{before} #{$1}#{$2}" : "#{before} #{$1}#{$2} #{$'}"
92
+ end
93
+
94
+ def indent_of(line)
95
+ expand_tabs(line)[/\A */].size
96
+ end
97
+
98
+ def expand_tabs(line)
99
+ line.sub(/\A[ \t]*/) { _1.gsub(Parser::TAB, "\\1#{" " * Parser::TAB_SIZE}") }
100
+ end
101
+
102
+ def compiled(source)
103
+ SuperSlim.compile(source, tailwind_classes: @tailwind_classes, format: :xml).gsub(/(?:\s*[;\n]\s*)+/, ";")
104
+ rescue Error => exception
105
+ exception.message
106
+ end
107
+
108
+ # Whitespace aside, wrapping only turns spaces into newlines that Ruby ignores after a comma, and newlines
109
+ # that end statements into semicolons.
110
+ def ruby_tokens(source)
111
+ code = SuperSlim.compile(source, tailwind_classes: @tailwind_classes, format: :xml)
112
+ tokens = Ripper.lex(code).flat_map do |_, type, token|
113
+ case type
114
+ when :on_sp, :on_ignored_nl then []
115
+ when :on_nl, :on_semicolon then [";"]
116
+ when :on_comment then [token.strip, ";"]
117
+ else [token]
118
+ end
119
+ end
120
+ tokens.chunk_while { |previous, token| previous == ";" && token == ";" }.map(&:first)
121
+ rescue Error
122
+ nil
123
+ end
124
+ end
125
+ end