kotoshu 0.3.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 (210) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +18 -0
  4. data/CHANGELOG.md +182 -0
  5. data/CLAUDE.md +172 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE +31 -0
  8. data/README.adoc +955 -0
  9. data/Rakefile +12 -0
  10. data/SECURITY.md +93 -0
  11. data/examples/01_basic_word_checking.rb +38 -0
  12. data/examples/02_text_document_checking.rb +77 -0
  13. data/examples/03_dictionary_backends.rb +137 -0
  14. data/examples/04_trie_data_structure.rb +146 -0
  15. data/examples/05_suggestion_algorithms.rb +239 -0
  16. data/examples/06_configuration_advanced.rb +287 -0
  17. data/examples/07_multi_language_dictionaries.rb +278 -0
  18. data/exe/kotoshu +6 -0
  19. data/lib/kotoshu/algorithms/capitalization.rb +276 -0
  20. data/lib/kotoshu/algorithms/lookup.rb +876 -0
  21. data/lib/kotoshu/algorithms/ngram_suggest.rb +270 -0
  22. data/lib/kotoshu/algorithms/permutations.rb +283 -0
  23. data/lib/kotoshu/algorithms/phonet_suggest.rb +167 -0
  24. data/lib/kotoshu/algorithms/suggest.rb +575 -0
  25. data/lib/kotoshu/algorithms.rb +14 -0
  26. data/lib/kotoshu/analyzers/semantic_analyzer.rb +295 -0
  27. data/lib/kotoshu/cache/base_cache.rb +596 -0
  28. data/lib/kotoshu/cache/cache.rb +91 -0
  29. data/lib/kotoshu/cache/frequency_cache.rb +224 -0
  30. data/lib/kotoshu/cache/language_cache.rb +454 -0
  31. data/lib/kotoshu/cache/lookup_cache.rb +166 -0
  32. data/lib/kotoshu/cache/model_cache.rb +513 -0
  33. data/lib/kotoshu/cache/suggestion_cache.rb +113 -0
  34. data/lib/kotoshu/cache.rb +40 -0
  35. data/lib/kotoshu/cli/auto_setup.rb +71 -0
  36. data/lib/kotoshu/cli/batch_reporter.rb +315 -0
  37. data/lib/kotoshu/cli/cache_command.rb +356 -0
  38. data/lib/kotoshu/cli/display_formatter.rb +431 -0
  39. data/lib/kotoshu/cli/errors.rb +36 -0
  40. data/lib/kotoshu/cli/interactive_reviewer.rb +319 -0
  41. data/lib/kotoshu/cli/language_resolver.rb +91 -0
  42. data/lib/kotoshu/cli/navigation_manager.rb +272 -0
  43. data/lib/kotoshu/cli/progress_reporter.rb +114 -0
  44. data/lib/kotoshu/cli/status_report.rb +130 -0
  45. data/lib/kotoshu/cli.rb +627 -0
  46. data/lib/kotoshu/commands/cache_command.rb +424 -0
  47. data/lib/kotoshu/commands/check_command.rb +312 -0
  48. data/lib/kotoshu/commands/model_command.rb +295 -0
  49. data/lib/kotoshu/components/passthrough_spell_checker.rb +72 -0
  50. data/lib/kotoshu/components/pos_tagger.rb +98 -0
  51. data/lib/kotoshu/components/spell_checker.rb +73 -0
  52. data/lib/kotoshu/components/synthesizer.rb +60 -0
  53. data/lib/kotoshu/components/tokenizer.rb +58 -0
  54. data/lib/kotoshu/components/whitespace_tokenizer.rb +96 -0
  55. data/lib/kotoshu/configuration/builder.rb +209 -0
  56. data/lib/kotoshu/configuration/resolver.rb +124 -0
  57. data/lib/kotoshu/configuration.rb +702 -0
  58. data/lib/kotoshu/core/exceptions.rb +165 -0
  59. data/lib/kotoshu/core/indexed_dictionary.rb +291 -0
  60. data/lib/kotoshu/core/models/affix_rule.rb +260 -0
  61. data/lib/kotoshu/core/models/result/document_result.rb +263 -0
  62. data/lib/kotoshu/core/models/result/word_result.rb +203 -0
  63. data/lib/kotoshu/core/models/word.rb +142 -0
  64. data/lib/kotoshu/core/trie/builder.rb +119 -0
  65. data/lib/kotoshu/core/trie/node.rb +94 -0
  66. data/lib/kotoshu/core/trie/trie.rb +249 -0
  67. data/lib/kotoshu/core.rb +28 -0
  68. data/lib/kotoshu/data/common_words/de.yml +1800 -0
  69. data/lib/kotoshu/data/common_words/en.yml +1215 -0
  70. data/lib/kotoshu/data/common_words/es.yml +750 -0
  71. data/lib/kotoshu/data/common_words/fr.yml +1015 -0
  72. data/lib/kotoshu/data/common_words/pt.yml +870 -0
  73. data/lib/kotoshu/data/common_words/ru.yml +484 -0
  74. data/lib/kotoshu/data/common_words_loader.rb +152 -0
  75. data/lib/kotoshu/data_structures/bloom_filter.rb +176 -0
  76. data/lib/kotoshu/debug_logger.rb +146 -0
  77. data/lib/kotoshu/debug_mode.rb +134 -0
  78. data/lib/kotoshu/defaults.rb +86 -0
  79. data/lib/kotoshu/dictionaries/catalog.rb +817 -0
  80. data/lib/kotoshu/dictionary/base.rb +237 -0
  81. data/lib/kotoshu/dictionary/cspell.rb +254 -0
  82. data/lib/kotoshu/dictionary/custom.rb +224 -0
  83. data/lib/kotoshu/dictionary/hunspell.rb +526 -0
  84. data/lib/kotoshu/dictionary/plain_text.rb +282 -0
  85. data/lib/kotoshu/dictionary/repository.rb +248 -0
  86. data/lib/kotoshu/dictionary/unified.rb +260 -0
  87. data/lib/kotoshu/dictionary/unix_words.rb +218 -0
  88. data/lib/kotoshu/documents/asciidoc_document.rb +441 -0
  89. data/lib/kotoshu/documents/document.rb +229 -0
  90. data/lib/kotoshu/documents/location.rb +139 -0
  91. data/lib/kotoshu/documents/markdown_document.rb +389 -0
  92. data/lib/kotoshu/documents/plain_text_document.rb +147 -0
  93. data/lib/kotoshu/embeddings/embedding_pipeline.rb +244 -0
  94. data/lib/kotoshu/embeddings/lru_cache.rb +233 -0
  95. data/lib/kotoshu/embeddings/onnx_runtime_model.rb +388 -0
  96. data/lib/kotoshu/embeddings/protocol.rb +83 -0
  97. data/lib/kotoshu/embeddings/protocols.rb +17 -0
  98. data/lib/kotoshu/embeddings/registry.rb +182 -0
  99. data/lib/kotoshu/embeddings/search.rb +192 -0
  100. data/lib/kotoshu/embeddings/similarity_engine.rb +248 -0
  101. data/lib/kotoshu/embeddings/similarity_search.rb +331 -0
  102. data/lib/kotoshu/embeddings/vocabulary.rb +257 -0
  103. data/lib/kotoshu/embeddings.rb +97 -0
  104. data/lib/kotoshu/fluent_checker.rb +91 -0
  105. data/lib/kotoshu/grammar/pattern_matchers/base_matcher.rb +48 -0
  106. data/lib/kotoshu/grammar/pattern_matchers/double_negative_matcher.rb +105 -0
  107. data/lib/kotoshu/grammar/pattern_matchers/possessive_context_matcher.rb +77 -0
  108. data/lib/kotoshu/grammar/pattern_matchers/vowel_sound_matcher.rb +83 -0
  109. data/lib/kotoshu/grammar/rule.rb +95 -0
  110. data/lib/kotoshu/grammar/rule_engine.rb +111 -0
  111. data/lib/kotoshu/grammar/rule_loader.rb +31 -0
  112. data/lib/kotoshu/grammar.rb +18 -0
  113. data/lib/kotoshu/integrity/audit_log.rb +88 -0
  114. data/lib/kotoshu/integrity/manifest.rb +117 -0
  115. data/lib/kotoshu/integrity/net_http.rb +46 -0
  116. data/lib/kotoshu/integrity.rb +25 -0
  117. data/lib/kotoshu/keyboard/layout.rb +115 -0
  118. data/lib/kotoshu/keyboard/layouts/azerty.rb +57 -0
  119. data/lib/kotoshu/keyboard/layouts/dvorak.rb +56 -0
  120. data/lib/kotoshu/keyboard/layouts/jcuken.rb +59 -0
  121. data/lib/kotoshu/keyboard/layouts/qwerty.rb +54 -0
  122. data/lib/kotoshu/keyboard/layouts/qwertz.rb +57 -0
  123. data/lib/kotoshu/keyboard/registry.rb +146 -0
  124. data/lib/kotoshu/keyboard.rb +60 -0
  125. data/lib/kotoshu/language/detector.rb +242 -0
  126. data/lib/kotoshu/language/identifier.rb +378 -0
  127. data/lib/kotoshu/language/languages/base.rb +256 -0
  128. data/lib/kotoshu/language/normalizer/base.rb +137 -0
  129. data/lib/kotoshu/language/registry.rb +147 -0
  130. data/lib/kotoshu/language/resources/ar/common_words.txt +6753 -0
  131. data/lib/kotoshu/language/resources/ar/confusion_sets.txt +11 -0
  132. data/lib/kotoshu/language/resources/de/common_words.txt +10003 -0
  133. data/lib/kotoshu/language/resources/de/confusion_sets.txt +246 -0
  134. data/lib/kotoshu/language/resources/en/common_words.txt +9979 -0
  135. data/lib/kotoshu/language/resources/en/confusion_sets.txt +871 -0
  136. data/lib/kotoshu/language/resources/es/common_words.txt +9992 -0
  137. data/lib/kotoshu/language/resources/es/confusion_sets.txt +17 -0
  138. data/lib/kotoshu/language/resources/fr/common_words.txt +9993 -0
  139. data/lib/kotoshu/language/resources/fr/confusion_sets.txt +76 -0
  140. data/lib/kotoshu/language/resources/pt/common_words.txt +9977 -0
  141. data/lib/kotoshu/language/resources/pt/confusion_sets.txt +18 -0
  142. data/lib/kotoshu/language/resources/ru/common_words.txt +9951 -0
  143. data/lib/kotoshu/language/resources/ru/confusion_sets.txt +5 -0
  144. data/lib/kotoshu/language/tokenizer/base.rb +170 -0
  145. data/lib/kotoshu/language/tokenizer/french_tokenizer.rb +170 -0
  146. data/lib/kotoshu/language/tokenizer/german_tokenizer.rb +41 -0
  147. data/lib/kotoshu/language/tokenizer/japanese_tokenizer.rb +60 -0
  148. data/lib/kotoshu/language/tokenizer/latin_tokenizer.rb +141 -0
  149. data/lib/kotoshu/language/tokenizer/portuguese_tokenizer.rb +160 -0
  150. data/lib/kotoshu/language/tokenizer/russian_tokenizer.rb +95 -0
  151. data/lib/kotoshu/language/tokenizer/spanish_tokenizer.rb +122 -0
  152. data/lib/kotoshu/language.rb +99 -0
  153. data/lib/kotoshu/languages/de/language.rb +546 -0
  154. data/lib/kotoshu/languages/en/language.rb +448 -0
  155. data/lib/kotoshu/languages/es/language.rb +459 -0
  156. data/lib/kotoshu/languages/fr/language.rb +493 -0
  157. data/lib/kotoshu/languages/ja/language.rb +477 -0
  158. data/lib/kotoshu/languages/pt/language.rb +423 -0
  159. data/lib/kotoshu/languages/ru/language.rb +404 -0
  160. data/lib/kotoshu/languages.rb +43 -0
  161. data/lib/kotoshu/metrics_collector.rb +222 -0
  162. data/lib/kotoshu/metrics_module.rb +110 -0
  163. data/lib/kotoshu/models/context.rb +119 -0
  164. data/lib/kotoshu/models/embedding_model.rb +182 -0
  165. data/lib/kotoshu/models/fasttext_model.rb +220 -0
  166. data/lib/kotoshu/models/nearest_neighbor.rb +87 -0
  167. data/lib/kotoshu/models/onnx_model.rb +333 -0
  168. data/lib/kotoshu/models/semantic_error.rb +165 -0
  169. data/lib/kotoshu/models/suggestion.rb +106 -0
  170. data/lib/kotoshu/models/word_embedding.rb +107 -0
  171. data/lib/kotoshu/paths.rb +53 -0
  172. data/lib/kotoshu/personal_dictionary.rb +94 -0
  173. data/lib/kotoshu/plugins/plugin.rb +61 -0
  174. data/lib/kotoshu/plugins/registry.rb +120 -0
  175. data/lib/kotoshu/project_config.rb +76 -0
  176. data/lib/kotoshu/readers/aff_data.rb +356 -0
  177. data/lib/kotoshu/readers/aff_reader.rb +375 -0
  178. data/lib/kotoshu/readers/condition_checker.rb +142 -0
  179. data/lib/kotoshu/readers/dic_reader.rb +118 -0
  180. data/lib/kotoshu/readers/file_reader.rb +347 -0
  181. data/lib/kotoshu/readers/lookup_builder.rb +299 -0
  182. data/lib/kotoshu/readers/readers.rb +6 -0
  183. data/lib/kotoshu/readers.rb +9 -0
  184. data/lib/kotoshu/resource_bundle.rb +30 -0
  185. data/lib/kotoshu/resource_manager.rb +295 -0
  186. data/lib/kotoshu/results/result.rb +165 -0
  187. data/lib/kotoshu/scripts/fasttext_to_onnx.py +275 -0
  188. data/lib/kotoshu/source_registry.rb +74 -0
  189. data/lib/kotoshu/spellchecker/parallel_checker.rb +90 -0
  190. data/lib/kotoshu/spellchecker.rb +298 -0
  191. data/lib/kotoshu/string_metrics.rb +153 -0
  192. data/lib/kotoshu/suggestions/context.rb +55 -0
  193. data/lib/kotoshu/suggestions/generator.rb +175 -0
  194. data/lib/kotoshu/suggestions/pipeline.rb +135 -0
  195. data/lib/kotoshu/suggestions/strategies/base_strategy.rb +296 -0
  196. data/lib/kotoshu/suggestions/strategies/composite_strategy.rb +140 -0
  197. data/lib/kotoshu/suggestions/strategies/edit_distance_strategy.rb +671 -0
  198. data/lib/kotoshu/suggestions/strategies/keyboard_proximity_strategy.rb +228 -0
  199. data/lib/kotoshu/suggestions/strategies/ngram_strategy.rb +130 -0
  200. data/lib/kotoshu/suggestions/strategies/phonetic_strategy.rb +329 -0
  201. data/lib/kotoshu/suggestions/strategies/semantic_strategy.rb +316 -0
  202. data/lib/kotoshu/suggestions/strategies/symspell_strategy.rb +275 -0
  203. data/lib/kotoshu/suggestions/suggestion.rb +174 -0
  204. data/lib/kotoshu/suggestions/suggestion_set.rb +238 -0
  205. data/lib/kotoshu/version.rb +5 -0
  206. data/lib/kotoshu.rb +493 -0
  207. data/script/validate_all_dictionaries.rb +444 -0
  208. data/sig/kotoshu.rbs +4 -0
  209. data/test_oop.rb +79 -0
  210. metadata +298 -0
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'navigation_manager'
4
+ require_relative 'display_formatter'
5
+ require_relative '../analyzers/semantic_analyzer'
6
+
7
+ module Kotoshu
8
+ module Cli
9
+ # Interactive review session for spell/grammar checking.
10
+ #
11
+ # Provides a user-friendly terminal interface for reviewing errors
12
+ # with full navigation support (forward, backward, jump, skip, accept).
13
+ #
14
+ # @example Starting an interactive session
15
+ # reviewer = InteractiveReviewer.new(document, analyzer)
16
+ # reviewer.run # Enters interactive loop
17
+ #
18
+ # @example Batch mode (non-interactive)
19
+ # reviewer = InteractiveReviewer.new(document, analyzer)
20
+ # reporter = reviewer.run_batch # Returns BatchReporter
21
+ class InteractiveReviewer
22
+ attr_reader :document, :analyzer, :navigation, :formatter
23
+
24
+ # Create a new interactive reviewer.
25
+ #
26
+ # @param document [Documents::Document] Document to review
27
+ # @param analyzer [Analyzers::SemanticAnalyzer] Error analyzer
28
+ # @param formatter [DisplayFormatter, nil] Display formatter (default: new instance)
29
+ def initialize(document, analyzer, formatter: nil)
30
+ raise ArgumentError, "Document required" unless document
31
+ raise ArgumentError, "Analyzer required" unless analyzer
32
+
33
+ @document = document
34
+ @analyzer = analyzer
35
+
36
+ # Analyze document for errors
37
+ errors = @analyzer.analyze(@document)
38
+
39
+ # Create navigation manager
40
+ @navigation = NavigationManager.new(errors)
41
+
42
+ # Create display formatter
43
+ @formatter = formatter || DisplayFormatter.new
44
+
45
+ @running = false
46
+ @show_context = true
47
+ end
48
+
49
+ # Run the interactive review loop.
50
+ #
51
+ # @return [Hash] Session summary with statistics
52
+ def run
53
+ @running = true
54
+
55
+ # Show welcome message
56
+ show_welcome
57
+
58
+ # Show summary screen
59
+ puts @formatter.summary_screen(@document, @navigation)
60
+
61
+ # Main interactive loop
62
+ while @running && @navigation.current
63
+ show_current_error
64
+ process_input(get_user_input)
65
+ end
66
+
67
+ # Show exit message
68
+ show_exit_summary
69
+
70
+ # Return session summary
71
+ session_summary
72
+ end
73
+
74
+ # Run in batch mode (non-interactive).
75
+ #
76
+ # @return [BatchReporter] Reporter with results
77
+ def run_batch
78
+ # Analyze all errors without user interaction
79
+ errors = @navigation.errors
80
+
81
+ # Apply all high-confidence corrections automatically
82
+ errors.each do |error|
83
+ if error.high_confidence? && error.suggestions&.any?
84
+ @navigation.accept_suggestion(error.recommended_suggestion)
85
+ end
86
+ end
87
+
88
+ # Return batch reporter
89
+ require_relative 'batch_reporter'
90
+ BatchReporter.new(@document, @navigation, @formatter)
91
+ end
92
+
93
+ # Check if session has errors.
94
+ #
95
+ # @return [Boolean] True if there are errors to review
96
+ def has_errors?
97
+ @navigation.errors.any?
98
+ end
99
+
100
+ # Get session statistics.
101
+ #
102
+ # @return [Hash] Statistics hash
103
+ def statistics
104
+ @navigation.statistics
105
+ end
106
+
107
+ private
108
+
109
+ # Show welcome message.
110
+ def show_welcome
111
+ puts ""
112
+ puts @formatter.colorize("╔═══════════════════════════════════════════════════════════════╗", :bold)
113
+ puts @formatter.colorize("║ Kotoshu Interactive Spell/Grammar Review ║", :bold)
114
+ puts @formatter.colorize("╚═══════════════════════════════════════════════════════════════╝", :bold)
115
+ puts ""
116
+ puts "Document: #{@document.name}"
117
+ puts "Language: #{@document.language_code}"
118
+ puts "Errors found: #{@navigation.errors.size}"
119
+ puts ""
120
+ puts "Type 'h' for help, 'q' to quit"
121
+ puts ""
122
+ end
123
+
124
+ # Show current error screen.
125
+ def show_current_error
126
+ error = @navigation.current
127
+ return unless error
128
+
129
+ index = @navigation.current_index + 1
130
+ total = @navigation.errors.size
131
+
132
+ puts @formatter.issue_screen(error, index: index, total: total, show_context: @show_context)
133
+ end
134
+
135
+ # Get user input from terminal.
136
+ #
137
+ # @return [String] User input
138
+ def get_user_input
139
+ print @formatter.prompt("Action>")
140
+ $stdin.gets&.chomp || 'q'
141
+ end
142
+
143
+ # Process user input command.
144
+ #
145
+ # @param input [String] User input
146
+ def process_input(input)
147
+ return if input.nil? || input.empty?
148
+
149
+ case input
150
+ when '', 'n', 'enter'
151
+ # Move to next error
152
+ @navigation.forward
153
+
154
+ when 'b', 'back'
155
+ # Move to previous error
156
+ @navigation.backward
157
+
158
+ when 's', 'skip'
159
+ # Skip current error
160
+ @navigation.skip_current
161
+
162
+ when /^(\d+)$/
163
+ # Accept suggestion by number
164
+ suggestion_number = $1.to_i - 1 # Convert to 0-based index
165
+ accept_suggestion_by_number(suggestion_number)
166
+
167
+ when 'j', 'jump'
168
+ # Jump to error
169
+ jump_to_error
170
+
171
+ when 'f', 'first'
172
+ @navigation.first
173
+
174
+ when 'l', 'last'
175
+ @navigation.last
176
+
177
+ when 'l', 'list'
178
+ # List all errors
179
+ puts @formatter.list_all_errors(@navigation)
180
+ puts @formatter.prompt("Press Enter to continue...")
181
+ $stdin.gets
182
+
183
+ when 't', 'toggle'
184
+ # Toggle context display
185
+ @show_context = !@show_context
186
+
187
+ when 'v', 'verbose'
188
+ # Toggle verbose mode
189
+ @formatter.verbose = !@formatter.verbose
190
+ puts @formatter.success("Verbose mode: #{@formatter.verbose ? 'ON' : 'OFF'}")
191
+
192
+ when '?'
193
+ # Show summary
194
+ puts @formatter.summary_screen(@document, @navigation)
195
+
196
+ when 'h', 'help'
197
+ # Show help
198
+ puts @formatter.help_screen
199
+ puts @formatter.prompt("Press Enter to continue...")
200
+ $stdin.gets
201
+
202
+ when 'q', 'quit'
203
+ # Quit and save
204
+ quit_with_save
205
+
206
+ when 'Q', 'QUIT'
207
+ # Quit without saving
208
+ quit_without_save
209
+
210
+ when '!', 'export'
211
+ # Export corrections
212
+ export_corrections
213
+
214
+ else
215
+ # Unknown command
216
+ puts @formatter.warning("Unknown command: #{input}. Type 'h' for help.")
217
+ end
218
+ end
219
+
220
+ # Accept suggestion by number.
221
+ #
222
+ # @param number [Integer] Suggestion number (0-based)
223
+ def accept_suggestion_by_number(number)
224
+ error = @navigation.current
225
+ return unless error
226
+
227
+ suggestions = error.suggestions || []
228
+ if number < 0 || number >= suggestions.size
229
+ puts @formatter.warning("Invalid suggestion number: #{number + 1}")
230
+ return
231
+ end
232
+
233
+ suggestion = suggestions[number]
234
+ @navigation.accept_suggestion(suggestion)
235
+ puts @formatter.success("Accepted: #{error.original} → #{suggestion.word}")
236
+ end
237
+
238
+ # Jump to specific error.
239
+ def jump_to_error
240
+ print @formatter.prompt("Jump to error number>")
241
+ input = $stdin.gets&.chomp
242
+
243
+ return unless input
244
+
245
+ number = input.to_i - 1 # Convert to 0-based
246
+ if number >= 0 && number < @navigation.errors.size
247
+ @navigation.jump_to(number)
248
+ else
249
+ puts @formatter.warning("Invalid error number: #{input}")
250
+ end
251
+ end
252
+
253
+ # Quit and save changes.
254
+ def quit_with_save
255
+ @running = false
256
+ puts @formatter.success("Changes saved!")
257
+ end
258
+
259
+ # Quit without saving.
260
+ def quit_without_save
261
+ @running = false
262
+ puts @formatter.warning("Quit without saving. No changes were made.")
263
+ end
264
+
265
+ # Export corrections to file.
266
+ def export_corrections
267
+ print @formatter.prompt("Export to file (default: corrections.json)>")
268
+ filepath = $stdin.gets&.chomp || 'corrections.json'
269
+
270
+ # Export corrections
271
+ corrections = @navigation.export_corrections
272
+
273
+ # Write to file
274
+ require 'json'
275
+ File.write(filepath, JSON.pretty_generate(corrections))
276
+
277
+ puts @formatter.export_summary(@navigation, filepath)
278
+ puts @formatter.prompt("Press Enter to continue...")
279
+ $stdin.gets
280
+ end
281
+
282
+ # Show exit summary.
283
+ def show_exit_summary
284
+ puts ""
285
+ puts @formatter.statistics(@navigation)
286
+ puts ""
287
+
288
+ if @navigation.modified.any?
289
+ puts @formatter.success("#{@navigation.modified.size} corrections applied")
290
+ end
291
+
292
+ if @navigation.skipped.any?
293
+ puts "#{@navigation.skipped.size} errors skipped"
294
+ end
295
+
296
+ pending = @navigation.errors.size - @navigation.modified.size - @navigation.skipped.size
297
+ if pending > 0
298
+ puts "#{pending} errors remaining"
299
+ end
300
+ end
301
+
302
+ # Get session summary.
303
+ #
304
+ # @return [Hash] Session summary
305
+ def session_summary
306
+ {
307
+ document: {
308
+ name: @document.name,
309
+ format: @document.format,
310
+ language: @document.language_code
311
+ },
312
+ statistics: @navigation.statistics,
313
+ corrections: @navigation.export_corrections,
314
+ history: @navigation.history_summary
315
+ }
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Cli
5
+ # Resolves which language a check should run in.
6
+ #
7
+ # The CLI's --language flag accepts:
8
+ # - omitted / "auto" → detect from content, fall back if needed
9
+ # - "default" → Configuration.default_language (no detection)
10
+ # - any other code → used as-is
11
+ #
12
+ # Pure logic — no IO. Detection delegates to Kotoshu::Language.detect
13
+ # which uses character-set heuristics and needs no model download.
14
+ # A detection only "sticks" if the detected language is set up
15
+ # (Kotoshu.setup? returns true); otherwise the configured default
16
+ # is used and a fallback note is included in the result.
17
+ class LanguageResolver
18
+ Result = Struct.new(:language, :detected, :fallback, :note, keyword_init: true)
19
+
20
+ # @param flag_value [String, nil] Raw --language flag value.
21
+ # @param default_language [String, nil] Configuration.default_language.
22
+ # @param detector [#detect] Object responding to .detect(text) -> String|nil.
23
+ # Defaults to Kotoshu::Language.
24
+ # @param setup_predicate [#call] Callable returning true if a language is
25
+ # set up. Defaults to Kotoshu.method(:setup?).
26
+ def initialize(flag_value:, default_language:,
27
+ detector: Kotoshu::Language,
28
+ setup_predicate: Kotoshu.method(:setup?))
29
+ @flag_value = flag_value
30
+ @default_language = default_language
31
+ @detector = detector
32
+ @setup_predicate = setup_predicate
33
+ end
34
+
35
+ # @param text [String] The document text, used only when flag is "auto".
36
+ # @return [Result]
37
+ def resolve(text:)
38
+ case @flag_value
39
+ when nil, "auto"
40
+ resolve_auto(text)
41
+ when "default"
42
+ Result.new(language: @default_language, detected: nil, fallback: nil,
43
+ note: nil)
44
+ else
45
+ Result.new(language: @flag_value, detected: nil, fallback: nil,
46
+ note: nil)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def resolve_auto(text)
53
+ detected = safe_detect(text)
54
+ if detected.nil?
55
+ return Result.new(language: @default_language, detected: nil,
56
+ fallback: @default_language,
57
+ note: "No language detected; using default '#{@default_language}'.")
58
+ end
59
+ if setup?(detected)
60
+ return Result.new(language: detected, detected: detected, fallback: nil,
61
+ note: "Detected: #{detected}.")
62
+ end
63
+
64
+ Result.new(language: @default_language, detected: detected,
65
+ fallback: @default_language,
66
+ note: "Detected: #{detected} (fallback: #{@default_language}).")
67
+ end
68
+
69
+ def safe_detect(text)
70
+ return nil if text.nil? || text.strip.empty?
71
+
72
+ detected = @detector.detect(text)
73
+ return nil if detected.nil? || detected.strip.empty?
74
+
75
+ normalize(detected)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ def normalize(code)
81
+ code.to_s.downcase.split(/[-_]/).first
82
+ end
83
+
84
+ def setup?(lang)
85
+ return false if lang.nil? || lang.strip.empty?
86
+
87
+ @setup_predicate.call(lang)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Cli
5
+ # Manages navigation through errors in interactive review mode.
6
+ #
7
+ # Tracks current position, user decisions, and provides filtering
8
+ # for efficient error review.
9
+ #
10
+ # @example Creating navigation for errors
11
+ # nav = NavigationManager.new(errors)
12
+ # nav.forward # Move to next error
13
+ # nav.accept_suggestion(error.suggestions.first)
14
+ class NavigationManager
15
+ attr_reader :errors, :current_index, :history, :skipped, :modified
16
+
17
+ # Create a new navigation manager.
18
+ #
19
+ # @param errors [Array<Models::SemanticError>] Sorted list of errors
20
+ def initialize(errors)
21
+ raise ArgumentError, "Errors cannot be nil" if errors.nil?
22
+ raise ArgumentError, "Errors must be an Array" unless errors.is_a?(Array)
23
+
24
+ @errors = errors.sort # Errors are Comparable
25
+ @current_index = 0
26
+ @history = []
27
+ @skipped = Set.new
28
+ @modified = Set.new
29
+ @filters = {}
30
+ end
31
+
32
+ # Get the current error.
33
+ #
34
+ # @return [Models::SemanticError, nil] Current error or nil
35
+ def current
36
+ return nil if @current_index >= @errors.size
37
+
38
+ @errors[@current_index]
39
+ end
40
+
41
+ # Check if there's a next error.
42
+ #
43
+ # @return [Boolean] True if there's a next error
44
+ def next?
45
+ @current_index < @errors.size - 1
46
+ end
47
+
48
+ # Check if there's a previous error.
49
+ #
50
+ # @return [Boolean] True if there's a previous error
51
+ def previous?
52
+ @current_index > 0
53
+ end
54
+
55
+ # Navigation methods
56
+
57
+ # Move to next error.
58
+ #
59
+ # @return [Models::SemanticError, nil] Next error or nil
60
+ def forward
61
+ return nil unless next?
62
+
63
+ @current_index += 1
64
+ current
65
+ end
66
+
67
+ # Move to previous error.
68
+ #
69
+ # @return [Models::SemanticError, nil] Previous error or nil
70
+ def backward
71
+ return nil unless previous?
72
+
73
+ @current_index -= 1
74
+ current
75
+ end
76
+
77
+ # Jump to specific error by index.
78
+ #
79
+ # @param index [Integer] Error index (0-based)
80
+ # @return [Models::SemanticError, nil] Error at index or nil
81
+ def jump_to(index)
82
+ return nil if index < 0 || index >= @errors.size
83
+
84
+ @current_index = index
85
+ current
86
+ end
87
+
88
+ # Jump to first error.
89
+ #
90
+ # @return [Models::SemanticError, nil] First error or nil
91
+ def first
92
+ jump_to(0)
93
+ end
94
+
95
+ # Jump to last error.
96
+ #
97
+ # @return [Models::SemanticError, nil] Last error or nil
98
+ def last
99
+ jump_to(@errors.size - 1)
100
+ end
101
+
102
+ # Skip the current error.
103
+ #
104
+ # @return [Models::SemanticError, nil] Next error or nil
105
+ def skip_current
106
+ @skipped.add(@current_index)
107
+ forward
108
+ end
109
+
110
+ # Accept a suggestion for the current error.
111
+ #
112
+ # Records the decision and marks the error as modified.
113
+ #
114
+ # @param suggestion [Models::Suggestion] The suggestion to accept
115
+ # @return [Models::SemanticError, nil] Next error or nil
116
+ def accept_suggestion(suggestion)
117
+ error = current
118
+ return nil unless error
119
+
120
+ record_decision(
121
+ error_id: error.id,
122
+ action: :accept,
123
+ original: error.original,
124
+ replacement: suggestion.word,
125
+ confidence: error.confidence
126
+ )
127
+
128
+ @modified.add(@current_index)
129
+ forward
130
+ end
131
+
132
+ # List all errors with their status.
133
+ #
134
+ # @return [Array<String>] Formatted error list
135
+ def list_all
136
+ @errors.each_with_index.map do |error, idx|
137
+ status = status_for(idx)
138
+ "#{idx + 1}. #{status} #{error.abbreviated}"
139
+ end
140
+ end
141
+
142
+ # Filter errors by minimum confidence.
143
+ #
144
+ # @param min_confidence [Float] Minimum confidence threshold (0.0 to 1.0)
145
+ # @return [Array<Models::SemanticError>] Filtered errors
146
+ def filter_by_confidence(min_confidence: 0.0)
147
+ @errors.select { |e| e.confidence >= min_confidence }
148
+ end
149
+
150
+ # Filter errors by type(s).
151
+ #
152
+ # @param types [Array<Symbol>] Error types to include
153
+ # @return [Array<Models::SemanticError>] Filtered errors
154
+ def filter_by_type(*types)
155
+ @errors.select { |e| types.include?(e.error_type) }
156
+ end
157
+
158
+ # Get only pending errors (not skipped or modified).
159
+ #
160
+ # @return [Array<Models::SemanticError>] Pending errors
161
+ def pending
162
+ @errors.each_with_index.reject do |_, idx|
163
+ @skipped.include?(idx) || @modified.include?(idx)
164
+ end.map(&:last)
165
+ end
166
+
167
+ # Get statistics about the errors.
168
+ #
169
+ # @return [Hash] Statistics hash
170
+ def statistics
171
+ {
172
+ total: @errors.size,
173
+ skipped: @skipped.size,
174
+ modified: @modified.size,
175
+ pending: pending.size,
176
+ by_type: @errors.group_by(&:error_type).transform_values(&:size),
177
+ by_confidence: {
178
+ high: @errors.count(&:high_confidence?),
179
+ medium: @errors.count { |e| e.confidence > 0.5 && e.confidence <= 0.8 },
180
+ low: @errors.count { |e| e.confidence <= 0.5 }
181
+ }
182
+ }
183
+ end
184
+
185
+ # Get all errors sorted by status (pending first).
186
+ #
187
+ # @return [Array<Models::SemanticError>] Errors sorted by status
188
+ def by_status
189
+ pending + @errors.each_with_index.select { |_, idx|
190
+ @modified.include?(idx)
191
+ }.map(&:last).reverse + @errors.each_with_index.select { |_, idx|
192
+ @skipped.include?(idx)
193
+ }.map(&:last).reverse
194
+ end
195
+
196
+ # Reset navigation state (clear all decisions).
197
+ #
198
+ # @return [void]
199
+ def reset
200
+ @current_index = 0
201
+ @history.clear
202
+ @skipped.clear
203
+ @modified.clear
204
+ end
205
+
206
+ # Export corrections as a list of changes.
207
+ #
208
+ # @return [Array<Hash>] List of correction changes
209
+ def export_corrections
210
+ @modified.to_a.sort.map do |idx|
211
+ error = @errors[idx]
212
+ suggestion = error.recommended_suggestion
213
+
214
+ {
215
+ line: error.location.line,
216
+ original: error.original,
217
+ replacement: suggestion.word,
218
+ error_type: error.error_type,
219
+ confidence: error.confidence
220
+ }
221
+ end
222
+ end
223
+
224
+ # Get user decision history.
225
+ #
226
+ # @return [Array<Hash>] List of decisions made
227
+ def history_summary
228
+ @history.map.with_index.map do |decision, idx|
229
+ {
230
+ id: idx + 1,
231
+ error_id: decision[:error_id],
232
+ action: decision[:action],
233
+ original: decision[:original],
234
+ replacement: decision[:replacement],
235
+ confidence: decision[:confidence],
236
+ timestamp: decision[:timestamp]
237
+ }
238
+ end
239
+ end
240
+
241
+ private
242
+
243
+ # Record a user decision.
244
+ #
245
+ # @param error_id [String] Error identifier
246
+ # @param action [Symbol] Action taken (:accept, :skip, :edit)
247
+ # @param original [String] Original text
248
+ # @param replacement [String] Replacement text
249
+ # @param confidence [Float] Confidence score
250
+ def record_decision(error_id:, action:, original:, replacement:, confidence:)
251
+ @history << {
252
+ error_id: error_id,
253
+ action: action,
254
+ original: original,
255
+ replacement: replacement,
256
+ confidence: confidence,
257
+ timestamp: Time.now
258
+ }
259
+ end
260
+
261
+ # Get status string for an error index.
262
+ #
263
+ # @param idx [Integer] Error index
264
+ # @return [String] Status string
265
+ def status_for(idx)
266
+ return "[DONE]" if @modified.include?(idx)
267
+ return "[SKIP]" if @skipped.include?(idx)
268
+ "[PENDING]"
269
+ end
270
+ end
271
+ end
272
+ end