codecombat/app/views/play/level/tome/editor/snippets.coffee
2016-07-27 16:37:57 -07:00

269 lines
13 KiB
CoffeeScript

###
This is essentially a copy from the snippet completer from Ace's ext/language-tools.js
However this completer assigns a score to the snippets to ensure that snippet suggestions are
treated better in the autocomplete than local values
###
{score} = fuzzaldrin
#score = (a, b) -> new Fuzziac(a).score b
lineBreak = /\r\n|[\n\r\u2028\u2029]/g
identifierRegex = /[\.a-zA-Z_0-9\$\-\u00A2-\uFFFF]/
Fuzziac = require './fuzziac' # https://github.com/stollcri/fuzziac.js
module.exports = (SnippetManager, autoLineEndings) ->
{Range} = ace.require 'ace/range'
util = ace.require 'ace/autocomplete/util'
identifierRegexps: [identifierRegex]
# Cleanup surrounding text
baseInsertSnippet = SnippetManager.insertSnippet
SnippetManager.insertSnippet = (editor, snippet) ->
# Remove dangling snippet prefixes
# Examples:
# "self self.moveUp()"
# "elf.self.moveUp()"
# "ssefl.moveUp()"
# "slef.moveUp()"
# TODO: This function is a mess
# TODO: Can some of this nonsense be done upstream in scrubSnippet?
cursor = editor.getCursorPosition()
line = editor.session.getLine cursor.row
if cursor.column > 0
prevWord = util.retrievePrecedingIdentifier line, cursor.column - 1, identifierRegex
if prevWord.length > 0
# Remove previous word if it's at the beginning of the snippet
prevWordIndex = snippet.toLowerCase().indexOf prevWord.toLowerCase()
if prevWordIndex is 0
range = new Range cursor.row, cursor.column - 1 - prevWord.length, cursor.row, cursor.column
editor.session.remove range
else
# console.log "Zatanna cursor.column=#{cursor.column} snippet='#{snippet}' line='#{line}' prevWord='#{prevWord}'"
# console.log "Zatanna prevWordIndex=#{prevWordIndex}"
# Lookup original completion
# TODO: Can we identify correct completer somehow?
for completer in editor.completers
if completer.completions?
for completion in completer.completions
if completion.snippet is snippet
originalCompletion = completion
break
break if originalCompletion
if originalCompletion?
# console.log 'Zatanna original completion', originalCompletion
# Get original snippet prefix (accounting for extra '\n' and possibly autoLineEndings at end)
lang = editor.session.getMode()?.$id?.substr 'ace/mode/'.length
# console.log 'Zatanna lang', lang, autoLineEndings[lang]?.length
extraEndLength = 1
extraEndLength += autoLineEndings[lang].length if autoLineEndings[lang]?
if snippetIndex = originalCompletion.content.indexOf snippet.substr(0, snippet.length - extraEndLength)
originalPrefix = originalCompletion.content.substring 0, snippetIndex
else
originalPrefix = ''
snippetStart = cursor.column - originalPrefix.length
# console.log "Zatanna originalPrefix='#{originalPrefix}' snippetStart=#{snippetStart}"
if snippetStart > 0 and snippetStart <= line.length
extraIndex = snippetStart - 1
# console.log "Zatanna prev char='#{line[extraIndex]}'"
if line[extraIndex] is '.'
# Fuzzy string match previous word before '.', and remove if a match to beginning of snippet
originalObject = originalCompletion.content.substring(0, originalCompletion.content.indexOf('.'))
prevObjectIndex = extraIndex - 1
# console.log "Zatanna prevObjectIndex=#{prevObjectIndex}"
if prevObjectIndex >= 0 and /\w/.test(line[prevObjectIndex])
prevObjectIndex-- while prevObjectIndex >= 0 and /\w/.test(line[prevObjectIndex])
prevObjectIndex++ if prevObjectIndex < 0 or not /\w/.test(line[prevObjectIndex])
# console.log "Zatanna prevObjectIndex=#{prevObjectIndex} extraIndex=#{extraIndex}"
prevObject = line.substring prevObjectIndex, extraIndex
#TODO: We use to use fuzziac here, but we forgot why. Using
# fuzzaldren for now.
#fuzzer = {score: (n) -> score originalObject, n}
fuzzer = new Fuzziac originalObject
finalScore = 0
if fuzzer
finalScore = fuzzer.score prevObject
# console.log "Zatanna originalObject='#{originalObject}' prevObject='#{prevObject}'", finalScore
if finalScore > 0.5
range = new Range cursor.row, prevObjectIndex, cursor.row, snippetStart
editor.session.remove range
else if /^[^.]+\./.test snippet
# Remove the first part of the snippet, and use whats there.
snippet = snippet.replace /^[^.]+\./, ''
else if /\w/.test(line[extraIndex])
# Remove any alphanumeric characters on this line immediately before prefix
extraIndex-- while extraIndex >= 0 and /\w/.test(line[extraIndex])
extraIndex++ if extraIndex < 0 or not /\w/.test(line[extraIndex])
range = new Range cursor.row, extraIndex, cursor.row, snippetStart
editor.session.remove range
#Remove anything that looks like an identifier after the completion
afterIndex = cursor.column
trailingText = line.substring afterIndex
match = trailingText.match /^[a-zA-Z_0-9]*(\(\s*\))?/
afterIndex += match[0].length if match
afterRange = new Range cursor.row, cursor.column, cursor.row, afterIndex
editor.session.remove afterRange
baseInsertSnippet.call @, editor, snippet
getCompletions: (editor, session, pos, prefix, callback) ->
# console.log "Zatanna getCompletions pos.column=#{pos.column} prefix=#{prefix}"
# Completion format:
# prefix: text that will be replaced by snippet
# caption: displayed left-justified in popup, and what's being matched
# snippet: what will be inserted into document
# score: used to order autocomplete snippet suggestions
# meta: displayed right-justfied in popup
lang = session.getMode()?.$id?.substr 'ace/mode/'.length
line = session.getLine pos.row
completions = []
#If the prefix is a member expression, supress completions
fullPrefix = getFullIdentifier session, pos
fullPrefixParts = fullPrefix.split /[.:]/g
word = getCurrentWord session, pos
if fullPrefixParts.length > 2
@completions = []
return callback null, completions
beginningOfLine = session.getLine(pos.row).substring(0,pos.column - prefix.length)
unless (fullPrefixParts.length < 3 and /^(hero|self|this|@)$/.test(fullPrefixParts[0]) ) or /^\s*$/.test(beginningOfLine)
console.log "Bailing", fullPrefixParts, '|', prefix, '|', beginningOfLine, '|', pos.column - prefix.length
@completions = completions
return callback null, completions
snippetMap = SnippetManager.snippetMap
SnippetManager.getActiveScopes(editor).forEach (scope) ->
snippets = snippetMap[scope] or []
for s in snippets
caption = s.name or s.tabTrigger
continue unless caption
[snippet, fuzzScore] = scrubSnippet s.content, caption, line, prefix, pos, lang, autoLineEndings, s.captureReturn
completions.push
content: s.content # Used internally by Zatanna, not by ace autocomplete
caption: caption
snippet: snippet
score: fuzzScore * s.importance ? 1.0
meta: s.meta or (if s.tabTrigger and not s.name then s.tabTrigger + '\u21E5' else 'snippets')
, @
#If the prefix is a reserved word, only exact prefix snippets match
keywords = session.getMode()?.$highlightRules?.$keywordList
if keywords and prefix in keywords
@completions = _.filter(completions, (x) -> x.caption.indexOf prefix is 0)
return callback null, @completions
# console.log 'Zatanna snippet completions', completions
@completions = completions
callback null, completions
# TODO: This shim doesn't work because our version of ace isn't updated to this change:
# TODO: https://github.com/ajaxorg/ace/commit/7b01a4273e91985c9177f53d238d6b83fe99dc56
# TODO: But, if it was we could use this and pass a 'completer: @' property for each completion
# insertMatch: (editor, data) ->
# console.log 'Zatanna snippets insertMatch', editor, data
# if data.snippet
# SnippetManager.insertSnippet editor, data.snippet
# else
# editor.execCommand "insertstring", data.value || data
getCurrentWord = (doc, pos) ->
end = pos.column
start = end - 1
text = doc.getLine(pos.row)
start-- while start >= 0 and not text[start].match /\s+|[\.\@]/
start++ if start >= 0
text.substring start, end
getFullIdentifier = (doc, pos) ->
end = pos.column
start = end - 1
text = doc.getLine(pos.row)
start-- while start >= 0 and not text[start].match /\s+/
start++ if start >= 0
text.substring start, end
scrubSnippet = (snippet, caption, line, input, pos, lang, autoLineEndings, captureReturn) ->
# console.log "Zatanna snippet=#{snippet} caption=#{caption} line=#{line} input=#{input} pos.column=#{pos.column} lang=#{lang}"
fuzzScore = 0.1
# input will be replaced by snippet
# trim snippet prefix and suffix if already in the document (line)
if prefixStart = snippet.toLowerCase().indexOf(input.toLowerCase()) > -1
snippetLines = (snippet.match(lineBreak) || []).length
captionStart = snippet.indexOf caption
# Calculate snippet prefixes and suffixes. E.g. full snippet might be: "self." + "moveLeft" + "()"
snippetPrefix = snippet.substring 0, captionStart
snippetSuffix = snippet.substring snippetPrefix.length + caption.length
# Calculate line prefixes and suffixes
# linePrefix: beginning portion of snippet that already exists
linePrefixIndex = pos.column - input.length - 1
if linePrefixIndex >= 0 and snippetPrefix.length > 0 and line[linePrefixIndex] is snippetPrefix[snippetPrefix.length - 1]
snippetPrefixIndex = snippetPrefix.length - 1
while line[linePrefixIndex] is snippetPrefix[snippetPrefixIndex]
break if linePrefixIndex is 0 or snippetPrefixIndex is 0
linePrefixIndex--
snippetPrefixIndex--
linePrefix = line.substr linePrefixIndex, pos.column - input.length - linePrefixIndex
else
linePrefix = ''
lineSuffix = line.substr pos.column, snippetSuffix.length - 1 + caption.length - input.length + 1
lineSuffix = '' if snippet.indexOf(lineSuffix) < 0
# TODO: This is broken for attack(find in Python, but seems ok in JavaScript.
# Don't eat existing matched parentheses
# console.log "Zatanna checking parentheses lineSuffix=#{lineSuffix} pos.column=#{pos.column} input.length=#{input.length}, prevChar=#{line[pos.column - input.length - 1]} line.length=#{line.length} nextChar=#{line[pos.column]}"
if pos.column - input.length >= 0 and line[pos.column - input.length - 1] is '(' and pos.column < line.length and line[pos.column] is ')' and lineSuffix is ')'
lineSuffix = ''
# Score match before updating snippet
fuzzScore += score snippet, linePrefix + input + lineSuffix
# Update snippet based on surrounding document/line
snippet = snippet.slice snippetPrefix.length if snippetPrefix.length > 0 and snippetPrefix is linePrefix
snippet = snippet.slice 0, snippet.length - lineSuffix.length if lineSuffix.length > 0
# Append automatic line ending and newline
# If at end of line
# And, no parentheses are before snippet. E.g. 'if ('
# And, line doesn't start with whitespace followed by 'if ' or 'elif '
# console.log "Zatanna autoLineEndings linePrefixIndex='#{linePrefixIndex}'"
if lineSuffix.length is 0 and /^\s*$/.test line.slice pos.column
# console.log 'Zatanna atLineEnd', pos.column, lineSuffix.length, line.slice(pos.column + lineSuffix.length), line
toLinePrefix = line.substring 0, linePrefixIndex
if linePrefixIndex < 0 or linePrefixIndex >= 0 and not /[\(\)]/.test(toLinePrefix) and not /^[ \t]*(?:if\b|elif\b)/.test(toLinePrefix)
snippet += autoLineEndings[lang] if snippetLines is 0 and autoLineEndings[lang]
snippet += "\n" if snippetLines is 0 and not /\$\{/.test(snippet)
if captureReturn and /^\s*$/.test(toLinePrefix)
snippet = captureReturn + linePrefix + snippet
# console.log "Zatanna snippetPrefix=#{snippetPrefix} linePrefix=#{linePrefix} snippetSuffix=#{snippetSuffix} lineSuffix=#{lineSuffix} snippet=#{snippet} score=#{fuzzScore}"
else
fuzzScore += score snippet, input
startsWith = (string, searchString, position) ->
position = position or 0
return string.substr(position, searchString.length) is searchString
# Prefixing is twice as good as fuzzy mathing?
fuzzScore *= 2 if startsWith(caption, input)
# All things equal, a shorter snippet is better
fuzzScore -= caption.length / 500
# Exact match is really good.
fuzzScore = 10 if caption == input
[snippet, fuzzScore]