right_now 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a566156485512651cffed05ab55379f0cab3b025fdc07f209341eea69dd53352
4
+ data.tar.gz: c7699767b0dd24e0e119ea29ccf0fa2f2d94669330e7f0c146fa7c02b6c0f1f2
5
+ SHA512:
6
+ metadata.gz: a17606397b14a73843e9942a339646b8d2698e405a69c846f5ede2b0a92c523311d8b053a3b55e2dd6a60a560908dfcf70ea06bc0bb0d9b0b43757eada5e8b9a
7
+ data.tar.gz: 53e202722d68b59fa46088fcce2395dcda200a909135bae509dfc7af8886204dd1b3d0d00424cc0e877f8b0ed41f573a8c1bb6715e602a74e0dc395cab4d866e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SEAHAL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # rightnow
2
+
3
+ Rails-aware code resolver.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ gem install right_now
9
+ ```
10
+
11
+ ## Quick Examples
12
+
13
+ ```sh
14
+ rn user
15
+ rn m user
16
+ rn c admin/users
17
+ rn helper abc def ghi
18
+ rn app/controllers/application_controller.rb
19
+ rn public robots
20
+ rn user --print
21
+ rn user --json
22
+ ```
23
+
24
+ ## What It Does
25
+ `rn` resolves a short query to the most likely existing file in a Rails project.
26
+
27
+ ## Query Model
28
+ `rn` supports three basic query styles:
29
+ * path-like queries are strongest
30
+ * kind-prefixed queries narrow the search scope
31
+ * generic queries search across known Rails paths
32
+
33
+ Slash-separated and space-separated forms are equivalent:
34
+ ```sh
35
+ rn c admin users
36
+ rn c admin/users
37
+ ```
38
+
39
+ ## Supported Kinds
40
+ The command feel is intentionally close to Rails generator conventions.
41
+ * `model` / `m`
42
+ * `controller` / `c`
43
+ * `helper` / `h`
44
+ * `view` / `v`
45
+ * `job` / `j`
46
+ * `service`
47
+ * `mailer`
48
+ * `channel`
49
+ * `public`
50
+
51
+ ## Output Modes
52
+ ```sh
53
+ rn user --print
54
+ rn user --json
55
+ ```
56
+ `--print` prints the best path only.
57
+ `--json` prints scored candidate data.
58
+
59
+ ## Search Scope
60
+ `rn` only searches Rails-relevant paths such as:
61
+ * `app/models`
62
+ * `app/controllers`
63
+ * `app/helpers`
64
+ * `app/views`
65
+ * `app/services`
66
+ * `app/jobs`
67
+ * `app/mailers`
68
+ * `app/channels`
69
+ * `public`
70
+
71
+ It does not search log files, `tmp`, `vendor`, `node_modules`, or arbitrary file contents.
72
+
73
+ ## Philosophy
74
+ * resolver, not search engine
75
+ * Rails-aware
76
+ * finite candidate set
77
+ * predictable heuristics
78
+ * generator-like ergonomics
79
+
80
+ ## Non-Goals
81
+ * no grep-style full text search
82
+ * no TUI yet
83
+ * no LSP integration
84
+ * no log viewer
85
+
86
+ ## Future
87
+ Possible future work includes `rni`, better ranking, and optional indexing.
data/exe/rn ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "rightnow"
6
+
7
+ exit RightNow::CLI.start(ARGV)
data/exe/rni ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "rightnow"
6
+
7
+ warn "rni is not implemented yet."
8
+ exit 1
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "shellwords"
5
+
6
+ module RightNow
7
+ class CLI
8
+ def self.start(argv = ARGV)
9
+ new(argv).run
10
+ end
11
+
12
+ def initialize(argv)
13
+ @query = Query.parse(argv)
14
+ @resolver = Resolver.new
15
+ end
16
+
17
+ def run
18
+ return handle_empty_query if query.empty?
19
+
20
+ results = resolver.resolve(query)
21
+
22
+ if query.json
23
+ puts JSON.pretty_generate(results)
24
+ return 0
25
+ end
26
+
27
+ if query.print
28
+ best = results.first
29
+ if best
30
+ puts best[:path]
31
+ return 0
32
+ end
33
+
34
+ return 1
35
+ end
36
+
37
+ return open_in_editor(results.first[:path]) ? 0 : 1 if results.any?
38
+
39
+ 1
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :query, :resolver
45
+
46
+ def handle_empty_query
47
+ if query.json
48
+ puts JSON.pretty_generate([])
49
+ return 0
50
+ end
51
+
52
+ 1
53
+ end
54
+
55
+ def open_in_editor(path)
56
+ command = Shellwords.split(editor_command)
57
+ return false if command.empty?
58
+
59
+ system(*command, File.join(resolver.root, path))
60
+ end
61
+
62
+ def editor_command
63
+ ENV["CR_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"] || "vim"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module RightNow
6
+ class Query
7
+ KIND_ALIASES = {
8
+ "m" => "model",
9
+ "model" => "model",
10
+ "c" => "controller",
11
+ "controller" => "controller",
12
+ "h" => "helper",
13
+ "helper" => "helper",
14
+ "v" => "view",
15
+ "view" => "view",
16
+ "j" => "job",
17
+ "job" => "job",
18
+ "service" => "service",
19
+ "mailer" => "mailer",
20
+ "channel" => "channel",
21
+ "public" => "public"
22
+ }.freeze
23
+
24
+ attr_reader :print, :json, :kind, :terms, :path_mode, :original_tokens
25
+
26
+ def self.parse(argv)
27
+ options = { print: false, json: false }
28
+
29
+ parser = OptionParser.new do |opts|
30
+ opts.on("--print", "Print only the best path") { options[:print] = true }
31
+ opts.on("--json", "Print top candidates as JSON") { options[:json] = true }
32
+ end
33
+
34
+ original_tokens = parser.parse(argv.dup)
35
+ normalized_tokens = original_tokens.flat_map { |token| token.split("/") }.reject(&:empty?)
36
+ path_mode = original_tokens.any? { |token| token.include?("/") }
37
+
38
+ kind = nil
39
+ terms = normalized_tokens.dup
40
+
41
+ if (first = terms.first) && KIND_ALIASES.key?(first.downcase)
42
+ kind = KIND_ALIASES.fetch(first.downcase)
43
+ terms = terms.drop(1)
44
+ end
45
+
46
+ if !path_mode
47
+ terms = terms.select { |term| term.length >= 2 }
48
+ end
49
+
50
+ new(
51
+ print: options[:print],
52
+ json: options[:json],
53
+ kind: kind,
54
+ terms: terms,
55
+ path_mode: path_mode,
56
+ original_tokens: original_tokens
57
+ )
58
+ end
59
+
60
+ def initialize(print:, json:, kind:, terms:, path_mode:, original_tokens:)
61
+ @print = print
62
+ @json = json
63
+ @kind = kind
64
+ @terms = terms
65
+ @path_mode = path_mode
66
+ @original_tokens = original_tokens
67
+ end
68
+
69
+ def empty?
70
+ terms.empty? && kind.nil? && !path_mode
71
+ end
72
+
73
+ def query_path
74
+ @query_path ||= normalized_path(terms)
75
+ end
76
+
77
+ private
78
+
79
+ def normalized_path(parts)
80
+ parts.join("/").downcase
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RightNow
4
+ class Resolver
5
+ SEARCH_SCOPES = {
6
+ "model" => "app/models",
7
+ "controller" => "app/controllers",
8
+ "helper" => "app/helpers",
9
+ "view" => "app/views",
10
+ "job" => "app/jobs",
11
+ "service" => "app/services",
12
+ "mailer" => "app/mailers",
13
+ "channel" => "app/channels",
14
+ "public" => "public"
15
+ }.freeze
16
+
17
+ attr_reader :root
18
+
19
+ def initialize(root: detect_root)
20
+ @root = root
21
+ end
22
+
23
+ def resolve(query)
24
+ candidates = collect_candidates(query)
25
+ ranked = candidates.map { |path| score_candidate(path, query) }
26
+ .sort_by { |candidate| [-candidate[:score], candidate[:path]] }
27
+ ranked.first(10)
28
+ end
29
+
30
+ def best_candidate(query)
31
+ resolve(query).first
32
+ end
33
+
34
+ def detect_root
35
+ current = Dir.pwd
36
+
37
+ loop do
38
+ return current if rails_project_root?(current)
39
+
40
+ parent = File.dirname(current)
41
+ return Dir.pwd if parent == current
42
+
43
+ current = parent
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def collect_candidates(query)
50
+ scopes = query.kind ? [SEARCH_SCOPES.fetch(query.kind)] : SEARCH_SCOPES.values
51
+ scopes.flat_map { |scope| glob_scope(scope) }.uniq
52
+ end
53
+
54
+ def glob_scope(scope)
55
+ pattern = File.join(root, scope, "**", "*")
56
+ Dir.glob(pattern, File::FNM_DOTMATCH)
57
+ .select { |path| File.file?(path) }
58
+ .map { |path| relative_path(path) }
59
+ .reject { |path| excluded_path?(path) }
60
+ end
61
+
62
+ def excluded_path?(path)
63
+ path.split(File::SEPARATOR).any? do |segment|
64
+ %w[node_modules vendor tmp log .git].include?(segment)
65
+ end
66
+ end
67
+
68
+ def relative_path(path)
69
+ path.sub(%r{\A#{Regexp.escape(root)}/?}, "")
70
+ end
71
+
72
+ def score_candidate(path, query)
73
+ score = 0
74
+ downcased_path = path.downcase
75
+ terms = query.terms.map(&:downcase)
76
+ kind_scope = query.kind && path.start_with?(SEARCH_SCOPES.fetch(query.kind) + "/")
77
+
78
+ score += 200 if kind_scope
79
+ score += exact_path_score(path, query)
80
+ score += basename_score(path, query)
81
+ score += ordered_terms_score(downcased_path, terms)
82
+ score += unordered_terms_score(downcased_path, terms)
83
+ score += kind_hint_score(path, query)
84
+ score += path_mode_score(downcased_path, query)
85
+
86
+ { path:, score: }
87
+ end
88
+
89
+ def exact_path_score(path, query)
90
+ return 0 if query.query_path.empty?
91
+
92
+ downcased = path.downcase
93
+ if downcased == query.query_path
94
+ 1_500
95
+ elsif downcased.end_with?(query.query_path)
96
+ 1_000
97
+ elsif downcased.include?(query.query_path)
98
+ 700
99
+ else
100
+ 0
101
+ end
102
+ end
103
+
104
+ def basename_score(path, query)
105
+ return 0 if query.terms.empty?
106
+
107
+ basename = File.basename(path, File.extname(path)).downcase
108
+ expected = expected_basename(query)
109
+
110
+ if expected && basename == expected
111
+ 500
112
+ elsif query.terms.map(&:downcase).include?(basename)
113
+ 250
114
+ else
115
+ 0
116
+ end
117
+ end
118
+
119
+ def ordered_terms_score(path, terms)
120
+ return 0 if terms.empty?
121
+ return 300 if subsequence?(terms, path_terms(path))
122
+
123
+ 0
124
+ end
125
+
126
+ def unordered_terms_score(path, terms)
127
+ return 0 if terms.empty?
128
+ return 100 if terms.all? { |term| path.include?(term) }
129
+
130
+ 0
131
+ end
132
+
133
+ def kind_hint_score(path, query)
134
+ return 0 unless query.kind
135
+
136
+ expected = expected_kind_path(query)
137
+ return 350 if expected && path.start_with?(expected)
138
+
139
+ 0
140
+ end
141
+
142
+ def path_mode_score(path, query)
143
+ return 0 unless query.path_mode
144
+ return 500 if path.include?(query.query_path)
145
+
146
+ 0
147
+ end
148
+
149
+ def expected_basename(query)
150
+ return nil if query.terms.empty?
151
+
152
+ case query.kind
153
+ when "model"
154
+ "#{singularize(query.terms.join("_"))}"
155
+ when "controller"
156
+ "#{pluralize(query.terms.last)}_controller"
157
+ when "helper"
158
+ "#{query.terms.last}_helper"
159
+ when "view"
160
+ query.terms.last
161
+ when "job"
162
+ "#{query.terms.join("_")}_job"
163
+ when "service"
164
+ "#{query.terms.join("_")}_service"
165
+ when "mailer"
166
+ "#{query.terms.join("_")}_mailer"
167
+ when "channel"
168
+ "#{query.terms.join("_")}_channel"
169
+ when "public"
170
+ query.terms.join("/")
171
+ end
172
+ end
173
+
174
+ def expected_kind_path(query)
175
+ return nil if query.terms.empty?
176
+
177
+ case query.kind
178
+ when "model"
179
+ File.join("app/models", "#{expected_basename(query)}.rb")
180
+ when "controller"
181
+ dir = query.terms.length > 1 ? query.terms[0..-2].join("/") : ""
182
+ base = "#{pluralize(query.terms.last)}_controller.rb"
183
+ dir.empty? ? File.join("app/controllers", base) : File.join("app/controllers", dir, base)
184
+ when "helper"
185
+ dir = query.terms.length > 1 ? query.terms[0..-2].join("/") : ""
186
+ base = "#{query.terms.last}_helper.rb"
187
+ dir.empty? ? File.join("app/helpers", base) : File.join("app/helpers", dir, base)
188
+ when "view"
189
+ dir = query.terms.length > 1 ? query.terms[0..-2].join("/") : ""
190
+ base = query.terms.last
191
+ dir.empty? ? File.join("app/views", base) : File.join("app/views", dir, base)
192
+ when "job"
193
+ File.join("app/jobs", "#{query.terms.join("_")}_job.rb")
194
+ when "service"
195
+ File.join("app/services", "#{query.terms.join("_")}_service.rb")
196
+ when "mailer"
197
+ File.join("app/mailers", "#{query.terms.join("_")}_mailer.rb")
198
+ when "channel"
199
+ File.join("app/channels", "#{query.terms.join("_")}_channel.rb")
200
+ when "public"
201
+ File.join("public", query.terms.join("/"))
202
+ end
203
+ end
204
+
205
+ def path_terms(path)
206
+ path.downcase.split(/[\/_.-]+/).reject(&:empty?)
207
+ end
208
+
209
+ def subsequence?(needles, haystack)
210
+ index = 0
211
+ needles.all? do |needle|
212
+ found = haystack[index..].find_index { |part| part.include?(needle) }
213
+ next false unless found
214
+
215
+ index += found + 1
216
+ true
217
+ end
218
+ end
219
+
220
+ def pluralize(term)
221
+ return term if term.end_with?("s") && !term.end_with?("ss")
222
+ return term.sub(/y\z/, "ies") if term.match?(/[^aeiou]y\z/)
223
+ return "#{term}es" if term.match?(/(s|x|z|ch|sh)\z/)
224
+
225
+ "#{term}s"
226
+ end
227
+
228
+ def singularize(term)
229
+ return term.sub(/ies\z/, "y") if term.end_with?("ies")
230
+ return term.sub(/ses\z/, "s") if term.end_with?("sses")
231
+ return term.delete_suffix("s") if term.end_with?("s") && !term.end_with?("ss") && !term.end_with?("us") && !term.end_with?("is")
232
+
233
+ term
234
+ end
235
+
236
+ def rails_project_root?(path)
237
+ File.directory?(File.join(path, "app")) && File.exist?(File.join(path, "config", "application.rb"))
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,3 @@
1
+ module RightNow
2
+ VERSION = "0.1.0"
3
+ end
data/lib/rightnow.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rightnow/version"
4
+ require_relative "rightnow/query"
5
+ require_relative "rightnow/resolver"
6
+ require_relative "rightnow/cli"
data/right_now.gemspec ADDED
@@ -0,0 +1,21 @@
1
+ require_relative "lib/rightnow/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "right_now"
5
+ spec.version = RightNow::VERSION
6
+ spec.summary = "Rails-aware file resolver"
7
+ spec.description = "rn resolves a user query to the most likely existing file in a Rails project."
8
+ spec.authors = ["SEAHAL"]
9
+ spec.homepage = "https://github.com/seahal/right_now"
10
+ spec.license = "MIT"
11
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
12
+
13
+ spec.files = (Dir.glob("{exe,lib}/**/*", File::FNM_DOTMATCH) +
14
+ %w[README.md LICENSE right_now.gemspec]).select { |path| File.file?(path) }
15
+ spec.bindir = "exe"
16
+ spec.executables = %w[rn rni]
17
+ spec.require_paths = ["lib"]
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: right_now
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SEAHAL
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: rn resolves a user query to the most likely existing file in a Rails
13
+ project.
14
+ executables:
15
+ - rn
16
+ - rni
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - exe/rn
23
+ - exe/rni
24
+ - lib/rightnow.rb
25
+ - lib/rightnow/cli.rb
26
+ - lib/rightnow/query.rb
27
+ - lib/rightnow/resolver.rb
28
+ - lib/rightnow/version.rb
29
+ - right_now.gemspec
30
+ homepage: https://github.com/seahal/right_now
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ homepage_uri: https://github.com/seahal/right_now
35
+ source_code_uri: https://github.com/seahal/right_now
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 3.0.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 4.0.6
51
+ specification_version: 4
52
+ summary: Rails-aware file resolver
53
+ test_files: []