knitsearch 0.1.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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "rails/generators/active_record"
6
+ require "active_record"
7
+
8
+ module Knitsearch
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ desc "Create FTS5 search index for a model. Usage: " \
13
+ "bin/rails generate knitsearch:install Article title body"
14
+
15
+ argument :model_name, type: :string, banner: "MODEL"
16
+ argument :columns, type: :array, default: [], banner: "column1 column2 ..."
17
+
18
+ class_option :dictionary, type: :string, default: "simple",
19
+ desc: "Dictionary for stemming: simple (none), english (default: simple)"
20
+ class_option :tokenizer, type: :string, default: nil,
21
+ desc: "[DEPRECATED] Use --dictionary instead"
22
+ class_option :associated, type: :array, default: [],
23
+ desc: "Associated fields to index, format: assoc:column. Repeat for multiple. Example: --associated user:display_name --associated tags:name"
24
+
25
+ source_root File.expand_path("templates", __dir__)
26
+
27
+ def self.next_migration_number(dirname)
28
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
29
+ end
30
+
31
+ def verify_sqlite_adapter
32
+ adapter = primary_adapter_from_configuration
33
+ return if adapter == "sqlite3"
34
+
35
+ raise ::Thor::Error,
36
+ "knitsearch requires SQLite. Detected adapter: #{adapter.inspect}. " \
37
+ "Use pg_search or your database's native FTS instead."
38
+ end
39
+
40
+ def verify_columns_provided
41
+ return unless columns.empty?
42
+
43
+ raise ::Thor::Error,
44
+ "knitsearch:install requires at least one column to index. " \
45
+ "Example: bin/rails generate knitsearch:install Article title body"
46
+ end
47
+
48
+ def verify_table_exists
49
+ return if table_exists?(source_table_name)
50
+
51
+ raise ::Thor::Error,
52
+ "Table `#{source_table_name}` does not exist. " \
53
+ "Run `bin/rails db:migrate` first to create the #{model_name} table."
54
+ end
55
+
56
+ def verify_column_names
57
+ invalid = columns.reject { |c| c.match?(/\A[a-z_][a-z0-9_]*\z/) }
58
+ return if invalid.empty?
59
+
60
+ raise ::Thor::Error,
61
+ "Column names must be lowercase identifiers (letters, digits, underscores). " \
62
+ "Invalid: #{invalid.inspect}"
63
+ end
64
+
65
+ def verify_migration_not_exists
66
+ pattern = File.join(destination_root, "db/migrate/*_create_#{fts_table_name}.rb")
67
+ existing = Dir.glob(pattern)
68
+ return if existing.empty?
69
+
70
+ raise ::Thor::Error,
71
+ "A migration for #{fts_table_name} already exists: #{File.basename(existing.first)}. " \
72
+ "Delete it first if you want to regenerate."
73
+ end
74
+
75
+ def create_migration
76
+ timestamp = self.class.next_migration_number("db/migrate")
77
+ migration_filename = "#{timestamp}_create_#{fts_table_name}.rb"
78
+ migration_path = File.join(destination_root, "db/migrate", migration_filename)
79
+
80
+ if options[:tokenizer].present?
81
+ puts "WARNING: --tokenizer is deprecated. Use --dictionary instead."
82
+ end
83
+
84
+ rich_text_cols = detect_rich_text_columns
85
+ dictionary = options[:dictionary] || "simple"
86
+ associated = parse_associated_against
87
+
88
+ associated_clause = associated.any? ? ",\n associated_against: #{associated.inspect}" : ""
89
+
90
+ migration_content = <<~RUBY
91
+ class Create#{fts_table_name.camelize} < ActiveRecord::Migration#{migration_version}
92
+ include Knitsearch::Migration
93
+
94
+ def up
95
+ create_searchable_table #{source_table_name.inspect},
96
+ columns: #{columns.inspect},
97
+ dictionary: #{dictionary.inspect},
98
+ rich_text_columns: #{rich_text_cols.inspect}#{associated_clause}
99
+ end
100
+
101
+ def down
102
+ drop_searchable_table #{source_table_name.inspect}
103
+ end
104
+ end
105
+ RUBY
106
+
107
+ create_file migration_path, migration_content
108
+
109
+ puts "\nMigration created: #{migration_path}"
110
+ puts "\nNext steps:"
111
+ puts " 1. bin/rails db:migrate"
112
+ if associated.any?
113
+ puts " 2. Add to #{model_name} model:"
114
+ puts " searchable_by("
115
+ puts " against: { #{columns.map { |c| "#{c}: \"A\"" }.join(", ")} },"
116
+ puts " associated_against: #{associated.inspect}"
117
+ puts " )"
118
+ puts " 3. bin/rails knitsearch:backfill[#{model_name}]"
119
+ else
120
+ puts " 2. Add to #{model_name} model:"
121
+ puts " searchable_by(against: { #{columns.map { |c| "#{c}: \"A\"" }.join(", ")} })"
122
+ puts " 3. bin/rails knitsearch:backfill[#{model_name}]"
123
+ end
124
+ end
125
+
126
+ no_tasks do
127
+ def model_class
128
+ @model_class ||= model_name.classify.constantize
129
+ rescue ::NameError
130
+ raise ::Thor::Error,
131
+ "Could not find model #{model_name}. " \
132
+ "Generate it first: bin/rails generate model #{model_name}"
133
+ end
134
+
135
+ def source_table_name
136
+ model_class.table_name
137
+ end
138
+
139
+ def fts_table_name
140
+ "#{source_table_name}_fts"
141
+ end
142
+
143
+ def migration_class_name
144
+ "Create#{fts_table_name.camelize}"
145
+ end
146
+
147
+ def migration_version
148
+ "[#{::Rails::VERSION::MAJOR}.#{::Rails::VERSION::MINOR}]"
149
+ end
150
+
151
+ def table_exists?(table_name)
152
+ ::ActiveRecord::Base.connection.table_exists?(table_name)
153
+ end
154
+
155
+ def detect_rich_text_columns
156
+ columns.select do |col|
157
+ model_class.respond_to?(:rich_text_attributes) &&
158
+ model_class.rich_text_attributes.include?(col.to_sym)
159
+ end
160
+ end
161
+
162
+ def parse_associated_against
163
+ return {} if options[:associated].empty?
164
+
165
+ result = {}
166
+
167
+ options[:associated].each do |item|
168
+ parts = item.split(":")
169
+ if parts.size < 2
170
+ raise ::Thor::Error,
171
+ "Invalid --associated format: #{item.inspect}. " \
172
+ "Expected: association:column (e.g., user:name) or association:column:weight (e.g., tags:name:C)"
173
+ end
174
+
175
+ assoc_name = parts[0].to_sym
176
+ column_name = parts[1].to_sym
177
+ weight = parts[2]&.upcase || "C"
178
+
179
+ unless [:A, :B, :C, :D].include?(weight.to_sym)
180
+ raise ::Thor::Error,
181
+ "Invalid weight for #{assoc_name}:#{column_name}: #{weight.inspect}. " \
182
+ "Must be A, B, C, or D."
183
+ end
184
+
185
+ unless model_class.reflect_on_association(assoc_name)
186
+ raise ::Thor::Error,
187
+ "#{model_name} does not have association #{assoc_name.inspect}. " \
188
+ "Check your model's association declarations."
189
+ end
190
+
191
+ result[assoc_name] = [column_name] unless result[assoc_name]
192
+ end
193
+
194
+ result
195
+ end
196
+ end
197
+
198
+ private
199
+ def primary_adapter_from_configuration
200
+ env_name = (defined?(::Rails) && ::Rails.respond_to?(:env)) ? ::Rails.env.to_s : (ENV["RAILS_ENV"] || "development")
201
+ configs = ::ActiveRecord::Base.configurations.configs_for(env_name: env_name)
202
+ return nil if configs.empty?
203
+
204
+ primary = configs.find { |c| c.name == "primary" } || configs.first
205
+ primary.adapter
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,7 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ include Knitsearch::Migration
3
+
4
+ def up
5
+ create_searchable_table <%= source_table_name.inspect %>, columns: <%= columns.inspect %>, tokenizer: <%= options[:tokenizer].to_sym.inspect %>
6
+ end
7
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "rails/generators/active_record"
6
+ require "active_record"
7
+
8
+ module Knitsearch
9
+ class MultisearchInstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ desc "Create FTS5 multi-model search index. Usage: bin/rails generate knitsearch:multisearch_install"
13
+
14
+ class_option :force, type: :boolean, default: false, desc: "Overwrite if migration already exists"
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ def self.next_migration_number(dirname)
19
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
20
+ end
21
+
22
+ def verify_sqlite_adapter
23
+ adapter = primary_adapter_from_configuration
24
+ return if adapter == "sqlite3"
25
+
26
+ raise ::Thor::Error,
27
+ "knitsearch multi-model search requires SQLite. Detected adapter: #{adapter.inspect}. " \
28
+ "Use a different full-text search solution for your database."
29
+ end
30
+
31
+ def verify_table_not_exists
32
+ return if !table_exists?("knitsearches")
33
+
34
+ return if options[:force]
35
+
36
+ raise ::Thor::Error,
37
+ "Table `knitsearches` already exists. " \
38
+ "Run `bin/rails generate knitsearch:multisearch_install --force` to overwrite."
39
+ end
40
+
41
+ def create_migration
42
+ timestamp = self.class.next_migration_number("db/migrate")
43
+ migration_filename = "#{timestamp}_create_knitsearches_multisearch.rb"
44
+ migration_path = File.join(destination_root, "db/migrate", migration_filename)
45
+
46
+ migration_content = <<~RUBY
47
+ class CreateKnitsearchesMultisearch < ActiveRecord::Migration#{migration_version}
48
+ include Knitsearch::Migration
49
+
50
+ def up
51
+ create_multisearch_table
52
+ end
53
+
54
+ def down
55
+ drop_multisearch_table
56
+ end
57
+ end
58
+ RUBY
59
+
60
+ create_file migration_path, migration_content
61
+
62
+ puts "\nMigration created: #{migration_path}"
63
+ puts "\nNext steps:"
64
+ puts " 1. bin/rails db:migrate"
65
+ puts " 2. Add `multisearchable against: [:column1, :column2]` to your models"
66
+ puts " 3. Run backfill for existing records: Model.knitsearch_multisearch_backfill!"
67
+ end
68
+
69
+ no_tasks do
70
+ def migration_version
71
+ "[#{::Rails::VERSION::MAJOR}.#{::Rails::VERSION::MINOR}]"
72
+ end
73
+
74
+ def table_exists?(table_name)
75
+ ::ActiveRecord::Base.connection.table_exists?(table_name)
76
+ end
77
+ end
78
+
79
+ private
80
+ def primary_adapter_from_configuration
81
+ env_name = (defined?(::Rails) && ::Rails.respond_to?(:env)) ? ::Rails.env.to_s : (ENV["RAILS_ENV"] || "development")
82
+ configs = ::ActiveRecord::Base.configurations.configs_for(env_name: env_name)
83
+ return nil if configs.empty?
84
+
85
+ primary = configs.find { |c| c.name == "primary" } || configs.first
86
+ primary.adapter
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Knitsearch
4
+ class Document < ActiveRecord::Base
5
+ self.table_name = "knitsearches"
6
+ belongs_to :searchable, polymorphic: true
7
+
8
+ def self.backfill!(model_class)
9
+ model_class.find_each(&:knitsearch_sync_document)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Knitsearch
6
+ class Engine < ::Rails::Engine
7
+ initializer "knitsearch.schema_dumper_ignore_tables" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ pattern = /(_fts|_fts_data|_fts_idx|_fts_content|_fts_docsize|_fts_config|_fts_vocab)$/
10
+ unless ActiveRecord::SchemaDumper.ignore_tables.include?(pattern)
11
+ ActiveRecord::SchemaDumper.ignore_tables << pattern
12
+ end
13
+ end
14
+ end
15
+
16
+ initializer "knitsearch.hook_multisearchable" do
17
+ ActiveSupport.on_load(:active_record) do
18
+ include Knitsearch::Multisearchable
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Knitsearch
4
+ module FuzzyCorrector
5
+ extend self
6
+
7
+ PREFIX_LENGTH = 3
8
+
9
+ def correct(query, vocab_table:, connection:, threshold:, skip_last: false)
10
+ return query if query.nil?
11
+
12
+ str = query.to_s
13
+ return query if str.strip.empty?
14
+ return query unless vocab_table_available?(connection, vocab_table)
15
+
16
+ tokens = str.split(/\s+/)
17
+ last_index = tokens.length - 1
18
+
19
+ corrected = tokens.each_with_index.map do |token, i|
20
+ if skip_last && i == last_index
21
+ token
22
+ else
23
+ correct_token(token, vocab_table: vocab_table, connection: connection, threshold: threshold)
24
+ end
25
+ end
26
+
27
+ corrected.join(" ")
28
+ end
29
+
30
+ private
31
+ def correct_token(token, vocab_table:, connection:, threshold:)
32
+ lowered = token.downcase
33
+ return token if lowered.length < PREFIX_LENGTH
34
+
35
+ prefix = lowered[0, PREFIX_LENGTH]
36
+ candidates = fetch_candidates(connection, vocab_table, prefix)
37
+ return token if candidates.empty?
38
+
39
+ scored = candidates
40
+ .map { |term, cnt| [term, cnt.to_i, Knitsearch::Levenshtein.distance(lowered, term)] }
41
+ .select { |_, _, d| d <= threshold }
42
+ return token if scored.empty?
43
+
44
+ exact = scored.find { |_, _, d| d == 0 }
45
+ if exact
46
+ max_freq = scored.map { |_, c, _| c }.max
47
+ # Keep the user's word unless it's >1 order of magnitude less common than alternatives.
48
+ # Log-scale comparison is corpus-independent: works equally on small and huge indexes.
49
+ if Math.log10(max_freq.to_f) - Math.log10(exact[1].to_f) < 1.0
50
+ return exact[0]
51
+ end
52
+ end
53
+
54
+ best = scored.min_by { |term, cnt, d| [-cnt, d, term] }
55
+ best ? best[0] : token
56
+ end
57
+
58
+ def fetch_candidates(connection, vocab_table, prefix)
59
+ binds = [ActiveRecord::Relation::QueryAttribute.new(
60
+ "prefix", "#{prefix}%", ActiveRecord::Type::Value.new
61
+ )]
62
+ result = connection.exec_query(
63
+ "SELECT term, cnt FROM #{connection.quote_table_name(vocab_table)} WHERE term LIKE ?",
64
+ "knitsearch_fuzzy",
65
+ binds
66
+ )
67
+ result.rows
68
+ rescue
69
+ []
70
+ end
71
+
72
+ def vocab_table_available?(connection, vocab_table)
73
+ connection.execute("SELECT 1 FROM #{connection.quote_table_name(vocab_table)} LIMIT 0")
74
+ true
75
+ rescue
76
+ false
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Knitsearch
6
+ module HasManyDependent
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Proc form, not `after_save_commit :knitsearch_refresh_has_many_parents`.
11
+ # Symbol-form after_commit callbacks silently no-op when the target method
12
+ # is defined on a module included into the class — the callback registers
13
+ # but dispatch never reaches the method. Procs work.
14
+ after_save_commit { |record| record.knitsearch_refresh_has_many_parents }
15
+ after_destroy_commit { |record| record.knitsearch_refresh_has_many_parents }
16
+ end
17
+
18
+ def knitsearch_refresh_has_many_parents
19
+ dependents = Knitsearch.has_many_dependents[self.class]
20
+ return unless dependents
21
+
22
+ dependents.each do |dependent|
23
+ parent_class = dependent[:parent]
24
+ inverse_fk_sym = dependent[:inverse_fk]
25
+ shadow_map = dependent[:columns]
26
+ parent_assoc = dependent[:parent_assoc]
27
+
28
+ # Determine which parents to update
29
+ parents_to_update = []
30
+
31
+ # Current parent (if FK is set)
32
+ current_fk_value = read_attribute(inverse_fk_sym)
33
+ if current_fk_value.present?
34
+ parents_to_update << current_fk_value
35
+ end
36
+
37
+ # Previous parent (if FK was changed)
38
+ if saved_change_to_attribute?(inverse_fk_sym)
39
+ old_fk_value = saved_changes[inverse_fk_sym]&.first
40
+ if old_fk_value.present?
41
+ parents_to_update << old_fk_value
42
+ end
43
+ end
44
+
45
+ # Update each affected parent
46
+ parents_to_update.uniq.each do |parent_id|
47
+ parent = parent_class.find_by(id: parent_id)
48
+ next unless parent
49
+
50
+ # Recompute shadow columns for this parent
51
+ updates = {}
52
+ shadow_map.each do |shadow_col, source_col|
53
+ values = parent.send(parent_assoc).pluck(source_col).compact.map(&:to_s)
54
+ updates[shadow_col] = values.any? ? values.join(" ") : nil
55
+ end
56
+
57
+ parent.update_columns(updates)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Knitsearch
6
+ module HasManyThroughJoinDependent
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Proc form, not `after_save_commit :knitsearch_refresh_through_parent_from_join`.
11
+ # Symbol-form after_commit callbacks silently no-op when the target method
12
+ # is defined on a module included into the class — the callback registers
13
+ # but dispatch never reaches the method. Procs work.
14
+ after_create_commit { |record| record.knitsearch_refresh_through_parent_from_join }
15
+ after_destroy_commit { |record| record.knitsearch_refresh_through_parent_from_join }
16
+ end
17
+
18
+ def knitsearch_refresh_through_parent_from_join
19
+ dependents = Knitsearch.has_many_through_dependents[self.class]
20
+ return unless dependents
21
+
22
+ dependents.each do |dependent|
23
+ parent_class = dependent[:parent_class]
24
+ parent_fk = dependent[:parent_fk]
25
+ parent_assoc = dependent[:parent_assoc]
26
+ shadow_map = dependent[:columns]
27
+
28
+ # Read the parent FK from the join row
29
+ parent_id = read_attribute(parent_fk)
30
+ next unless parent_id.present?
31
+
32
+ # Find the parent and refresh its shadow columns
33
+ parent = parent_class.find_by(id: parent_id)
34
+ next unless parent
35
+
36
+ # Recompute shadow columns for this parent
37
+ updates = {}
38
+ shadow_map.each do |shadow_col, source_col|
39
+ values = parent.send(parent_assoc).pluck(source_col).compact.map(&:to_s)
40
+ updates[shadow_col] = values.any? ? values.join(" ") : nil
41
+ end
42
+
43
+ parent.update_columns(updates)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Knitsearch
6
+ module HasManyThroughTargetDependent
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Proc form, not `after_update_commit :knitsearch_refresh_through_parents_from_target`.
11
+ # Symbol-form after_commit callbacks silently no-op when the target method
12
+ # is defined on a module included into the class — the callback registers
13
+ # but dispatch never reaches the method. Procs work.
14
+ after_update_commit { |record| record.knitsearch_refresh_through_parents_from_target }
15
+ end
16
+
17
+ def knitsearch_refresh_through_parents_from_target
18
+ dependents = Knitsearch.has_many_through_target_dependents[self.class]
19
+ return unless dependents
20
+
21
+ dependents.each do |dependent|
22
+ join_class = dependent[:join_class]
23
+ parent_class = dependent[:parent_class]
24
+ parent_fk = dependent[:parent_fk]
25
+ target_fk = dependent[:target_fk]
26
+ parent_assoc = dependent[:parent_assoc]
27
+ shadow_map = dependent[:columns]
28
+ source_columns = dependent[:source_columns]
29
+
30
+ # Guard: only refresh if any indexed source column actually changed
31
+ changed_indexed_columns = saved_changes.keys.map(&:to_sym) & source_columns.map(&:to_sym)
32
+ return if changed_indexed_columns.empty?
33
+
34
+ # Find all parent IDs that have this target through any join row
35
+ parent_ids = join_class.where(target_fk => id).pluck(parent_fk).uniq
36
+
37
+ # Refresh each affected parent
38
+ parent_ids.each do |parent_id|
39
+ parent = parent_class.find_by(id: parent_id)
40
+ next unless parent
41
+
42
+ # Recompute shadow columns for this parent
43
+ updates = {}
44
+ shadow_map.each do |shadow_col, source_col|
45
+ values = parent.send(parent_assoc).pluck(source_col).compact.map(&:to_s)
46
+ updates[shadow_col] = values.any? ? values.join(" ") : nil
47
+ end
48
+
49
+ parent.update_columns(updates)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Knitsearch
6
+ # HTML highlighter for search results. Replaces placeholder marks inserted by
7
+ # FTS5's highlight() function with <mark> tags. The marks are control characters
8
+ # chosen to be unlikely in user content.
9
+ module Highlighter
10
+ extend self
11
+
12
+ def render(text)
13
+ return nil if text.nil?
14
+
15
+ # Escape user content FIRST, then convert sentinels to <mark>. Reordering
16
+ # this would render user-stored HTML verbatim and produce stored XSS.
17
+ CGI.escapeHTML(text.to_s)
18
+ .gsub(CGI.escapeHTML(opening_mark), "<mark>")
19
+ .gsub(CGI.escapeHTML(closing_mark), "</mark>")
20
+ .html_safe
21
+ end
22
+
23
+ def opening_mark
24
+ OPENING_MARK
25
+ end
26
+
27
+ def closing_mark
28
+ CLOSING_MARK
29
+ end
30
+
31
+ private
32
+
33
+ OPENING_MARK = "\x02knitsearch_open\x03"
34
+ CLOSING_MARK = "\x02knitsearch_close\x03"
35
+ end
36
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Knitsearch
4
+ module Levenshtein
5
+ extend self
6
+
7
+ def distance(a, b)
8
+ a = (a || "").to_s
9
+ b = (b || "").to_s
10
+ return b.length if a.empty?
11
+ return a.length if b.empty?
12
+
13
+ # Ensure b is the shorter — O(min(a,b)) space
14
+ a, b = b, a if a.length < b.length
15
+
16
+ prev = (0..b.length).to_a
17
+ curr = Array.new(b.length + 1)
18
+
19
+ a.each_char.with_index(1) do |ac, i|
20
+ curr[0] = i
21
+ b.each_char.with_index(1) do |bc, j|
22
+ cost = ac == bc ? 0 : 1
23
+ curr[j] = [
24
+ curr[j - 1] + 1, # insert
25
+ prev[j] + 1, # delete
26
+ prev[j - 1] + cost # substitute
27
+ ].min
28
+ end
29
+ prev, curr = curr, prev
30
+ end
31
+
32
+ prev[b.length]
33
+ end
34
+ end
35
+ end