mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-13 01:01:34 -05:00
337 lines
15 KiB
CoffeeScript
337 lines
15 KiB
CoffeeScript
utils = require 'core/utils'
|
|
|
|
defaults =
|
|
autoLineEndings:
|
|
# Mapping ace mode language to line endings to automatically insert
|
|
# E.g. javascript: ";"
|
|
{}
|
|
basic: true
|
|
snippetsLangDefaults: true
|
|
liveCompletion: true
|
|
language: 'javascript'
|
|
languagePrefixes: 'this.,@,self.'
|
|
completers:
|
|
snippets: true
|
|
|
|
|
|
|
|
# TODO: Should we be hooking in completers differently?
|
|
# TODO: https://github.com/ajaxorg/ace/blob/f133231df8c1f39156cc230ce31e66103ef4b1e2/lib/ace/ext/language_tools.js#L202
|
|
|
|
# TODO: Should show popup if we have a snippet match in Autocomplete.filterCompletions
|
|
# TODO: https://github.com/ajaxorg/ace/blob/695e24c41844c17fb2029f073d06338cd73ec33e/lib/ace/autocomplete.js#L449
|
|
|
|
# TODO: Create list of manual test cases
|
|
|
|
module.exports = class Zatanna
|
|
Tokenizer = ''
|
|
BackgroundTokenizer = ''
|
|
|
|
constructor: (aceEditor, options) ->
|
|
{Tokenizer} = ace.require 'ace/tokenizer'
|
|
{BackgroundTokenizer} = ace.require 'ace/background_tokenizer'
|
|
|
|
@editor = aceEditor
|
|
config = ace.require 'ace/config'
|
|
|
|
options ?= {}
|
|
|
|
defaultsCopy = _.extend {}, defaults
|
|
@options = _.merge defaultsCopy, options
|
|
|
|
|
|
#TODO: Renable option validation if we care
|
|
#validationResult = optionsValidator @options
|
|
#unless validationResult.valid
|
|
# throw new Error "Invalid Zatanna options: " + JSON.stringify(validationResult.errors, null, 4)
|
|
|
|
ace.config.loadModule 'ace/ext/language_tools', () =>
|
|
@snippetManager = ace.require('ace/snippets').snippetManager
|
|
|
|
# Prevent tabbing a selection trigging an incorrect autocomplete
|
|
# E.g. Given this.moveRight() selecting ".moveRight" from left to right and hitting tab yields this.this.moveRight()()
|
|
# TODO: Figure out how to intercept this properly
|
|
# TODO: Or, override expandSnippet command
|
|
# TODO: Or, SnippetManager's expandSnippetForSelection
|
|
@snippetManager.expandWithTab = -> return false
|
|
|
|
# Define a background tokenizer that constantly tokenizes the code
|
|
highlightRules = new (@editor.getSession().getMode().HighlightRules)()
|
|
tokenizer = new Tokenizer highlightRules.getRules()
|
|
@bgTokenizer = new BackgroundTokenizer tokenizer, @editor
|
|
aceDocument = @editor.getSession().getDocument()
|
|
@bgTokenizer.setDocument aceDocument
|
|
@bgTokenizer.start(0)
|
|
|
|
@setAceOptions()
|
|
@copyCompleters()
|
|
@activateCompleter()
|
|
@editor.commands.on 'afterExec', @doLiveCompletion
|
|
|
|
setAceOptions: () ->
|
|
aceOptions =
|
|
'enableLiveAutocompletion': @options.liveCompletion
|
|
'enableBasicAutocompletion': @options.basic
|
|
'enableSnippets': @options.completers.snippets
|
|
|
|
@editor.setOptions aceOptions
|
|
@editor.completer?.autoSelect = true
|
|
|
|
copyCompleters: () ->
|
|
@completers = {snippets: {}, text: {}, keywords: {}}
|
|
if @editor.completers?
|
|
[@completers.snippets.comp, @completers.text.comp, @completers.keywords.comp] = @editor.completers
|
|
if @options.completers.snippets
|
|
@completers.snippets = pos: 0
|
|
# Replace the default snippet completer with our custom one
|
|
@completers.snippets.comp = require('./snippets') @snippetManager, @options.autoLineEndings
|
|
|
|
activateCompleter: (comp) ->
|
|
if Array.isArray comp
|
|
@editor.completers = comp
|
|
else if typeof comp is 'string'
|
|
if @completers[comp]? and @editor.completers[@completers[comp].pos] isnt @completers[comp].comp
|
|
@editor.completers.splice(@completers[comp].pos, 0, @completers[comp].comp)
|
|
else
|
|
@editor.completers = []
|
|
for type, comparator of @completers
|
|
if @options.completers[type] is true
|
|
@activateCompleter type
|
|
|
|
addSnippets: (snippets, language) ->
|
|
@options.language = language
|
|
ace.config.loadModule 'ace/ext/language_tools', () =>
|
|
@snippetManager = ace.require('ace/snippets').snippetManager
|
|
snippetModulePath = 'ace/snippets/' + language
|
|
ace.config.loadModule snippetModulePath, (m) =>
|
|
if m?
|
|
@snippetManager.files[language] = m
|
|
@snippetManager.unregister m.snippets if m.snippets?.length > 0
|
|
@snippetManager.unregister @oldSnippets if @oldSnippets?
|
|
m.snippets = if @options.snippetsLangDefaults then @snippetManager.parseSnippetFile m.snippetText else []
|
|
m.snippets.push s for s in snippets
|
|
@snippetManager.register m.snippets
|
|
@oldSnippets = m.snippets
|
|
|
|
setLiveCompletion: (val) ->
|
|
if val is true or val is false
|
|
@options.liveCompletion = val
|
|
@setAceOptions()
|
|
|
|
set: (setting, value) ->
|
|
switch setting
|
|
when 'snippets' or 'completers.snippets'
|
|
return unless typeof value is 'boolean'
|
|
@options.completers.snippets = value
|
|
@setAceOptions()
|
|
@activateCompleter 'snippets'
|
|
when 'basic'
|
|
return unless typeof value is 'boolean'
|
|
@options.basic = value
|
|
@setAceOptions()
|
|
@activateCompleter()
|
|
when 'liveCompletion'
|
|
return unless typeof value is 'boolean'
|
|
@options.liveCompletion = value
|
|
@setAceOptions()
|
|
@activateCompleter()
|
|
when 'language'
|
|
return unless typeof value is 'string'
|
|
@options.language = value
|
|
@setAceOptions()
|
|
@activateCompleter()
|
|
when 'completers.keywords'
|
|
return unless typeof value is 'boolean'
|
|
@options.completers.keywords = value
|
|
@activateCompleter()
|
|
when 'completers.text'
|
|
return unless typeof value is 'boolean'
|
|
@options.completers.text = value
|
|
@activateCompleter()
|
|
return
|
|
|
|
on: -> @paused = false
|
|
off: -> @paused = true
|
|
|
|
doLiveCompletion: (e) =>
|
|
# console.log 'Zatanna doLiveCompletion', e
|
|
return unless @options.basic or @options.liveCompletion or @options.completers.snippets
|
|
return if @paused
|
|
|
|
TokenIterator = TokenIterator or ace.require('ace/token_iterator').TokenIterator
|
|
editor = e.editor
|
|
text = e.args or ""
|
|
hasCompleter = editor.completer and editor.completer.activated
|
|
|
|
# We don't want to autocomplete with no prefix
|
|
if e.command.name is "backspace" or e.command.name is "insertstring"
|
|
pos = editor.getCursorPosition()
|
|
token = (new TokenIterator editor.getSession(), pos.row, pos.column).getCurrentToken()
|
|
if token? and token.type not in ['comment', 'string']
|
|
prefix = @getCompletionPrefix editor
|
|
# Bake a fresh autocomplete every keystroke
|
|
editor.completer?.detach() if hasCompleter
|
|
|
|
# Only autocomplete if there's a prefix that can be matched
|
|
if (prefix)
|
|
unless (editor.completer)
|
|
|
|
# Create new autocompleter
|
|
Autocomplete = ace.require('ace/autocomplete').Autocomplete
|
|
|
|
# Overwrite "Shift-Return" to Esc + Return instead
|
|
# https://github.com/ajaxorg/ace/blob/695e24c41844c17fb2029f073d06338cd73ec33e/lib/ace/autocomplete.js#L208
|
|
# TODO: Need a better way to update this command. This is super shady.
|
|
# TODO: Shift-Return errors when Autocomplete is open, dying on this call:
|
|
# TODO: calls editor.completer.insertMatch(true) in lib/ace/autocomplete.js
|
|
if Autocomplete?.prototype?.commands?
|
|
exitAndReturn = (editor) =>
|
|
# TODO: Execute a proper Return that selects the Autocomplete if open
|
|
editor.completer.detach()
|
|
@editor.insert "\n"
|
|
Autocomplete.prototype.commands["Shift-Return"] = exitAndReturn
|
|
|
|
editor.completer = new Autocomplete()
|
|
|
|
# Disable autoInsert and show popup
|
|
editor.completer.autoSelect = true
|
|
editor.completer.autoInsert = false
|
|
editor.completer.showPopup(editor)
|
|
|
|
# Hide popup if too many suggestions
|
|
# TODO: Completions aren't asked for unless we show popup, so this is super hacky
|
|
# TODO: Backspacing to yield more suggestions does not close popup
|
|
if editor.completer?.completions?.filtered?.length > 20
|
|
editor.completer.detach()
|
|
|
|
# Update popup CSS after it's been launched
|
|
# TODO: Popup has original CSS on first load, and then visibly/weirdly changes based on these updates
|
|
# TODO: Find better way to extend popup.
|
|
else if editor.completer.popup?
|
|
$('.ace_autocomplete').find('.ace_content').css('cursor', 'pointer')
|
|
$('.ace_autocomplete').css('font-size', @options.popupFontSizePx + 'px') if @options.popupFontSizePx?
|
|
$('.ace_autocomplete').css('line-height', @options.popupLineHeightPx + 'px') if @options.popupLineHeightPx?
|
|
$('.ace_autocomplete').css('width', @options.popupWidthPx + 'px') if @options.popupWidthPx?
|
|
editor.completer.popup.resize?()
|
|
|
|
# TODO: Can't change padding before resize(), but changing it afterwards clears new padding
|
|
# TODO: Figure out how to hook into events rather than using setTimeout()
|
|
# fixStuff = =>
|
|
# $('.ace_autocomplete').find('.ace_line').css('color', 'purple')
|
|
# $('.ace_autocomplete').find('.ace_line').css('padding', '20px')
|
|
# # editor.completer.popup.resize?(true)
|
|
# setTimeout fixStuff, 1000
|
|
|
|
# Update tokens for text completer
|
|
if @options.completers.text and e.command.name in ['backspace', 'del', 'insertstring', 'removetolinestart', 'Enter', 'Return', 'Space', 'Tab']
|
|
@bgTokenizer.fireUpdateEvent 0, @editor.getSession().getLength()
|
|
|
|
getCompletionPrefix: (editor) ->
|
|
# TODO: this is not used to get prefix that is passed to completer.getCompletions
|
|
# TODO: Autocomplete.gatherCompletions is using this (no regex 3rd param):
|
|
# TODO: var prefix = util.retrievePrecedingIdentifier(line, pos.column);
|
|
util = util or ace.require 'ace/autocomplete/util'
|
|
pos = editor.getCursorPosition()
|
|
line = editor.session.getLine pos.row
|
|
prefix = null
|
|
editor.completers?.forEach (completer) ->
|
|
if completer?.identifierRegexps
|
|
completer.identifierRegexps.forEach (identifierRegex) ->
|
|
if not prefix and identifierRegex
|
|
prefix = util.retrievePrecedingIdentifier line, pos.column, identifierRegex
|
|
prefix = util.retrievePrecedingIdentifier line, pos.column unless prefix?
|
|
prefix
|
|
|
|
addCodeCombatSnippets: (level, spellView, e) ->
|
|
snippetEntries = []
|
|
source = spellView.getSource()
|
|
haveFindNearestEnemy = false
|
|
haveFindNearest = false
|
|
for group, props of e.propGroups
|
|
for prop in props
|
|
if _.isString prop # organizePalette
|
|
owner = group
|
|
else # organizePaletteHero
|
|
owner = prop.owner
|
|
prop = prop.prop
|
|
doc = _.find (e.allDocs['__' + prop] ? []), (doc) ->
|
|
return true if doc.owner is owner
|
|
return (owner is 'this' or owner is 'more') and (not doc.owner? or doc.owner is 'this')
|
|
if doc?.snippets?[e.language]
|
|
name = doc.name
|
|
content = doc.snippets[e.language].code
|
|
if /loop/.test(content) and level.get 'moveRightLoopSnippet'
|
|
# Replace default loop snippet with an embedded moveRight()
|
|
content = switch e.language
|
|
when 'python' then 'loop:\n self.moveRight()\n ${1:}'
|
|
when 'javascript' then 'loop {\n this.moveRight();\n ${1:}\n}'
|
|
else content
|
|
if /loop/.test(content) and level.isType('course', 'course-ladder')
|
|
# Temporary hackery to make it look like we meant while True: in our loop snippets until we can update everything
|
|
content = switch e.language
|
|
when 'python' then content.replace /loop:/, 'while True:'
|
|
when 'javascript' then content.replace /loop/, 'while (true)'
|
|
when 'lua' then content.replace /loop/, 'while true then'
|
|
when 'coffeescript' then content
|
|
else content
|
|
name = switch e.language
|
|
when 'python' then 'while True'
|
|
when 'coffeescript' then 'loop'
|
|
else 'while true'
|
|
# For now, update autocomplete to use hero instead of self/this, if hero is already used in the source.
|
|
# Later, we should make this happen all the time - or better yet update the snippets.
|
|
if /hero/.test(source) or not /(self[\.\:]|this\.|\@)/.test(source)
|
|
thisToken =
|
|
'python': /self/,
|
|
'javascript': /this/,
|
|
'lua': /self/
|
|
if thisToken[e.language] and thisToken[e.language].test(content)
|
|
content = content.replace thisToken[e.language], 'hero'
|
|
|
|
entry =
|
|
content: content
|
|
meta: $.i18n.t('keyboard_shortcuts.press_enter', defaultValue: 'press enter')
|
|
name: name
|
|
tabTrigger: doc.snippets[e.language].tab
|
|
importance: doc.autoCompletePriority ? 1.0
|
|
haveFindNearestEnemy ||= name is 'findNearestEnemy'
|
|
haveFindNearest ||= name is 'findNearest'
|
|
if name is 'attack'
|
|
# Postpone this until we know if findNearestEnemy is available
|
|
attackEntry = entry
|
|
else
|
|
snippetEntries.push entry
|
|
|
|
if doc.userShouldCaptureReturn
|
|
varName = doc.userShouldCaptureReturn.variableName ? 'result'
|
|
entry.captureReturn = switch e.language
|
|
when 'javascript' then 'var ' + varName + ' = '
|
|
#when 'lua' then 'local ' + varName + ' = ' # TODO: should we do this?
|
|
else varName + ' = '
|
|
|
|
# TODO: Generalize this snippet replacement
|
|
# TODO: Where should this logic live, and what format should it be in?
|
|
if attackEntry?
|
|
unless haveFindNearestEnemy or haveFindNearest or level.get('slug') in ['known-enemy', 'course-known-enemy']
|
|
# No findNearestEnemy, so update attack snippet to string-based target
|
|
# (On Known Enemy, we are introducing enemy2 = "Gert", so we want them to do attack(enemy2).)
|
|
attackEntry.content = attackEntry.content.replace '${1:enemy}', '"${1:Enemy Name}"'
|
|
snippetEntries.push attackEntry
|
|
|
|
# Add copied hero. entries for most important ones that start with hero.
|
|
sortedEntries = _.sortBy snippetEntries, (entry) -> -1 * parseInt(entry.importance ? 0)
|
|
for entry in sortedEntries
|
|
if entry.content?.indexOf('hero.') is 0
|
|
newEntry = _.cloneDeep(entry)
|
|
entry.name = "hero.#{newEntry.name}"
|
|
snippetEntries.push(newEntry)
|
|
break if snippetEntries.length - sortedEntries.length >= 10
|
|
|
|
if haveFindNearest and not haveFindNearestEnemy
|
|
spellView.translateFindNearest()
|
|
|
|
# window.zatannaInstance = @zatanna # For debugging. Make sure to not leave active when committing.
|
|
# window.snippetEntries = snippetEntries
|
|
lang = utils.aceEditModes[e.language].substr 'ace/mode/'.length
|
|
@addSnippets snippetEntries, lang
|
|
spellView.editorLang = lang
|