herb 0.6.1-x86-linux-musl → 0.7.0-x86-linux-musl

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/ext/herb/nodes.c +6 -4
  4. data/lib/herb/3.0/herb.so +0 -0
  5. data/lib/herb/3.1/herb.so +0 -0
  6. data/lib/herb/3.2/herb.so +0 -0
  7. data/lib/herb/3.3/herb.so +0 -0
  8. data/lib/herb/3.4/herb.so +0 -0
  9. data/lib/herb/ast/helpers.rb +26 -0
  10. data/lib/herb/ast/nodes.rb +7 -3
  11. data/lib/herb/cli.rb +158 -1
  12. data/lib/herb/engine/compiler.rb +399 -0
  13. data/lib/herb/engine/debug_visitor.rb +321 -0
  14. data/lib/herb/engine/error_formatter.rb +420 -0
  15. data/lib/herb/engine/parser_error_overlay.rb +767 -0
  16. data/lib/herb/engine/validation_error_overlay.rb +182 -0
  17. data/lib/herb/engine/validation_errors.rb +65 -0
  18. data/lib/herb/engine/validator.rb +75 -0
  19. data/lib/herb/engine/validators/accessibility_validator.rb +31 -0
  20. data/lib/herb/engine/validators/nesting_validator.rb +95 -0
  21. data/lib/herb/engine/validators/security_validator.rb +71 -0
  22. data/lib/herb/engine.rb +366 -0
  23. data/lib/herb/project.rb +3 -3
  24. data/lib/herb/version.rb +1 -1
  25. data/lib/herb/visitor.rb +2 -0
  26. data/lib/herb.rb +2 -0
  27. data/sig/herb/ast/helpers.rbs +16 -0
  28. data/sig/herb/ast/nodes.rbs +4 -2
  29. data/sig/herb/engine/compiler.rbs +109 -0
  30. data/sig/herb/engine/debug.rbs +38 -0
  31. data/sig/herb/engine/debug_visitor.rbs +70 -0
  32. data/sig/herb/engine/error_formatter.rbs +47 -0
  33. data/sig/herb/engine/parser_error_overlay.rbs +41 -0
  34. data/sig/herb/engine/validation_error_overlay.rbs +35 -0
  35. data/sig/herb/engine/validation_errors.rbs +45 -0
  36. data/sig/herb/engine/validator.rbs +37 -0
  37. data/sig/herb/engine/validators/accessibility_validator.rbs +19 -0
  38. data/sig/herb/engine/validators/nesting_validator.rbs +25 -0
  39. data/sig/herb/engine/validators/security_validator.rbs +23 -0
  40. data/sig/herb/engine.rbs +72 -0
  41. data/sig/herb/visitor.rbs +2 -0
  42. data/sig/herb_c_extension.rbs +7 -0
  43. data/sig/serialized_ast_nodes.rbs +1 -0
  44. data/src/ast_nodes.c +2 -1
  45. data/src/ast_pretty_print.c +2 -1
  46. data/src/element_source.c +11 -0
  47. data/src/include/ast_nodes.h +3 -1
  48. data/src/include/element_source.h +13 -0
  49. data/src/include/version.h +1 -1
  50. data/src/parser.c +3 -0
  51. data/src/parser_helpers.c +1 -0
  52. metadata +30 -2
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herb
4
+ class Engine
5
+ class ValidationErrorOverlay
6
+ CONTEXT_LINES = 2
7
+
8
+ VALIDATOR_BADGES = {
9
+ "SecurityValidator" => { label: "Security", color: "#dc2626" },
10
+ "NestingValidator" => { label: "Nesting", color: "#f59e0b" },
11
+ "AccessibilityValidator" => { label: "A11y", color: "#3b82f6" },
12
+ }.freeze
13
+
14
+ SEVERITY_COLORS = {
15
+ "error" => "#dc2626",
16
+ "warning" => "#f59e0b",
17
+ "info" => "#3b82f6",
18
+ }.freeze
19
+
20
+ def initialize(source, error, filename: nil)
21
+ @source = source
22
+ @error = error
23
+ @filename = filename || "unknown"
24
+ @lines = source.lines
25
+ end
26
+
27
+ def generate_fragment
28
+ location = @error[:location]
29
+ line_num = location&.start&.line || 1
30
+ col_num = location&.start&.column || 1
31
+
32
+ validator_info = VALIDATOR_BADGES[@error[:source]] || { label: @error[:source], color: "#6b7280" }
33
+ severity_color = SEVERITY_COLORS[@error[:severity].to_s] || "#6b7280"
34
+
35
+ code_snippet = generate_code_snippet(line_num, col_num)
36
+
37
+ <<~HTML
38
+ <div class="herb-validation-item" data-severity="#{escape_attr(@error[:severity].to_s)}">
39
+ <div class="herb-validation-header">
40
+ <span class="herb-validation-badge" style="background: #{validator_info[:color]}">
41
+ #{escape_html(validator_info[:label])}
42
+ </span>
43
+ <span class="herb-validation-location">
44
+ #{escape_html(@filename)}:#{line_num}:#{col_num}
45
+ </span>
46
+ </div>
47
+ <div class="herb-validation-message" style="color: #{severity_color}">
48
+ #{escape_html(@error[:message])}
49
+ </div>
50
+ #{code_snippet}
51
+ #{generate_suggestion_html if @error[:suggestion]}
52
+ </div>
53
+ HTML
54
+ end
55
+
56
+ private
57
+
58
+ def generate_code_snippet(line_num, col_num)
59
+ start_line = [line_num - CONTEXT_LINES, 1].max
60
+ end_line = [line_num + CONTEXT_LINES, @lines.length].min
61
+
62
+ code_lines = [] #: Array[String]
63
+ (start_line..end_line).each do |line|
64
+ line_content = @lines[line - 1] || ""
65
+ is_error_line = line == line_num
66
+
67
+ highlighted_content = syntax_highlight(line_content.chomp)
68
+
69
+ if is_error_line
70
+ code_lines << <<~HTML
71
+ <div class="herb-code-line herb-error-line">
72
+ <div class="herb-line-number">#{line}</div>
73
+ <div class="herb-line-content">#{highlighted_content}</div>
74
+ </div>
75
+ HTML
76
+
77
+ if col_num.positive?
78
+ pointer = "#{" " * (col_num - 1)}^"
79
+ code_lines << <<~HTML
80
+ <div class="herb-error-pointer">#{escape_html(pointer)}</div>
81
+ HTML
82
+ end
83
+ else
84
+ code_lines << <<~HTML
85
+ <div class="herb-code-line">
86
+ <div class="herb-line-number">#{line}</div>
87
+ <div class="herb-line-content">#{highlighted_content}</div>
88
+ </div>
89
+ HTML
90
+ end
91
+ end
92
+
93
+ <<~HTML
94
+ <div class="herb-code-snippet">
95
+ #{code_lines.join}
96
+ </div>
97
+ HTML
98
+ end
99
+
100
+ def generate_suggestion_html
101
+ <<~HTML
102
+ <div class="herb-validation-suggestion">
103
+ <span class="herb-suggestion-icon">💡</span>
104
+ #{escape_html(@error[:suggestion])}
105
+ </div>
106
+ HTML
107
+ end
108
+
109
+ def syntax_highlight(code)
110
+ lex_result = ::Herb.lex(code)
111
+ return escape_html(code) if lex_result.errors.any?
112
+
113
+ tokens = lex_result.value
114
+ highlight_with_tokens(tokens, code)
115
+ rescue StandardError
116
+ escape_html(code)
117
+ end
118
+
119
+ def highlight_with_tokens(tokens, code)
120
+ return escape_html(code) if tokens.nil? || tokens.empty?
121
+
122
+ highlighted = ""
123
+ last_end = 0
124
+
125
+ tokens.each do |token|
126
+ char_offset = get_character_offset(code, token.location.start.line, token.location.start.column)
127
+ char_end = get_character_offset(code, token.location.end_point.line, token.location.end_point.column)
128
+
129
+ highlighted += escape_html(code[last_end...char_offset]) if char_offset > last_end
130
+
131
+ token_text = code[char_offset...char_end]
132
+ highlighted += apply_token_style(token, token_text)
133
+ last_end = char_end
134
+ end
135
+
136
+ highlighted += escape_html(code[last_end..]) if last_end < code.length
137
+
138
+ highlighted
139
+ end
140
+
141
+ def get_character_offset(_content, line, column)
142
+ return column - 1 if line == 1
143
+
144
+ column - 1
145
+ end
146
+
147
+ def apply_token_style(token, text)
148
+ escaped_text = escape_html(text)
149
+
150
+ case token.type
151
+ when "TOKEN_ERB_START", "TOKEN_ERB_END"
152
+ "<span class=\"herb-erb\">#{escaped_text}</span>"
153
+ when "TOKEN_ERB_CONTENT"
154
+ "<span class=\"herb-erb-content\">#{escaped_text}</span>"
155
+ when "TOKEN_HTML_TAG_START", "TOKEN_HTML_TAG_START_CLOSE", "TOKEN_HTML_TAG_END", "TOKEN_HTML_TAG_SELF_CLOSE", "TOKEN_IDENTIFIER"
156
+ "<span class=\"herb-tag\">#{escaped_text}</span>"
157
+ when "TOKEN_HTML_ATTRIBUTE_NAME"
158
+ "<span class=\"herb-attr\">#{escaped_text}</span>"
159
+ when "TOKEN_QUOTE", "TOKEN_HTML_ATTRIBUTE_VALUE"
160
+ "<span class=\"herb-value\">#{escaped_text}</span>"
161
+ when "TOKEN_HTML_COMMENT_START", "TOKEN_HTML_COMMENT_END", "TOKEN_HTML_COMMENT_CONTENT"
162
+ "<span class=\"herb-comment\">#{escaped_text}</span>"
163
+ else
164
+ escaped_text
165
+ end
166
+ end
167
+
168
+ def escape_html(text)
169
+ text.to_s
170
+ .gsub("&", "&amp;")
171
+ .gsub("<", "&lt;")
172
+ .gsub(">", "&gt;")
173
+ .gsub('"', "&quot;")
174
+ .gsub("'", "&#39;")
175
+ end
176
+
177
+ def escape_attr(text)
178
+ escape_html(text).gsub("\n", "&#10;").gsub("\r", "&#13;")
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+ # typed: false
3
+
4
+ # rbs_inline: disabled
5
+
6
+ module Herb
7
+ class Engine
8
+ class SecurityError < StandardError
9
+ attr_reader :line, :column, :filename, :suggestion
10
+
11
+ def initialize(message, line: nil, column: nil, filename: nil, suggestion: nil)
12
+ @line = line
13
+ @column = column
14
+ @filename = filename
15
+ @suggestion = suggestion
16
+
17
+ super(build_error_message(message))
18
+ end
19
+
20
+ private
21
+
22
+ def build_error_message(message)
23
+ parts = [] #: Array[String]
24
+
25
+ if @filename || (@line && @column)
26
+ location_parts = [] #: Array[String]
27
+
28
+ location_parts << @filename if @filename
29
+ location_parts << "#{@line}:#{@column}" if @line && @column
30
+
31
+ parts << location_parts.join(":")
32
+ end
33
+
34
+ parts << message
35
+
36
+ parts << "Suggestion: #{@suggestion}" if @suggestion
37
+
38
+ parts.join(" - ")
39
+ end
40
+ end
41
+
42
+ module ValidationErrors
43
+ class ValidationError
44
+ attr_reader :type, :location, :message
45
+
46
+ def initialize(type, location, message)
47
+ @type = type
48
+ @location = location
49
+ @message = message
50
+ end
51
+ end
52
+
53
+ class SecurityValidationError
54
+ attr_reader :type, :location, :message, :suggestion
55
+
56
+ def initialize(location, message, suggestion)
57
+ @type = "SecurityError"
58
+ @location = location
59
+ @message = message
60
+ @suggestion = suggestion
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herb
4
+ class Engine
5
+ class Validator < Herb::Visitor
6
+ attr_reader :diagnostics
7
+
8
+ def initialize
9
+ super
10
+
11
+ @diagnostics = []
12
+ end
13
+
14
+ def validate(node)
15
+ visit(node)
16
+ end
17
+
18
+ def error(message, location, code: nil, source: nil)
19
+ add_diagnostic(message, location, :error, code: code, source: source)
20
+ end
21
+
22
+ def warning(message, location, code: nil, source: nil)
23
+ add_diagnostic(message, location, :warning, code: code, source: source)
24
+ end
25
+
26
+ def info(message, location, code: nil, source: nil)
27
+ add_diagnostic(message, location, :info, code: code, source: source)
28
+ end
29
+
30
+ def hint(message, location, code: nil, source: nil)
31
+ add_diagnostic(message, location, :hint, code: code, source: source)
32
+ end
33
+
34
+ def errors?
35
+ @diagnostics.any? { |diagnostic| diagnostic[:severity] == :error }
36
+ end
37
+
38
+ def warnings?
39
+ @diagnostics.any? { |diagnostic| diagnostic[:severity] == :warning }
40
+ end
41
+
42
+ def errors
43
+ @diagnostics.select { |diagnostic| diagnostic[:severity] == :error }
44
+ end
45
+
46
+ def warnings
47
+ @diagnostics.select { |diagnostic| diagnostic[:severity] == :warning }
48
+ end
49
+
50
+ def clear_diagnostics
51
+ @diagnostics.clear
52
+ end
53
+
54
+ def diagnostic_count(severity = nil)
55
+ return @diagnostics.length unless severity
56
+
57
+ @diagnostics.count { |diagnostic| diagnostic[:severity] == severity }
58
+ end
59
+
60
+ private
61
+
62
+ def add_diagnostic(message, location, severity, code: nil, source: nil)
63
+ diagnostic = {
64
+ message: message,
65
+ location: location,
66
+ severity: severity,
67
+ code: code,
68
+ source: source || self.class.name,
69
+ }
70
+
71
+ @diagnostics << diagnostic
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require_relative "../validator"
5
+
6
+ module Herb
7
+ class Engine
8
+ module Validators
9
+ class AccessibilityValidator < Validator
10
+ def visit_html_attribute_node(node)
11
+ validate_attribute(node)
12
+ super
13
+ end
14
+
15
+ private
16
+
17
+ def validate_attribute(node)
18
+ # TODO: Add accessibility attribute validation
19
+ end
20
+
21
+ def validate_id_format(node)
22
+ # TODO: Add ID format validation
23
+ end
24
+
25
+ def add_validation_error(type, location, message)
26
+ error(message, location, code: type, source: "AccessibilityValidator")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ # typed: false
3
+
4
+ # rbs_inline: disabled
5
+
6
+ require_relative "../validator"
7
+
8
+ module Herb
9
+ class Engine
10
+ module Validators
11
+ class NestingValidator < Validator
12
+ def visit_html_element_node(node)
13
+ validate_html_nesting(node)
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def validate_html_nesting(node)
20
+ tag_name = node.tag_name&.value&.downcase
21
+ return unless tag_name
22
+
23
+ case tag_name
24
+ when "p"
25
+ validate_no_block_elements_in_paragraph(node)
26
+ when "a"
27
+ validate_no_nested_anchors(node)
28
+ when "button"
29
+ validate_no_interactive_in_button(node)
30
+ end
31
+ end
32
+
33
+ def validate_no_block_elements_in_paragraph(node)
34
+ block_elements = %w[div section article header footer nav aside p h1 h2 h3 h4 h5 h6 ul ol dl table form]
35
+
36
+ node.body.each do |child|
37
+ next unless child.is_a?(Herb::AST::HTMLElementNode)
38
+
39
+ child_tag = child.tag_name&.value&.downcase
40
+ next unless child_tag && block_elements.include?(child_tag)
41
+
42
+ add_validation_error(
43
+ "InvalidNestingError",
44
+ child.location,
45
+ "Block element <#{child_tag}> cannot be nested inside <p> at line #{child.location.start.line}"
46
+ )
47
+ end
48
+ end
49
+
50
+ def validate_no_nested_anchors(node)
51
+ find_nested_elements(node, "a") do |nested|
52
+ add_validation_error(
53
+ "NestedAnchorError",
54
+ nested.location,
55
+ "Anchor <a> cannot be nested inside another anchor at line #{nested.location.start.line}"
56
+ )
57
+ end
58
+ end
59
+
60
+ def validate_no_interactive_in_button(node)
61
+ interactive_elements = %w[a button input select textarea]
62
+
63
+ node.body.each do |child|
64
+ next unless child.is_a?(Herb::AST::HTMLElementNode)
65
+
66
+ child_tag = child.tag_name&.value&.downcase
67
+ next unless child_tag && interactive_elements.include?(child_tag)
68
+
69
+ add_validation_error(
70
+ "InvalidNestingError",
71
+ child.location,
72
+ "Interactive element <#{child_tag}> cannot be nested inside <button> at line #{child.location.start.line}"
73
+ )
74
+ end
75
+ end
76
+
77
+ def find_nested_elements(node, tag_name, &block)
78
+ node.body.each do |child|
79
+ if child.is_a?(Herb::AST::HTMLElementNode)
80
+ if child.tag_name&.value&.downcase == tag_name
81
+ yield child
82
+ else
83
+ find_nested_elements(child, tag_name, &block)
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ def add_validation_error(type, location, message)
90
+ error(message, location, code: type, source: "NestingValidator")
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require_relative "../validator"
5
+
6
+ module Herb
7
+ class Engine
8
+ module Validators
9
+ class SecurityValidator < Validator
10
+ def visit_html_open_tag_node(node)
11
+ validate_tag_security(node)
12
+
13
+ super
14
+ end
15
+
16
+ def visit_html_attribute_name_node(node)
17
+ validate_attribute_name_security(node)
18
+
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def validate_tag_security(node)
25
+ node.children.each do |child|
26
+ next if child.is_a?(Herb::AST::HTMLAttributeNode)
27
+ next if child.is_a?(Herb::AST::WhitespaceNode)
28
+
29
+ next unless child.is_a?(Herb::AST::ERBContentNode) && erb_outputs?(child)
30
+
31
+ add_security_error(
32
+ child.location,
33
+ "ERB output tags (<%= %>) are not allowed in attribute position.",
34
+ "Use control flow (<% %>) with static attributes instead."
35
+ )
36
+ end
37
+ end
38
+
39
+ def validate_attribute_name_security(node)
40
+ node.children.each do |child|
41
+ next unless child.is_a?(Herb::AST::ERBContentNode) && erb_outputs?(child)
42
+
43
+ add_security_error(
44
+ child.location,
45
+ "ERB output in attribute names is not allowed for security reasons.",
46
+ "Use static attribute names with dynamic values instead."
47
+ )
48
+ end
49
+ end
50
+
51
+ def add_security_error(location, message, suggestion)
52
+ add_diagnostic(message, location, :error, code: "SecurityViolation", source: "SecurityValidator",
53
+ suggestion: suggestion)
54
+ end
55
+
56
+ def add_diagnostic(message, location, severity, code: nil, source: nil, suggestion: nil)
57
+ diagnostic = {
58
+ message: message,
59
+ location: location,
60
+ severity: severity,
61
+ code: code,
62
+ source: source || self.class.name,
63
+ suggestion: suggestion,
64
+ }
65
+
66
+ @diagnostics << diagnostic
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end