Class: OKF::CLI::Search

Inherits:
Command show all
Defined in:
lib/okf/cli/search.rb

Overview

Ranked text retrieval — the browser page's search brought to the CLI on the same engine (a MiniFTS index) and extended to bodies. Terms after the directory are ANDed tokens, matched whole or by prefix (Ruby regexps with --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward where they hit (title > id > tags > type/description > body) and carry one bounded context snippet, so "which concept covers X?" costs a few rows, not a body read. Advisory read: exit 0 even with no matches. Exact by default — the consuming agent is the fuzzy layer, until it asks not to be.

Constant Summary collapse

CAPABILITY_FLAGS =

The core raises :regexp; a user typed --regexp. Translating here keeps the flag vocabulary in the shell, where it belongs, and lets the message end with the fix rather than only the complaint: an engine that can do what was asked is named, so the next command is obvious.

{ regexp: "--regexp", fuzzy: "--fuzzy" }.freeze

Constants inherited from Command

Command::DUCK_TYPE

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Command

hidden?, #initialize

Constructor Details

This class inherits a constructor from OKF::CLI::Command

Class Method Details

.groupObject



24
25
26
# File 'lib/okf/cli/search.rb', line 24

def self.group
  :read
end

.help_rowsObject



28
29
30
31
32
# File 'lib/okf/cli/search.rb', line 28

def self.help_rows
  [
    [ "search    <dir|@slug…|@all> <term…> [--regexp|--fuzzy]", "find concepts by text or regexp, ranked (@all: every bundle)" ]
  ]
end

.idObject



20
21
22
# File 'lib/okf/cli/search.rb', line 20

def self.id
  :search
end

Instance Method Details

#call(argv) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/okf/cli/search.rb', line 34

def call(argv)
  options = { json: false, regexp: false, fuzzy: false, engine: nil }
  parser = OptionParser.new do |o|
    o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
    search_engine_note(o)
    json_flags(o, options, "emit the matches as JSON")
    projection_flags(o, options)
    o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
      "than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
    o.on("--fuzzy",
      "tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
    o.on("--engine NAME", "match with this engine instead of the default",
      "(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
    o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
    filter_flags(o, options, :type, :area, :tag)
    help_flag(o)
  end
  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    @err.puts e.message
    return 2
  end

  # Registry mode — leading @refs, @all among them — searches several bundles
  # and labels every match; a plain dir keeps the classic single-bundle output.
  if argv.first&.start_with?("@")
    pairs = ref_targets(argv) or return 2
    dir = nil
  else
    dir = argv.shift
    if dir.nil?
      @err.puts parser.banner
      return 2
    end
    dir = resolve_ref(dir) or return 2
  end

  terms = argv
  if terms.empty?
    @err.puts parser.banner
    return 2
  end

  # A non-leading @arg is a literal term by the grammar — say so, since the
  # user may have meant a ref (refs must lead) and would otherwise see only
  # a silent zero-match.
  stray = terms.find { |term| term.start_with?("@") }
  @err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray

  unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
  return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?

  # Two query languages, not two dials on one: a regexp is matched against raw
  # text, --fuzzy is an edit distance over indexed tokens. Silently honouring
  # one and dropping the other would answer a question nobody asked.
  if options[:regexp] && options[:fuzzy]
    return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
  end

  return multi_search(pairs, terms, options) if pairs

  folder = OKF::Bundle::Folder.load(dir)
  report_skipped(folder)
  rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
    fuzzy: options[:fuzzy], engine: options[:engine])
  keep = filter_ids(folder, options)
  rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
  return print_search_json(dir, terms, rows, options) if options[:json]

  print_search(dir, terms, rows, folder.bundle.concepts.size)
  0
rescue RegexpError => e
  usage_error("invalid pattern: #{e.message}")
rescue OKF::Bundle::Search::UnknownEngine => e
  usage_error(e.message)
rescue OKF::Bundle::Search::UnsupportedQuery => e
  usage_error(unsupported_query_message(e))
end