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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +35 -0
- data/.gitignore +12 -0
- data/.qlty/.gitignore +7 -0
- data/.qlty/configs/.yamllint.yaml +21 -0
- data/.qlty/qlty.toml +101 -0
- data/.rspec +3 -0
- data/.rubocop.yml +289 -75
- data/Gemfile.lock +65 -26
- data/{LICENSE → LICENSE.txt} +6 -6
- data/README.md +106 -12
- data/Rakefile +5 -1
- data/cooklang.gemspec +35 -0
- data/lib/cooklang/builders/recipe_builder.rb +64 -0
- data/lib/cooklang/builders/step_builder.rb +76 -0
- data/lib/cooklang/cookware.rb +43 -0
- data/lib/cooklang/formatter.rb +61 -0
- data/lib/cooklang/formatters/text.rb +18 -0
- data/lib/cooklang/ingredient.rb +60 -0
- data/lib/cooklang/lexer.rb +282 -0
- data/lib/cooklang/metadata.rb +98 -0
- data/lib/cooklang/note.rb +27 -0
- data/lib/cooklang/parser.rb +41 -0
- data/lib/cooklang/parsers/cookware_parser.rb +133 -0
- data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
- data/lib/cooklang/parsers/timer_parser.rb +135 -0
- data/lib/cooklang/processors/element_parser.rb +45 -0
- data/lib/cooklang/processors/metadata_processor.rb +129 -0
- data/lib/cooklang/processors/step_processor.rb +208 -0
- data/lib/cooklang/processors/token_processor.rb +104 -0
- data/lib/cooklang/recipe.rb +72 -0
- data/lib/cooklang/section.rb +33 -0
- data/lib/cooklang/step.rb +99 -0
- data/lib/cooklang/timer.rb +65 -0
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -1
- data/lib/cooklang.rb +22 -1
- data/spec/comprehensive_spec.rb +179 -0
- data/spec/cooklang_spec.rb +38 -0
- data/spec/fixtures/canonical.yaml +837 -0
- data/spec/formatters/text_spec.rb +189 -0
- data/spec/integration/canonical_spec.rb +211 -0
- data/spec/lexer_spec.rb +357 -0
- data/spec/models/cookware_spec.rb +116 -0
- data/spec/models/ingredient_spec.rb +192 -0
- data/spec/models/metadata_spec.rb +241 -0
- data/spec/models/note_spec.rb +65 -0
- data/spec/models/recipe_spec.rb +171 -0
- data/spec/models/section_spec.rb +65 -0
- data/spec/models/step_spec.rb +236 -0
- data/spec/models/timer_spec.rb +173 -0
- data/spec/parser_spec.rb +398 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/token_stream_spec.rb +278 -0
- metadata +141 -24
- data/.ruby-version +0 -1
- data/CHANGELOG.md +0 -5
- data/bin/console +0 -15
- data/bin/setup +0 -8
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../lexer"
|
4
|
+
require_relative "../note"
|
5
|
+
|
6
|
+
module Cooklang
|
7
|
+
module Processors
|
8
|
+
class TokenProcessor
|
9
|
+
class << self
|
10
|
+
def strip_comments(tokens)
|
11
|
+
result = []
|
12
|
+
i = 0
|
13
|
+
|
14
|
+
while i < tokens.length
|
15
|
+
token = tokens[i]
|
16
|
+
|
17
|
+
case token.type
|
18
|
+
when :comment_line
|
19
|
+
i = handle_comment_line(tokens, i, result)
|
20
|
+
when :comment_block_start
|
21
|
+
i = skip_comment_block(tokens, i)
|
22
|
+
else
|
23
|
+
result << token
|
24
|
+
i += 1
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
result
|
29
|
+
end
|
30
|
+
|
31
|
+
def extract_notes(tokens)
|
32
|
+
notes = []
|
33
|
+
content_tokens = []
|
34
|
+
i = 0
|
35
|
+
|
36
|
+
while i < tokens.length
|
37
|
+
if tokens[i].type == :note_marker
|
38
|
+
note_content, i = extract_single_note(tokens, i)
|
39
|
+
notes << Note.new(content: note_content) unless note_content.empty?
|
40
|
+
else
|
41
|
+
content_tokens << tokens[i]
|
42
|
+
i += 1
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
[notes, content_tokens]
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
def extract_single_note(tokens, start_index)
|
51
|
+
note_text = ""
|
52
|
+
i = start_index + 1 # Skip >
|
53
|
+
|
54
|
+
while i < tokens.length && tokens[i].type != :newline
|
55
|
+
note_text += tokens[i].value if tokens[i].type == :text
|
56
|
+
i += 1
|
57
|
+
end
|
58
|
+
|
59
|
+
[note_text.strip, i]
|
60
|
+
end
|
61
|
+
|
62
|
+
def handle_comment_line(tokens, index, result)
|
63
|
+
index += 1
|
64
|
+
return skip_to_newline(tokens, index) unless index < tokens.length && tokens[index].type == :text
|
65
|
+
|
66
|
+
text_token = tokens[index]
|
67
|
+
newline_pos = text_token.value.index("\n")
|
68
|
+
|
69
|
+
if newline_pos
|
70
|
+
handle_text_with_newline(text_token, newline_pos, result)
|
71
|
+
index + 1
|
72
|
+
else
|
73
|
+
skip_to_newline(tokens, index + 1)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def handle_text_with_newline(text_token, newline_pos, result)
|
78
|
+
remaining_text = text_token.value[(newline_pos + 1)..]
|
79
|
+
return unless remaining_text && !remaining_text.empty?
|
80
|
+
|
81
|
+
result << Token.new(
|
82
|
+
:text,
|
83
|
+
remaining_text,
|
84
|
+
text_token.position,
|
85
|
+
text_token.line,
|
86
|
+
text_token.column
|
87
|
+
)
|
88
|
+
end
|
89
|
+
|
90
|
+
def skip_to_newline(tokens, index)
|
91
|
+
index += 1 while index < tokens.length && tokens[index].type != :newline
|
92
|
+
index
|
93
|
+
end
|
94
|
+
|
95
|
+
def skip_comment_block(tokens, index)
|
96
|
+
index += 1
|
97
|
+
index += 1 while index < tokens.length && tokens[index].type != :comment_block_end
|
98
|
+
index += 1 if index < tokens.length && tokens[index].type == :comment_block_end
|
99
|
+
index
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cooklang
|
4
|
+
class Recipe
|
5
|
+
attr_reader :ingredients, :cookware, :timers, :steps, :metadata, :sections, :notes
|
6
|
+
|
7
|
+
def initialize(**components)
|
8
|
+
@ingredients = freeze_component(components[:ingredients])
|
9
|
+
@cookware = freeze_component(components[:cookware])
|
10
|
+
@timers = freeze_component(components[:timers])
|
11
|
+
@steps = freeze_component(components[:steps])
|
12
|
+
@metadata = components[:metadata] || Metadata.new
|
13
|
+
@sections = freeze_component(components[:sections])
|
14
|
+
@notes = freeze_component(components[:notes])
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
def freeze_component(value)
|
19
|
+
(value || []).freeze
|
20
|
+
end
|
21
|
+
|
22
|
+
public
|
23
|
+
|
24
|
+
def ingredients_hash
|
25
|
+
@ingredients.each_with_object({}) do |ingredient, hash|
|
26
|
+
hash[ingredient.name] = {
|
27
|
+
quantity: ingredient.quantity,
|
28
|
+
unit: ingredient.unit
|
29
|
+
}.compact
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def steps_text
|
34
|
+
@steps.map(&:to_text)
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_h
|
38
|
+
{
|
39
|
+
ingredients: @ingredients.map(&:to_h),
|
40
|
+
cookware: @cookware.map(&:to_h),
|
41
|
+
timers: @timers.map(&:to_h),
|
42
|
+
steps: @steps.map(&:to_h),
|
43
|
+
metadata: @metadata.to_h,
|
44
|
+
sections: @sections.map(&:to_h),
|
45
|
+
notes: @notes.map(&:to_h)
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def ==(other)
|
50
|
+
return false unless other.is_a?(Recipe)
|
51
|
+
|
52
|
+
comparable_attributes.all? do |attr|
|
53
|
+
send(attr) == other.send(attr)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
def comparable_attributes
|
59
|
+
%i[ingredients cookware timers steps metadata sections notes]
|
60
|
+
end
|
61
|
+
|
62
|
+
public
|
63
|
+
|
64
|
+
def eql?(other)
|
65
|
+
self == other
|
66
|
+
end
|
67
|
+
|
68
|
+
def hash
|
69
|
+
[ingredients, cookware, timers, steps, metadata].hash
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cooklang
|
4
|
+
class Section
|
5
|
+
attr_reader :name, :steps
|
6
|
+
|
7
|
+
def initialize(name:, steps: [])
|
8
|
+
@name = name&.to_s&.freeze
|
9
|
+
@steps = steps.freeze
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_s
|
13
|
+
name || "Section"
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_h
|
17
|
+
{
|
18
|
+
name: name,
|
19
|
+
steps: steps.map(&:to_h)
|
20
|
+
}.compact
|
21
|
+
end
|
22
|
+
|
23
|
+
def ==(other)
|
24
|
+
other.is_a?(Section) &&
|
25
|
+
name == other.name &&
|
26
|
+
steps == other.steps
|
27
|
+
end
|
28
|
+
|
29
|
+
def hash
|
30
|
+
[name, steps].hash
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cooklang
|
4
|
+
class Step
|
5
|
+
attr_reader :segments
|
6
|
+
|
7
|
+
def initialize(segments:)
|
8
|
+
@segments = segments.freeze
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_text
|
12
|
+
text_parts = @segments.map do |segment|
|
13
|
+
case segment
|
14
|
+
when Hash
|
15
|
+
case segment[:type]
|
16
|
+
when :ingredient
|
17
|
+
segment[:name]
|
18
|
+
when :cookware
|
19
|
+
segment[:name]
|
20
|
+
when :timer
|
21
|
+
segment[:name] || "timer"
|
22
|
+
else
|
23
|
+
segment[:value] || ""
|
24
|
+
end
|
25
|
+
when String
|
26
|
+
segment
|
27
|
+
when Ingredient
|
28
|
+
segment.name
|
29
|
+
when Cookware
|
30
|
+
segment.name
|
31
|
+
when Timer
|
32
|
+
if segment.name
|
33
|
+
"#{segment.name} for #{segment.duration} #{segment.unit}"
|
34
|
+
elsif segment.duration && segment.unit
|
35
|
+
"for #{segment.duration} #{segment.unit}"
|
36
|
+
else
|
37
|
+
"timer"
|
38
|
+
end
|
39
|
+
else
|
40
|
+
segment.to_s
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
result = text_parts.join
|
45
|
+
|
46
|
+
# Special normalization for metadata break patterns:
|
47
|
+
# Convert sequences like "--- \n text \n ---" to "--- text ---"
|
48
|
+
if result.include?("---")
|
49
|
+
result = result.gsub(/---\s*\n\s*/, "--- ").gsub(/\s*\n\s*---/, " ---")
|
50
|
+
end
|
51
|
+
|
52
|
+
result.rstrip
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_h
|
56
|
+
{
|
57
|
+
segments: @segments
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
def ==(other)
|
62
|
+
return false unless other.is_a?(Step)
|
63
|
+
|
64
|
+
segments == other.segments
|
65
|
+
end
|
66
|
+
|
67
|
+
def eql?(other)
|
68
|
+
self == other
|
69
|
+
end
|
70
|
+
|
71
|
+
def hash
|
72
|
+
segments.hash
|
73
|
+
end
|
74
|
+
|
75
|
+
def ingredients_used
|
76
|
+
@segments.filter_map { |segment| segment[:name] if segment.is_a?(Hash) && segment[:type] == :ingredient }
|
77
|
+
end
|
78
|
+
|
79
|
+
def cookware_used
|
80
|
+
@segments.filter_map { |segment| segment[:name] if segment.is_a?(Hash) && segment[:type] == :cookware }
|
81
|
+
end
|
82
|
+
|
83
|
+
def timers_used
|
84
|
+
@segments.select { |segment| segment.is_a?(Hash) && segment[:type] == :timer }
|
85
|
+
end
|
86
|
+
|
87
|
+
def has_ingredients?
|
88
|
+
ingredients_used.any?
|
89
|
+
end
|
90
|
+
|
91
|
+
def has_cookware?
|
92
|
+
cookware_used.any?
|
93
|
+
end
|
94
|
+
|
95
|
+
def has_timers?
|
96
|
+
timers_used.any?
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cooklang
|
4
|
+
class Timer
|
5
|
+
attr_reader :name, :duration, :unit
|
6
|
+
|
7
|
+
def initialize(duration:, unit:, name: nil)
|
8
|
+
@name = name&.to_s&.freeze
|
9
|
+
@duration = duration
|
10
|
+
@unit = unit&.to_s&.freeze
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
result = ""
|
15
|
+
result += "#{@name}: " if @name
|
16
|
+
result += "#{@duration} #{@unit}"
|
17
|
+
result
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_h
|
21
|
+
{
|
22
|
+
name: @name,
|
23
|
+
duration: @duration,
|
24
|
+
unit: @unit
|
25
|
+
}.compact
|
26
|
+
end
|
27
|
+
|
28
|
+
def ==(other)
|
29
|
+
return false unless other.is_a?(Timer)
|
30
|
+
|
31
|
+
name == other.name &&
|
32
|
+
duration == other.duration &&
|
33
|
+
unit == other.unit
|
34
|
+
end
|
35
|
+
|
36
|
+
def eql?(other)
|
37
|
+
self == other
|
38
|
+
end
|
39
|
+
|
40
|
+
def hash
|
41
|
+
[name, duration, unit].hash
|
42
|
+
end
|
43
|
+
|
44
|
+
def total_seconds
|
45
|
+
return @duration unless @unit
|
46
|
+
|
47
|
+
case @unit.downcase
|
48
|
+
when "second", "seconds", "sec", "s"
|
49
|
+
@duration
|
50
|
+
when "minute", "minutes", "min", "m"
|
51
|
+
@duration * 60
|
52
|
+
when "hour", "hours", "hr", "h"
|
53
|
+
@duration * 3600
|
54
|
+
when "day", "days", "d"
|
55
|
+
@duration * 86_400
|
56
|
+
else
|
57
|
+
@duration
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def has_name?
|
62
|
+
!@name.nil?
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Cooklang
|
6
|
+
class TokenStream
|
7
|
+
include Enumerable
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
# Delegate array-like methods to @tokens
|
11
|
+
def_delegators :@tokens, :size, :length, :empty?
|
12
|
+
|
13
|
+
attr_reader :position
|
14
|
+
|
15
|
+
def initialize(tokens)
|
16
|
+
@tokens = tokens
|
17
|
+
@position = 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def current
|
21
|
+
@tokens[@position]
|
22
|
+
end
|
23
|
+
|
24
|
+
def peek(offset = 1)
|
25
|
+
@tokens[@position + offset]
|
26
|
+
end
|
27
|
+
|
28
|
+
def consume(expected_type = nil)
|
29
|
+
return nil if eof?
|
30
|
+
return nil if expected_type && current&.type != expected_type
|
31
|
+
|
32
|
+
token = current
|
33
|
+
@position += 1
|
34
|
+
token
|
35
|
+
end
|
36
|
+
|
37
|
+
def eof?
|
38
|
+
@position >= @tokens.length
|
39
|
+
end
|
40
|
+
|
41
|
+
# Ruby Enumerable support
|
42
|
+
def each
|
43
|
+
while !eof?
|
44
|
+
yield consume
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# StringScanner-inspired methods
|
49
|
+
def scan(type)
|
50
|
+
consume if check(type)
|
51
|
+
end
|
52
|
+
|
53
|
+
def check(type)
|
54
|
+
current&.type == type
|
55
|
+
end
|
56
|
+
|
57
|
+
def skip(type)
|
58
|
+
@position += 1 if check(type)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Convenience methods for complex parsing
|
62
|
+
def consume_while(&block)
|
63
|
+
result = []
|
64
|
+
while !eof? && block.call(current)
|
65
|
+
result << consume
|
66
|
+
end
|
67
|
+
result
|
68
|
+
end
|
69
|
+
|
70
|
+
def consume_until(&block)
|
71
|
+
result = []
|
72
|
+
until eof? || block.call(current)
|
73
|
+
result << consume
|
74
|
+
end
|
75
|
+
result
|
76
|
+
end
|
77
|
+
|
78
|
+
def skip_whitespace
|
79
|
+
consume_while { |token| token.type == :whitespace }
|
80
|
+
end
|
81
|
+
|
82
|
+
# Advanced iteration with lookahead
|
83
|
+
def each_with_lookahead
|
84
|
+
return enum_for(:each_with_lookahead) unless block_given?
|
85
|
+
|
86
|
+
(0...(@tokens.length - 1)).each do |i|
|
87
|
+
yield @tokens[i], @tokens[i + 1]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# StringScanner-inspired position methods
|
92
|
+
def rest
|
93
|
+
@tokens[@position..]
|
94
|
+
end
|
95
|
+
|
96
|
+
def reset
|
97
|
+
@position = 0
|
98
|
+
end
|
99
|
+
|
100
|
+
def rewind(steps = 1)
|
101
|
+
@position = [@position - steps, 0].max
|
102
|
+
end
|
103
|
+
|
104
|
+
# Utility methods
|
105
|
+
def find_next(type)
|
106
|
+
(@position...@tokens.length).find { |i| @tokens[i].type == type }
|
107
|
+
end
|
108
|
+
|
109
|
+
def find_next_matching(&block)
|
110
|
+
(@position...@tokens.length).find { |i| block.call(@tokens[i]) }
|
111
|
+
end
|
112
|
+
|
113
|
+
# Create a new stream starting from current position
|
114
|
+
def slice_from_current
|
115
|
+
TokenStream.new(@tokens[@position..])
|
116
|
+
end
|
117
|
+
|
118
|
+
# Public interface methods to avoid instance_variable_get/set
|
119
|
+
attr_reader :tokens
|
120
|
+
|
121
|
+
def position=(new_position)
|
122
|
+
@position = [new_position, 0].max
|
123
|
+
@position = [@position, @tokens.length].min
|
124
|
+
end
|
125
|
+
|
126
|
+
def advance_to(new_position)
|
127
|
+
self.position = new_position
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
data/lib/cooklang/version.rb
CHANGED
data/lib/cooklang.rb
CHANGED
@@ -1,8 +1,29 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "cooklang/version"
|
4
|
+
require_relative "cooklang/lexer"
|
5
|
+
require_relative "cooklang/parser"
|
6
|
+
require_relative "cooklang/recipe"
|
7
|
+
require_relative "cooklang/ingredient"
|
8
|
+
require_relative "cooklang/cookware"
|
9
|
+
require_relative "cooklang/timer"
|
10
|
+
require_relative "cooklang/step"
|
11
|
+
require_relative "cooklang/metadata"
|
12
|
+
require_relative "cooklang/section"
|
13
|
+
require_relative "cooklang/note"
|
14
|
+
require_relative "cooklang/formatter"
|
15
|
+
require_relative "cooklang/formatters/text"
|
4
16
|
|
5
17
|
module Cooklang
|
6
18
|
class Error < StandardError; end
|
7
|
-
|
19
|
+
class ParseError < Error; end
|
20
|
+
|
21
|
+
def self.parse(input)
|
22
|
+
parser = Parser.new
|
23
|
+
parser.parse(input)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.parse_file(file_path)
|
27
|
+
parse(File.read(file_path))
|
28
|
+
end
|
8
29
|
end
|