Module: Space::Src::Nav

Defined in:
lib/space_src/nav.rb

Overview

Fuzzy navigator over on-disk source checkouts.

Checkouts live at base_dir/<host>/<owner>/<name> (depth-3 dirs). Matching is a case-insensitive SUBSEQUENCE against “owner/name” —host is excluded from the match but is part of the resolved path.

Ranking is fzf-inspired: contiguity bonus, word-boundary bonus, earliness (earlier first match wins). Ties break by target string asc then host asc — deterministic total order.

Class Method Summary collapse

Class Method Details

.dispatch(query, stdout, stderr, base_dir) ⇒ Object

Cd-contract executor. Scans base_dir for checkouts, fuzzy-matches query, applies the 0/1/many contract:

- exactly one match  → absolute path on last stdout line, returns 0
- zero matches       → message on stderr, returns 1
- multiple matches   → ranked candidates on stdout, returns 1


81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/space_src/nav.rb', line 81

def self.dispatch(query, stdout, stderr, base_dir)
  entries = scan(base_dir)
  matches = rank(entries, query)

  case matches.length
  when 0
    stderr.puts "src: no match for '#{query}'"
    1
  when 1
    stdout.puts matches.first[:path]
    0
  else
    matches.each { |m| stdout.puts "#{m[:host]}/#{m[:target]}" }
    1
  end
end

.match_positions(query, target) ⇒ Object

Pure: find the leftmost match positions of query chars (case-insensitive) as a subsequence into target. Returns an array of integer indices, or nil if the query is not a subsequence of the target.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/space_src/nav.rb', line 32

def self.match_positions(query, target)
  q = query.downcase
  t = target.downcase
  positions = []
  qi = 0
  t.each_char.with_index do |c, i|
    if c == q[qi]
      positions << i
      qi += 1
      return positions if qi == q.length
    end
  end
  nil
end

.rank(entries, query) ⇒ Object

Pure: match and rank a list of entry hashes against query. Returns entries annotated with :score, sorted best-first. Tie-break: target asc, then host asc.



65
66
67
68
69
70
71
72
73
74
# File 'lib/space_src/nav.rb', line 65

def self.rank(entries, query)
  scored = entries.filter_map do |e|
    t = e[:target].downcase
    positions = match_positions(query, t)
    next unless positions
    score = score_match(positions, t)
    e.merge(score:)
  end
  scored.sort_by { |e| [-e[:score], e[:target], e[:host]] }
end

.scan(base_dir) ⇒ Object

Enumerate all depth-3 directories under base_dir. Returns array of hashes: owner:, name:, target:, path:.



16
17
18
19
20
21
22
23
24
25
26
27
# File 'lib/space_src/nav.rb', line 16

def self.scan(base_dir)
  pattern = File.join(base_dir, "*", "*", "*")
  prefix = base_dir.chomp("/") + "/"
  Dir.glob(pattern).filter_map do |path|
    next unless File.directory?(path)
    relative = path.delete_prefix(prefix)
    parts = relative.split("/")
    next unless parts.length == 3
    host, owner, name = parts
    {host:, owner:, name:, target: "#{owner}/#{name}", path:}
  end
end

.score_match(positions, target_lower) ⇒ Object

Pure: compute a score for a set of match positions within target_lower. Higher score = better match.

contiguity: each consecutive pair of matched indices scores +10
word boundary: each position at start of string or right after
  '/', '-', '_' scores +5
earliness: subtract the first matched position (earlier = higher score)


53
54
55
56
57
58
59
60
# File 'lib/space_src/nav.rb', line 53

def self.score_match(positions, target_lower)
  contiguity = positions.each_cons(2).count { |a, b| b == a + 1 } * 10
  boundary = positions.count do |p|
    p == 0 || "/\\-_".include?(target_lower[p - 1])
  end * 5
  earliness = -positions.first
  contiguity + boundary + earliness
end