howl.file_search

local searchers, tmp_dir, config, searcher

setup ->
  searchers = {name, def for name, def in pairs file_search.searchers}
  for name in pairs searchers
    file_search.unregister_searcher name

  tmp_dir = File.tmpdir!
  config = howl.config.for_file tmp_dir

teardown ->
  for name in pairs file_search.searchers
    file_search.unregister_searcher name

  for _, def in pairs searchers
    file_search.register_searcher def

  tmp_dir\delete_all!

before_each ->
  searcher = {
    name: 'test'
    description: 'test'
    handler: -> {}
  }

register_searcher(def)

raises an error for missing attributes

assert.raises "name", ->
  file_search.register_searcher description: 'desc', handler: ->

assert.raises "description", ->
  file_search.register_searcher name: 'name', handler: ->

assert.raises "handler", ->
  file_search.register_searcher name: 'test', description: 'desc'
before_each ->
  file_search.register_searcher searcher
  config.file_searcher = 'test'

returns matches and the used searcher

matches = {}
searcher.handler = -> matches
_, used_searcher = file_search.search tmp_dir, 'foo'
assert.equal searcher, used_searcher

(when the searcher returns matches directly)

returns matches from the specified searcher

matches = {
  { path: 'urk.txt', file: tmp_dir\join('urk'), line_nr: 1, message: 'foo'}
}
searcher.handler = -> matches
res = file_search.search tmp_dir, 'foo'
assert.same matches, res

raises an error if the searcher omits required match fields

matches = {}
searcher.handler = -> matches

matches[1] = { line_nr: 1, message: 'foo' }
assert.raises 'path', -> file_search.search tmp_dir, 'foo'

matches[1] = { path: 'urk.txt', message: 'foo' }
assert.raises 'line_nr', -> file_search.search tmp_dir, 'foo'

matches[1] = { path: 'urk.txt', line_nr: 1 }
assert.raises 'message', -> file_search.search tmp_dir, 'foo'

sets .file from path if not provided

searcher.handler = -> { { path: 'urk.txt', line_nr: 1, message: 'foo'} }
res = file_search.search tmp_dir, 'foo'
assert.equal tmp_dir\join('urk.txt'), res[1].file

(when the searcher returns a process object)

it "returns matches from the process' output", (done) ->

howl_async ->
  searcher.handler = -> Process.open_pipe 'echo "file.ext:10: foo"'
  res = file_search.search tmp_dir, 'foo'
  assert.same {
    {message: 'foo', path: 'file.ext', line_nr: 10, file: tmp_dir\join('file.ext')}
  }, res
  done!

(when the searcher returns a string)

it 'returns matches from running the string as a command', (done) ->

howl_async ->
  searcher.handler = -> 'echo "file.ext:10: foo"'
  res = file_search.search tmp_dir, 'foo'
  assert.same {
    {message: 'foo', path: 'file.ext', line_nr: 10, file: tmp_dir\join('file.ext')}
  }, res
  done!

(when a search process exits with an exit code of 1)

it 'return zero matches', (done) ->

howl_async ->
  searcher.handler = -> 'exit 1'
  res = file_search.search tmp_dir, 'foo'
  assert.same {}, res
  done!

(selecting the searcher)

raises an error if the specified searcher is not available

searcher.is_available = -> false
assert.raises 'unavailable', -> file_search.search tmp_dir, 'foo'

allows passing an explicit searcher using an explicit `searcher` table

my_searcher = {
  name: 'custom',
  description: 'pass-directly',
  handler: -> {
    { line_nr: 1, file: tmp_dir\join('my'), path: 'my', message: 'custom' }
  }
}
res = file_search.search tmp_dir, 'foo', searcher: my_searcher
assert.same my_searcher.handler!, res

allows passing an explicit searcher using an explicit `searcher` string

my_searcher = {
  name: 'my_searcher',
  description: 'pass-directly',
  handler: -> {
    { line_nr: 1, file: tmp_dir\join('my'), path: 'my', message: 'custom' }
  }
}
file_search.register_searcher my_searcher
res = file_search.search tmp_dir, 'foo', searcher: 'my_searcher'
assert.same my_searcher.handler!, res

sort(matches, context)

match = (message, path, line_nr = 1) ->
  {:message, :path, :line_nr, file: tmp_dir\join(path)}

messages = (matches) -> [m.message for m in *matches]

prefers standalone matches to substring matches

sorted = file_search.sort {
  match('a fool', 'sub1')
  match('bar foo zed', 'alone')
  match('food for thought', 'sub2')
}, tmp_dir, 'foo'
assert.equal 'alone', sorted[1].path

prefers matches where the term is included in the match's base name

sorted = file_search.sort {
  match('notbase', 'foo/zed.moon')
  match('base', 'bar/foo.moon')
}, tmp_dir, 'foo'
assert.same {'base', 'notbase'}, messages(sorted)

penalizes matches in test files

