searcher.coffee 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. #
  2. # Match functions
  3. #
  4. SEPARATOR = '.'
  5. query =
  6. queryLength =
  7. value =
  8. valueLength =
  9. matcher = # current match function
  10. fuzzyRegexp = # query fuzzy regexp
  11. index = # position of the query in the string being matched
  12. lastIndex = # last position of the query in the string being matched
  13. match = # regexp match data
  14. matchIndex =
  15. matchLength =
  16. score = # score for the current match
  17. separators = # counter
  18. i = null # cursor
  19. `function exactMatch() {`
  20. index = value.indexOf(query)
  21. return unless index >= 0
  22. lastIndex = value.lastIndexOf(query)
  23. if index isnt lastIndex
  24. return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0)
  25. else
  26. return scoreExactMatch()
  27. `}`
  28. `function scoreExactMatch() {`
  29. # Remove one point for each unmatched character.
  30. score = 100 - (valueLength - queryLength)
  31. if index > 0
  32. # If the character preceding the query is a dot, assign the same score
  33. # as if the query was found at the beginning of the string, minus one.
  34. if value.charAt(index - 1) is SEPARATOR
  35. score += index - 1
  36. # Don't match a single-character query unless it's found at the beginning
  37. # of the string or is preceded by a dot.
  38. else if queryLength is 1
  39. return
  40. # (1) Remove one point for each unmatched character up to the nearest
  41. # preceding dot or the beginning of the string.
  42. # (2) Remove one point for each unmatched character following the query.
  43. else
  44. i = index - 2
  45. i-- while i >= 0 and value.charAt(i) isnt SEPARATOR
  46. score -= (index - i) + # (1)
  47. (valueLength - queryLength - index) # (2)
  48. # Remove one point for each dot preceding the query, except for the one
  49. # immediately before the query.
  50. separators = 0
  51. i = index - 2
  52. while i >= 0
  53. separators++ if value.charAt(i) is SEPARATOR
  54. i--
  55. score -= separators
  56. # Remove five points for each dot following the query.
  57. separators = 0
  58. i = valueLength - queryLength - index - 1
  59. while i >= 0
  60. separators++ if value.charAt(index + queryLength + i) is SEPARATOR
  61. i--
  62. score -= separators * 5
  63. return Math.max 1, score
  64. `}`
  65. `function fuzzyMatch() {`
  66. return if valueLength <= queryLength or value.indexOf(query) >= 0
  67. return unless match = fuzzyRegexp.exec(value)
  68. matchIndex = match.index
  69. matchLength = match[0].length
  70. score = scoreFuzzyMatch()
  71. if match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1))
  72. matchIndex = i + match.index
  73. matchLength = match[0].length
  74. return Math.max(score, scoreFuzzyMatch())
  75. else
  76. return score
  77. `}`
  78. `function scoreFuzzyMatch() {`
  79. # When the match is at the beginning of the string or preceded by a dot.
  80. if matchIndex is 0 or value.charAt(matchIndex - 1) is SEPARATOR
  81. return Math.max 66, 100 - matchLength
  82. # When the match is at the end of the string.
  83. else if matchIndex + matchLength is valueLength
  84. return Math.max 33, 67 - matchLength
  85. # When the match is in the middle of the string.
  86. else
  87. return Math.max 1, 34 - matchLength
  88. `}`
  89. #
  90. # Searchers
  91. #
  92. class app.Searcher
  93. $.extend @prototype, Events
  94. CHUNK_SIZE = 20000
  95. DEFAULTS =
  96. max_results: app.config.max_results
  97. fuzzy_min_length: 3
  98. SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g
  99. EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/
  100. INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/
  101. EMPTY_PARANTHESES_REGEXP = /\(\)/
  102. EVENT_REGEXP = /\ event$/
  103. DOT_REGEXP = /\.+/g
  104. WHITESPACE_REGEXP = /\s/g
  105. EMPTY_STRING = ''
  106. ELLIPSIS = '...'
  107. STRING = 'string'
  108. @normalizeString: (string) ->
  109. string
  110. .toLowerCase()
  111. .replace ELLIPSIS, EMPTY_STRING
  112. .replace EVENT_REGEXP, EMPTY_STRING
  113. .replace INFO_PARANTHESES_REGEXP, EMPTY_STRING
  114. .replace SEPARATORS_REGEXP, SEPARATOR
  115. .replace DOT_REGEXP, SEPARATOR
  116. .replace EMPTY_PARANTHESES_REGEXP, EMPTY_STRING
  117. .replace WHITESPACE_REGEXP, EMPTY_STRING
  118. @normalizeQuery: (string) ->
  119. string = @normalizeString(string)
  120. string.replace EOS_SEPARATORS_REGEXP, '$1.'
  121. constructor: (options = {}) ->
  122. @options = $.extend {}, DEFAULTS, options
  123. find: (data, attr, q) ->
  124. @kill()
  125. @data = data
  126. @attr = attr
  127. @query = q
  128. @setup()
  129. if @isValid() then @match() else @end()
  130. return
  131. setup: ->
  132. query = @query = @constructor.normalizeQuery(@query)
  133. queryLength = query.length
  134. @dataLength = @data.length
  135. @matchers = [exactMatch]
  136. @totalResults = 0
  137. @setupFuzzy()
  138. return
  139. setupFuzzy: ->
  140. if queryLength >= @options.fuzzy_min_length
  141. fuzzyRegexp = @queryToFuzzyRegexp(query)
  142. @matchers.push(fuzzyMatch)
  143. else
  144. fuzzyRegexp = null
  145. return
  146. isValid: ->
  147. queryLength > 0 and query isnt SEPARATOR
  148. end: ->
  149. @triggerResults [] unless @totalResults
  150. @trigger 'end'
  151. @free()
  152. return
  153. kill: ->
  154. if @timeout
  155. clearTimeout @timeout
  156. @free()
  157. return
  158. free: ->
  159. @data = @attr = @dataLength = @matchers = @matcher = @query =
  160. @totalResults = @scoreMap = @cursor = @timeout = null
  161. return
  162. match: =>
  163. if not @foundEnough() and @matcher = @matchers.shift()
  164. @setupMatcher()
  165. @matchChunks()
  166. else
  167. @end()
  168. return
  169. setupMatcher: ->
  170. @cursor = 0
  171. @scoreMap = new Array(101)
  172. return
  173. matchChunks: =>
  174. @matchChunk()
  175. if @cursor is @dataLength or @scoredEnough()
  176. @delay @match
  177. @sendResults()
  178. else
  179. @delay @matchChunks
  180. return
  181. matchChunk: ->
  182. matcher = @matcher
  183. for [0...@chunkSize()]
  184. value = @data[@cursor][@attr]
  185. if value.split # string
  186. valueLength = value.length
  187. @addResult(@data[@cursor], score) if score = matcher()
  188. else # array
  189. score = 0
  190. for value in @data[@cursor][@attr]
  191. valueLength = value.length
  192. score = Math.max(score, matcher() || 0)
  193. @addResult(@data[@cursor], score) if score > 0
  194. @cursor++
  195. return
  196. chunkSize: ->
  197. if @cursor + CHUNK_SIZE > @dataLength
  198. @dataLength % CHUNK_SIZE
  199. else
  200. CHUNK_SIZE
  201. scoredEnough: ->
  202. @scoreMap[100]?.length >= @options.max_results
  203. foundEnough: ->
  204. @totalResults >= @options.max_results
  205. addResult: (object, score) ->
  206. (@scoreMap[Math.round(score)] or= []).push(object)
  207. @totalResults++
  208. return
  209. getResults: ->
  210. results = []
  211. for objects in @scoreMap by -1 when objects
  212. results.push.apply results, objects
  213. results[0...@options.max_results]
  214. sendResults: ->
  215. results = @getResults()
  216. @triggerResults results if results.length
  217. return
  218. triggerResults: (results) ->
  219. @trigger 'results', results
  220. return
  221. delay: (fn) ->
  222. @timeout = setTimeout(fn, 1)
  223. queryToFuzzyRegexp: (string) ->
  224. chars = string.split ''
  225. chars[i] = $.escapeRegexp(char) for char, i in chars
  226. new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/
  227. class app.SynchronousSearcher extends app.Searcher
  228. match: =>
  229. if @matcher
  230. @allResults or= []
  231. @allResults.push.apply @allResults, @getResults()
  232. super
  233. free: ->
  234. @allResults = null
  235. super
  236. end: ->
  237. @sendResults true
  238. super
  239. sendResults: (end) ->
  240. if end and @allResults?.length
  241. @triggerResults @allResults
  242. delay: (fn) ->
  243. fn()