<commit_before>{View, $$} = require 'space-pen'
$ = require 'jquery'
_ = require 'underscore'
Range = require 'range'
Editor = require 'editor'
fuzzyFilter = require 'fuzzy-filter'

module.exports =
class Autocomplete extends View
  @content: ->
    @div class: 'autocomplete', tabindex: -1, =>
      @ol outlet: 'matchesList'
      @subview 'miniEditor', new Editor(mini: true)

  editor: null
  miniEditor: null
  currentBuffer: null
  wordList: null
  wordRegex: /\w+/g
  allMatches: null
  filteredMatches: null
  currentMatchIndex: null
  isAutocompleting: false
  originalSelectionBufferRange: null
  originalSelectedText: null

  @activate: (rootView) ->
    new Autocomplete(editor) for editor in rootView.editors()
    rootView.on 'editor-open', (e, editor) ->
      new Autocomplete(editor) unless editor.is('.autocomplete .mini')

  initialize: (@editor) ->
    requireStylesheet 'autocomplete.css'
    @handleEvents()
    @setCurrentBuffer(@editor.buffer)

  handleEvents: ->
    @editor.on 'editor-path-change', => @setCurrentBuffer(@editor.buffer)
    @editor.on 'before-remove', => @currentBuffer?.off '.autocomplete'

    @editor.on 'autocomplete:attach', => @attach()
    @editor.on 'autocomplete:cancel', => @cancel()
    @on 'autocomplete:confirm', => @confirm()

    @matchesList.on 'mousedown', (e) =>
      index = $(e.target).attr('index')
      @selectMatchAtIndex(index) if index?
      false

    @matchesList.on 'mouseup', =>
      if @selectedMatch()
        @confirm()
      else
        @cancel()

    @miniEditor.buffer.on 'change', (e) =>
      @filterMatches() if @parent()[0]

    @miniEditor.preempt 'move-up', =>
      @selectPreviousMatch()
      false

    @miniEditor.preempt 'move-down', =>
      @selectNextMatch()
      false

    @miniEditor.preempt 'textInput', (e) =>
      text = e.originalEvent.data
      unless text.match(@wordRegex)
        @confirm()
        @editor.insertText(text)
        false

  setCurrentBuffer: (buffer) ->
    @currentBuffer?.off '.autocomplete'
    @currentBuffer = buffer
    @buildWordList()
    @currentBuffer.on 'change.autocomplete', (e) =>
      @buildWordList() unless @isAutocompleting

  buildWordList: () ->
    @wordList = _.unique(@currentBuffer.getText().match(@wordRegex))

  confirm: ->
    @confirmed = true
    @editor.getSelection().clearSelection()
    @detach()
    return unless match = @selectedMatch()
    position = @editor.getCursorBufferPosition()
    @editor.setCursorBufferPosition([position.row, position.column + match.suffix.length])

  cancel: ->
    @detach()
    @editor.buffer.change(@currentMatchBufferRange, @originalSelectedText) if @currentMatchBufferRange
    @editor.setSelectionBufferRange(@originalSelectionBufferRange)

  attach: ->
    @confirmed = false
    @miniEditor.on 'focusout', => @cancel() unless @confirmed

    @originalSelectedText = @editor.getSelectedText()
    @originalSelectionBufferRange = @editor.getSelection().getBufferRange()
    @allMatches = @findMatchesForCurrentSelection()


    originalCursorPosition = @editor.getCursorScreenPosition()
    @filterMatches()
    @editor.lines.append(this)
    @setPosition(originalCursorPosition)

    @miniEditor.focus()

  detach: ->
    @editor.off(".autocomplete")
    @editor.focus()
    super
    @miniEditor.buffer.setText('')

  setPosition: (originalCursorPosition) ->
    { left, top } = @editor.pixelPositionForScreenPosition(originalCursorPosition)
    potentialTop = top + @editor.lineHeight
    potentialBottom = potentialTop + @outerHeight()
    if potentialBottom > @editor.scroller.scrollBottom()
      @css(left: left, bottom: @editor.lines.height() - top, top: 'inherit')
    else
      @css(left: left, top: potentialTop, bottom: 'inherit')

  selectPreviousMatch: ->
    previousIndex = @currentMatchIndex - 1
    previousIndex = @filteredMatches.length - 1 if previousIndex < 0
    @selectMatchAtIndex(previousIndex)

  selectNextMatch: ->
    nextIndex = (@currentMatchIndex + 1) % @filteredMatches.length
    @selectMatchAtIndex(nextIndex)

  selectMatchAtIndex: (index) ->
    @currentMatchIndex = index
    @matchesList.find("li").removeClass "selected"

    liToSelect = @matchesList.find("li:eq(#{index})")
    liToSelect.addClass "selected"

    topOfLiToSelect = liToSelect.position().top + @matchesList.scrollTop()
    bottomOfLiToSelect = topOfLiToSelect + liToSelect.outerHeight()
    if topOfLiToSelect < @matchesList.scrollTop()
      @matchesList.scrollTop(topOfLiToSelect)
    else if bottomOfLiToSelect > @matchesList.scrollBottom()
      @matchesList.scrollBottom(bottomOfLiToSelect)

    @replaceSelectedTextWithMatch @selectedMatch()

  selectedMatch: ->
    @filteredMatches[@currentMatchIndex]

  filterMatches: ->
    @filteredMatches = fuzzyFilter(@allMatches, @miniEditor.getText(), key: 'word')
    @renderMatchList()

  renderMatchList: ->
    @matchesList.empty()
    if @filteredMatches.length > 0
      @matchesList.append($$ -> @li match.word, index: index) for match, index in @filteredMatches
    else
      @matchesList.append($$ -> @li "No matches found")

    @selectMatchAtIndex(0) if @filteredMatches.length > 0

  findMatchesForCurrentSelection: ->
    selection = @editor.getSelection()
    {prefix, suffix} = @prefixAndSuffixOfSelection(selection)

    if (prefix.length + suffix.length) > 0
      regex = new RegExp("^#{prefix}(.+)#{suffix}$", "i")
      currentWord = prefix + @editor.getSelectedText() + suffix
      for word in @wordList when regex.test(word) and word != currentWord
        match = regex.exec(word)
        {prefix, suffix, word, infix: match[1]}
    else
      []

  replaceSelectedTextWithMatch: (match) ->
    selection = @editor.getSelection()
    startPosition = selection.getBufferRange().start
    @isAutocompleting = true
    @editor.insertText(match.infix)

    @currentMatchBufferRange = [startPosition, [startPosition.row, startPosition.column + match.infix.length]]
    @editor.setSelectionBufferRange(@currentMatchBufferRange)
    @isAutocompleting = false

  prefixAndSuffixOfSelection: (selection) ->
    selectionRange = selection.getBufferRange()
    lineRange = [[selectionRange.start.row, 0], [selectionRange.end.row, @editor.lineLengthForBufferRow(selectionRange.end.row)]]
    [prefix, suffix] = ["", ""]

    @currentBuffer.scanInRange @wordRegex, lineRange, (match, range, {stop}) ->
      stop() if range.start.isGreaterThan(selectionRange.end)

      if range.intersectsWith(selectionRange)
        prefixOffset = selectionRange.start.column - range.start.column
        suffixOffset = selectionRange.end.column - range.end.column

        prefix = match[0][0...prefixOffset] if range.start.isLessThan(selectionRange.start)
        suffix = match[0][suffixOffset..] if range.end.isGreaterThan(selectionRange.end)

    {prefix, suffix}