sorted = file_search.sort {
  match('spec', 'foo/zed_spec.moon')
  match('test', 'foo/zed_test.moon')
  match('specd', 'foo/zed-spec.moon')
  match('testd', 'foo/zed-test.moon')
  match('testp', 'foo/test_test.moon')
  match('base', 'foo/zed.moon')
}, tmp_dir, 'foo'
assert.same 'base', messages(sorted)[1]

groups matches by path for same-score matches

sorted = file_search.sort {
  match('foo', 'one.moon')
  match('foo', 'two.moon')
  match('bar', 'one.moon')
  match('bar', 'two.moon')
}, tmp_dir, 'xxx'
assert.equal sorted[1].path, sorted[2].path

always orders matches in the same file by line nr

sorted = file_search.sort {
  match('3', 'file.moon', 3)
  match('1', 'file.moon', 1)
  match('2', 'file.moon', 2)
}, tmp_dir, 'xxx'
assert.same {'1', '2', '3'}, messages(sorted)

(when context is provided)

local buffer

before_each ->
  buffer = howl.Buffer!

prefers matches close to the current context directory

buffer.file = tmp_dir\join 'first/second/file.txt'
sorted = file_search.sort {
  match('twoup', 'twoup.txt') -- distance 3
  match('samedir', 'first/second/samedir.txt') -- distance 1
  match('same', 'first/second/file.txt') -- distance 0
  match('oneup', 'first/oneup.txt') -- distance 2
  match('diffroot', 'other/otro/annan.txt') --distance 5
}, tmp_dir, 'foo', buffer\context_at(1)

assert.same {'same', 'samedir', 'oneup', 'twoup', 'diffroot'}, messages(sorted)

prefers matches in files sharing the same name cluster

buffer.file = tmp_dir\join 'foo.moon'
sorted = file_search.sort {
  match('notsame', 'food.moon')
  match('spec', 'foo_spec.moon')
  match('other', 'angry/fools.moon')
}, tmp_dir, 'foo', buffer\context_at(1)

assert.same {'spec', 'notsame', 'other'}, messages(sorted)

buffer.file = tmp_dir\join 'foo_spec.moon'
sorted = file_search.sort {
  match('notsame', 'food.moon')
  match('main', 'foo.moon')
  match('other', 'angry/fools.moon')
}, tmp_dir, 'search', buffer\context_at(1)

assert.same {'main', 'notsame', 'other'}, messages(sorted)

the native searcher

local search

setup ->
  search = searchers.native.handler

handles multiple matches in a file correctly

hit = tmp_dir\join('hit.txt')
hit.contents = ([[
food
snafoo
bafoon
]]).stripped
res = search tmp_dir, 'foo'
assert.same {
  {path: 'hit.txt', line_nr: 1, column: 1, message: 'food'},
  {path: 'hit.txt', line_nr: 2, column: 4, message: 'snafoo'},
  {path: 'hit.txt', line_nr: 3, column: 3, message: 'bafoon'},
}, res

handles a match at the end of a file, preceeding an empty line

hit = tmp_dir\join('hit.txt')
hit.contents = 'foo\n'
res = search tmp_dir, 'foo'
assert.same {
  {path: 'hit.txt', line_nr: 1, column: 1, message: 'foo'},
}, res

is case insensitive

hit = tmp_dir\join('hit.txt')
hit.contents = 'foo\nFOO'
res = search tmp_dir, 'fOo'
assert.same {
  {path: 'hit.txt', line_nr: 1, column: 1, message: 'foo'},
  {path: 'hit.txt', line_nr: 2, column: 1, message: 'FOO'},
}, res

only reports the first match for a given line

hit = tmp_dir\join('hit.txt')
hit.contents = 'in barbary there is a bar\n'
res = search tmp_dir, 'bar'
assert.same {
  {
    path: 'hit.txt',
    line_nr: 1,
    column: 4,
    message: 'in barbary there is a bar'
  },
}, res

limits messages to the given max_message_length option

hit = tmp_dir\join('hit.txt')
hit.contents = string.rep 'x', 100
res = search tmp_dir, 'x', max_message_length: 50
assert.equals 50, #res[1].message

handles binary files without issue

ffi = require('ffi')
bin = tmp_dir\join('bin')
data = ffi.new 'char[1024]'
for i = 0, 1023
  data[i] = math.random(255)
bin.contents = ffi.string(data, 1024)
res = search tmp_dir, 'notlikely'
assert.equals 0, #res

(when the whole_word option is set)

only finds whole words

hit = tmp_dir\join('hit.txt')
hit.contents = ([[
bar
fubar
barred
barbary
in a bar
]]).stripped
res = search tmp_dir, 'bar', whole_word: true
assert.same {
  {path: 'hit.txt', line_nr: 1, column: 1, message: 'bar'},
  {path: 'hit.txt', line_nr: 5, column: 6, message: 'in a bar'},
}, res