ollama-ruby 0.12.1 → 0.14.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -0
  3. data/CHANGES.md +39 -0
  4. data/README.md +70 -144
  5. data/Rakefile +5 -17
  6. data/bin/ollama_cli +37 -6
  7. data/lib/ollama/client/command.rb +2 -2
  8. data/lib/ollama/dto.rb +4 -0
  9. data/lib/ollama/version.rb +1 -1
  10. data/lib/ollama.rb +0 -11
  11. data/ollama-ruby.gemspec +11 -22
  12. data/spec/ollama/message_spec.rb +9 -0
  13. metadata +25 -255
  14. data/bin/ollama_chat +0 -1248
  15. data/config/redis.conf +0 -5
  16. data/docker-compose.yml +0 -10
  17. data/lib/ollama/documents/cache/common.rb +0 -36
  18. data/lib/ollama/documents/cache/memory_cache.rb +0 -44
  19. data/lib/ollama/documents/cache/records.rb +0 -87
  20. data/lib/ollama/documents/cache/redis_backed_memory_cache.rb +0 -39
  21. data/lib/ollama/documents/cache/redis_cache.rb +0 -68
  22. data/lib/ollama/documents/cache/sqlite_cache.rb +0 -215
  23. data/lib/ollama/documents/splitters/character.rb +0 -72
  24. data/lib/ollama/documents/splitters/semantic.rb +0 -91
  25. data/lib/ollama/documents.rb +0 -184
  26. data/lib/ollama/utils/cache_fetcher.rb +0 -38
  27. data/lib/ollama/utils/chooser.rb +0 -52
  28. data/lib/ollama/utils/colorize_texts.rb +0 -65
  29. data/lib/ollama/utils/fetcher.rb +0 -175
  30. data/lib/ollama/utils/file_argument.rb +0 -34
  31. data/lib/ollama/utils/math.rb +0 -48
  32. data/lib/ollama/utils/tags.rb +0 -67
  33. data/spec/assets/embeddings.json +0 -1
  34. data/spec/assets/prompt.txt +0 -1
  35. data/spec/ollama/documents/cache/memory_cache_spec.rb +0 -97
  36. data/spec/ollama/documents/cache/redis_backed_memory_cache_spec.rb +0 -118
  37. data/spec/ollama/documents/cache/redis_cache_spec.rb +0 -121
  38. data/spec/ollama/documents/cache/sqlite_cache_spec.rb +0 -141
  39. data/spec/ollama/documents/splitters/character_spec.rb +0 -110
  40. data/spec/ollama/documents/splitters/semantic_spec.rb +0 -56
  41. data/spec/ollama/documents_spec.rb +0 -162
  42. data/spec/ollama/utils/cache_fetcher_spec.rb +0 -43
  43. data/spec/ollama/utils/colorize_texts_spec.rb +0 -13
  44. data/spec/ollama/utils/fetcher_spec.rb +0 -137
  45. data/spec/ollama/utils/file_argument_spec.rb +0 -17
  46. data/spec/ollama/utils/tags_spec.rb +0 -53
