adobe/brackets

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

Summary

Maintainability
D
2 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.
 *
 */

/*jslint regexp: true */

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

    var AppInit             = brackets.getModule("utils/AppInit"),
        ExtensionUtils      = brackets.getModule("utils/ExtensionUtils"),
        CodeHintManager     = brackets.getModule("editor/CodeHintManager"),
        CSSUtils            = brackets.getModule("language/CSSUtils"),
        HTMLUtils           = brackets.getModule("language/HTMLUtils"),
        LanguageManager     = brackets.getModule("language/LanguageManager"),
        PreferencesManager  = brackets.getModule("preferences/PreferencesManager"),
        TokenUtils          = brackets.getModule("utils/TokenUtils"),
        StringMatch         = brackets.getModule("utils/StringMatch"),
        ColorUtils          = brackets.getModule("utils/ColorUtils"),
        Strings             = brackets.getModule("strings"),
        CSSProperties       = require("text!CSSProperties.json"),
        properties          = JSON.parse(CSSProperties);


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

    // Context of the last request for hints: either CSSUtils.PROP_NAME,
    // CSSUtils.PROP_VALUE or null.
    var lastContext,
        stringMatcherOptions = { preferPrefixMatches: true };

    /**
     * @constructor
     */
    function CssPropHints() {
        this.primaryTriggerKeys = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-()";
        this.secondaryTriggerKeys = ":";
        this.exclusion = null;
    }

    /**
     * Get the CSS style text of the file open in the editor for this hinting session.
     * For a CSS file, this is just the text of the file. For an HTML file,
     * this will be only the text in the <style> tags.
     *
     * @return {string} the "css" text that can be sent to CSSUtils to extract all named flows.
     */
    CssPropHints.prototype.getCssStyleText = function () {
        if (LanguageManager.getLanguageForPath(this.editor.document.file.fullPath).getId() === "html") {
            // Collect text in all style blocks
            var text = "",
                styleBlocks = HTMLUtils.findBlocks(this.editor, "css");

            styleBlocks.forEach(function (styleBlock) {
                text += styleBlock.text;
            });

            return text;
        } else {
            // css file, just return the text
            return this.editor.document.getText();
        }
    };

    /**
     * Extract all the named flows from any "flow-from" or "flow-into" properties
     * in the current document. If we have the cached list of named flows and the
     * cursor is still on the same line as the cached cursor, then the cached list
     * is returned. Otherwise, we recollect all named flows and update the cache.
     *
     * @return {Array.<string>} All named flows available in the current document.
     */
    CssPropHints.prototype.getNamedFlows = function () {
        if (this.namedFlowsCache) {
            // If the cursor is no longer on the same line, then the cache is stale.
            // Delete cache so we can extract all named flows again.
            if (this.namedFlowsCache.cursor.line !== this.cursor.line) {
                this.namedFlowsCache = null;
            }
        }

        if (!this.namedFlowsCache) {
            this.namedFlowsCache = {};
            this.namedFlowsCache.flows = CSSUtils.extractAllNamedFlows(this.getCssStyleText());
            this.namedFlowsCache.cursor = { line: this.cursor.line, ch: this.cursor.ch };
        }

        return this.namedFlowsCache.flows;
    };

    /**
     * Check whether the exclusion is still the same as text after the cursor.
     * If not, reset it to null.
     *
     * @param {boolean} propNameOnly
     * true to indicate that we update the exclusion only if the cursor is inside property name context.
     * Otherwise, we also update exclusion for property value context.
     */
    CssPropHints.prototype.updateExclusion = function (propNameOnly) {
        var textAfterCursor;
        if (this.exclusion && this.info) {
            if (this.info.context === CSSUtils.PROP_NAME) {
                textAfterCursor = this.info.name.substr(this.info.offset);
            } else if (!propNameOnly && this.info.context === CSSUtils.PROP_VALUE) {
                textAfterCursor = this.info.value.substr(this.info.offset);
            }
            if (!CodeHintManager.hasValidExclusion(this.exclusion, textAfterCursor)) {
                this.exclusion = null;
            }
        }
    };

    /**
     * Determines whether CSS propertyname or -name 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.
     */
    CssPropHints.prototype.hasHints = function (editor, implicitChar) {
        this.editor = editor;
        var cursor = this.editor.getCursorPos();

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

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

        if (implicitChar) {
            this.updateExclusion(false);
            if (this.info.context === CSSUtils.PROP_NAME) {
                // Check if implicitChar is the first character typed before an existing property name.
                if (!this.exclusion && this.info.offset === 1 && implicitChar === this.info.name[0]) {
                    this.exclusion = this.info.name.substr(this.info.offset);
                }
            }

            return (this.primaryTriggerKeys.indexOf(implicitChar) !== -1) ||
                   (this.secondaryTriggerKeys.indexOf(implicitChar) !== -1);
        } else if (this.info.context === CSSUtils.PROP_NAME) {
            if (this.info.offset === 0) {
                this.exclusion = this.info.name;
            } else {
                this.updateExclusion(true);
            }
        }

        return true;
    };

    /**
     * Returns a sorted and formatted list of hints with the query substring
     * highlighted.
     *
     * @param {Array.<Object>} hints - the list of hints to format
     * @param {string} query - querystring used for highlighting matched
     *      portions of each hint
     * @return {Array.jQuery} sorted Array of jQuery DOM elements to insert
     */
    function formatHints(hints, query) {
        var hasColorSwatch = hints.some(function (token) {
            return token.color;
        });

        StringMatch.basicMatchSort(hints);
        return hints.map(function (token) {
            var $hintObj = $("<span>").addClass("brackets-css-hints");

            // highlight the matched portion of each hint
            if (token.stringRanges) {
                token.stringRanges.forEach(function (item) {
                    if (item.matched) {
                        $hintObj.append($("<span>")
                            .text(item.text)
                            .addClass("matched-hint"));
                    } else {
                        $hintObj.append(item.text);
                    }
                });
            } else {
                $hintObj.text(token.value);
            }

            if (hasColorSwatch) {
                $hintObj = ColorUtils.formatColorHint($hintObj, token.color);
            }

            return $hintObj;
        });
    }

    /**
     * Returns a list of availble CSS propertyname or -value hints if possible for the current
     * editor context.
     *
     * @param {Editor} 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 {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.
     */
    CssPropHints.prototype.getHints = function (implicitChar) {
        this.cursor = this.editor.getCursorPos();
        this.info = CSSUtils.getInfoAtPos(this.editor, this.cursor);

        var needle = this.info.name,
            valueNeedle = "",
            context = this.info.context,
            valueArray,
            type,
            namedFlows,
            result,
            selectInitial = false;

        // Clear the exclusion if the user moves the cursor with left/right arrow key.
        this.updateExclusion(true);

        if (context === CSSUtils.PROP_VALUE) {

            // Always select initial value
            selectInitial = true;

            // We need to end the session and begin a new session if the ( char is typed to
            // get arguments into the list when typing too fast
            if (implicitChar === "(") {
                return true;
            }

            // When switching from a NAME to a VALUE context, restart the session
            // to give other more specialized providers a chance to intervene.
            if (lastContext === CSSUtils.PROP_NAME) {
                return true;
            } else {
                lastContext = CSSUtils.PROP_VALUE;
            }

            if (!properties[needle]) {
                return null;
            }

            // Cursor is in an existing property value or partially typed value
            if (!this.info.isNewItem && this.info.index !== -1) {
                valueNeedle = this.info.values[this.info.index].trim();
                valueNeedle = valueNeedle.substr(0, this.info.offset);
            }

            valueArray = properties[needle].values;
            type = properties[needle].type;
            if (type === "named-flow") {
                namedFlows = this.getNamedFlows();

                if (valueNeedle.length === this.info.offset && namedFlows.indexOf(valueNeedle) !== -1) {
                    // Exclude the partially typed named flow at cursor since it
                    // is not an existing one used in other css rule.
                    namedFlows.splice(namedFlows.indexOf(valueNeedle), 1);
                }

                valueArray = valueArray.concat(namedFlows);
            } else if (type === "color") {
                valueArray = valueArray.concat(ColorUtils.COLOR_NAMES.map(function (color) {
                    return { text: color, color: color };
                }));
                valueArray.push("transparent", "currentColor");
            }

            result = $.map(valueArray, function (pvalue) {
                var result = StringMatch.stringMatch(pvalue.text || pvalue, valueNeedle, stringMatcherOptions);
                if (result) {
                    if (pvalue.color) {
                        result.color = pvalue.color;
                    }

                    return result;
                }
            });

            return {
                hints: formatHints(result, valueNeedle),
                match: null, // the CodeHintManager should not format the results
                selectInitial: selectInitial
            };
        } else if (context === CSSUtils.PROP_NAME) {

            // Select initial property if anything has been typed
            if (this.primaryTriggerKeys.indexOf(implicitChar) !== -1 || needle !== "") {
                selectInitial = true;
            }

            if (lastContext === CSSUtils.PROP_VALUE) {
                // close the session if we're coming from a property value
                // see https://github.com/adobe/brackets/issues/9496
                return null;
            }

            lastContext = CSSUtils.PROP_NAME;
            needle = needle.substr(0, this.info.offset);

            result = $.map(properties, function (pvalues, pname) {
                var result = StringMatch.stringMatch(pname, needle, stringMatcherOptions);
                if (result) {
                    return result;
                }
            });

            return {
                hints: formatHints(result, needle),
                match: null, // the CodeHintManager should not format the results
                selectInitial: selectInitial,
                handleWideResults: false
            };
        }
        return null;
    };

    /**
     * Inserts a given CSS protertyname or -value hint into the current editor context.
     *
     * @param {String} hint
     * 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.
     */
    CssPropHints.prototype.insertHint = function (hint) {
        var offset = this.info.offset,
            cursor = this.editor.getCursorPos(),
            start = {line: -1, ch: -1},
            end = {line: -1, ch: -1},
            keepHints = false,
            adjustCursor = false,
            newCursor,
            ctx;

        if (hint.jquery) {
            hint = hint.text();
        }

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

        start.line = end.line = cursor.line;
        start.ch = cursor.ch - offset;

        if (this.info.context === CSSUtils.PROP_NAME) {
            keepHints = true;
            var textAfterCursor = this.info.name.substr(this.info.offset);
            if (this.info.name.length === 0 || CodeHintManager.hasValidExclusion(this.exclusion, textAfterCursor)) {
                // It's a new insertion, so append a colon and set keepHints
                // to show property value hints.
                hint += ": ";
                end.ch = start.ch;
                end.ch += offset;

                if (this.exclusion) {
                    // Append a space to the end of hint to insert and then adjust
                    // the cursor before that space.
                    hint += " ";
                    adjustCursor = true;
                    newCursor = { line: cursor.line,
                                  ch: start.ch + hint.length - 1 };
                    this.exclusion = null;
                }
            } else {
                // It's a replacement of an existing one or just typed in property.
                // So we need to check whether there is an existing colon following
                // the current property name. If a colon already exists, then we also
                // adjust the cursor position and show code hints for property values.
                end.ch = start.ch + this.info.name.length;
                ctx = TokenUtils.getInitialContext(this.editor._codeMirror, cursor);
                if (ctx.token.string.length > 0 && !/\S/.test(ctx.token.string)) {
                    // We're at the very beginning of a property name. So skip it
                    // before we locate the colon following it.
                    TokenUtils.moveNextToken(ctx);
                }
                if (TokenUtils.moveSkippingWhitespace(TokenUtils.moveNextToken, ctx) && ctx.token.string === ":") {
                    adjustCursor = true;
                    newCursor = { line: cursor.line,
                                  ch: cursor.ch + (hint.length - this.info.name.length) };
                    // Adjust cursor to the position after any whitespace that follows the colon, if there is any.
                    if (TokenUtils.moveNextToken(ctx) && ctx.token.string.length > 0 && !/\S/.test(ctx.token.string)) {
                        newCursor.ch += ctx.token.string.length;
                    }
                } else {
                    hint += ": ";
                }
            }
        } else {
            if (!this.info.isNewItem && this.info.index !== -1) {
                // Replacing an existing property value or partially typed value
                end.ch = start.ch + this.info.values[this.info.index].length;
            } else {
                // Inserting a new property value
                end.ch = start.ch;
            }

            var parenMatch = hint.match(/\(.*?\)/);
            if (parenMatch) {
                // value has (...), so place cursor inside opening paren
                // and keep hints open
                adjustCursor = true;
                newCursor = { line: cursor.line,
                              ch: start.ch + parenMatch.index + 1 };
                keepHints = true;
            }
        }

        // 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(hint, start, end);

        if (adjustCursor) {
            this.editor.setCursorPos(newCursor);
        }

        return keepHints;
    };

    AppInit.appReady(function () {
        var cssPropHints = new CssPropHints();
        CodeHintManager.registerHintProvider(cssPropHints, ["css", "scss", "less"], 1);

        ExtensionUtils.loadStyleSheet(module, "styles/brackets-css-hints.css");

        // For unit testing
        exports.cssPropHintProvider = cssPropHints;
    });
});