cooklang 0.1.0 → 1.0.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +35 -0
  3. data/.gitignore +12 -0
  4. data/.qlty/.gitignore +7 -0
  5. data/.qlty/configs/.yamllint.yaml +21 -0
  6. data/.qlty/qlty.toml +101 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +289 -75
  9. data/Gemfile.lock +65 -26
  10. data/{LICENSE → LICENSE.txt} +6 -6
  11. data/README.md +106 -12
  12. data/Rakefile +5 -1
  13. data/cooklang.gemspec +35 -0
  14. data/lib/cooklang/builders/recipe_builder.rb +64 -0
  15. data/lib/cooklang/builders/step_builder.rb +76 -0
  16. data/lib/cooklang/cookware.rb +43 -0
  17. data/lib/cooklang/formatter.rb +61 -0
  18. data/lib/cooklang/formatters/text.rb +18 -0
  19. data/lib/cooklang/ingredient.rb +60 -0
  20. data/lib/cooklang/lexer.rb +282 -0
  21. data/lib/cooklang/metadata.rb +98 -0
  22. data/lib/cooklang/note.rb +27 -0
  23. data/lib/cooklang/parser.rb +41 -0
  24. data/lib/cooklang/parsers/cookware_parser.rb +133 -0
  25. data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
  26. data/lib/cooklang/parsers/timer_parser.rb +135 -0
  27. data/lib/cooklang/processors/element_parser.rb +45 -0
  28. data/lib/cooklang/processors/metadata_processor.rb +129 -0
  29. data/lib/cooklang/processors/step_processor.rb +208 -0
  30. data/lib/cooklang/processors/token_processor.rb +104 -0
  31. data/lib/cooklang/recipe.rb +72 -0
  32. data/lib/cooklang/section.rb +33 -0
  33. data/lib/cooklang/step.rb +99 -0
  34. data/lib/cooklang/timer.rb +65 -0
  35. data/lib/cooklang/token_stream.rb +130 -0
  36. data/lib/cooklang/version.rb +1 -1
  37. data/lib/cooklang.rb +22 -1
  38. data/spec/comprehensive_spec.rb +179 -0
  39. data/spec/cooklang_spec.rb +38 -0
  40. data/spec/fixtures/canonical.yaml +837 -0
  41. data/spec/formatters/text_spec.rb +189 -0
  42. data/spec/integration/canonical_spec.rb +211 -0
  43. data/spec/lexer_spec.rb +357 -0
  44. data/spec/models/cookware_spec.rb +116 -0
  45. data/spec/models/ingredient_spec.rb +192 -0
  46. data/spec/models/metadata_spec.rb +241 -0
  47. data/spec/models/note_spec.rb +65 -0
  48. data/spec/models/recipe_spec.rb +171 -0
  49. data/spec/models/section_spec.rb +65 -0
  50. data/spec/models/step_spec.rb +236 -0
  51. data/spec/models/timer_spec.rb +173 -0
  52. data/spec/parser_spec.rb +398 -0
  53. data/spec/spec_helper.rb +23 -0
  54. data/spec/token_stream_spec.rb +278 -0
  55. metadata +141 -24
  56. data/.ruby-version +0 -1
  57. data/CHANGELOG.md +0 -5
  58. data/bin/console +0 -15
  59. data/bin/setup +0 -8
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ingredient"
4
+ require_relative "../token_stream"
5
+
6
+ module Cooklang
7
+ module Parsers
8
+ class IngredientParser
9
+ def initialize(stream)
10
+ @stream = stream
11
+ end
12
+
13
+ def parse
14
+ return nil unless @stream.current&.type == :ingredient_marker
15
+ @stream.consume(:ingredient_marker) # Skip the @ marker
16
+
17
+ return nil if invalid_syntax?
18
+
19
+ brace_index = find_next_brace
20
+
21
+ if brace_index
22
+ parse_braced_ingredient(brace_index)
23
+ else
24
+ parse_simple_ingredient
25
+ end
26
+ end
27
+
28
+ private
29
+ def invalid_syntax?
30
+ @stream.current&.type == :text && @stream.current.value.start_with?(" ")
31
+ end
32
+
33
+ def find_next_brace
34
+ @stream.find_next(:open_brace)
35
+ end
36
+
37
+ def parse_braced_ingredient(brace_index)
38
+ name = extract_name_until_brace(brace_index)
39
+ @stream.consume(:open_brace) # Skip open brace
40
+
41
+ quantity, unit = extract_quantity_and_unit
42
+ @stream.consume(:close_brace) # Skip close brace
43
+
44
+ notes = extract_notes
45
+
46
+ # Default values according to original logic
47
+ quantity = "some" if (quantity.nil? || quantity == "") && (unit.nil? || unit == "")
48
+ unit = nil if unit == ""
49
+
50
+ Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes)
51
+ end
52
+
53
+ def parse_simple_ingredient
54
+ remaining_text = nil
55
+
56
+ if @stream.current&.type == :text
57
+ text = @stream.current.value
58
+ if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
59
+ name = ::Regexp.last_match(1)
60
+ remaining_text = ::Regexp.last_match(2)
61
+ else
62
+ name = text.strip
63
+ end
64
+ @stream.consume
65
+ else
66
+ name = ""
67
+ end
68
+
69
+ ingredient = Ingredient.new(name: name, quantity: "some", unit: nil, notes: nil)
70
+ [ingredient, remaining_text]
71
+ end
72
+
73
+ def extract_name_until_brace(brace_index)
74
+ name_parts = []
75
+ @stream.position
76
+
77
+ while @stream.position < brace_index && !@stream.eof?
78
+ case @stream.current.type
79
+ when :text, :hyphen
80
+ name_parts << @stream.current.value
81
+ end
82
+ @stream.consume
83
+ end
84
+
85
+ name_parts.join.strip
86
+ end
87
+
88
+ def extract_quantity_and_unit
89
+ quantity = nil
90
+ unit = nil
91
+ text_parts = []
92
+
93
+ while !@stream.eof? && @stream.current.type != :close_brace
94
+ case @stream.current.type
95
+ when :percent
96
+ if !text_parts.empty?
97
+ quantity_text = text_parts.join.strip
98
+ quantity = parse_quantity_value(quantity_text)
99
+ end
100
+ text_parts = []
101
+ @stream.consume
102
+ when :text
103
+ text_parts << @stream.current.value
104
+ @stream.consume
105
+ else
106
+ @stream.consume
107
+ end
108
+ end
109
+
110
+ if !text_parts.empty?
111
+ combined_text = text_parts.join.strip
112
+ if quantity.nil?
113
+ # Try to parse as numeric quantity first, fallback to string if not purely numeric
114
+ if combined_text.match?(/^\d+$/)
115
+ quantity = combined_text.to_i
116
+ unit = ""
117
+ elsif combined_text.match?(/^\d+\.\d+$/)
118
+ quantity = combined_text.to_f
119
+ unit = ""
120
+ else
121
+ # Non-numeric content, keep as string quantity
122
+ quantity = combined_text
123
+ unit = ""
124
+ end
125
+ else
126
+ unit = combined_text
127
+ end
128
+ end
129
+
130
+ [quantity, unit]
131
+ end
132
+
133
+ def parse_quantity_value(text)
134
+ case text
135
+ when /^\d+$/
136
+ text.to_i
137
+ when /^\d+\.\d+$/
138
+ text.to_f
139
+ else
140
+ text
141
+ end
142
+ end
143
+
144
+ def extract_quantity_from_text(text)
145
+ # Simple quantity extraction - this could use QuantityExtractor if needed
146
+ if text.match(/^(\d+(?:\.\d+)?)\s*(.*)$/)
147
+ quantity_val = ::Regexp.last_match(1)
148
+ rest = ::Regexp.last_match(2)
149
+
150
+ quantity = quantity_val.include?(".") ? quantity_val.to_f : quantity_val.to_i
151
+ unit = rest.empty? ? nil : rest
152
+
153
+ [quantity, unit]
154
+ else
155
+ [text, nil]
156
+ end
157
+ end
158
+
159
+ def extract_notes
160
+ return nil unless @stream.current&.type == :open_paren
161
+
162
+ @stream.consume(:open_paren) # Skip open paren
163
+ notes_parts = []
164
+
165
+ while !@stream.eof? && @stream.current.type != :close_paren
166
+ if @stream.current.type == :text
167
+ notes_parts << @stream.current.value
168
+ end
169
+ @stream.consume
170
+ end
171
+
172
+ @stream.consume(:close_paren) if @stream.current&.type == :close_paren
173
+
174
+ notes = notes_parts.join.strip
175
+ notes.empty? ? nil : notes
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../timer"
4
+ require_relative "../token_stream"
5
+
6
+ module Cooklang
7
+ module Parsers
8
+ class TimerParser
9
+ def initialize(stream)
10
+ @stream = stream
11
+ end
12
+
13
+ def parse
14
+ return nil unless @stream.current&.type == :timer_marker
15
+ @stream.consume(:timer_marker) # Skip the ~ marker
16
+
17
+ return nil if invalid_syntax?
18
+
19
+ brace_index = find_next_brace
20
+
21
+ if brace_index
22
+ parse_named_timer(brace_index)
23
+ else
24
+ parse_simple_timer
25
+ end
26
+ end
27
+
28
+ private
29
+ def invalid_syntax?
30
+ @stream.current&.type == :text && @stream.current.value.start_with?(" ")
31
+ end
32
+
33
+ def find_next_brace
34
+ @stream.find_next(:open_brace)
35
+ end
36
+
37
+ def parse_named_timer(brace_index)
38
+ name = nil
39
+
40
+ # Extract name if there's text before the brace
41
+ if @stream.position < brace_index && @stream.current&.type == :text
42
+ name = @stream.current.value.strip
43
+ @stream.advance_to(brace_index)
44
+ end
45
+
46
+ @stream.consume(:open_brace) # Skip open brace
47
+
48
+ duration, unit = extract_duration
49
+ @stream.consume(:close_brace) # Skip close brace
50
+
51
+ Timer.new(name: name, duration: duration, unit: unit)
52
+ end
53
+
54
+ def parse_simple_timer
55
+ remaining_text = nil
56
+ name = nil
57
+
58
+ if @stream.current&.type == :text
59
+ text = @stream.current.value
60
+ if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
61
+ name = ::Regexp.last_match(1)
62
+ remaining_text = ::Regexp.last_match(2)
63
+ else
64
+ name = text.strip
65
+ end
66
+ @stream.consume
67
+ end
68
+
69
+ timer = Timer.new(name: name, duration: nil, unit: nil)
70
+ [timer, remaining_text]
71
+ end
72
+
73
+ def extract_duration
74
+ duration_parts = []
75
+ unit = nil
76
+
77
+ while !@stream.eof? && @stream.current.type != :close_brace
78
+ case @stream.current.type
79
+ when :text, :hyphen
80
+ duration_parts << @stream.current.value
81
+ @stream.consume
82
+ when :percent
83
+ @stream.consume
84
+ # Unit comes after percent
85
+ if @stream.current&.type == :text
86
+ unit = @stream.current.value
87
+ @stream.consume
88
+ end
89
+ else
90
+ @stream.consume
91
+ end
92
+ end
93
+
94
+ return [nil, nil] if duration_parts.empty?
95
+
96
+ duration_text = duration_parts.join
97
+
98
+ if unit
99
+ duration = parse_duration_value(duration_text)
100
+ [duration, unit]
101
+ else
102
+ parse_duration_with_unit(duration_text)
103
+ end
104
+ end
105
+
106
+ def parse_duration_value(text)
107
+ case text
108
+ when /^\d+$/
109
+ text.to_i
110
+ when /^\d+\.\d+$/
111
+ text.to_f
112
+ else
113
+ text # Keep ranges like "2-3"
114
+ end
115
+ end
116
+
117
+ def parse_duration_with_unit(text)
118
+ # Simple duration parsing - could be enhanced with DurationExtractor
119
+ # First check if it's just a number (integer or decimal)
120
+ if text.match?(/^(\d+(?:\.\d+)?)$/)
121
+ parsed_duration = text.include?(".") ? text.to_f : text.to_i
122
+ [parsed_duration, nil]
123
+ elsif text.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/)
124
+ duration = ::Regexp.last_match(1)
125
+ unit = ::Regexp.last_match(2)
126
+
127
+ parsed_duration = duration.include?(".") ? duration.to_f : duration.to_i
128
+ [parsed_duration, unit]
129
+ else
130
+ [text, nil]
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../token_stream"
4
+ require_relative "../parsers/ingredient_parser"
5
+ require_relative "../parsers/cookware_parser"
6
+ require_relative "../parsers/timer_parser"
7
+
8
+ module Cooklang
9
+ module Processors
10
+ class ElementParser
11
+ class << self
12
+ def parse_ingredient(tokens, start_index)
13
+ parse_with_parser(tokens, start_index, Parsers::IngredientParser)
14
+ end
15
+
16
+ def parse_cookware(tokens, start_index)
17
+ parse_with_parser(tokens, start_index, Parsers::CookwareParser)
18
+ end
19
+
20
+ def parse_timer(tokens, start_index)
21
+ parse_with_parser(tokens, start_index, Parsers::TimerParser)
22
+ end
23
+
24
+ private
25
+ def parse_with_parser(tokens, start_index, parser_class)
26
+ stream = TokenStream.new(tokens)
27
+ stream.advance_to(start_index)
28
+
29
+ parser = parser_class.new(stream)
30
+ result = parser.parse
31
+ consumed = stream.position - start_index
32
+
33
+ # Handle the return format
34
+ if result.is_a?(Array)
35
+ [result[0], consumed, result[1]]
36
+ elsif result
37
+ [result, consumed, nil]
38
+ else
39
+ [nil, 1, nil]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../metadata"
4
+
5
+ module Cooklang
6
+ module Processors
7
+ class MetadataProcessor
8
+ class << self
9
+ def extract_metadata(tokens)
10
+ metadata = Metadata.new
11
+ content_tokens = []
12
+ 0
13
+
14
+ # Check for YAML front matter
15
+ if tokens.first&.type == :yaml_delimiter
16
+ metadata, content_tokens, _ = extract_yaml_frontmatter(tokens)
17
+ else
18
+ content_tokens = tokens.dup
19
+ end
20
+
21
+ # Extract inline metadata
22
+ extract_inline_metadata(content_tokens, metadata)
23
+
24
+ [metadata, content_tokens]
25
+ end
26
+
27
+ private
28
+ def extract_yaml_frontmatter(tokens)
29
+ metadata = Metadata.new
30
+ i = 1 # Skip the first ---
31
+ yaml_content = []
32
+
33
+ # Find the closing --- delimiter
34
+ while i < tokens.length
35
+ if tokens[i].type == :yaml_delimiter
36
+ # Found closing delimiter
37
+ break
38
+ elsif tokens[i].type == :text
39
+ yaml_content << tokens[i].value
40
+ elsif tokens[i].type == :newline
41
+ yaml_content << "\n"
42
+ elsif tokens[i].type == :metadata_marker
43
+ yaml_content << ">>"
44
+ end
45
+ i += 1
46
+ end
47
+
48
+ # Skip the closing --- if we found it
49
+ i += 1 if i < tokens.length && tokens[i].type == :yaml_delimiter
50
+
51
+ # Parse YAML content if we have any
52
+ if yaml_content.any?
53
+ yaml_text = yaml_content.join.strip
54
+ parse_yaml_content(yaml_text, metadata) unless yaml_text.empty?
55
+ end
56
+
57
+ # Return remaining tokens
58
+ remaining_tokens = tokens[i..]
59
+
60
+ [metadata, remaining_tokens, i]
61
+ end
62
+
63
+ def parse_yaml_content(yaml_text, metadata)
64
+ # Simple YAML parsing - split by lines and parse key-value pairs
65
+ yaml_text.split("\n").each do |line|
66
+ line = line.strip
67
+ next if line.empty? || line.start_with?("#")
68
+
69
+ if line.match(/^([^:]+):\s*(.*)$/)
70
+ key = ::Regexp.last_match(1).strip
71
+ value = ::Regexp.last_match(2).strip
72
+
73
+ # Remove quotes if present
74
+ value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
75
+
76
+ # Parse numeric values
77
+ parsed_value = parse_metadata_value(value)
78
+ metadata[key] = parsed_value unless value.empty?
79
+ end
80
+ end
81
+ end
82
+
83
+ def extract_inline_metadata(tokens, metadata)
84
+ tokens_to_remove = []
85
+
86
+ tokens.each_with_index do |token, index|
87
+ next unless token.type == :metadata_marker
88
+
89
+ # Look for metadata pattern: >> key: value
90
+ if index + 1 < tokens.length && tokens[index + 1].type == :text
91
+ text = tokens[index + 1].value.strip
92
+
93
+ if text.match(/^([^:]+):\s*(.+)$/)
94
+ key = ::Regexp.last_match(1).strip
95
+ value = ::Regexp.last_match(2).strip
96
+
97
+ # Remove quotes if present
98
+ value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
99
+
100
+ # Parse numeric values
101
+ parsed_value = parse_metadata_value(value)
102
+ metadata[key] = parsed_value
103
+
104
+ # Mark both marker and text tokens for removal
105
+ tokens_to_remove << index << (index + 1)
106
+ end
107
+ end
108
+ end
109
+
110
+ # Remove only the specific metadata tokens that were processed
111
+ tokens.reject!.with_index { |token, index| tokens_to_remove.include?(index) }
112
+ end
113
+
114
+ def parse_metadata_value(value)
115
+ return value if value.empty?
116
+
117
+ # Try to parse as integer
118
+ return value.to_i if value.match?(/^\d+$/)
119
+
120
+ # Try to parse as float
121
+ return value.to_f if value.match?(/^\d+\.\d+$/)
122
+
123
+ # Return as string
124
+ value
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "element_parser"
4
+ require_relative "../token_stream"
5
+ require_relative "../builders/step_builder"
6
+
7
+ module Cooklang
8
+ module Processors
9
+ class StepProcessor
10
+ class << self
11
+ def parse_steps(tokens)
12
+ # Group tokens into steps (separated by blank lines)
13
+ step_groups = split_into_step_groups(tokens)
14
+
15
+ # Parse each step group into step data
16
+ steps = step_groups.map { |step_tokens| parse_step(step_tokens) }
17
+
18
+ # Filter out nil steps (those without content)
19
+ steps.compact.select { |step_data| step_data.is_a?(Hash) && has_content?(step_data[:segments]) }
20
+ end
21
+
22
+ private
23
+ def split_into_step_groups(tokens)
24
+ groups = []
25
+ current_group = []
26
+ i = 0
27
+
28
+ while i < tokens.length
29
+ token = tokens[i]
30
+
31
+ if token.type == :newline && blank_line_ahead?(tokens, i)
32
+ groups = finalize_current_group(groups, current_group)
33
+ current_group = []
34
+ i = skip_blank_line(tokens, i)
35
+ else
36
+ current_group << token
37
+ i += 1
38
+ end
39
+ end
40
+
41
+ finalize_current_group(groups, current_group)
42
+ end
43
+
44
+ def blank_line_ahead?(tokens, index)
45
+ next_index = index + 1
46
+ return false if next_index >= tokens.length
47
+
48
+ # Look for consecutive newlines or newline + whitespace + newline
49
+ if tokens[next_index].type == :newline
50
+ true
51
+ elsif tokens[next_index].type == :text && tokens[next_index].value.strip.empty?
52
+ # Check if there's a newline after the whitespace
53
+ next_next_index = next_index + 1
54
+ next_next_index < tokens.length && tokens[next_next_index].type == :newline
55
+ else
56
+ false
57
+ end
58
+ end
59
+
60
+ def parse_step(tokens)
61
+ builder = Builders::StepBuilder.new
62
+ stream = TokenStream.new(tokens)
63
+
64
+ while !stream.eof?
65
+ process_token_with_stream(stream, builder)
66
+ end
67
+
68
+ return nil unless builder.has_content?
69
+
70
+ # Return the expected hash format for compatibility
71
+ {
72
+ segments: builder.send(:remove_trailing_newlines, builder.segments.dup),
73
+ ingredients: builder.ingredients,
74
+ cookware: builder.cookware,
75
+ timers: builder.timers,
76
+ section_name: builder.section_name
77
+ }
78
+ end
79
+
80
+ def process_token_with_stream(stream, builder)
81
+ token = stream.current
82
+
83
+ case token.type
84
+ when :ingredient_marker
85
+ process_ingredient_with_stream(stream, builder)
86
+ when :cookware_marker
87
+ process_cookware_with_stream(stream, builder)
88
+ when :timer_marker
89
+ process_timer_with_stream(stream, builder)
90
+ when :text
91
+ builder.add_text(token.value)
92
+ stream.consume
93
+ when :newline
94
+ builder.add_text("\n")
95
+ stream.consume
96
+ when :yaml_delimiter
97
+ # Preserve yaml_delimiter as literal text when not in YAML context
98
+ builder.add_text(token.value)
99
+ stream.consume
100
+ when :open_brace, :close_brace, :open_paren, :close_paren, :percent
101
+ builder.add_text(token.value)
102
+ stream.consume
103
+ when :section_marker
104
+ process_section_with_stream(stream, builder)
105
+ else
106
+ stream.consume
107
+ end
108
+ end
109
+
110
+
111
+ def process_ingredient_with_stream(stream, builder)
112
+ process_element_with_stream(stream, builder, :ingredient)
113
+ end
114
+
115
+ def process_cookware_with_stream(stream, builder)
116
+ process_element_with_stream(stream, builder, :cookware)
117
+ end
118
+
119
+ def process_timer_with_stream(stream, builder)
120
+ process_element_with_stream(stream, builder, :timer)
121
+ end
122
+
123
+ def process_element_with_stream(stream, builder, element_type)
124
+ # Get the current position to pass to ElementParser
125
+ start_index = stream.position
126
+ tokens = stream.tokens
127
+
128
+ # Parse the element using the appropriate ElementParser method
129
+ element, consumed, remaining_text = case element_type
130
+ when :ingredient
131
+ ElementParser.parse_ingredient(tokens, start_index)
132
+ when :cookware
133
+ ElementParser.parse_cookware(tokens, start_index)
134
+ when :timer
135
+ ElementParser.parse_timer(tokens, start_index)
136
+ end
137
+
138
+ if element.nil?
139
+ builder.add_text(stream.current.value)
140
+ stream.consume
141
+ else
142
+ # Add the element using the appropriate builder method
143
+ case element_type
144
+ when :ingredient
145
+ builder.add_ingredient(element)
146
+ when :cookware
147
+ builder.add_cookware(element)
148
+ when :timer
149
+ builder.add_timer(element)
150
+ end
151
+
152
+ builder.add_remaining_text(remaining_text)
153
+ # Move stream position forward by consumed tokens
154
+ stream.advance_to(start_index + consumed)
155
+ end
156
+ end
157
+
158
+ def process_section_with_stream(stream, builder)
159
+ stream.consume # Skip section marker
160
+
161
+ if stream.current&.type == :text
162
+ text_content = stream.current.value
163
+ # Handle both actual newlines and literal \n sequences
164
+ newline_pos = text_content.index("\n") || text_content.index("\\n")
165
+
166
+ if newline_pos
167
+ # Section name is everything before the newline
168
+ section_name = text_content[0...newline_pos].strip
169
+ builder.set_section_name(section_name)
170
+ else
171
+ # No newline in this token, take the whole text as section name
172
+ section_name = text_content.strip
173
+ builder.set_section_name(section_name)
174
+ end
175
+ stream.consume
176
+ end
177
+ end
178
+
179
+
180
+
181
+
182
+
183
+
184
+ def has_content?(segments)
185
+ segments.any? { |segment| segment != "\n" && !(segment.is_a?(String) && segment.strip.empty?) }
186
+ end
187
+
188
+ def finalize_current_group(groups, current_group)
189
+ return groups unless group_has_content?(current_group)
190
+
191
+ groups << current_group
192
+ groups
193
+ end
194
+
195
+ def group_has_content?(group)
196
+ group.any? { |t| t.type != :newline }
197
+ end
198
+
199
+ def skip_blank_line(tokens, start_index)
200
+ i = start_index + 1
201
+ i += 1 while i < tokens.length && (tokens[i].type == :newline ||
202
+ (tokens[i].type == :text && tokens[i].value.strip.empty?))
203
+ i
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end