@@ -1,184 +0,0 @@
1
- require 'numo/narray'
2
- require 'digest'
3
- require 'kramdown/ansi'
4
-
5
- class Ollama::Documents
6
- end
7
- module Ollama::Documents::Cache
8
- end
9
- require 'ollama/documents/cache/records'
10
- require 'ollama/documents/cache/memory_cache'
11
- require 'ollama/documents/cache/redis_cache'
12
- require 'ollama/documents/cache/redis_backed_memory_cache'
13
- require 'ollama/documents/cache/sqlite_cache'
14
- module Ollama::Documents::Splitters
15
- end
16
- require 'ollama/documents/splitters/character'
17
- require 'ollama/documents/splitters/semantic'
18
-
19
- class Ollama::Documents
20
- include Kramdown::ANSI::Width
21
- include Ollama::Documents::Cache
22
-
23
- Record = Class.new Ollama::Documents::Cache::Records::Record
24
-
25
- def initialize(ollama:, model:, model_options: nil, collection: nil, embedding_length: 1_024, cache: MemoryCache, database_filename: nil, redis_url: nil, debug: false)
26
- collection ||= default_collection
27
- @ollama, @model, @model_options, @collection, @debug =
28
- ollama, model, model_options, collection.to_sym, debug
29
- database_filename ||= ':memory:'
30
- @cache = connect_cache(cache, redis_url, embedding_length, database_filename)
31
- end
32
-
33
- def default_collection
34
- :default
35
- end
36
-
37
- attr_reader :ollama, :model, :collection, :cache
38
-
39
- def collection=(new_collection)
40
- @collection = new_collection.to_sym
41
- @cache.prefix = prefix
42
- end
43
-
44
- def add(inputs, batch_size: nil, source: nil, tags: [])
45
- inputs = Array(inputs)
46
- batch_size ||= 10
47
- tags = Ollama::Utils::Tags.new(tags, source:)
48
- if source
49
- tags.add(File.basename(source).gsub(/\?.*/, ''), source:)
50
- end
51
- inputs.map! { |i|
52
- text = i.respond_to?(:read) ? i.read : i.to_s
53
- text
54
- }
55
- inputs.reject! { |i| exist?(i) }
56
- inputs.empty? and return self
57
- if @debug
58
- puts Ollama::Utils::ColorizeTexts.new(inputs)
59
- end
60
- batches = inputs.each_slice(batch_size).
61
- with_infobar(
62
- label: "Add #{truncate(tags.to_s(link: false), percentage: 25)}",
63
- total: inputs.size
64
- )
65
- batches.each do |batch|
66
- embeddings = fetch_embeddings(model:, options: @model_options, input: batch)
67
- batch.zip(embeddings) do |text, embedding|
68
- norm = @cache.norm(embedding)
69
- self[text] = Record[text:, embedding:, norm:, source:, tags: tags.to_a]
70
- end
71
- infobar.progress by: batch.size
72
- end
73
- infobar.newline
74
- self
75
- end
76
- alias << add
77
-
78
- def [](text)
79
- @cache[key(text)]
80
- end
81
-
82
- def []=(text, record)
83
- @cache[key(text)] = record
84
- end
85
-
86
- def exist?(text)
87
- @cache.key?(key(text))
88
- end
89
-
90
- def delete(text)
91
- @cache.delete(key(text))
92
- end
93
-
94
- def size
95
- @cache.size
96
- end
97
-
98
- def clear(tags: nil)
99
- @cache.clear(tags:)
100
- self
101
- end
102
-
103
- def find(string, tags: nil, prompt: nil, max_records: nil)
104
- needle = convert_to_vector(string, prompt:)
105
- @cache.find_records(needle, tags:, max_records: nil)
106
- end
107
-
108
- def find_where(string, text_size: nil, text_count: nil, **opts)
109
- if text_count
110
- opts[:max_records] = text_count
111
- end
112
- records = find(string, **opts)
113
- size, count = 0, 0
114
- records.take_while do |record|
115
- if text_size and (size += record.text.size) > text_size
116
- next false
117
- end
118
- if text_count and (count += 1) > text_count
119
- next false
120
- end
121
- true
122
- end
123
- end
124
-
125
- def collections
126
- ([ default_collection ] + @cache.collections('%s-' % self.class)).uniq
127
- end
128
-
129
- def tags
130
- @cache.tags
131
- end
132
-
133
- private
134
-
135
- def connect_cache(cache_class, redis_url, embedding_length, database_filename)
136
- cache = nil
137
- if (cache_class.instance_method(:redis) rescue nil)
138
- begin
139
- cache = cache_class.new(prefix:, url: redis_url, object_class: Record)
140
- cache.size
141
- rescue Redis::CannotConnectError
142
- STDERR.puts(
143
- "Cannot connect to redis URL #{redis_url.inspect}, "\
144
- "falling back to MemoryCache."
145
- )
146
- end
147
- elsif cache_class == SQLiteCache
148
- cache = cache_class.new(
149
- prefix:,
150
- embedding_length:,
151
- filename: database_filename,
152
- debug: @debug
153
- )
154
- end
155
- ensure
156
- cache ||= MemoryCache.new(prefix:,)
157
- cache.respond_to?(:find_records) or cache.extend(Records::FindRecords)
158
- cache.extend(Records::Tags)
159
- if cache.respond_to?(:redis)
160
- cache.extend(Records::RedisFullEach)
161
- end
162
- return cache
163
- end
164
-
165
- def convert_to_vector(input, prompt: nil)
166
- if prompt
167
- input = prompt % input
168
- end
169
- input.is_a?(String) and input = fetch_embeddings(model:, input:).first
170
- @cache.convert_to_vector(input)
171
- end
172
-
173
- def fetch_embeddings(model:, input:, options: nil)
174
- @ollama.embed(model:, input:, options:).embeddings
175
- end
176
-
177
- def prefix
178
- '%s-%s-' % [ self.class, @collection ]
179
- end
180
-
181
- def key(input)
182
- Digest::SHA256.hexdigest(input)
183
- end
184
- end
@@ -1,38 +0,0 @@
1
- require 'digest/md5'
2
-
3
- class Ollama::Utils::CacheFetcher
4
- def initialize(cache)
5
- @cache = cache
6
- end
7
-
8
- def get(url, &block)
9
- block or raise ArgumentError, 'require block argument'
10
- body = @cache[key(:body, url)]
11
- content_type = @cache[key(:content_type, url)]
12
- content_type = MIME::Types[content_type].first
13
- if body && content_type
14
- io = StringIO.new(body)
15
- io.rewind
16
- io.extend(Ollama::Utils::Fetcher::HeaderExtension)
17
- io.content_type = content_type
18
- block.(io)
19
- end
20
- end
21
-
22
- def put(url, io)
23
- io.rewind
24
- body = io.read
25
- body.empty? and return
26
- content_type = io.content_type
27
- content_type.nil? and return
28
- @cache.set(key(:body, url), body, ex: io.ex)
29
- @cache.set(key(:content_type, url), content_type.to_s, ex: io.ex)
30
- self
31
- end
32
-
33
- private
34
-
35
- def key(type, url)
36
- [ type, Digest::MD5.hexdigest(url) ] * ?-
37
- end
38
- end
@@ -1,52 +0,0 @@
1
- require 'amatch'
2
- require 'search_ui'
3
- require 'term/ansicolor'
4
-
5
- module Ollama::Utils::Chooser
6
- include SearchUI
7
- include Term::ANSIColor
8
-
9
- module_function
10
-
11
- # The choose method presents a list of entries and prompts the user
12
- # for input, allowing them to select one entry based on their input.
13
- #
14
- # @param entries [Array] the list of entries to present to the user
15
- # @param prompt [String] the prompt message to display when asking for input (default: 'Search? %s')
16
- # @param return_immediately [Boolean] whether to immediately return the first entry if there is only one or nil when there is none (default: false)
17
- #
18
- # @return [Object] the selected entry, or nil if no entry was chosen
19
- #
20
- # @example
21
- # choose(['entry1', 'entry2'], prompt: 'Choose an option:')
22
- def choose(entries, prompt: 'Search? %s', return_immediately: false)
23
- if return_immediately && entries.size <= 1
24
- return entries.first
25
- end
26
- entry = Search.new(
27
- prompt:,
28
- match: -> answer {
29
- matcher = Amatch::PairDistance.new(answer.downcase)
30
- matches = entries.map { |n| [ n, -matcher.similar(n.to_s.downcase) ] }.
31
- select { |_, s| s < 0 }.sort_by(&:last).map(&:first)
32
- matches.empty? and matches = entries
33
- matches.first(Tins::Terminal.lines - 1)
34
- },
35
- query: -> _answer, matches, selector {
36
- matches.each_with_index.map { |m, i|
37
- i == selector ? "#{blue{?⮕}} #{on_blue{m}}" : " #{m.to_s}"
38
- } * ?\n
39
- },
40
- found: -> _answer, matches, selector {
41
- matches[selector]
42
- },
43
- output: STDOUT
44
- ).start
45
- if entry
46
- entry
47
- else
48
- print clear_screen, move_home
49
- nil
50
- end
51
- end
52
- end
@@ -1,65 +0,0 @@
1
- require 'term/ansicolor'
2
- require 'kramdown/ansi'
3
-
4
- class Ollama::Utils::ColorizeTexts
5
- include Math
6
- include Term::ANSIColor
7
- include Kramdown::ANSI::Width
8
-
9
- # Initializes a new instance of Ollama::Utils::ColorizeTexts
10
- #
11
- # @param [Array<String>] texts the array of strings to be displayed with colors
12
- #
13
- # @return [Ollama::Utils::ColorizeTexts] an instance of Ollama::Utils::ColorizeTexts
14
- def initialize(*texts)
15
- texts = texts.map(&:to_a)
16
- @texts = Array(texts.flatten)
17
- end
18
-
19
- # Returns a string representation of the object, including all texts content,
20
- # colored differently and their sizes.
21
- #
22
- # @return [String] The formatted string.
23
- def to_s
24
- result = +''
25
- @texts.each_with_index do |t, i|
26
- color = colors[(t.hash ^ i.hash) % colors.size]
27
- wrap(t, percentage: 90).each_line { |l|
28
- result << on_color(color) { color(text_color(color)) { l } }
29
- }
30
- result << "\n##{bold{t.size.to_s}} \n\n"
31
- end
32
- result
33
- end
34
-
35
- private
36
-
37
- # Returns the nearest RGB color to the given ANSI color
38
- #
39
- # @param [color] color The ANSI color attribute
40
- #
41
- # @return [Array<RGBTriple>] An array containing two RGB colors, one for black and
42
- # one for white text, where the first is the closest match to the input color
43
- # when printed on a black background, and the second is the closest match
44
- # when printed on a white background.
45
- def text_color(color)
46
- color = Term::ANSIColor::Attribute[color]
47
- [
48
- Attribute.nearest_rgb_color('#000'),
49
- Attribute.nearest_rgb_color('#fff'),
50
- ].max_by { |t| t.distance_to(color) }
51
- end
52
-
53
- # Returns an array of colors for each step in the gradient
54
- #
55
- # @return [Array<Array<Integer>>] An array of RGB color arrays
56
- def colors
57
- @colors ||= (0..255).map { |i|
58
- [
59
- 128 + 128 * sin(PI * i / 32.0),
60
- 128 + 128 * sin(PI * i / 64.0),
61
- 128 + 128 * sin(PI * i / 128.0),
62
- ].map { _1.clamp(0, 255).round }
63
- }
64
- end
65
- end
@@ -1,175 +0,0 @@
1
- require 'tempfile'
2
- require 'tins/unit'
3
- require 'infobar'
4
- require 'mime-types'
5
- require 'stringio'
6
- require 'ollama/utils/cache_fetcher'
7
-
8
- class Ollama::Utils::Fetcher
9
- module HeaderExtension
10
- attr_accessor :content_type
11
-
12
- attr_accessor :ex
13
-
14
- def self.failed
15
- object = StringIO.new.extend(self)
16
- object.content_type = MIME::Types['text/plain'].first
17
- object
18
- end
19
- end
20
-
21
- class RetryWithoutStreaming < StandardError; end
22
-
23
- def self.get(url, **options, &block)
24
- cache = options.delete(:cache) and
25
- cache = Ollama::Utils::CacheFetcher.new(cache)
26
- if result = cache&.get(url, &block)
27
- infobar.puts "Getting #{url.to_s.inspect} from cache."
28
- return result
29
- else
30
- new(**options).send(:get, url) do |tmp|
31
- result = block.(tmp)
32
- if cache && !tmp.is_a?(StringIO)
33
- tmp.rewind
34
- cache.put(url, tmp)
35
- end
36
- result
37
- end
38
- end
39
- end
40
-
41
- def self.normalize_url(url)
42
- url = url.to_s
43
- url = URI.decode_uri_component(url)
44
- url = url.sub(/#.*/, '')
45
- URI::Parser.new.escape(url).to_s
46
- end
47
-
48
- def self.read(filename, &block)
49
- if File.exist?(filename)
50
- File.open(filename) do |file|
51
- file.extend(Ollama::Utils::Fetcher::HeaderExtension)
52
- file.content_type = MIME::Types.type_for(filename).first
53
- block.(file)
54
- end
55
- else
56
- STDERR.puts "File #{filename.to_s.inspect} doesn't exist."
57
- end
58
- end
59
-
60
- def self.execute(command, &block)
61
- Tempfile.open do |tmp|
62
- IO.popen(command) do |command|
63
- until command.eof?
64
- tmp.write command.read(1 << 14)
65
- end
66
- tmp.rewind
67
- tmp.extend(Ollama::Utils::Fetcher::HeaderExtension)
68
- tmp.content_type = MIME::Types['text/plain'].first
69
- block.(tmp)
70
- end
71
- end
72
- rescue => e
73
- STDERR.puts "Cannot execute #{command.inspect} (#{e})"
74
- if @debug && !e.is_a?(RuntimeError)
75
- STDERR.puts "#{e.backtrace * ?\n}"
76
- end
77
- yield HeaderExtension.failed
78
- end
79
-
80
- def initialize(debug: false, http_options: {})
81
- @debug = debug
82
- @started = false
83
- @streaming = true
84
- @http_options = http_options
85
- end
86
-
87
- private
88
-
89
- def excon(url, **options)
90
- url = self.class.normalize_url(url)
91
- Excon.new(url, options.merge(@http_options))
92
- end
93
-
94
- def get(url, &block)
95
- response = nil
96
- Tempfile.open do |tmp|
97
- infobar.label = 'Getting'
98
- if @streaming
99
- response = excon(url, headers:, response_block: callback(tmp)).request(method: :get)
100
- response.status != 200 || !@started and raise RetryWithoutStreaming
101
- decorate_io(tmp, response)
102
- infobar.finish
103
- block.(tmp)
104
- else
105
- response = excon(url, headers:, middlewares:).request(method: :get)
106
- if response.status != 200
107
- raise "invalid response status code"
108
- end
109
- body = response.body
110
- tmp.print body
111
- infobar.update(message: message(body.size, body.size), force: true)
112
- decorate_io(tmp, response)
113
- infobar.finish
114
- block.(tmp)
115
- end
116
- end
117
- rescue RetryWithoutStreaming
118
- @streaming = false
119
- retry
120
- rescue => e
121
- STDERR.puts "Cannot get #{url.to_s.inspect} (#{e}): #{response&.status_line || 'n/a'}"
122
- if @debug && !e.is_a?(RuntimeError)
123
- STDERR.puts "#{e.backtrace * ?\n}"
124
- end
125
- yield HeaderExtension.failed
126
- end
127
-
128
- def headers
129
- {
130
- 'User-Agent' => Ollama::Client.user_agent,
131
- }
132
- end
133
-
134
- def middlewares
135
- (Excon.defaults[:middlewares] + [ Excon::Middleware::RedirectFollower ]).uniq
136
- end
137
-
138
- private
139
-
140
- def decorate_io(tmp, response)
141
- tmp.rewind
142
- tmp.extend(HeaderExtension)
143
- if content_type = MIME::Types[response.headers['content-type']].first
144
- tmp.content_type = content_type
145
- end
146
- if cache_control = response.headers['cache-control'] and
147
- cache_control !~ /no-store|no-cache/ and
148
- ex = cache_control[/s-maxage\s*=\s*(\d+)/, 1] || cache_control[/max-age\s*=\s*(\d+)/, 1]
149
- then
150
- tmp.ex = ex.to_i
151
- end
152
- end
153
-
154
- def callback(tmp)
155
- -> chunk, remaining_bytes, total_bytes do
156
- total = total_bytes or next
157
- current = total_bytes - remaining_bytes
158
- if @started
159
- infobar.counter.progress(by: total - current)
160
- else
161
- @started = true
162
- infobar.counter.reset(total:, current:)
163
- end
164
- infobar.update(message: message(current, total), force: true)
165
- tmp.print(chunk)
166
- end
167
- end
168
-
169
- def message(current, total)
170
- progress = '%s/%s' % [ current, total ].map {
171
- Tins::Unit.format(_1, format: '%.2f %U')
172
- }
173
- '%l ' + progress + ' in %te, ETA %e @%E'
174
- end
175
- end
@@ -1,34 +0,0 @@
1
- module Ollama::Utils::FileArgument
2
- module_function
3
-
4
- # Returns the contents of a file or string, or a default value if neither is provided.
5
- #
6
- # @param [String] path_or_content The path to a file or a string containing
7
- # the content.
8
- #
9
- # @param [String] default The default value to return if no valid input is
10
- # given. Defaults to nil.
11
- #
12
- # @return [String] The contents of the file, the string, or the default value.
13
- #
14
- # @example Get the contents of a file
15
- # get_file_argument('path/to/file')
16
- #
17
- # @example Use a string as content
18
- # get_file_argument('string content')
19
- #
20
- # @example Return a default value if no valid input is given
21
- # get_file_argument(nil, default: 'default content')
22
- def get_file_argument(path_or_content, default: nil)
23
- if path_or_content.present? && path_or_content.size < 2 ** 15 &&
24
- File.basename(path_or_content).size < 2 ** 8 &&
25
- File.exist?(path_or_content)
26
- then
27
- File.read(path_or_content)
28
- elsif path_or_content.present?
29
- path_or_content
30
- else
31
- default
32
- end
33
- end
34
- end
@@ -1,48 +0,0 @@
1
- module Ollama::Utils::Math
2
- # Returns the cosine similarity between two vectors +a+ and +b+, 1.0 is
3
- # exactly the same, 0.0 means decorrelated.
4
- #
5
- # @param [Vector] a The first vector
6
- # @param [Vector] b The second vector
7
- # @option a_norm [Float] a The Euclidean norm of vector a (default: calculated from a)
8
- # @option b_norm [Float] b The Euclidean norm of vector b (default: calculated from b)
9
- #
10
- # @return [Float] The cosine similarity between the two vectors
11
- #
12
- # @example Calculate the cosine similarity between two vectors
13
- # cosine_similarity(a: [1, 2], b: [3, 4])
14
- #
15
- # @see #convert_to_vector
16
- # @see #norm
17
- def cosine_similarity(a:, b:, a_norm: norm(a), b_norm: norm(b))
18
- a, b = convert_to_vector(a), convert_to_vector(b)
19
- a.dot(b) / (a_norm * b_norm)
20
- end
21
-
22
- # Returns the Euclidean norm (magnitude) of a vector.
23
- #
24
- # @param vector [Array] The input vector.
25
- #
26
- # @return [Float] The magnitude of the vector.
27
- #
28
- # @example
29
- # norm([3, 4]) # => 5.0
30
- def norm(vector)
31
- s = 0.0
32
- vector.each { s += _1.abs2 }
33
- Math.sqrt(s)
34
- end
35
-
36
- # Converts an array to a Numo NArray.
37
- #
38
- # @param [Array] vector The input array to be converted.
39
- #
40
- # @return [Numo::NArray] The converted NArray, or the original if it's already a Numo NArray.
41
- #
42
- # @example Convert an array to a Numo NArray
43
- # convert_to_vector([1, 2, 3]) # => Numo::NArray[1, 2, 3]
44
- def convert_to_vector(vector)
45
- vector.is_a?(Numo::NArray) and return vector
46
- Numo::NArray[*vector]
47
- end
48
- end
@@ -1,67 +0,0 @@
1
- class Ollama::Utils::Tags
2
- class Tag < String
3
- include Term::ANSIColor
4
-
5
- def initialize(tag, source: nil)
6
- super(tag.to_s.gsub(/\A#+/, ''))
7
- self.source = source
8
- end
9
-
10
- attr_accessor :source
11
-
12
- alias_method :internal, :to_s
13
-
14
- def to_s(link: true)
15
- tag_string = start_with?(?#) ? super() : ?# + super()
16
- my_source = source
17
- if link && my_source
18
- unless my_source =~ %r(\A(https?|file)://)
19
- my_source = 'file://%s' % File.expand_path(my_source)
20
- end
21
- hyperlink(my_source) { tag_string }
22
- else
23
- tag_string
24
- end
25
- end
26
- end
27
-
28
- def initialize(tags = [], source: nil)
29
- tags = Array(tags)
30
- @set = []
31
- tags.each { |tag| add(tag, source:) }
32
- end
33
-
34
- def add(tag, source: nil)
35
- unless tag.is_a?(Tag)
36
- tag = Tag.new(tag, source:)
37
- end
38
- index = @set.bsearch_index { _1 >= tag }
39
- if index == nil
40
- @set.push(tag)
41
- elsif @set.at(index) != tag
42
- @set.insert(index, tag)
43
- end
44
- self
45
- end
46
-
47
- def empty?
48
- @set.empty?
49
- end
50
-
51
- def size
52
- @set.size
53
- end
54
-
55
- def clear
56
- @set.clear
57
- end
58
-
59
- def each(&block)
60
- @set.each(&block)
61
- end
62
- include Enumerable
63
-
64
- def to_s(link: true)
65
- @set.map { |tag| tag.to_s(link:) } * ' '
66
- end
67
- end