zen_apropos 0.2.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 +7 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +262 -0
- data/Rakefile +7 -0
- data/bin/zen_apropos +181 -0
- data/lib/zen_apropos/annotations_parser.rb +38 -0
- data/lib/zen_apropos/configuration.rb +14 -0
- data/lib/zen_apropos/engine.rb +156 -0
- data/lib/zen_apropos/entry.rb +38 -0
- data/lib/zen_apropos/index.rb +56 -0
- data/lib/zen_apropos/linter.rb +84 -0
- data/lib/zen_apropos/query_parser.rb +38 -0
- data/lib/zen_apropos/result_formatter.rb +122 -0
- data/lib/zen_apropos/result_grouper.rb +58 -0
- data/lib/zen_apropos/source.rb +21 -0
- data/lib/zen_apropos/sources/rake_source.rb +156 -0
- data/lib/zen_apropos/version.rb +3 -0
- data/lib/zen_apropos/zen_desc.rb +38 -0
- data/lib/zen_apropos.rb +25 -0
- data/zen_apropos.gemspec +33 -0
- metadata +103 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Search engine that ties together parsing, indexing, searching, grouping, and formatting
|
|
2
|
+
#
|
|
3
|
+
# Usage:
|
|
4
|
+
# ZenApropos::Engine.new.search("employee")
|
|
5
|
+
# ZenApropos::Engine.new.search("team:finance safety:destructive")
|
|
6
|
+
module ZenApropos
|
|
7
|
+
class Engine
|
|
8
|
+
attr_reader :index, :plain, :last_results
|
|
9
|
+
|
|
10
|
+
def initialize(root_path: Dir.pwd, plain: false, glob_patterns: nil)
|
|
11
|
+
source = Sources::RakeSource.new(
|
|
12
|
+
root_path: root_path,
|
|
13
|
+
glob_patterns: glob_patterns || Sources::RakeSource::DEFAULT_PATHS
|
|
14
|
+
)
|
|
15
|
+
@index = Index.new(source: source)
|
|
16
|
+
@plain = plain
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def help
|
|
20
|
+
<<~HELP
|
|
21
|
+
|
|
22
|
+
๐ ZenApropos - Rake Task Search
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
CLI: zen_apropos <query> [--plain]
|
|
26
|
+
Console: apropos "query"
|
|
27
|
+
|
|
28
|
+
Query Syntax:
|
|
29
|
+
Free text Search task names, descriptions, annotations, and source
|
|
30
|
+
team:<name> Filter by team annotation
|
|
31
|
+
safety:<level> Filter by safety level (safe, caution, destructive)
|
|
32
|
+
namespace:<ns> Filter by rake namespace
|
|
33
|
+
keyword:<word> Filter by declared keyword
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
apropos "employee"
|
|
37
|
+
apropos "reindex"
|
|
38
|
+
apropos "namespace:indexer"
|
|
39
|
+
apropos "team:finance safety:destructive"
|
|
40
|
+
apropos "Employee.find_each"
|
|
41
|
+
apropos "reindex", plain: true
|
|
42
|
+
|
|
43
|
+
HELP
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def search(raw_query)
|
|
47
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
|
|
49
|
+
parsed = QueryParser.parse(raw_query)
|
|
50
|
+
results = find_entries(parsed)
|
|
51
|
+
matches = build_match_context(results, parsed[:text])
|
|
52
|
+
|
|
53
|
+
grouper = ResultGrouper.new(results, active_filters: parsed[:filters])
|
|
54
|
+
grouped = grouper.group
|
|
55
|
+
group_dimension = results.length >= 5 ? grouper.best_dimension : nil
|
|
56
|
+
|
|
57
|
+
# Store results in display order so interactive viewer matches the numbered output
|
|
58
|
+
@last_results = grouped.values.flatten
|
|
59
|
+
|
|
60
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
61
|
+
|
|
62
|
+
formatter = ResultFormatter.new(
|
|
63
|
+
results: results,
|
|
64
|
+
grouped_results: grouped,
|
|
65
|
+
query: raw_query,
|
|
66
|
+
group_dimension: group_dimension,
|
|
67
|
+
matches: matches,
|
|
68
|
+
elapsed: elapsed
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
plain ? formatter.format_plain : formatter.format_rich
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns raw filtered results (for interactive mode)
|
|
75
|
+
def search_entries(raw_query)
|
|
76
|
+
find_entries(QueryParser.parse(raw_query))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def find_entries(parsed)
|
|
82
|
+
results = filter(index.entries, parsed)
|
|
83
|
+
text_search(results, parsed[:text])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def filter(entries, parsed)
|
|
87
|
+
filters = parsed[:filters]
|
|
88
|
+
return entries if filters.empty?
|
|
89
|
+
|
|
90
|
+
entries.select do |entry|
|
|
91
|
+
filters.all? do |key, value|
|
|
92
|
+
case key
|
|
93
|
+
when :team then entry.team&.downcase == value.downcase
|
|
94
|
+
when :safety then entry.safety&.downcase == value.downcase
|
|
95
|
+
when :namespace then entry.namespace&.downcase == value.downcase
|
|
96
|
+
when :keyword then entry.keywords.any? { |k| k.downcase == value.downcase }
|
|
97
|
+
else true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def text_search(entries, text)
|
|
104
|
+
return entries if text.nil? || text.empty?
|
|
105
|
+
|
|
106
|
+
query_lower = text.downcase
|
|
107
|
+
|
|
108
|
+
entries.select do |entry|
|
|
109
|
+
entry.name.downcase.include?(query_lower) ||
|
|
110
|
+
entry.description.downcase.include?(query_lower) ||
|
|
111
|
+
entry.keywords.any? { |k| k.downcase.include?(query_lower) } ||
|
|
112
|
+
entry.source.downcase.include?(query_lower)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Builds 3-line source context for entries that matched on source code
|
|
117
|
+
def build_match_context(entries, text)
|
|
118
|
+
return {} if text.nil? || text.empty?
|
|
119
|
+
|
|
120
|
+
context = {}
|
|
121
|
+
query_lower = text.downcase
|
|
122
|
+
|
|
123
|
+
entries.each do |entry|
|
|
124
|
+
# Only show source context if the match was in source (not name/desc)
|
|
125
|
+
next if entry.name.downcase.include?(query_lower)
|
|
126
|
+
next if entry.description.downcase.include?(query_lower)
|
|
127
|
+
|
|
128
|
+
next unless entry.source.downcase.include?(query_lower)
|
|
129
|
+
|
|
130
|
+
source_lines = entry.source.lines
|
|
131
|
+
source_lines.each_with_index do |line, i|
|
|
132
|
+
next unless line.downcase.include?(query_lower)
|
|
133
|
+
|
|
134
|
+
actual_line = entry.line_number + i
|
|
135
|
+
context_lines = {}
|
|
136
|
+
|
|
137
|
+
# 1 line before
|
|
138
|
+
if i > 0
|
|
139
|
+
context_lines[actual_line - 1] = source_lines[i - 1]
|
|
140
|
+
end
|
|
141
|
+
# matched line
|
|
142
|
+
context_lines[actual_line] = line
|
|
143
|
+
# 1 line after
|
|
144
|
+
if i < source_lines.length - 1
|
|
145
|
+
context_lines[actual_line + 1] = source_lines[i + 1]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
context[entry.name] = { source_lines: context_lines, matched_line: actual_line }
|
|
149
|
+
break # only show first match
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
context
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Represents a single searchable rake task entry
|
|
2
|
+
module ZenApropos
|
|
3
|
+
Entry = Struct.new(
|
|
4
|
+
:name, # full task name e.g. "indexer:employees"
|
|
5
|
+
:namespace, # namespace portion e.g. "indexer"
|
|
6
|
+
:description, # the desc string
|
|
7
|
+
:file_path, # absolute path to .rake file
|
|
8
|
+
:line_number, # line where task is defined
|
|
9
|
+
:source, # full source of the task block
|
|
10
|
+
:annotations, # hash of @zen_desc metadata { team:, safety:, keywords: }
|
|
11
|
+
:args, # array of argument names e.g. ["days_ago"]
|
|
12
|
+
keyword_init: true
|
|
13
|
+
) do
|
|
14
|
+
def team
|
|
15
|
+
annotations[:team]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def safety
|
|
19
|
+
annotations[:safety]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def keywords
|
|
23
|
+
annotations[:keywords] || []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def relative_path
|
|
27
|
+
file_path.sub("#{Dir.pwd}/", '')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def usage
|
|
31
|
+
if args&.any?
|
|
32
|
+
"bundle exec rake #{name}[#{args.join(',')}]"
|
|
33
|
+
else
|
|
34
|
+
"bundle exec rake #{name}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
# Builds and caches the search index with mtime-based invalidation
|
|
4
|
+
#
|
|
5
|
+
# Cache is stored in tmp/zen_apropos_index.cache
|
|
6
|
+
# Rebuilds automatically when any .rake file is newer than the cache
|
|
7
|
+
module ZenApropos
|
|
8
|
+
class Index
|
|
9
|
+
CACHE_PATH = 'tmp/zen_apropos_index.cache'.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :source
|
|
12
|
+
|
|
13
|
+
def initialize(source:)
|
|
14
|
+
@source = source
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def entries
|
|
18
|
+
return @cached_entries if cache_fresh?
|
|
19
|
+
|
|
20
|
+
build_and_cache
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def cache_fresh?
|
|
26
|
+
return false unless File.exist?(cache_path)
|
|
27
|
+
|
|
28
|
+
cached = Marshal.load(File.binread(cache_path))
|
|
29
|
+
return false unless cached.is_a?(Hash) && cached[:glob_patterns] == source.glob_patterns
|
|
30
|
+
|
|
31
|
+
cache_mtime = File.mtime(cache_path)
|
|
32
|
+
fresh = source.scan.none? { |file| File.mtime(file) > cache_mtime }
|
|
33
|
+
@cached_entries = cached[:entries] if fresh
|
|
34
|
+
fresh
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_and_cache
|
|
40
|
+
all_entries = source.entries
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(cache_path))
|
|
44
|
+
File.binwrite(cache_path, Marshal.dump({ entries: all_entries, glob_patterns: source.glob_patterns }))
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
# Permission error or read-only filesystem โ skip caching, return entries
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
all_entries
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def cache_path
|
|
53
|
+
File.join(source.root_path, CACHE_PATH)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Checks rake tasks for missing annotations
|
|
2
|
+
#
|
|
3
|
+
# Usage:
|
|
4
|
+
# ZenApropos::Linter.new.run # Check all rake files
|
|
5
|
+
# ZenApropos::Linter.new(changed_only: true).run # Check only changed files
|
|
6
|
+
module ZenApropos
|
|
7
|
+
class Linter
|
|
8
|
+
attr_reader :warnings
|
|
9
|
+
|
|
10
|
+
def initialize(changed_only: false)
|
|
11
|
+
@changed_only = changed_only
|
|
12
|
+
@warnings = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
files = rake_files
|
|
17
|
+
files.each { |file| check_file(file) }
|
|
18
|
+
print_results
|
|
19
|
+
warnings.any? ? 1 : 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def tag
|
|
25
|
+
ZenApropos.configuration.tag
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rake_files
|
|
29
|
+
if @changed_only
|
|
30
|
+
changed = `git diff --name-only --diff-filter=ACMR HEAD~1 2>/dev/null`.strip.split("\n")
|
|
31
|
+
changed.select { |f| f.end_with?('.rake') && File.exist?(f) }
|
|
32
|
+
else
|
|
33
|
+
patterns = ZenApropos.configuration.glob_patterns || Sources::RakeSource::DEFAULT_PATHS
|
|
34
|
+
patterns.flat_map { |p| Dir[p] }.sort
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_file(file_path)
|
|
39
|
+
lines = File.readlines(file_path)
|
|
40
|
+
|
|
41
|
+
lines.each_with_index do |line, index|
|
|
42
|
+
next unless line =~ /^\s*desc\s+['"]/
|
|
43
|
+
next if preceding_has_tags?(lines, index)
|
|
44
|
+
|
|
45
|
+
task_name = extract_next_task_name(lines, index)
|
|
46
|
+
next unless task_name
|
|
47
|
+
|
|
48
|
+
warnings << { file: file_path, line: index + 1, task_name: task_name }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def preceding_has_tags?(lines, desc_index)
|
|
53
|
+
i = desc_index - 1
|
|
54
|
+
while i >= 0
|
|
55
|
+
line = lines[i]
|
|
56
|
+
return true if ZenApropos.configuration.tag_pattern.match?(line)
|
|
57
|
+
return true if line =~ /^\s*zen_desc\s+/
|
|
58
|
+
break unless line =~ /^\s*(#|$)/
|
|
59
|
+
i -= 1
|
|
60
|
+
end
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_next_task_name(lines, desc_index)
|
|
65
|
+
lines[desc_index + 1..desc_index + 3]&.each do |line|
|
|
66
|
+
return Regexp.last_match(1) if line =~ /^\s*task\s+[:'"]?(\w+)/
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print_results
|
|
72
|
+
if warnings.empty?
|
|
73
|
+
puts "\nโ
All rake tasks have @#{tag} annotations.\n"
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
puts ''
|
|
78
|
+
warnings.each do |w|
|
|
79
|
+
puts "โ #{w[:file]}:#{w[:line]} โ task \"#{w[:task_name]}\" has no @#{tag} annotations"
|
|
80
|
+
end
|
|
81
|
+
puts "\n#{warnings.length} task(s) missing annotations. Consider adding @#{tag} tags or using zen_desc.\n"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Parses a search query into structured filters and free-text terms
|
|
2
|
+
#
|
|
3
|
+
# Examples:
|
|
4
|
+
# "employee" => { text: "employee", filters: {} }
|
|
5
|
+
# "team:finance safety:destructive" => { text: "", filters: { team: "finance", safety: "destructive" } }
|
|
6
|
+
# "employee team:search" => { text: "employee", filters: { team: "search" } }
|
|
7
|
+
module ZenApropos
|
|
8
|
+
class QueryParser
|
|
9
|
+
FILTER_KEYS = %w[team safety namespace keyword].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(raw_query)
|
|
12
|
+
@raw_query = raw_query.to_s.strip
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse
|
|
16
|
+
filters = {}
|
|
17
|
+
text_parts = []
|
|
18
|
+
|
|
19
|
+
@raw_query.split(/\s+/).each do |token|
|
|
20
|
+
key, value = token.split(':', 2)
|
|
21
|
+
|
|
22
|
+
if FILTER_KEYS.include?(key) && value && !value.empty?
|
|
23
|
+
filters[key.to_sym] = value
|
|
24
|
+
else
|
|
25
|
+
text_parts << token
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
{ text: text_parts.join(' '), filters: filters }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def parse(raw_query)
|
|
34
|
+
new(raw_query).parse
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Formats search results for terminal output
|
|
2
|
+
#
|
|
3
|
+
# Two modes:
|
|
4
|
+
# - Rich (default): colored boxes grouped by dimension
|
|
5
|
+
# - Plain (--plain): pipeable tab-separated output
|
|
6
|
+
module ZenApropos
|
|
7
|
+
class ResultFormatter
|
|
8
|
+
SAFETY_BADGES = {
|
|
9
|
+
'safe' => "\e[32mโก safe\e[0m",
|
|
10
|
+
'caution' => "\e[33mโ ๏ธ caution\e[0m",
|
|
11
|
+
'destructive' => "\e[31m๐ด destructive\e[0m"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
RESET = "\e[0m".freeze
|
|
15
|
+
CYAN = "\e[36m".freeze
|
|
16
|
+
BOLD = "\e[1m".freeze
|
|
17
|
+
DIM = "\e[2m".freeze
|
|
18
|
+
YELLOW = "\e[33m".freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :results, :grouped_results, :query, :group_dimension, :matches, :elapsed
|
|
21
|
+
|
|
22
|
+
def initialize(results:, grouped_results:, query:, group_dimension:, matches: {}, elapsed: 0)
|
|
23
|
+
@results = results
|
|
24
|
+
@grouped_results = grouped_results
|
|
25
|
+
@query = query
|
|
26
|
+
@group_dimension = group_dimension
|
|
27
|
+
@matches = matches
|
|
28
|
+
@elapsed = elapsed
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def format_rich
|
|
32
|
+
output = []
|
|
33
|
+
output << header_box
|
|
34
|
+
output << ''
|
|
35
|
+
|
|
36
|
+
global_index = 0
|
|
37
|
+
grouped_results.each do |group_name, entries|
|
|
38
|
+
output << format_group(group_name, entries, start_index: global_index)
|
|
39
|
+
output << ''
|
|
40
|
+
global_index += entries.length
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
output << summary_line
|
|
44
|
+
output.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_plain
|
|
48
|
+
results.map do |entry|
|
|
49
|
+
safety = entry.safety || '-'
|
|
50
|
+
team = entry.team || '-'
|
|
51
|
+
[
|
|
52
|
+
entry.name.ljust(40),
|
|
53
|
+
(entry.description || '').ljust(50),
|
|
54
|
+
safety.ljust(12),
|
|
55
|
+
team
|
|
56
|
+
].join(' | ')
|
|
57
|
+
end.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def header_box
|
|
63
|
+
title = "ZenApropos โ #{results.length} results for \"#{query}\""
|
|
64
|
+
group_label = group_dimension ? "Grouped by: #{group_dimension}" : nil
|
|
65
|
+
|
|
66
|
+
width = 60
|
|
67
|
+
lines = []
|
|
68
|
+
lines << "#{CYAN}โญ#{'โ' * width}โฎ#{RESET}"
|
|
69
|
+
lines << "#{CYAN}โ#{RESET} #{BOLD}#{title}#{RESET}#{' ' * [0, width - title.length - 2].max}#{CYAN}โ#{RESET}"
|
|
70
|
+
if group_label
|
|
71
|
+
lines << "#{CYAN}โ#{RESET} #{DIM}#{group_label}#{RESET}#{' ' * [0, width - group_label.length - 2].max}#{CYAN}โ#{RESET}"
|
|
72
|
+
end
|
|
73
|
+
lines << "#{CYAN}โฐ#{'โ' * width}โฏ#{RESET}"
|
|
74
|
+
lines.join("\n")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def format_group(group_name, entries, start_index: 0)
|
|
78
|
+
lines = []
|
|
79
|
+
lines << "#{CYAN}โ #{BOLD}#{group_name}#{RESET} #{'โ' * [0, 55 - group_name.to_s.length].max}"
|
|
80
|
+
|
|
81
|
+
entries.each_with_index do |entry, i|
|
|
82
|
+
number = start_index + i + 1
|
|
83
|
+
lines << "#{CYAN}โ#{RESET} #{DIM}[#{number}]#{RESET} #{BOLD}rake #{entry.name}#{RESET}"
|
|
84
|
+
lines << "#{CYAN}โ#{RESET} #{entry.description}" unless entry.description.empty?
|
|
85
|
+
lines << "#{CYAN}โ#{RESET} #{DIM}$ #{entry.usage}#{RESET}"
|
|
86
|
+
lines << "#{CYAN}โ#{RESET} #{format_badges(entry)}" if entry.safety || entry.team
|
|
87
|
+
lines << format_match_context(entry) if matches[entry.name]
|
|
88
|
+
lines << "#{CYAN}โ#{RESET}" if i < entries.length - 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
lines << "#{CYAN}โ#{'โ' * 58}#{RESET}"
|
|
92
|
+
lines.join("\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def format_badges(entry)
|
|
96
|
+
parts = []
|
|
97
|
+
parts << SAFETY_BADGES[entry.safety] if entry.safety
|
|
98
|
+
parts << "#{DIM}team: #{entry.team}#{RESET}" if entry.team
|
|
99
|
+
parts.join(" ยท ")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_match_context(entry)
|
|
103
|
+
match_info = matches[entry.name]
|
|
104
|
+
return '' unless match_info && match_info[:source_lines]
|
|
105
|
+
|
|
106
|
+
lines = []
|
|
107
|
+
match_info[:source_lines].each do |line_num, content|
|
|
108
|
+
prefix = match_info[:matched_line] == line_num ? "#{YELLOW}>" : " "
|
|
109
|
+
lines << "#{CYAN}โ#{RESET} #{DIM}#{prefix} #{line_num}:#{RESET} #{content.rstrip}"
|
|
110
|
+
end
|
|
111
|
+
lines.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def summary_line
|
|
115
|
+
group_count = grouped_results.keys.length
|
|
116
|
+
group_word = group_dimension || 'groups'
|
|
117
|
+
elapsed_str = format('%.2fs', elapsed)
|
|
118
|
+
|
|
119
|
+
"#{results.length} results across #{group_count} #{group_word}#{group_count > 1 ? 's' : ''} (#{elapsed_str})"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Auto-groups search results by the most useful dimension
|
|
2
|
+
#
|
|
3
|
+
# Logic:
|
|
4
|
+
# - Skip any dimension used as a filter (it's constant across results)
|
|
5
|
+
# - Pick the dimension with the best cluster distribution
|
|
6
|
+
# - Fall back to namespace if all else is equal
|
|
7
|
+
# - No grouping for < 5 results
|
|
8
|
+
module ZenApropos
|
|
9
|
+
class ResultGrouper
|
|
10
|
+
DIMENSIONS = %i[namespace team safety].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :entries, :active_filters
|
|
13
|
+
|
|
14
|
+
def initialize(entries, active_filters: {})
|
|
15
|
+
@entries = entries
|
|
16
|
+
@active_filters = active_filters
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def group
|
|
20
|
+
return { nil => entries } if entries.length < 5
|
|
21
|
+
|
|
22
|
+
dimension = best_dimension
|
|
23
|
+
grouped = entries.group_by { |e| dimension_value(e, dimension) || '(none)' }
|
|
24
|
+
|
|
25
|
+
# Sort groups by size descending
|
|
26
|
+
grouped.sort_by { |_key, items| -items.length }.to_h
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def best_dimension
|
|
30
|
+
candidates = DIMENSIONS.reject { |d| active_filters.key?(d) }
|
|
31
|
+
return :namespace if candidates.empty?
|
|
32
|
+
|
|
33
|
+
# Pick the dimension that creates the most balanced groups
|
|
34
|
+
# (not 1 giant group, not all singletons)
|
|
35
|
+
candidates.max_by { |d| grouping_score(d) } || :namespace
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def grouping_score(dimension)
|
|
41
|
+
groups = entries.group_by { |e| dimension_value(e, dimension) }
|
|
42
|
+
|
|
43
|
+
return 0 if groups.length <= 1 # everything in one group = useless
|
|
44
|
+
return 0 if groups.length == entries.length # all singletons = useless
|
|
45
|
+
|
|
46
|
+
# Prefer more groups with reasonable sizes
|
|
47
|
+
groups.length.to_f / entries.length
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dimension_value(entry, dimension)
|
|
51
|
+
case dimension
|
|
52
|
+
when :namespace then entry.namespace.to_s.empty? ? nil : entry.namespace
|
|
53
|
+
when :team then entry.team
|
|
54
|
+
when :safety then entry.safety
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Base class for source adapters
|
|
2
|
+
#
|
|
3
|
+
# Subclasses must implement:
|
|
4
|
+
# #entries - returns Array of Entry objects
|
|
5
|
+
# #scan - returns Array of file paths to process
|
|
6
|
+
# #parse - returns Array of Entry objects for a single file
|
|
7
|
+
module ZenApropos
|
|
8
|
+
class Source
|
|
9
|
+
def entries
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def scan
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(_file_path)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|