adobe/brackets

View on GitHub
src/search/FindUtils.js

Summary

Maintainability
B
6 hrs
Test Coverage
/*
 * Copyright (c) 2014 - 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";

    var Async               = require("utils/Async"),
        DocumentManager     = require("document/DocumentManager"),
        MainViewManager     = require("view/MainViewManager"),
        FileSystem          = require("filesystem/FileSystem"),
        FileUtils           = require("file/FileUtils"),
        ProjectManager      = require("project/ProjectManager"),
        PreferencesManager  = require("preferences/PreferencesManager"),
        EventDispatcher     = require("utils/EventDispatcher"),
        Strings             = require("strings"),
        StringUtils         = require("utils/StringUtils"),
        _                   = require("thirdparty/lodash");

    var nodeSearchDisabled = false,
        instantSearchDisabled = false,
        indexingInProgress = false,
        nodeSearchCount = 0,
        collapseResults = false;

    EventDispatcher.makeEventDispatcher(exports);

    // define preferences for find in files
    PreferencesManager.definePreference("findInFiles.nodeSearch", "boolean", true, {
        description: Strings.DESCRIPTION_FIND_IN_FILES_NODE
    });
    PreferencesManager.definePreference("findInFiles.instantSearch", "boolean", true, {
        description: Strings.DESCRIPTION_FIND_IN_FILES_INSTANT
    });

    /**
     * returns true if the used disabled node based search in his preferences
     * @return {boolean}
     */
    function _prefNodeSearchDisabled() {
        return !PreferencesManager.get("findInFiles.nodeSearch");
    }

    /**
     * returns true if the used instant search in his preferences
     * @return {boolean}
     */
    function _prefInstantSearchDisabled() {
        return !PreferencesManager.get("findInFiles.instantSearch");
    }

    /**
     * Given a replace string that contains $-expressions, replace them with data from the given
     * regexp match info.
     * NOTE: we can't just use the ordinary replace() function here because the string has been
     * extracted from the original text and so might be missing some context that the regexp matched.
     * @param {string} replaceWith The string containing the $-expressions.
     * @param {Object} match The match data from the regexp.
     * @return {string} The replace text with the $-expressions substituted.
     */
    function parseDollars(replaceWith, match) {
        replaceWith = replaceWith.replace(/(\$+)(\d{1,2}|&)/g, function (whole, dollars, index) {
            if (dollars.length % 2 === 1) { // make sure dollar signs don't escape themselves (like $$1, $$$$&)
                if (index === "&") { // handle $&
                    // slice the first dollar (but leave any others to get unescaped below) and return the
                    // whole match
                    return dollars.substr(1) + (match[0] || "");
                } else {
                    // now we're sure index is an integer, so we can parse it
                    var parsedIndex = parseInt(index, 10);
                    if (parsedIndex !== 0) { // handle $n or $nn, but don't handle $0 or $00
                        // slice the first dollar (but leave any others to get unescaped below) and return the
                        // the corresponding match
                        return dollars.substr(1) + (match[parsedIndex] || "");
                    }
                }
            }
            // this code gets called if the dollar signs escape themselves or if $0/$00 (not handled) was present
            return whole; // return everything to get handled below
        });
        // replace escaped dollar signs (i.e. $$, $$$$, ...) with single ones (unescaping)
        replaceWith = replaceWith.replace(/\$\$/g, "$");
        return replaceWith;
    }

    /**
     * Does a set of replacements in a single document in memory.
     * @param {!Document} doc The document to do the replacements in.
     * @param {Object} matchInfo The match info for this file, as returned by `_addSearchMatches()`. Might be mutated.
     * @param {string} replaceText The text to replace each result with.
     * @param {boolean=} isRegexp Whether the original query was a regexp.
     * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an error if there were one or more errors.
     */
    function _doReplaceInDocument(doc, matchInfo, replaceText, isRegexp) {
        // Double-check that the open document's timestamp matches the one we recorded. This
        // should normally never go out of sync, because if it did we wouldn't start the
        // replace in the first place (due to the fact that we immediately close the search
        // results panel whenever we detect a filesystem change that affects the results),
        // but we want to double-check in case we don't happen to get the change in time.
        // This will *not* handle cases where the document has been edited in memory since
        // the matchInfo was generated.
        if (doc.diskTimestamp.getTime() !== matchInfo.timestamp.getTime()) {
            return new $.Deferred().reject(exports.ERROR_FILE_CHANGED).promise();
        }

        // Do the replacements in reverse document order so the offsets continue to be correct.
        doc.batchOperation(function () {
            matchInfo.matches.reverse().forEach(function (match) {
                if (match.isChecked) {
                    doc.replaceRange(isRegexp ? parseDollars(replaceText, match.result) : replaceText, match.start, match.end);
                }
            });
        });

        return new $.Deferred().resolve().promise();
    }

    /**
     * Does a set of replacements in a single file on disk.
     * @param {string} fullPath The full path to the file.
     * @param {Object} matchInfo The match info for this file, as returned by `_addSearchMatches()`.
     * @param {string} replaceText The text to replace each result with.
     * @param {boolean=} isRegexp Whether the original query was a regexp.
     * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an error if there were one or more errors.
     */
    function _doReplaceOnDisk(fullPath, matchInfo, replaceText, isRegexp) {
        var file = FileSystem.getFileForPath(fullPath);
        return DocumentManager.getDocumentText(file, true).then(function (contents, timestamp, lineEndings) {
            if (timestamp.getTime() !== matchInfo.timestamp.getTime()) {
                // Return a promise that we'll reject immediately. (We can't just return the
                // error since this is the success handler.)
                return new $.Deferred().reject(exports.ERROR_FILE_CHANGED).promise();
            }

            // Note that this assumes that the matches are sorted.
            // TODO: is there a more efficient way to do this in a large string?
            var result = [],
                lastIndex = 0;
            matchInfo.matches.forEach(function (match) {
                if (match.isChecked) {
                    result.push(contents.slice(lastIndex, match.startOffset));
                    result.push(isRegexp ? parseDollars(replaceText, match.result) : replaceText);
                    lastIndex = match.endOffset;
                }
            });
            result.push(contents.slice(lastIndex));

            var newContents = result.join("");
            // TODO: duplicated logic from Document - should refactor this?
            if (lineEndings === FileUtils.LINE_ENDINGS_CRLF) {
                newContents = newContents.replace(/\n/g, "\r\n");
            }

            return Async.promisify(file, "write", newContents);
        });
    }

    /**
     * Does a set of replacements in a single file. If the file is already open in a Document in memory,
     * will do the replacement there, otherwise does it directly on disk.
     * @param {string} fullPath The full path to the file.
     * @param {Object} matchInfo The match info for this file, as returned by `_addSearchMatches()`.
     * @param {string} replaceText The text to replace each result with.
     * @param {Object=} options An options object:
     *      forceFilesOpen: boolean - Whether to open the file in an editor and do replacements there rather than doing the
     *          replacements on disk. Note that even if this is false, files that are already open in editors will have replacements
     *          done in memory.
     *      isRegexp: boolean - Whether the original query was a regexp. If true, $-substitution is performed on the replaceText.
     * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an error if there were one or more errors.
     */
    function _doReplaceInOneFile(fullPath, matchInfo, replaceText, options) {
        var doc = DocumentManager.getOpenDocumentForPath(fullPath);
        options = options || {};
        // If we're forcing files open, or if the document is in the working set but not actually open
        // yet, we want to open the file and do the replacement in memory.
        if (!doc && (options.forceFilesOpen || MainViewManager.findInWorkingSet(MainViewManager.ALL_PANES, fullPath) !== -1)) {
            return DocumentManager.getDocumentForPath(fullPath).then(function (newDoc) {
                return _doReplaceInDocument(newDoc, matchInfo, replaceText, options.isRegexp);
            });
        } else if (doc) {
            return _doReplaceInDocument(doc, matchInfo, replaceText, options.isRegexp);
        } else {
            return _doReplaceOnDisk(fullPath, matchInfo, replaceText, options.isRegexp);
        }
    }

    /**
     * @private
     * Returns true if a search result has any checked matches.
     */
    function hasCheckedMatches(result) {
        return result.matches.some(function (match) { return match.isChecked; });
    }

    /**
     * Given a set of search results, replaces them with the given replaceText, either on disk or in memory.
     * Checks timestamps to ensure replacements are not performed in files that have changed on disk since
     * the original search results were generated. However, does *not* check whether edits have been performed
     * in in-memory documents since the search; it's up to the caller to guarantee this hasn't happened.
     * (When called from the standard Find in Files UI, SearchResultsView guarantees this. If called headlessly,
     * the caller needs to track changes.)
     *
     * Replacements in documents that are already open in memory at the start of the replacement are guaranteed to
     * happen synchronously; replacements in files on disk will return an error if the on-disk file changes between
     * the time performReplacements() is called and the time the replacement actually happens.
     *
     * @param {Object.<fullPath: string, {matches: Array.<{start: {line:number,ch:number}, end: {line:number,ch:number}, startOffset: number, endOffset: number, line: string}>, collapsed: boolean}>} results
     *      The list of results to replace, as returned from _doSearch..
     * @param {string} replaceText The text to replace each result with.
     * @param {?Object} options An options object:
     *      forceFilesOpen: boolean - Whether to open all files in editors and do replacements there rather than doing the
     *          replacements on disk. Note that even if this is false, files that are already open in editors will have replacements
     *          done in memory.
     *      isRegexp: boolean - Whether the original query was a regexp. If true, $-substitution is performed on the replaceText.
     * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an array of errors
     *      if there were one or more errors. Each individual item in the array will be a {item: string, error: string} object,
     *      where item is the full path to the file that could not be updated, and error is either a FileSystem error or one
     *      of the `FindUtils.ERROR_*` constants.
     */
    function performReplacements(results, replaceText, options) {
        return Async.doInParallel_aggregateErrors(Object.keys(results), function (fullPath) {
            return _doReplaceInOneFile(fullPath, results[fullPath], replaceText, options);
        }).done(function () {
            if (options && options.forceFilesOpen) {
                // If the currently selected document wasn't modified by the search, or there is no open document,
                // then open the first modified document.
                var doc = DocumentManager.getCurrentDocument();
                if (!doc ||
                        !results[doc.file.fullPath] ||
                        !hasCheckedMatches(results[doc.file.fullPath])) {
                    // Figure out the first modified document. This logic is slightly different from
                    // SearchResultsView._getSortedFiles() because it doesn't sort the currently open file to
                    // the top. But if the currently open file were in the search results, we wouldn't be
                    // doing this anyway.
                    var sortedPaths = Object.keys(results).sort(FileUtils.comparePaths),
                        firstPath = _.find(sortedPaths, function (path) {
                            return hasCheckedMatches(results[path]);
                        });

                    if (firstPath) {
                        var newDoc = DocumentManager.getOpenDocumentForPath(firstPath);
                        // newDoc might be null if the replacement failed.
                        if (newDoc) {
                            // @todo change the `_edit` call to this:
                            //
                            ///    CommandManager.execute(Commands.FILE_OPEN, {fullPath: firstPath});
                            //
                            // The problem with doing that is that the promise returned by this
                            // function has already been resolved by `Async.doInParallel()` and
                            // `CommandManager.execute` is an asynchronous operation.
                            // An asynchronous open can't be waited on (since the promise has been
                            //  resolved already) so use the synchronous version so that the next `done`
                            //  handler is blocked until the open completes
                            MainViewManager._edit(MainViewManager.ACTIVE_PANE, newDoc);
                        }
                    }
                }
            }
        });
    }

    /**
     * Returns label text to indicate the search scope. Already HTML-escaped.
     * @param {?Entry} scope
     * @return {string}
     */
    function labelForScope(scope) {
        if (scope) {
            return StringUtils.format(
                Strings.FIND_IN_FILES_SCOPED,
                StringUtils.breakableUrl(
                    ProjectManager.makeProjectRelativeIfPossible(scope.fullPath)
                )
            );
        } else {
            return Strings.FIND_IN_FILES_NO_SCOPE;
        }
    }

    /**
     * Parses the given query into a regexp, and returns whether it was valid or not.
     * @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo
     * @return {{queryExpr: RegExp, valid: boolean, empty: boolean, error: string}}
     *      queryExpr - the regexp representing the query
     *      valid - set to true if query is a nonempty string or a valid regexp.
     *      empty - set to true if query was empty.
     *      error - set to an error string if valid is false and query is nonempty.
     */
    function parseQueryInfo(queryInfo) {
        var queryExpr;

        if (!queryInfo || !queryInfo.query) {
            return {empty: true};
        }

        // For now, treat all matches as multiline (i.e. ^/$ match on every line, not the whole
        // document). This is consistent with how single-file find works. Eventually we should add
        // an option for this.
        var flags = "gm";
        if (!queryInfo.isCaseSensitive) {
            flags += "i";
        }

        // Is it a (non-blank) regex?
        if (queryInfo.isRegexp) {
            try {
                queryExpr = new RegExp(queryInfo.query, flags);
            } catch (e) {
                return {valid: false, error: e.message};
            }
        } else {
            // Query is a plain string. Turn it into a regexp
            queryExpr = new RegExp(StringUtils.regexEscape(queryInfo.query), flags);
        }
        return {valid: true, queryExpr: queryExpr};
    }

     /**
     * Prioritizes the open file and then the working set files to the starting of the list of files
     * @param {Array.<*>} files An array of file paths or file objects to sort
     * @param {?string} firstFile If specified, the path to the file that should be sorted to the top.
     * @return {Array.<*>}
     */
    function prioritizeOpenFile(files, firstFile) {
        var workingSetFiles = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES),
            workingSetFileFound = {},
            fileSetWithoutWorkingSet = [],
            startingWorkingFileSet = [],
            propertyName = "",
            i = 0;
        firstFile = firstFile || "";

        // Create a working set path map which indicates if a file in working set is found in file list
        for (i = 0; i < workingSetFiles.length; i++) {
            workingSetFileFound[workingSetFiles[i].fullPath] = false;
        }

        // Remove all the working set files from the filtration list
        fileSetWithoutWorkingSet = files.filter(function (key) {
            if (workingSetFileFound[key] !== undefined) {
                workingSetFileFound[key] = true;
                return false;
            }
            return true;
        });

        //push in the first file
        if (workingSetFileFound[firstFile] === true) {
            startingWorkingFileSet.push(firstFile);
            workingSetFileFound[firstFile] = false;
        }
        //push in the rest of working set files already present in file list
        for (propertyName in workingSetFileFound) {
            if (workingSetFileFound.hasOwnProperty(propertyName) && workingSetFileFound[propertyName]) {
                startingWorkingFileSet.push(propertyName);
            }
        }
        return startingWorkingFileSet.concat(fileSetWithoutWorkingSet);
    }


    /**
     * Returns the path of the currently open file or null if there isn't one open
     * @return {?string}
     */
    function getOpenFilePath() {
        var currentDoc = DocumentManager.getCurrentDocument();
        return currentDoc ? currentDoc.file.fullPath : null;
    }

    /**
     * enable/disable instant search
     * @param {boolean} disable true to disable node based search
     */
    function setInstantSearchDisabled(disable) {
        instantSearchDisabled = disable;
    }

    /**
     * if instant search is disabled, this will return true we can only do instant search through node
     * @return {boolean}
     */
    function isInstantSearchDisabled() {
        return _prefNodeSearchDisabled() || _prefInstantSearchDisabled() || nodeSearchDisabled || instantSearchDisabled;
    }

    /**
     * enable/disable node based search
     * @param {boolean} disable true to disable node based search
     */
    function setNodeSearchDisabled(disable) {
        if (disable) {
            // only set disable. Enabling node earch doesnt mean we have to enable instant search.
            setInstantSearchDisabled(disable);
        }
        nodeSearchDisabled = disable;
    }

    /**
     * if node search is disabled, this will return true
     * @return {boolean}
     */
    function isNodeSearchDisabled() {
        return _prefNodeSearchDisabled() || nodeSearchDisabled;
    }

    /**
     * check if a search is progressing in node
     * @return {Boolean} true if search is processing in node
     */
    function isNodeSearchInProgress() {
        if (nodeSearchCount === 0) {
            return false;
        } else {
            return true;
        }
    }


    /**
     * Raises an event when the file filters applied to a search changes
     */
    function notifyFileFiltersChanged() {
        exports.trigger(exports.SEARCH_FILE_FILTERS_CHANGED);
    }

    /**
     * Raises an event when the search scope changes[say search in a sub drictory in the project]
     */
    function notifySearchScopeChanged() {
        exports.trigger(exports.SEARCH_SCOPE_CHANGED);
    }

    /**
     * Notifies that a node search has started so that we FindUtils can figure out
     * if any outstanding node search requests are pendind
     */
    function notifyNodeSearchStarted() {
        nodeSearchCount++;
    }

    /**
     * Notifies that a node search has finished so that we FindUtils can figure out
     * if any outstanding node search requests are pendind
     */
    function notifyNodeSearchFinished() {
        nodeSearchCount--;
    }

    /**
     * Notifies that a node has started indexing the files
     */
    function notifyIndexingStarted() {
        indexingInProgress = true;
        exports.trigger(exports.SEARCH_INDEXING_STARTED);
    }

    /**
     * Notifies that a node has finished indexing the files
     */
    function notifyIndexingFinished() {
        indexingInProgress = false;
        exports.trigger(exports.SEARCH_INDEXING_FINISHED);
    }

    /**
     * Return true if indexing is in pregress in node
     * @return {boolean} true if files are being indexed in node
     */
    function isIndexingInProgress() {
        return indexingInProgress;
    }

    /**
     * Set if we need to collapse all results in the results pane
     * @param {boolean} collapse true to collapse
     */
    function setCollapseResults(collapse) {
        collapseResults = collapse;
        exports.trigger(exports.SEARCH_COLLAPSE_RESULTS);
    }

    /**
     * check if results should be collapsed
     * @return {boolean} true if results should be collapsed
     */
    function isCollapsedResults() {
        return collapseResults;
    }

    /**
     * Returns the health data pertaining to Find in files
     */
    function getHealthReport() {
        return {
            prefNodeSearchDisabled : _prefNodeSearchDisabled(),
            prefInstantSearchDisabled : _prefInstantSearchDisabled()
        };
    }

    exports.parseDollars                    = parseDollars;
    exports.hasCheckedMatches               = hasCheckedMatches;
    exports.performReplacements             = performReplacements;
    exports.labelForScope                   = labelForScope;
    exports.parseQueryInfo                  = parseQueryInfo;
    exports.prioritizeOpenFile              = prioritizeOpenFile;
    exports.getOpenFilePath                 = getOpenFilePath;
    exports.setNodeSearchDisabled           = setNodeSearchDisabled;
    exports.isNodeSearchDisabled            = isNodeSearchDisabled;
    exports.setInstantSearchDisabled        = setInstantSearchDisabled;
    exports.isInstantSearchDisabled         = isInstantSearchDisabled;
    exports.isNodeSearchInProgress          = isNodeSearchInProgress;
    exports.isIndexingInProgress            = isIndexingInProgress;
    exports.setCollapseResults              = setCollapseResults;
    exports.isCollapsedResults              = isCollapsedResults;
    exports.getHealthReport                 = getHealthReport;
    exports.ERROR_FILE_CHANGED              = "fileChanged";

    // event notification functions
    exports.notifyFileFiltersChanged        = notifyFileFiltersChanged;
    exports.notifySearchScopeChanged        = notifySearchScopeChanged;
    exports.notifyNodeSearchStarted         = notifyNodeSearchStarted;
    exports.notifyNodeSearchFinished        = notifyNodeSearchFinished;
    exports.notifyIndexingStarted           = notifyIndexingStarted;
    exports.notifyIndexingFinished          = notifyIndexingFinished;

    // events raised by FindUtils
    exports.SEARCH_FILE_FILTERS_CHANGED              = "fileFiltersChanged";
    exports.SEARCH_SCOPE_CHANGED                     = "searchScopeChanged";
    exports.SEARCH_INDEXING_STARTED                  = "searchIndexingStarted";
    exports.SEARCH_INDEXING_FINISHED                 = "searchIndexingFinished";
    exports.SEARCH_COLLAPSE_RESULTS                  = "searchCollapseResults";
});