<commit_msg>optimization: Use hash instead of _.unique when building autocomplete word list<commit_after>{View, $$} = require 'space-pen'
$ = require 'jquery'
_ = require 'underscore'
Range = require 'range'
Editor = require 'editor'
fuzzyFilter = require 'fuzzy-filter'

module.exports =
class Autocomplete extends View
  @content: ->
    @div class: 'autocomplete', tabindex: -1, =>
      @ol outlet: 'matchesList'
      @subview 'miniEditor', new Editor(mini: true)

  editor: null
  miniEditor: null
  currentBuffer: null
  wordList: null
  wordRegex: /\w+/g
  allMatches: null
  filteredMatches: null
  currentMatchIndex: null
  isAutocompleting: false
  originalSelectionBufferRange: null
  originalSelectedText: null

  @activate: (rootView) ->
    new Autocomplete(editor) for editor in rootView.editors()
    rootView.on 'editor-open', (e, editor) ->
      new Autocomplete(editor) unless editor.is('.autocomplete .mini')

  initialize: (@editor) ->
    requireStylesheet 'autocomplete.css'
    @handleEvents()
    @setCurrentBuffer(@editor.buffer)

  handleEvents: ->
    @editor.on 'editor-path-change', => @setCurrentBuffer(@editor.buffer)
    @editor.on 'before-remove', => @currentBuffer?.off '.autocomplete'

    @editor.on 'autocomplete:attach', => @attach()
    @editor.on 'autocomplete:cancel', => @cancel()
    @on 'autocomplete:confirm', => @confirm()

    @matchesList.on 'mousedown', (e) =>
      index = $(e.target).attr('index')
      @selectMatchAtIndex(index) if index?
      false

    @matchesList.on 'mouseup', =>
      if @selectedMatch()
        @confirm()
      else
        @cancel()

    @miniEditor.buffer.on 'change', (e) =>
      @filterMatches() if @parent()[0]

    @miniEditor.preempt 'move-up', =>
      @selectPreviousMatch()
      false

    @miniEditor.preempt 'move-down', =>
      @selectNextMatch()
      false

    @miniEditor.preempt 'textInput', (e) =>
      text = e.originalEvent.data
      unless text.match(@wordRegex)
        @confirm()
        @editor.insertText(text)
        false

  setCurrentBuffer: (buffer) ->
    @currentBuffer?.off '.autocomplete'
    @currentBuffer = buffer
    @buildWordList()
    @currentBuffer.on 'change.autocomplete', (e) =>
      @buildWordList() unless @isAutocompleting

  buildWordList: () ->
    wordHash = {}
    for word in @currentBuffer.getText().match(@wordRegex)
      wordHash[word] ?= true

    @wordList = Object.keys(wordHash)

  confirm: ->
    @confirmed = true
    @editor.getSelection().clearSelection()
    @detach()
    return unless match = @selectedMatch()
    position = @editor.getCursorBufferPosition()
    @editor.setCursorBufferPosition([position.row, position.column + match.suffix.length])

  cancel: ->
    @detach()
    @editor.buffer.change(@currentMatchBufferRange, @originalSelectedText) if @currentMatchBufferRange
    @editor.setSelectionBufferRange(@originalSelectionBufferRange)

  attach: ->
    @confirmed = false
    @miniEditor.on 'focusout', => @cancel() unless @confirmed

    @originalSelectedText = @editor.getSelectedText()
    @originalSelectionBufferRange = @editor.getSelection().getBufferRange()
    @allMatches = @findMatchesForCurrentSelection()


    originalCursorPosition = @editor.getCursorScreenPosition()
    @filterMatches()
    @editor.lines.append(this)
    @setPosition(originalCursorPosition)

    @miniEditor.focus()

  detach: ->
    @editor.off(".autocomplete")
    @editor.focus()
    super
    @miniEditor.buffer.setText('')

  setPosition: (originalCursorPosition) ->
    { left, top } = @editor.pixelPositionForScreenPosition(originalCursorPosition)
    potentialTop = top + @editor.lineHeight
    potentialBottom = potentialTop + @outerHeight()
    if potentialBottom > @editor.scroller.scrollBottom()
      @css(left: left, bottom: @editor.lines.height() - top, top: 'inherit')
    else
      @css(left: left, top: potentialTop, bottom: 'inherit')

  selectPreviousMatch: ->
    previousIndex = @currentMatchIndex - 1
    previousIndex = @filteredMatches.length - 1 if previousIndex < 0
    @selectMatchAtIndex(previousIndex)

  selectNextMatch: ->
    nextIndex = (@currentMatchIndex + 1) % @filteredMatches.length
    @selectMatchAtIndex(nextIndex)

  selectMatchAtIndex: (index) ->
    @currentMatchIndex = index
    @matchesList.find("li").removeClass "selected"

    liToSelect = @matchesList.find("li:eq(#{index})")
    liToSelect.addClass "selected"

    topOfLiToSelect = liToSelect.position().top + @matchesList.scrollTop()
    bottomOfLiToSelect = topOfLiToSelect + liToSelect.outerHeight()
    if topOfLiToSelect < @matchesList.scrollTop()
      @matchesList.scrollTop(topOfLiToSelect)
    else if bottomOfLiToSelect > @matchesList.scrollBottom()
      @matchesList.scrollBottom(bottomOfLiToSelect)

    @replaceSelectedTextWithMatch @selectedMatch()

  selectedMatch: ->
    @filteredMatches[@currentMatchIndex]

  filterMatches: ->
    @filteredMatches = fuzzyFilter(@allMatches, @miniEditor.getText(), key: 'word')
    @renderMatchList()

  renderMatchList: ->
    @matchesList.empty()
    if @filteredMatches.length > 0
      @matchesList.append($$ -> @li match.word, index: index) for match, index in @filteredMatches
    else
      @matchesList.append($$ -> @li "No matches found")

    @selectMatchAtIndex(0) if @filteredMatches.length > 0

  findMatchesForCurrentSelection: ->
    selection = @editor.getSelection()
    {prefix, suffix} = @prefixAndSuffixOfSelection(selection)

    if (prefix.length + suffix.length) > 0
      regex = new RegExp("^#{prefix}(.+)#{suffix}$", "i")
      currentWord = prefix + @editor.getSelectedText() + suffix
      for word in @wordList when regex.test(word) and word != currentWord
        match = regex.exec(word)
        {prefix, suffix, word, infix: match[1]}
    else
      []

  replaceSelectedTextWithMatch: (match) ->
    selection = @editor.getSelection()
    startPosition = selection.getBufferRange().start
    @isAutocompleting = true
    @editor.insertText(match.infix)

    @currentMatchBufferRange = [startPosition, [startPosition.row, startPosition.column + match.infix.length]]
    @editor.setSelectionBufferRange(@currentMatchBufferRange)
    @isAutocompleting = false

  prefixAndSuffixOfSelection: (selection) ->
    selectionRange = selection.getBufferRange()
    lineRange = [[selectionRange.start.row, 0], [selectionRange.end.row, @editor.lineLengthForBufferRow(selectionRange.end.row)]]
    [prefix, suffix] = ["", ""]

    @currentBuffer.scanInRange @wordRegex, lineRange, (match, range, {stop}) ->
      stop() if range.start.isGreaterThan(selectionRange.end)

      if range.intersectsWith(selectionRange)
        prefixOffset = selectionRange.start.column - range.start.column
        suffixOffset = selectionRange.end.column - range.end.column

        prefix = match[0][0...prefixOffset] if range.start.isLessThan(selectionRange.start)
        suffix = match[0][suffixOffset..] if range.end.isGreaterThan(selectionRange.end)

    {prefix, suffix}

{'fraction_non_alphanumeric': 0.13031837916063677, 'fraction_numerical': 0.002170767004341534, 'mean_word_length': 3.8820204874602613, 'pattern_counts': {'":': 0, '<': 7, '<?xml version=': 0, '>': 89, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '109074', 'n_tokens_mistral': 4229, 'n_tokens_neox': 4032, 'n_words': 807}