okf 1.9.0 → 1.11.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 +4 -4
- data/CHANGELOG.md +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
|
|
6
|
+
# act on entries — `set` keys on the bundle's path, so --as means one thing
|
|
7
|
+
# ("the slug this entry has") whether it adds or renames. `default`/`rename`
|
|
8
|
+
# act on slugs, the names actually to hand once a bundle is registered. Every
|
|
9
|
+
# positional stays unambiguous, and `config` is left free for real settings.
|
|
10
|
+
class Registry < Command
|
|
11
|
+
# The `registry` umbrella's subcommands — the dispatch, and the words a
|
|
12
|
+
# flag-first invocation is checked against.
|
|
13
|
+
SUBCOMMANDS = %w[set del list default rename].freeze
|
|
14
|
+
|
|
15
|
+
def self.id
|
|
16
|
+
:registry
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.group
|
|
20
|
+
:registry
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.help_rows
|
|
24
|
+
[
|
|
25
|
+
[ "registry list [--json]", "list registered bundles (* marks the default)" ],
|
|
26
|
+
[ "registry set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
|
|
27
|
+
[ "registry del <dir|@slug>", "remove a bundle from the registry" ],
|
|
28
|
+
[ "registry default <@slug>", "move a bundle to the front (the default)" ],
|
|
29
|
+
[ "registry rename <@slug> <new>", "rename a registered bundle (<new> is a new name, not a ref)" ]
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(argv)
|
|
34
|
+
require "okf/registry"
|
|
35
|
+
|
|
36
|
+
sub = argv.first
|
|
37
|
+
case sub
|
|
38
|
+
when "set" then registry_set(argv.drop(1))
|
|
39
|
+
when "del" then registry_del(argv.drop(1))
|
|
40
|
+
when "list" then registry_list(argv.drop(1))
|
|
41
|
+
when "default" then registry_default(argv.drop(1))
|
|
42
|
+
when "rename" then registry_rename(argv.drop(1))
|
|
43
|
+
else
|
|
44
|
+
# A bare word that isn't a known subcommand is a typo (`registry remove x`
|
|
45
|
+
# must not silently render the list and read as success).
|
|
46
|
+
return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
|
|
47
|
+
|
|
48
|
+
# Same rule for a subcommand hiding behind a flag: `registry --json set
|
|
49
|
+
# dir` would otherwise list an empty registry and exit 0, having written
|
|
50
|
+
# nothing the user asked for. It cannot just be dispatched from wherever
|
|
51
|
+
# it turns up — the word may be a flag's value (`registry --as set <dir>`
|
|
52
|
+
# asks for the slug "set"), and a grammar where that reading depends on
|
|
53
|
+
# which flag precedes it is a trapdoor. So the subcommand must lead, and
|
|
54
|
+
# the error says which one was found rather than guessing at the intent.
|
|
55
|
+
stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
|
|
56
|
+
return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
|
|
57
|
+
|
|
58
|
+
registry_list(argv)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Add a bundle to the persistent registry (so a later bare `okf server` finds
|
|
65
|
+
# it), or update one already there. The entry is keyed by the bundle's path: a
|
|
66
|
+
# path already registered refreshes its title in place, and --as renames it. A
|
|
67
|
+
# new path is added, slugged by directory basename unless --as says otherwise.
|
|
68
|
+
def registry_set(argv)
|
|
69
|
+
options = { as: nil, default: false }
|
|
70
|
+
parser = OptionParser.new do |o|
|
|
71
|
+
o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
|
|
72
|
+
o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
|
|
73
|
+
o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
|
|
74
|
+
help_flag(o)
|
|
75
|
+
end
|
|
76
|
+
# No no_extras? here: positional_dir has already refused a trailing
|
|
77
|
+
# argument. The sibling subcommands need the call because they take their
|
|
78
|
+
# positional through `positional`, which does not check.
|
|
79
|
+
dir = positional_dir(parser, argv) or return 2
|
|
80
|
+
|
|
81
|
+
reg = OKF::Registry.load
|
|
82
|
+
# Said before the upsert: after it, an update is indistinguishable from an
|
|
83
|
+
# add, and "registered" for what was a rename reads as a duplicate entry.
|
|
84
|
+
known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
|
|
85
|
+
entry = reg.add(dir, as: options[:as], default: options[:default])
|
|
86
|
+
# Through report_skipped like every other bundle-reading verb: the reader
|
|
87
|
+
# tolerates a file it cannot open, so a count taken straight off the graph
|
|
88
|
+
# reports "0 concepts" for a bundle whose files are simply unreadable.
|
|
89
|
+
folder = OKF::Bundle::Folder.load(entry.path)
|
|
90
|
+
report_skipped(folder)
|
|
91
|
+
count = folder.graph(minimal: true).nodes.size
|
|
92
|
+
@out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
|
|
93
|
+
0
|
|
94
|
+
rescue OKF::Error => e
|
|
95
|
+
usage_error(e.message)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Remove a bundle from the persistent registry by slug or by its directory.
|
|
99
|
+
def registry_del(argv)
|
|
100
|
+
parser = OptionParser.new do |o|
|
|
101
|
+
o.banner = "Usage: okf registry del <dir|@slug>"
|
|
102
|
+
help_flag(o)
|
|
103
|
+
end
|
|
104
|
+
slug = positional(parser, argv) or return 2
|
|
105
|
+
no_extras?(argv) or return 2
|
|
106
|
+
|
|
107
|
+
reg = OKF::Registry.load
|
|
108
|
+
slug = registry_slug(slug, reg) or return 2
|
|
109
|
+
removed = reg.remove(slug)
|
|
110
|
+
return usage_error("no such bundle: #{slug}") unless removed
|
|
111
|
+
|
|
112
|
+
@out.puts "removed #{removed.slug}"
|
|
113
|
+
0
|
|
114
|
+
rescue OKF::Error => e
|
|
115
|
+
usage_error(e.message)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def registry_list(argv)
|
|
119
|
+
options = { json: false }
|
|
120
|
+
parser = OptionParser.new do |o|
|
|
121
|
+
o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
|
|
122
|
+
"okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
|
|
123
|
+
json_flags(o, options, "emit the registry as JSON")
|
|
124
|
+
help_flag(o)
|
|
125
|
+
end
|
|
126
|
+
begin
|
|
127
|
+
parser.parse!(argv)
|
|
128
|
+
rescue OptionParser::ParseError => e
|
|
129
|
+
@err.puts e.message
|
|
130
|
+
return 2
|
|
131
|
+
end
|
|
132
|
+
no_extras?(argv) or return 2
|
|
133
|
+
|
|
134
|
+
reg = OKF::Registry.load
|
|
135
|
+
return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
|
|
136
|
+
|
|
137
|
+
print_registry(reg)
|
|
138
|
+
0
|
|
139
|
+
rescue OKF::Error => e
|
|
140
|
+
usage_error(e.message)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Choose which registered bundle a bare `okf server` opens at `/`, by moving
|
|
144
|
+
# it to the front of the registry. The listing is ordered and the JSON is
|
|
145
|
+
# meant to be hand-editable, so the move is stated rather than left to be
|
|
146
|
+
# discovered from a reordered file.
|
|
147
|
+
def registry_default(argv)
|
|
148
|
+
parser = OptionParser.new do |o|
|
|
149
|
+
o.banner = "Usage: okf registry default <@slug>\n " \
|
|
150
|
+
"moves it to the front — the first registered bundle is the default until you do"
|
|
151
|
+
help_flag(o)
|
|
152
|
+
end
|
|
153
|
+
slug = positional(parser, argv) or return 2
|
|
154
|
+
no_extras?(argv) or return 2
|
|
155
|
+
|
|
156
|
+
reg = OKF::Registry.load
|
|
157
|
+
slug = registry_slug(slug, reg) or return 2
|
|
158
|
+
reg.default = slug
|
|
159
|
+
@out.puts "default bundle → #{reg.default.slug} (now first)"
|
|
160
|
+
0
|
|
161
|
+
rescue OKF::Error => e
|
|
162
|
+
usage_error(e.message)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The @ref grammar for a verb that takes a *slug*, read by name. These three
|
|
166
|
+
# must reach an entry whose directory is gone — that is the one worth
|
|
167
|
+
# deleting or renaming — so they cannot go through resolve_ref, which
|
|
168
|
+
# insists the directory exist. Without this the refs only appeared to work:
|
|
169
|
+
# `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
|
|
170
|
+
# accident while a bare `@` normalized to "" and failed. Returns the slug,
|
|
171
|
+
# or nil after reporting.
|
|
172
|
+
def registry_slug(arg, registry)
|
|
173
|
+
return arg unless arg.start_with?("@")
|
|
174
|
+
|
|
175
|
+
asked = arg[1..-1]
|
|
176
|
+
return asked unless asked.empty?
|
|
177
|
+
|
|
178
|
+
default = registry.default
|
|
179
|
+
return default.slug if default
|
|
180
|
+
|
|
181
|
+
@err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Rename a registered bundle's slug — its mount path and switcher name.
|
|
186
|
+
def registry_rename(argv)
|
|
187
|
+
parser = OptionParser.new do |o|
|
|
188
|
+
o.banner = "Usage: okf registry rename <@slug> <new>"
|
|
189
|
+
help_flag(o)
|
|
190
|
+
end
|
|
191
|
+
parser.parse!(argv)
|
|
192
|
+
old_slug, new_slug = argv.shift(2)
|
|
193
|
+
if old_slug.nil? || new_slug.nil?
|
|
194
|
+
@err.puts parser.banner
|
|
195
|
+
return 2
|
|
196
|
+
end
|
|
197
|
+
no_extras?(argv) or return 2
|
|
198
|
+
|
|
199
|
+
reg = OKF::Registry.load
|
|
200
|
+
# The old name may be a ref; the new one is a name being minted, never one.
|
|
201
|
+
old_slug = registry_slug(old_slug, reg) or return 2
|
|
202
|
+
entry = reg.rename(old_slug, new_slug)
|
|
203
|
+
# The slug it *found*, not the argv that found it: rename normalizes to look
|
|
204
|
+
# the entry up, so echoing the raw ask names a bundle that never existed.
|
|
205
|
+
@out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
|
|
206
|
+
0
|
|
207
|
+
rescue OptionParser::ParseError => e
|
|
208
|
+
@err.puts e.message
|
|
209
|
+
2
|
|
210
|
+
rescue OKF::Error => e
|
|
211
|
+
usage_error(e.message)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def print_registry(reg)
|
|
215
|
+
return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
|
|
216
|
+
|
|
217
|
+
rows = reg.listing
|
|
218
|
+
width = rows.map { |row| row[:slug].length }.max
|
|
219
|
+
rows.each do |row|
|
|
220
|
+
marker = row[:default] ? "*" : " "
|
|
221
|
+
missing = row[:missing] ? " (missing)" : ""
|
|
222
|
+
@out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
register(Registry)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The static counterpart to `server`: bake the whole bundle into one
|
|
6
|
+
# self-contained HTML file (bodies, catalog, index, logs baked in, no server
|
|
7
|
+
# needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
|
|
8
|
+
class Render < Command
|
|
9
|
+
def self.id
|
|
10
|
+
:render
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.group
|
|
14
|
+
:act
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.help_rows
|
|
18
|
+
[
|
|
19
|
+
[ "render <dir|@slug> [-o FILE] [--layout NAME] [...]", "write a static, self-contained HTML graph" ]
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(argv)
|
|
24
|
+
require "okf/render/graph"
|
|
25
|
+
|
|
26
|
+
options = { output: nil, title: nil, link: nil, layout: "cose" }
|
|
27
|
+
parser = OptionParser.new do |o|
|
|
28
|
+
o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
|
|
29
|
+
o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
|
|
30
|
+
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
31
|
+
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
32
|
+
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
33
|
+
help_flag(o)
|
|
34
|
+
end
|
|
35
|
+
dir = positional_dir(parser, argv) or return 2
|
|
36
|
+
|
|
37
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
38
|
+
report_skipped(folder)
|
|
39
|
+
html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
|
|
40
|
+
if options[:output]
|
|
41
|
+
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
42
|
+
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
|
43
|
+
# that means "failing bundle".
|
|
44
|
+
begin
|
|
45
|
+
File.write(options[:output], html)
|
|
46
|
+
rescue SystemCallError => e
|
|
47
|
+
return usage_error("cannot write #{options[:output]}: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
# Off the bundle, not a second graph: Graph.build maps one node per
|
|
50
|
+
# concept, so the counts are identical — and Folder#graph is not
|
|
51
|
+
# memoized, so asking for one here would build a whole second graph
|
|
52
|
+
# (Render::Graph.static already built one) to print one number. Only
|
|
53
|
+
# the graph, to be exact: the concepts are parsed once at Folder.load
|
|
54
|
+
# and Graph.build reads them from memory, so this costs no disk.
|
|
55
|
+
count = folder.bundle.concepts.size
|
|
56
|
+
@out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
|
|
57
|
+
else
|
|
58
|
+
@out.print html
|
|
59
|
+
end
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
register(Render)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Ranked text retrieval — the browser page's search brought to the CLI on the
|
|
6
|
+
# same engine (a MiniFTS index) and extended to bodies. Terms after the
|
|
7
|
+
# directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
|
|
8
|
+
# --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
|
|
9
|
+
# where they hit (title > id > tags > type/description > body) and carry one
|
|
10
|
+
# bounded context snippet, so "which concept covers X?" costs a few rows, not
|
|
11
|
+
# a body read. Advisory read: exit 0 even with no matches. Exact by default —
|
|
12
|
+
# the consuming agent is the fuzzy layer, until it asks not to be.
|
|
13
|
+
class Search < Command
|
|
14
|
+
# The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
|
|
15
|
+
# the flag vocabulary in the shell, where it belongs, and lets the message end
|
|
16
|
+
# with the fix rather than only the complaint: an engine that *can* do what was
|
|
17
|
+
# asked is named, so the next command is obvious.
|
|
18
|
+
CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
|
|
19
|
+
|
|
20
|
+
def self.id
|
|
21
|
+
:search
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.group
|
|
25
|
+
:read
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.help_rows
|
|
29
|
+
[
|
|
30
|
+
[ "search <dir|@slug…|@all> <term…> [--regexp|--fuzzy]", "find concepts by text or regexp, ranked (@all: every bundle)" ]
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(argv)
|
|
35
|
+
options = { json: false, regexp: false, fuzzy: false, engine: nil }
|
|
36
|
+
parser = OptionParser.new do |o|
|
|
37
|
+
o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--dir D] [--tag T] [--json]"
|
|
38
|
+
search_engine_note(o)
|
|
39
|
+
json_flags(o, options, "emit the matches as JSON")
|
|
40
|
+
projection_flags(o, options)
|
|
41
|
+
o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
|
|
42
|
+
"than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
|
|
43
|
+
o.on("--fuzzy",
|
|
44
|
+
"tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
|
|
45
|
+
o.on("--engine NAME", "match with this engine instead of the default",
|
|
46
|
+
"(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
|
|
47
|
+
o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
|
|
48
|
+
filter_flags(o, options, :type, :area, :tag)
|
|
49
|
+
help_flag(o)
|
|
50
|
+
end
|
|
51
|
+
begin
|
|
52
|
+
parser.parse!(argv)
|
|
53
|
+
rescue OptionParser::ParseError => e
|
|
54
|
+
@err.puts e.message
|
|
55
|
+
return 2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Registry mode — leading @refs, @all among them — searches several bundles
|
|
59
|
+
# and labels every match; a plain dir keeps the classic single-bundle output.
|
|
60
|
+
if argv.first&.start_with?("@")
|
|
61
|
+
pairs = ref_targets(argv) or return 2
|
|
62
|
+
dir = nil
|
|
63
|
+
else
|
|
64
|
+
dir = argv.shift
|
|
65
|
+
if dir.nil?
|
|
66
|
+
@err.puts parser.banner
|
|
67
|
+
return 2
|
|
68
|
+
end
|
|
69
|
+
dir = resolve_ref(dir) or return 2
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
terms = argv
|
|
73
|
+
if terms.empty?
|
|
74
|
+
@err.puts parser.banner
|
|
75
|
+
return 2
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A non-leading @arg is a literal term by the grammar — say so, since the
|
|
79
|
+
# user may have meant a ref (refs must lead) and would otherwise see only
|
|
80
|
+
# a silent zero-match.
|
|
81
|
+
stray = terms.find { |term| term.start_with?("@") }
|
|
82
|
+
@err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
|
|
83
|
+
|
|
84
|
+
unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
|
|
85
|
+
return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
|
|
86
|
+
|
|
87
|
+
# Two query languages, not two dials on one: a regexp is matched against raw
|
|
88
|
+
# text, --fuzzy is an edit distance over indexed tokens. Silently honouring
|
|
89
|
+
# one and dropping the other would answer a question nobody asked.
|
|
90
|
+
if options[:regexp] && options[:fuzzy]
|
|
91
|
+
return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return multi_search(pairs, terms, options) if pairs
|
|
95
|
+
|
|
96
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
97
|
+
report_skipped(folder)
|
|
98
|
+
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
|
|
99
|
+
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
100
|
+
keep = filter_ids(folder, options)
|
|
101
|
+
rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
|
|
102
|
+
return print_search_json(dir, terms, rows, options) if options[:json]
|
|
103
|
+
|
|
104
|
+
print_search(dir, terms, rows, folder.bundle.concepts.size)
|
|
105
|
+
0
|
|
106
|
+
rescue RegexpError => e
|
|
107
|
+
usage_error("invalid pattern: #{e.message}")
|
|
108
|
+
rescue OKF::Bundle::Search::UnknownEngine => e
|
|
109
|
+
usage_error(e.message)
|
|
110
|
+
rescue OKF::Bundle::Search::UnsupportedQuery => e
|
|
111
|
+
usage_error(unsupported_query_message(e))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Every registered bundle, as [slug, dir] pairs — what @all expands to.
|
|
117
|
+
# Asking for everything tolerates gaps: a registered directory that has since
|
|
118
|
+
# vanished is skipped with a note, the same forgiveness the hub shows a stale
|
|
119
|
+
# entry. Naming one bundle demands it, so a plain @slug still fails hard.
|
|
120
|
+
def all_targets
|
|
121
|
+
registry = load_registry
|
|
122
|
+
return nil unless registry
|
|
123
|
+
|
|
124
|
+
if registry.empty?
|
|
125
|
+
@err.puts "error: no bundles registered (okf registry set <dir>)"
|
|
126
|
+
return nil
|
|
127
|
+
end
|
|
128
|
+
pairs = []
|
|
129
|
+
registry.each do |entry|
|
|
130
|
+
if File.directory?(entry.path)
|
|
131
|
+
pairs << [ entry.slug, entry.path ]
|
|
132
|
+
else
|
|
133
|
+
skip_registered(entry)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
if pairs.empty?
|
|
137
|
+
@err.puts "error: every registered bundle is missing on disk (okf registry list)"
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
pairs
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
|
|
144
|
+
# "one" is the default, and must be searched once. `@all @one` is the same
|
|
145
|
+
# story with a wider first ref: all ⊇ one, so the result is right and the
|
|
146
|
+
# duplicate simply drops. No error branch, because there is no wrong answer
|
|
147
|
+
# to warn about.
|
|
148
|
+
def ref_targets(argv)
|
|
149
|
+
refs = []
|
|
150
|
+
refs << argv.shift while argv.first&.start_with?("@")
|
|
151
|
+
pairs = []
|
|
152
|
+
refs.each do |ref|
|
|
153
|
+
found = all_ref?(ref) ? all_targets : ref_pair(ref)
|
|
154
|
+
return nil unless found
|
|
155
|
+
|
|
156
|
+
found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
|
|
157
|
+
end
|
|
158
|
+
pairs
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One @ref as a single-element [[slug, dir]], or nil after reporting.
|
|
162
|
+
def ref_pair(ref)
|
|
163
|
+
path = resolve_registered(ref)
|
|
164
|
+
unless path
|
|
165
|
+
# Only an unknown slug is plausibly a mistyped term — a broken registry
|
|
166
|
+
# or a gone directory has nothing to do with the grammar.
|
|
167
|
+
@err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
|
|
168
|
+
return nil
|
|
169
|
+
end
|
|
170
|
+
[ [ ref_slugs[path], path ] ]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Search every bundle at once and merge the rankings, each row labeled with
|
|
174
|
+
# its bundle's slug. The bundles go in as *one* corpus rather than one search
|
|
175
|
+
# each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
|
|
176
|
+
# statistics and then interleaving the lists would let the same match score
|
|
177
|
+
# differently for no reason a reader could see. One index, one ranking.
|
|
178
|
+
#
|
|
179
|
+
# Filters stay per-bundle — they are per-folder questions — so they apply to
|
|
180
|
+
# the merged rows by (slug, id) afterwards.
|
|
181
|
+
def multi_search(pairs, terms, options)
|
|
182
|
+
bundles = []
|
|
183
|
+
keeps = {}
|
|
184
|
+
total = 0
|
|
185
|
+
pairs.each do |slug, dir|
|
|
186
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
187
|
+
report_skipped(folder)
|
|
188
|
+
total += folder.bundle.concepts.size
|
|
189
|
+
bundles << [ slug, folder.bundle ]
|
|
190
|
+
keep = filter_ids(folder, options)
|
|
191
|
+
keeps[slug] = keep unless keep.nil?
|
|
192
|
+
end
|
|
193
|
+
rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
|
|
194
|
+
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
195
|
+
rows = rows.select { |row| !keeps.key?(row[:slug]) || keeps[row[:slug]].include?(row[:id]) }
|
|
196
|
+
return print_multi_search_json(pairs, terms, rows, options) if options[:json]
|
|
197
|
+
|
|
198
|
+
print_multi_search(pairs, terms, rows, total)
|
|
199
|
+
0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def print_search(dir, terms, rows, total)
|
|
203
|
+
@out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
|
|
204
|
+
if rows.empty?
|
|
205
|
+
@out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
|
|
206
|
+
return
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
width = rows.map { |row| row[:id].length }.max
|
|
210
|
+
rows.each do |row|
|
|
211
|
+
@out.puts
|
|
212
|
+
@out.puts " #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
|
|
213
|
+
@out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def print_search_json(dir, terms, rows, options)
|
|
218
|
+
emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def print_multi_search(pairs, terms, rows, total)
|
|
222
|
+
@out.puts "Search — #{pairs.map { |slug, _| "@#{slug}" }.join(" ")} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
|
|
223
|
+
if rows.empty?
|
|
224
|
+
@out.puts " no matches — fewer or broader terms, or scan `okf tags @<slug>` for a bundle's vocabulary"
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
slug_width = rows.map { |row| row[:slug].length }.max + 1
|
|
229
|
+
width = rows.map { |row| row[:id].length }.max
|
|
230
|
+
rows.each do |row|
|
|
231
|
+
@out.puts
|
|
232
|
+
@out.puts " #{"@#{row[:slug]}".ljust(slug_width)} #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
|
|
233
|
+
@out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The head maps every searched slug to its directory once, so a row's
|
|
238
|
+
# `slug` resolves to `<dir>/<id>.md` without a second lookup — and without
|
|
239
|
+
# repeating a long path on every row.
|
|
240
|
+
def print_multi_search_json(pairs, terms, rows, options)
|
|
241
|
+
head = { "bundles" => pairs.map { |slug, dir| { "slug" => slug, "dir" => dir } } }
|
|
242
|
+
emit_list_json(head, "matches", rows.map { |row| stringify(row) }, options, { "query" => terms }, "matches_by_ref")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def unsupported_query_message(error)
|
|
246
|
+
wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
|
|
247
|
+
return "no available search engine offers #{wanted}" if error.engine.nil?
|
|
248
|
+
|
|
249
|
+
able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
|
|
250
|
+
message = "--engine #{error.engine} does not support #{wanted}"
|
|
251
|
+
message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
|
|
252
|
+
message
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The engine story, told once, in the only place there is to tell it. `search`
|
|
256
|
+
# routes on what the query needs — a pattern needs the scan, --fuzzy needs the
|
|
257
|
+
# index — and says nothing about it at runtime: no note on stderr, nothing in
|
|
258
|
+
# the header, and deliberately no --engine flag. So this is where a user learns
|
|
259
|
+
# that the exactness a token index gives up is still reachable, and that -e is
|
|
260
|
+
# how. Without it that capability is present but undiscoverable.
|
|
261
|
+
#
|
|
262
|
+
# It leads rather than trails because #help_flag registers -h with `on_tail`,
|
|
263
|
+
# which OptionParser renders after every separator: a closing paragraph would
|
|
264
|
+
# print *above* the -h line and split the option list in half. Stating the
|
|
265
|
+
# matching model before the flags reads better anyway.
|
|
266
|
+
def search_engine_note(parser)
|
|
267
|
+
parser.separator ""
|
|
268
|
+
parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
|
|
269
|
+
parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
|
|
270
|
+
parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
|
|
271
|
+
parser.separator "better ranking and the engine the browser page runs, at the cost of that"
|
|
272
|
+
parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
|
|
273
|
+
parser.separator ""
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The registered engines, read at parse time so an addon that registers one
|
|
277
|
+
# shows up in `--help` without the CLI knowing it exists.
|
|
278
|
+
def engine_names
|
|
279
|
+
OKF::Bundle::Search.engines.map(&:id).join(" | ")
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
register(Search)
|
|
284
|
+
end
|
|
285
|
+
end
|