adobe/brackets

View on GitHub
src/extensions/default/UrlCodeHints/main.js

Summary

Maintainability
D
3 days
Test Coverage
/*
 * Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 */

define(function (require, exports, module) {
    "use strict";

    // Brackets modules
    var AppInit             = brackets.getModule("utils/AppInit"),
        CodeHintManager     = brackets.getModule("editor/CodeHintManager"),
        CSSUtils            = brackets.getModule("language/CSSUtils"),
        FileSystem          = brackets.getModule("filesystem/FileSystem"),
        FileUtils           = brackets.getModule("file/FileUtils"),
        HTMLUtils           = brackets.getModule("language/HTMLUtils"),
        PreferencesManager  = brackets.getModule("preferences/PreferencesManager"),
        ProjectManager      = brackets.getModule("project/ProjectManager"),
        StringUtils         = brackets.getModule("utils/StringUtils"),
        PathUtils           = brackets.getModule("thirdparty/path-utils/path-utils"),
        Strings             = brackets.getModule("strings"),
        Data                = require("text!data.json"),

        urlHints,
        data,
        htmlAttrs,
        styleModes      = ["css", "text/x-less", "text/x-scss"];


    PreferencesManager.definePreference("codehint.UrlCodeHints", "boolean", true, {
        description: Strings.DESCRIPTION_URL_CODE_HINTS
    });

    /**
     * @constructor
     */
    function UrlCodeHints() {}

    /**
     * Helper function to create a list of urls to existing files based on the query.
     * @param {{queryStr: string}} query -- a query object, used to filter the code hints
     *
     * @return {Array.<string>|$.Deferred} The (possibly deferred) hints.
     */
    UrlCodeHints.prototype._getUrlList = function (query) {
        var directory,
            doc,
            docDir,
            queryDir = "",
            queryUrl,
            result = [],
            self,
            targetDir,
            unfiltered = [];

        doc = this.editor && this.editor.document;
        if (!doc || !doc.file) {
            return result;
        }

        docDir = FileUtils.getDirectoryPath(doc.file.fullPath);

        // get relative path from query string
        queryUrl = PathUtils.parseUrl(query.queryStr);
        if (queryUrl) {
            queryDir = queryUrl.directory;
        }

        // build target folder path
        if (queryDir.length > 0 && queryDir[0] === "/") {
            // site-root relative path
            targetDir = ProjectManager.getProjectRoot().fullPath +
                        decodeURI(queryDir).substring(1);
        } else {
            // page relative path
            targetDir = docDir + decodeURI(queryDir);
        }

        // Get list of files from target folder. Getting the file/folder info is an
        // asynch operation, so it works like this:
        //
        // The initial pass initiates the asynchronous retrieval of data and returns an
        // empty list, so no code hints are displayed. In the async callback, the code
        // hints and the original query are stored in a cache, and then the process to
        // show code hints is re-initiated.
        //
        // During the next pass, there should now be code hints cached from the initial
        // pass, but user may have typed while file/folder info was being retrieved from
        // disk, so we need to make sure code hints still apply to current query. If so,
        // display them, otherwise, clear cache and start over.
        //
        // As user types within a folder, the same unfiltered file/folder list is still
        // valid and re-used from cache. Filtering based on user input is done outside
        // of this method. When user moves to a new folder, then the cache is deleted,
        // and file/folder info for new folder is then retrieved.

        if (this.cachedHints) {
            // url hints have been cached, so determine if they're stale
            if (!this.cachedHints.query ||
                    this.cachedHints.query.tag !== query.tag ||
                    this.cachedHints.query.attrName !== query.attrName ||
                    this.cachedHints.queryDir !== queryDir ||
                    this.cachedHints.docDir !== docDir) {

                // delete stale cache
                this.cachedHints = null;
            }
        }

        if (this.cachedHints) {
            // use cached hints
            unfiltered = this.cachedHints.unfiltered;

        } else {
            directory = FileSystem.getDirectoryForPath(targetDir);
            self = this;

            if (self.cachedHints && self.cachedHints.deferred) {
                self.cachedHints.deferred.reject();
            }
            // create empty object so we can detect "waiting" state
            self.cachedHints = {};
            self.cachedHints.deferred = $.Deferred();
            self.cachedHints.unfiltered = [];

            directory.getContents(function (err, contents) {
                var currentDeferred, entryStr, syncResults;

                if (!err) {
                    contents.forEach(function (entry) {
                        if (ProjectManager.shouldShow(entry)) {
                            // convert to doc relative path
                            entryStr = queryDir + entry._name;
                            if (entry._isDirectory) {
                                entryStr += "/";
                            }

                            // code hints show the unencoded string so the
                            // choices are easier to read.  The encoded string
                            // will still be inserted into the editor.
                            unfiltered.push(entryStr);
                        }
                    });

                    self.cachedHints.unfiltered = unfiltered;
                    self.cachedHints.query      = query;
                    self.cachedHints.queryDir   = queryDir;
                    self.cachedHints.docDir     = docDir;

                    if (self.cachedHints.deferred.state() !== "rejected") {
                        currentDeferred = self.cachedHints.deferred;

                        // Since we've cached the results, the next call to _getUrlList should be synchronous.
                        // If it isn't, we've got a problem and should reject both the current deferred
                        // and any new deferred that got created on the call.
                        syncResults = self._getUrlList(query);
                        if (syncResults instanceof Array) {
                            currentDeferred.resolveWith(self, [syncResults]);
                        } else {
                            if (currentDeferred && currentDeferred.state() === "pending") {
                                currentDeferred.reject();
                            }

                            if (self.cachedHints.deferred &&
                                    self.cachedHints.deferred.state() === "pending") {
                                self.cachedHints.deferred.reject();
                                self.cachedHints.deferred = null;
                            }
                        }
                    }
                }
            });

            return self.cachedHints.deferred;
        }

        // build list

        // without these entries, typing "../" will not display entries for containing folder
        if (queryUrl.filename === ".") {
            result.push(queryDir + ".");
        } else if (queryUrl.filename === "..") {
            result.push(queryDir + "..");
        }

        // add file/folder entries
        unfiltered.forEach(function (item) {
            result.push(item);
        });

        // TODO: filter by desired file type based on tag, type attr, etc.

        // TODO: add list item to top of list to popup modal File Finder dialog
        // New string: "Browse..." or "Choose a File..."
        // Command: Commands.FILE_OPEN

        return result;
    };

    /**
     * Helper function that determines the possible value hints for a given html tag/attribute name pair
     *
     * @param {{queryStr: string}} query
     * The current query
     *
     * @return {{hints: (Array.<string>|$.Deferred), sortFunc: ?function(string, string): number}}
     * The (possibly deferred) hints and the sort function to use on thise hints.
     */
    UrlCodeHints.prototype._getUrlHints = function (query) {
        var hints = [],
            sortFunc;

        // Do not show hints after "?" in url
        if (query.queryStr.indexOf("?") === -1) {

            // Default behavior for url hints is do not close on select.
            this.closeOnSelect = false;
            hints = this._getUrlList(query);
            sortFunc = StringUtils.urlSort;
        }

        return { hints: hints, sortFunc: sortFunc };
    };

    /**
     * Determines whether url hints are available in the current editor
     * context.
     *
     * @param {Editor} editor
     * A non-null editor object for the active window.
     *
     * @param {string} implicitChar
     * Either null, if the hinting request was explicit, or a single character
     * that represents the last insertion and that indicates an implicit
     * hinting request.
     *
     * @return {boolean}
     * Determines whether the current provider is able to provide hints for
     * the given editor context and, in case implicitChar is non-null,
     * whether it is appropriate to do so.
     */
    UrlCodeHints.prototype.hasHints = function (editor, implicitChar) {
        var mode = editor.getModeForSelection();
        if (mode === "html") {
            return this.hasHtmlHints(editor, implicitChar);
        } else if (styleModes.indexOf(mode) > -1) {
            return this.hasCssHints(editor, implicitChar);
        }

        return false;
    };

    /**
     * Helper function for hasHints() for CSS.
     *
     * @param {Editor} editor
     * A non-null editor object for the active window.
     *
     * @param {string} implicitChar
     * Either null, if the hinting request was explicit, or a single character
     * that represents the last insertion and that indicates an implicit
     * hinting request.
     *
     * @return {boolean}
     * Determines whether the current provider is able to provide hints for
     * the given editor context and, in case implicitChar is non-null,
     * whether it is appropriate to do so.
     */
    UrlCodeHints.prototype.hasCssHints = function (editor, implicitChar) {
        this.editor = editor;
        var cursor = this.editor.getCursorPos();

        this.info = CSSUtils.getInfoAtPos(editor, cursor);

        if (this.info.context !== CSSUtils.PROP_VALUE && this.info.context !== CSSUtils.IMPORT_URL) {
            return false;
        }

        // collect existing value
        var i,
            val = "";

        for (i = 0; i <= this.info.index && i < this.info.values.length; i++) {
            if (i < this.info.index) {
                val += this.info.values[i];
            } else {
                val += this.info.values[i].substring(0, this.info.offset);
            }
        }

        // starts with "url(" ?
        if (val.match(/^\s*url\(/i)) {
            return true;
        }

        return false;
    };

    /**
     * Helper function for hasHints() for HTML.
     *
     * @param {Editor} editor
     * A non-null editor object for the active window.
     *
     * @param {string} implicitChar
     * Either null, if the hinting request was explicit, or a single character
     * that represents the last insertion and that indicates an implicit
     * hinting request.
     *
     * @return {boolean}
     * Determines whether the current provider is able to provide hints for
     * the given editor context and, in case implicitChar is non-null,
     * whether it is appropriate to do so.
     */
    UrlCodeHints.prototype.hasHtmlHints = function (editor, implicitChar) {
        var tagInfo,
            query,
            tokenType;

        this.editor = editor;

        tagInfo = HTMLUtils.getTagInfo(editor, editor.getCursorPos());
        query = null;
        tokenType = tagInfo.position.tokenType;

        if (tokenType === HTMLUtils.ATTR_VALUE) {

            // Verify that attribute name has hintable values
            if (htmlAttrs[tagInfo.attr.name]) {

                if (tagInfo.position.offset >= 0) {
                    query = tagInfo.attr.value.slice(0, tagInfo.position.offset);
                } else {
                    // We get negative offset for a quoted attribute value with some leading whitespaces
                    // as in <a rel= "rtl" where the cursor is just to the right of the "=".
                    // So just set the queryStr to an empty string.
                    query = "";
                }

                var hintsAndSortFunc = this._getUrlHints({queryStr: query}),
                    hints = hintsAndSortFunc.hints;

                if (hints instanceof Array) {
                    // If we got synchronous hints, check if we have something we'll actually use
                    var i, foundPrefix = false;
                    query = query.toLowerCase();
                    for (i = 0; i < hints.length; i++) {
                        if (hints[i].toLowerCase().indexOf(query) === 0) {
                            foundPrefix = true;
                            break;
                        }
                    }

                    if (!foundPrefix) {
                        query = null;
                    }
                }
            }
        }

        return (query !== null);
    };

    /**
     * Returns a list of available url hints, if possible, for the current
     * editor context.
     *
     * @return {jQuery.Deferred|{
     *              hints: Array.<string|jQueryObject>,
     *              match: string,
     *              selectInitial: boolean,
     *              handleWideResults: boolean}}
     * Null if the provider wishes to end the hinting session. Otherwise, a
     * response object that provides
     * 1. a sorted array hints that consists of strings
     * 2. a string match that is used by the manager to emphasize matching
     *    substrings when rendering the hint list
     * 3. a boolean that indicates whether the first result, if one exists, should be
     *    selected by default in the hint list window.
     * 4. handleWideResults, a boolean (or undefined) that indicates whether
     *    to allow result string to stretch width of display.
     */
    UrlCodeHints.prototype.getHints = function (key) {
        var mode = this.editor.getModeForSelection(),
            cursor = this.editor.getCursorPos(),
            filter = "",
            hints = [],
            sortFunc,
            query = { queryStr: "" },
            result = [];

        if (mode === "html") {
            var tagInfo = HTMLUtils.getTagInfo(this.editor, cursor),
                tokenType = tagInfo.position.tokenType;

            if (tokenType !== HTMLUtils.ATTR_VALUE || !htmlAttrs[tagInfo.attr.name]) {
                return null;
            }

            if (tagInfo.position.offset >= 0) {
                query.queryStr = tagInfo.attr.value.slice(0, tagInfo.position.offset);
            }
            this.info = tagInfo;

        } else if (styleModes.indexOf(mode) > -1) {
            this.info = CSSUtils.getInfoAtPos(this.editor, cursor);

            var context = this.info.context;
            if (context !== CSSUtils.PROP_VALUE && context !== CSSUtils.IMPORT_URL) {
                return null;
            }

            // Cursor is in an existing property value or partially typed value
            if (this.info.index !== -1) {

                // Collect value up to (item) index/(char) offset
                var i, val = "";
                for (i = 0; i < this.info.index; i++) {
                    val += this.info.values[i];
                }
                // index may exceed length of array for multiple-value case
                if (this.info.index < this.info.values.length) {
                    val += this.info.values[this.info.index].substr(0, this.info.offset);
                }

                // Strip "url("
                val = val.replace(/^\s*url\(/i, "");

                // Keep track of leading whitespace and strip it
                var matchWhitespace = val.match(/^\s*/);
                if (matchWhitespace) {
                    this.info.leadingWhitespace = matchWhitespace[0];
                    val = val.substring(matchWhitespace[0].length);
                } else {
                    this.info.leadingWhitespace = null;
                }

                // Keep track of opening quote and strip it
                if (val.match(/^["']/)) {
                    this.info.openingQuote = val[0];
                    val = val.substring(1);
                } else {
                    this.info.openingQuote = null;
                }

                query.queryStr = val;
            }

        } else {
            return null;
        }

        if (query.queryStr !== null) {
            filter = query.queryStr;
            var hintsAndSortFunc = this._getUrlHints(query);
            hints = hintsAndSortFunc.hints;
            sortFunc = hintsAndSortFunc.sortFunc;
        }
        this.info.filter = filter;

        if (hints instanceof Array && hints.length) {
            // Array was returned
            var lowerCaseFilter = filter.toLowerCase();
            console.assert(!result.length);
            result = $.map(hints, function (item) {
                if (item.toLowerCase().indexOf(lowerCaseFilter) === 0) {
                    return item;
                }
            }).sort(sortFunc);

            return {
                hints: result,
                match: query.queryStr,
                selectInitial: true,
                handleWideResults: false
            };

        } else if (hints instanceof Object && hints.hasOwnProperty("done")) {
            // Deferred hints were returned
            var deferred = $.Deferred();
            hints.done(function (asyncHints) {
                var lowerCaseFilter = filter.toLowerCase();
                result = $.map(asyncHints, function (item) {
                    if (item.toLowerCase().indexOf(lowerCaseFilter) === 0) {
                        return item;
                    }
                }).sort(sortFunc);

                deferred.resolveWith(this, [{
                    hints: result,
                    match: query.queryStr,
                    selectInitial: true,
                    handleWideResults: false
                }]);
            });

            return deferred;
        }

        return null;
    };

    /**
     * Inserts a given url hint into the current editor context.
     *
     * @param {jQuery.Object} completion
     * The hint to be inserted into the editor context.
     *
     * @return {boolean}
     * Indicates whether the manager should follow hint insertion with an
     * additional explicit hint request.
     */
    UrlCodeHints.prototype.insertHint = function (completion) {
        var mode = this.editor.getModeForSelection();

        // Encode the string just prior to inserting the hint into the editor
        completion = encodeURI(completion);

        if (mode === "html") {
            return this.insertHtmlHint(completion);
        } else if (styleModes.indexOf(mode) > -1) {
            return this.insertCssHint(completion);
        }

        return false;
    };

    /**
     * Get distance between 2 positions.
     *
     * Assumption: pos2 >= pos1
     *
     * Note that this function is designed to work on CSSUtils info.values array,
     * so this could be made a method if that is converted to an object.
     *
     * @param {Array.<string>}  array  - strings to be searched
     * @param {{index: number, offset: number}} pos1 - starting index/offset in index string
     * @param {{index: number, offset: number}} pos2 - ending index/offset in index string
     *
     * @return {number}
     * Number of characters between 2 positions
     */
    UrlCodeHints.prototype.getCharOffset = function (array, pos1, pos2) {
        var i, count = 0;

        if (pos1.index === pos2.index) {
            return (pos2.offset >= pos1.offset) ? (pos2.offset - pos1.offset) : 0;
        } else if (pos1.index < pos2.index) {
            if (pos1.index < 0 || pos1.index >= array.length || pos2.index < 0 || pos2.index >= array.length) {
                return 0;
            }

            for (i = pos1.index; i <= pos2.index; i++) {
                if (i === pos1.index) {
                    count += (array[i].length - pos1.offset);
                } else if (i === pos2.index) {
                    count += pos2.offset;
                } else {
                    count += array[i].length;
                }
            }
        }

        return count;
    };

    /**
     * Finds next position in array of specified char.
     *
     * Note that this function is designed to work on CSSUtils info.values array,
     * so this could be made a method if that is converted to an object.
     *
     * @param {Array}  array - strings to be searched
     * @param {string} ch    - char to search for
     * @param {{index: number, offset: number}} pos - starting index/offset in index string
     *
     * @return {{index: number, offset: number}}
     * Index of array, and offset in string where char found.
     */
    UrlCodeHints.prototype.findNextPosInArray = function (array, ch, pos) {
        var i, o, searchOffset;
        for (i = pos.index; i < array.length; i++) {
            // Only use offset on index, then offset of 0 after that
            searchOffset = (i === pos.index) ? pos.offset : 0;
            o = array[i].indexOf(ch, searchOffset);

            if (o !== -1) {
                return { index: i, offset: o };
            }
        }
        return { index: -1, offset: -1 };
    };

    /**
     * Inserts a given css url hint into the current editor context.
     *
     * @param {jQuery.Object} completion
     * The hint to be inserted into the editor context.
     *
     * @return {boolean}
     * Indicates whether the manager should follow hint insertion with an
     * additional explicit hint request.
     */
    UrlCodeHints.prototype.insertCssHint = function (completion) {
        var cursor = this.editor.getCursorPos(),
            start  = { line: cursor.line, ch: cursor.ch },
            end    = { line: cursor.line, ch: cursor.ch };

        var hasClosingQuote = false,
            hasClosingParen = false,
            insertText      = completion,
            moveLen         = 0,
            closingPos      = { index: -1, offset: -1 },
            searchResult    = { index: -1, offset: -1 };

        if (this.info.context !== CSSUtils.PROP_VALUE && this.info.context !== CSSUtils.IMPORT_URL) {
            return false;
        }

        // Special handling for URL hinting -- if the completion is a file name
        // and not a folder, then close the code hint list.
        if (!this.closeOnSelect && completion.match(/\/$/) === null) {
            this.closeOnSelect = true;
        }

        // Look for optional closing quote
        if (this.info.openingQuote) {
            closingPos = this.findNextPosInArray(this.info.values, this.info.openingQuote, this.info);
            hasClosingQuote = (closingPos.index !== -1);
        }

        // Look for closing paren
        if (hasClosingQuote) {
            searchResult = this.findNextPosInArray(this.info.values, ")", closingPos);
            hasClosingParen = (searchResult.index !== -1);
        } else {
            // index may exceed length of array for multiple-value case
            closingPos = this.findNextPosInArray(this.info.values, ")", this.info);
            hasClosingParen = (closingPos.index !== -1);
        }

        // Insert folder names, but replace file names, so if a file is selected
        // (i.e. closeOnSelect === true), then adjust insert char positions to
        // replace existing value, if there is a closing paren
        if (this.closeOnSelect) {
            if (closingPos.index !== -1) {
                end.ch += this.getCharOffset(this.info.values, this.info, closingPos);
            }
        } else {
            // If next char is "/", then overwrite it since we're inserting a "/"
            var nextSlash = this.findNextPosInArray(this.info.values, "/", this.info);
            if (nextSlash.index === this.info.index && nextSlash.offset === this.info.offset) {
                end.ch += 1;
            }
        }
        if (this.info.filter.length > 0) {
            start.ch -= this.info.filter.length;
        }

        // Append matching quote, whitespace, paren
        if (this.info.openingQuote && !hasClosingQuote) {
            insertText += this.info.openingQuote;
        }
        if (!hasClosingParen) {
            // Add trailing whitespace to match leading whitespace
            if (this.info.leadingWhitespace) {
                insertText += this.info.leadingWhitespace;
            }
            insertText += ")";
        }

        // HACK (tracking adobe/brackets#1688): We talk to the private CodeMirror instance
        // directly to replace the range instead of using the Document, as we should. The
        // reason is due to a flaw in our current document synchronization architecture when
        // inline editors are open.
        this.editor._codeMirror.replaceRange(insertText, start, end);

        // Adjust cursor position
        if (this.closeOnSelect) {
            // If there is existing closing quote and/or paren, move the cursor past them
            moveLen = (hasClosingQuote ? 1 : 0) + (hasClosingParen ? 1 : 0);
            if (moveLen > 0) {
                this.editor.setCursorPos(start.line, start.ch + completion.length + moveLen);
            }
            return false;

        } else {
            // If closing quote and/or paren are added, move the cursor to where it would have been
            moveLen = ((this.info.openingQuote && !hasClosingQuote) ? 1 : 0) + (!hasClosingParen ? 1 : 0);
            if (moveLen > 0) {
                this.editor.setCursorPos(start.line, start.ch + completion.length);
            }
        }

        return true;
    };

    /**
     * Inserts a given html url hint into the current editor context.
     *
     * @param {jQuery.Object} completion
     * The hint to be inserted into the editor context.
     *
     * @return {boolean}
     * Indicates whether the manager should follow hint insertion with an
     * additional explicit hint request.
     */
    UrlCodeHints.prototype.insertHtmlHint = function (completion) {
        var cursor = this.editor.getCursorPos(),
            start = {line: -1, ch: -1},
            end = {line: -1, ch: -1},
            tagInfo = HTMLUtils.getTagInfo(this.editor, cursor),
            tokenType = tagInfo.position.tokenType,
            charCount = 0,
            endQuote = "",
            shouldReplace = false;

        if (tokenType === HTMLUtils.ATTR_VALUE) {
            // Special handling for URL hinting -- if the completion is a file name
            // and not a folder, then close the code hint list.
            if (!this.closeOnSelect && completion.match(/\/$/) === null) {
                this.closeOnSelect = true;

                // Insert folder names, but replace file names
                shouldReplace = true;
            }

            if (!tagInfo.attr.hasEndQuote) {
                endQuote = tagInfo.attr.quoteChar;
                if (endQuote) {
                    completion += endQuote;
                } else if (tagInfo.position.offset === 0) {
                    completion = "\"" + completion + "\"";
                }
            }

            if (shouldReplace) {
                // Replace entire value
                charCount = tagInfo.attr.value.length;
            } else {
                // Replace filter (to insert new selection)
                charCount = this.info.filter.length;

                // If next char is "/", then overwrite it since we're inserting a "/"
                if (this.info.attr.value.length > charCount && this.info.attr.value[charCount] === "/") {
                    charCount += 1;
                }
            }
        }

        end.line = start.line = cursor.line;
        start.ch = cursor.ch - tagInfo.position.offset;
        end.ch = start.ch + charCount;

        this.editor.document.replaceRange(completion, start, end);

        if (!this.closeOnSelect) {
            // If we append the missing quote, then we need to adjust the cursor postion
            // to keep the code hint list open.
            if (tokenType === HTMLUtils.ATTR_VALUE && !tagInfo.attr.hasEndQuote) {
                this.editor.setCursorPos(start.line, start.ch + completion.length - 1);
            }
            return true;
        }

        if (tokenType === HTMLUtils.ATTR_VALUE && tagInfo.attr.hasEndQuote) {
            // Move the cursor to the right of the existing end quote after value insertion.
            this.editor.setCursorPos(start.line, start.ch + completion.length + 1);
        }

        return false;
    };

    function _clearCachedHints() {
        // Verify cache exists and is not deferred
        if (urlHints && urlHints.cachedHints && urlHints.cachedHints.deferred &&
                urlHints.cachedHints.deferred.state() !== "pending") {

            // Cache may or may not be stale. Main benefit of cache is to limit async lookups
            // during typing. File tree updates cannot happen during typing, so it's probably
            // not worth determining whether cache may still be valid. Just delete it.
            urlHints.cachedHints = null;
        }
    }

    AppInit.appReady(function () {
        data            = JSON.parse(Data);
        htmlAttrs       = data.htmlAttrs;

        urlHints        = new UrlCodeHints();
        CodeHintManager.registerHintProvider(urlHints, ["css", "html", "less", "scss"], 5);

        FileSystem.on("change", _clearCachedHints);
        FileSystem.on("rename", _clearCachedHints);

        // For unit testing
        exports.hintProvider = urlHints;
    });
});