diff --git a/src/brackets.js b/src/brackets.js index e565ea0d874..31eba186c68 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -59,6 +59,7 @@ define(function (require, exports, module) { // Load dependent modules var Global = require("utils/Global"), AppInit = require("utils/AppInit"), + LanguageManager = require("language/LanguageManager"), ProjectManager = require("project/ProjectManager"), DocumentManager = require("document/DocumentManager"), EditorManager = require("editor/EditorManager"), @@ -179,55 +180,58 @@ define(function (require, exports, module) { $testDiv.remove(); } - - // Load all extensions. This promise will complete even if one or more - // extensions fail to load. - var extensionLoaderPromise = ExtensionLoader.init(params.get("extensions")); - - // Load the initial project after extensions have loaded - extensionLoaderPromise.always(function () { - // Finish UI initialization - var initialProjectPath = ProjectManager.getInitialProjectPath(); - ProjectManager.openProject(initialProjectPath).always(function () { - _initTest(); - - // If this is the first launch, and we have an index.html file in the project folder (which should be - // the samples folder on first launch), open it automatically. (We explicitly check for the - // samples folder in case this is the first time we're launching Brackets after upgrading from - // an old version that might not have set the "afterFirstLaunch" pref.) - var prefs = PreferencesManager.getPreferenceStorage(PREFERENCES_CLIENT_ID), - deferred = new $.Deferred(); - if (!params.get("skipSampleProjectLoad") && !prefs.getValue("afterFirstLaunch")) { - prefs.setValue("afterFirstLaunch", "true"); - if (ProjectManager.isWelcomeProjectPath(initialProjectPath)) { - var dirEntry = new NativeFileSystem.DirectoryEntry(initialProjectPath); - - dirEntry.getFile("index.html", {}, function (fileEntry) { - var promise = CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: fileEntry.fullPath }); - promise.pipe(deferred.resolve, deferred.reject); - }, deferred.reject); + + // Load default languages + LanguageManager.ready.always(function () { + // Load all extensions. This promise will complete even if one or more + // extensions fail to load. + var extensionLoaderPromise = ExtensionLoader.init(params.get("extensions")); + + // Load the initial project after extensions have loaded + extensionLoaderPromise.always(function () { + // Finish UI initialization + var initialProjectPath = ProjectManager.getInitialProjectPath(); + ProjectManager.openProject(initialProjectPath).always(function () { + _initTest(); + + // If this is the first launch, and we have an index.html file in the project folder (which should be + // the samples folder on first launch), open it automatically. (We explicitly check for the + // samples folder in case this is the first time we're launching Brackets after upgrading from + // an old version that might not have set the "afterFirstLaunch" pref.) + var prefs = PreferencesManager.getPreferenceStorage(PREFERENCES_CLIENT_ID), + deferred = new $.Deferred(); + if (!params.get("skipSampleProjectLoad") && !prefs.getValue("afterFirstLaunch")) { + prefs.setValue("afterFirstLaunch", "true"); + if (ProjectManager.isWelcomeProjectPath(initialProjectPath)) { + var dirEntry = new NativeFileSystem.DirectoryEntry(initialProjectPath); + + dirEntry.getFile("index.html", {}, function (fileEntry) { + var promise = CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: fileEntry.fullPath }); + promise.pipe(deferred.resolve, deferred.reject); + }, deferred.reject); + } else { + deferred.resolve(); + } } else { deferred.resolve(); } - } else { - deferred.resolve(); - } - - deferred.always(function () { - // Signal that Brackets is loaded - AppInit._dispatchReady(AppInit.APP_READY); - PerfUtils.addMeasurement("Application Startup"); - }); - - // See if any startup files were passed to the application - if (brackets.app.getPendingFilesToOpen) { - brackets.app.getPendingFilesToOpen(function (err, files) { - files.forEach(function (filename) { - CommandManager.execute(Commands.FILE_OPEN, { fullPath: filename }); - }); + deferred.always(function () { + // Signal that Brackets is loaded + AppInit._dispatchReady(AppInit.APP_READY); + + PerfUtils.addMeasurement("Application Startup"); }); - } + + // See if any startup files were passed to the application + if (brackets.app.getPendingFilesToOpen) { + brackets.app.getPendingFilesToOpen(function (err, files) { + files.forEach(function (filename) { + CommandManager.execute(Commands.FILE_OPEN, { fullPath: filename }); + }); + }); + } + }); }); }); diff --git a/src/editor/Editor.js b/src/editor/Editor.js index a8509aab2cf..0de045a3100 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -456,7 +456,7 @@ define(function (require, exports, module) { // We'd like undefined/null/"" to mean plain text mode. CodeMirror defaults to plaintext for any // unrecognized mode, but it complains on the console in that fallback case: so, convert // here so we're always explicit, avoiding console noise. - return this.document.getLanguage().mode || "text/plain"; + return this.document.getLanguage().getMode() || "text/plain"; }; @@ -1208,7 +1208,7 @@ define(function (require, exports, module) { * * @return {?(Object|string)} Name of syntax-highlighting mode, or object containing a "name" property * naming the mode along with configuration options required by the mode. - * See {@link Languages#getLanguageForFileExtension()} and {@link Language#mode}. + * See {@link LanguageManager#getLanguageForFileExtension()} and {@link Language#getMode()}. */ Editor.prototype.getModeForSelection = function () { // Check for mixed mode info @@ -1241,7 +1241,7 @@ define(function (require, exports, module) { /** * Gets the syntax-highlighting mode for the document. * - * @return {Object|String} Object or Name of syntax-highlighting mode; see {@link Languages#getLanguageForFileExtension()} and {@link Language#mode}. + * @return {Object|String} Object or Name of syntax-highlighting mode; see {@link LanguageManager#getLanguageForFileExtension()} and {@link Language#getMode()}. */ Editor.prototype.getModeForDocument = function () { return this._codeMirror.getOption("mode"); diff --git a/src/editor/EditorCommandHandlers.js b/src/editor/EditorCommandHandlers.js index 99668d3b456..5907b61517a 100644 --- a/src/editor/EditorCommandHandlers.js +++ b/src/editor/EditorCommandHandlers.js @@ -468,8 +468,9 @@ define(function (require, exports, module) { var language = editor.getLanguageForSelection(); - if (language.blockComment) { - blockCommentPrefixSuffix(editor, language.blockComment.prefix, language.blockComment.suffix, language.lineComment ? language.lineComment.prefix : null); + if (language.hasBlockCommentSyntax()) { + // getLineCommentPrefix returns null if no line comment syntax is defined + blockCommentPrefixSuffix(editor, language.getBlockCommentPrefix(), language.getBlockCommentSuffix(), language.getLineCommentPrefix()); } } @@ -485,10 +486,10 @@ define(function (require, exports, module) { var language = editor.getLanguageForSelection(); - if (language.lineComment) { - lineCommentPrefix(editor, language.lineComment.prefix); - } else if (language.blockComment) { - lineCommentPrefixSuffix(editor, language.blockComment.prefix, language.blockComment.suffix); + if (language.hasLineCommentSyntax()) { + lineCommentPrefix(editor, language.getLineCommentPrefix()); + } else if (language.hasBlockCommentSyntax()) { + lineCommentPrefixSuffix(editor, language.getBlockCommentPrefix(), language.getBlockCommentSuffix()); } } diff --git a/src/extensions/default/JavaScriptCodeHints/ScopeManager.js b/src/extensions/default/JavaScriptCodeHints/ScopeManager.js index 180e09deb1e..55bf00450db 100644 --- a/src/extensions/default/JavaScriptCodeHints/ScopeManager.js +++ b/src/extensions/default/JavaScriptCodeHints/ScopeManager.js @@ -440,7 +440,7 @@ define(function (require, exports, module) { file = split.file; if (file.indexOf(".") > 1) { // ignore /.dotfiles - var mode = LanguageManager.getLanguageForFileExtension(entry.fullPath).mode; + var mode = LanguageManager.getLanguageForFileExtension(entry.fullPath).getMode(); if (mode === HintUtils.MODE_NAME) { DocumentManager.getDocumentForPath(path).done(function (document) { refreshOuterScope(dir, file, document.getText()); diff --git a/src/extensions/default/LESSSupport/main.js b/src/extensions/default/LESSSupport/main.js index 54a1a4bc179..ef233d4b3ee 100644 --- a/src/extensions/default/LESSSupport/main.js +++ b/src/extensions/default/LESSSupport/main.js @@ -30,7 +30,7 @@ define(function (require, exports, module) { var LanguageManager = brackets.getModule("language/LanguageManager"); - var language = LanguageManager.defineLanguage("less", { + LanguageManager.defineLanguage("less", { name: "LESS", mode: "less", fileExtensions: ["less"], diff --git a/src/language/LanguageManager.js b/src/language/LanguageManager.js index dfabc6de84e..d149bde51a0 100644 --- a/src/language/LanguageManager.js +++ b/src/language/LanguageManager.js @@ -34,7 +34,7 @@ * var language = LanguageManager.getLanguage(""); * * To define your own languages, call defineLanguage(): - * var language = LanguageManager.defineLanguage("haskell", { + * LanguageManager.defineLanguage("haskell", { * name: "Haskell", * mode: "haskell", * fileExtensions: ["hs"], @@ -42,22 +42,28 @@ * lineComment: "--" * }); * + * To use that language and it's related mode, wait for the returned promise to be resolved: + * LanguageManager.defineLanguage("haskell", definition).done(function (language) { + * console.log("Language " + language.name + " is now available!"); + * }); + * * You can also refine an existing language. Currently you can only set the comment styles: + * var language = LanguageManager.getLanguage("haskell"); * language.setLineComment("--"); * language.setBlockComment("{-", "-}"); * * Some CodeMirror modes define variations of themselves. They are called MIME modes. - * To find out existing MIME modes, search for "CodeMirror.defineMIME" in thirdparty/CodeMirror2/mode - * For instance, C++, C# and Java all use the clike mode. + * To find existing MIME modes, search for "CodeMirror.defineMIME" in thirdparty/CodeMirror2/mode + * For instance, C++, C# and Java all use the clike (C-like) mode with different settings and a different MIME name. * You can refine the mode definition by specifying the MIME mode as well: * var language = LanguageManager.defineLanguage("csharp", { * name: "C#", * mode: ["clike", "text/x-csharp"], * ... * }); - * Definining the base mode is still necessary to know which file to load. - * Later however, language.mode will either refer to the MIME mode, - * or the base mode if no MIME mode has been specified. + * Defining the base mode is still necessary to know which file to load. + * However, language.getMode() will return just the MIME mode if one was + * specified. * * If you need to configure a mode, you can just create a new MIME mode and use that: * CodeMirror.defineMIME("text/x-brackets-html", { @@ -74,27 +80,23 @@ * * If a mode is not shipped with our CodeMirror distribution, you need to first load it yourself. * If the mode is part of our CodeMirror distribution, it gets loaded automatically. - * - * To wait until the mode is loaded and set, use the language.modeReady promise: - * language.modeReady.done(function () { - * // ... - * }); - * Note that this will never resolve for languages without a mode. */ define(function (require, exports, module) { "use strict"; // Dependencies - var _defaultLanguagesJSON = require("text!language/languages.json"); + var Async = require("utils/Async"), + _defaultLanguagesJSON = require("text!language/languages.json"); // State - var _fallbackLanguage = null, - _languages = {}, - _fileExtensionsToLanguageMap = {}, - _modeToLanguageMap = {}; - + var _fallbackLanguage = null, + _pendingLanguages = {}, + _languages = {}, + _fileExtensionToLanguageMap = {}, + _modeToLanguageMap = {}, + _ready; // Helper functions @@ -122,6 +124,21 @@ define(function (require, exports, module) { } } + /** + * Lowercases the file extension and ensures it doesn't start with a dot. + * @param {!string} extension The file extension + * @return {string} The normalized file extension + */ + function _normalizeFileExtension(extension) { + // Remove a leading dot if present + if (extension.charAt(0) === ".") { + extension = extension.substr(1); + } + + // Make checks below case-INsensitive + return extension.toLowerCase(); + } + /** * Monkey-patch CodeMirror to prevent modes from being overwritten by extensions. * We may rely on the tokens provided by some of these modes. @@ -141,7 +158,6 @@ define(function (require, exports, module) { * Adds a global mode-to-language association. * @param {!string} mode The mode to associate the language with * @param {!Language} language The language to associate with the mode - * @private */ function _setLanguageForMode(mode, language) { if (_modeToLanguageMap[mode]) { @@ -162,30 +178,23 @@ define(function (require, exports, module) { } /** - * Resolves a file extension to a Language object + * Resolves a file extension to a Language object. * @param {!string} path Path to or extension of the file to find a language for * @return {Language} The language for the provided file type or the fallback language */ function getLanguageForFileExtension(path) { - var extension = PathUtils.filenameExtension(path); - - if (extension.charAt(0) === ".") { - extension = extension.substr(1); - } + var extension = _normalizeFileExtension(PathUtils.filenameExtension(path)), + language = _fileExtensionToLanguageMap[extension]; - // Make checks below case-INsensitive - extension = extension.toLowerCase(); - - var language = _fileExtensionsToLanguageMap[extension]; if (!language) { - console.log("Called LanguageManager.getLanguageForFileExtension with an unhandled file extension: " + extension); + console.log("Called LanguageManager.getLanguageForFileExtension with an unhandled file extension:", extension); } return language || _fallbackLanguage; } /** - * Resolves a CodeMirror mode to a Language object + * Resolves a CodeMirror mode to a Language object. * @param {!string} mode CodeMirror mode * @return {Language} The language for the provided mode or the fallback language */ @@ -210,9 +219,9 @@ define(function (require, exports, module) { function Language(id, name) { _validateString(id, "Language ID"); // Make sure the ID is a string that can safely be used universally by the computer - as a file name, as an object key, as part of a URL, etc. - // Hence we use _ instead of "." since this makes it easier to parse a file name containing a language ID - if (!id.match(/^[a-z]+(\.[a-z]+)*$/)) { - throw new Error("Invalid language ID \"" + id + "\": Only groups of letters a-z are allowed, separated by _ (i.e. \"cpp\" or \"foo_bar\")"); + // Hence we use "_" instead of "." since the latter often has special meaning + if (!id.match(/^[a-z0-9]+(_[a-z0-9]+)*$/)) { + throw new Error("Invalid language ID \"" + id + "\": Only groups of lower case letters and numbers are allowed, separated by underscores."); } if (_languages[id]) { throw new Error("Language \"" + id + "\" is already defined"); @@ -220,76 +229,109 @@ define(function (require, exports, module) { _validateNonEmptyString(name, "name"); - this.id = id; - this.name = name; + this._id = id; + this._name = name; this._fileExtensions = []; this._modeToLanguageMap = {}; - - // Since setting the mode is asynchronous when the mode hasn't been loaded yet, offer a reliable way to wait until it is ready - this._modeReady = new $.Deferred(); - this.modeReady = this._modeReady.promise(); - - _languages[id] = this; } + /** @type {string} Identifier for this language */ - Language.prototype.id = null; + Language.prototype._id = null; + + /** @type {string} Human-readable name of this language */ + Language.prototype._name = null; + + /** @type {string} CodeMirror mode for this language */ + Language.prototype._mode = null; + + /** @type {Array.} File extensions that use this language */ + Language.prototype._fileExtensions = null; + + /** @type {{ prefix: string }} Line comment syntax */ + Language.prototype._lineCommentSyntax = null; + + /** @type {Object.} Which language to use for what CodeMirror mode */ + Language.prototype._modeToLanguageMap = null; + + /** @type {{ prefix: string, suffix: string }} Block comment syntax */ + Language.prototype._blockCommentSyntax = null; + + /** + * Returns the identifier for this language. + * @return {string} The identifier + */ + Language.prototype.getId = function () { + return this._id; + }; - /** @type {string} Human-readable name of the language */ - Language.prototype.name = null; + /** + * Returns the human-readable name of this language. + * @return {string} The name + */ + Language.prototype.getName = function () { + return this._name; + }; - /** @type {$.Promise} Promise that resolves when the mode has been loaded and set */ - Language.prototype.modeReady = null; + /** + * Returns the CodeMirror mode for this language. + * @return {string} The mode + */ + Language.prototype.getMode = function () { + return this._mode; + }; /** - * Sets the mode for this language. + * Loads a mode and sets it for this language. * - * @param {string|Array.} definition.mode CodeMirror mode (i.e. "htmlmixed"), optionally with a MIME mode defined by that mode ["clike", "text/x-c++src"] - * Unless the mode is located in thirdparty/CodeMirror2/mode//.js, you need to first load it yourself. - * @return {Language} This language + * @param {string|Array.} mode CodeMirror mode (i.e. "htmlmixed"), optionally with a MIME mode defined by that mode ["clike", "text/x-c++src"] + * Unless the mode is located in thirdparty/CodeMirror2/mode//.js, you need to first load it yourself. + * + * @return {$.Promise} A promise object that will be resolved when the mode is loaded and set */ - Language.prototype._setMode = function (mode) { - if (!mode) { - return; - } + Language.prototype._loadAndSetMode = function (mode) { + var result = new $.Deferred(), + self = this, + mimeMode; // Mode can be an array specifying a mode plus a MIME mode defined by that mode ["clike", "text/x-c++src"] - var language = this; - // Mode can be an array specifying a mode plus a MIME mode defined by that mode ["clike", "text/x-c++src"] - var mimeMode; - if ($.isArray(mode)) { + if (Array.isArray(mode)) { if (mode.length !== 2) { throw new Error("Mode must either be a string or an array containing two strings"); } mimeMode = mode[1]; mode = mode[0]; } - + + // mode must not be empty. Use "null" (the string "null") mode for plain text _validateNonEmptyString(mode, "mode"); var finish = function () { - var i; - if (!CodeMirror.modes[mode]) { - throw new Error("CodeMirror mode \"" + mode + "\" is not loaded"); + result.reject("CodeMirror mode \"" + mode + "\" is not loaded"); + return; } if (mimeMode) { var modeConfig = CodeMirror.mimeModes[mimeMode]; + if (!modeConfig) { - throw new Error("CodeMirror MIME mode \"" + mimeMode + "\" not found"); + result.reject("CodeMirror MIME mode \"" + mimeMode + "\" not found"); + return; } - if (modeConfig.name !== mode) { - throw new Error("CodeMirror MIME mode \"" + mimeMode + "\" does not belong to mode \"" + mode + "\""); + + // modeConfig can be a string or mode object + if (modeConfig !== mode && modeConfig.name !== mode) { + result.reject("CodeMirror MIME mode \"" + mimeMode + "\" does not belong to mode \"" + mode + "\""); + return; } } // This mode is now only about what to tell CodeMirror // The base mode was only necessary to load the proper mode file - language.mode = mimeMode || mode; - _setLanguageForMode(language.mode, language); + self._mode = mimeMode || mode; - language._modeReady.resolve(language); + result.resolve(self); }; if (CodeMirror.modes[mode]) { @@ -297,6 +339,8 @@ define(function (require, exports, module) { } else { require(["thirdparty/CodeMirror2/mode/" + mode + "/" + mode], finish); } + + return result.promise(); }; /** @@ -304,62 +348,92 @@ define(function (require, exports, module) { * @return {Array.} File extensions used by this language */ Language.prototype.getFileExtensions = function () { + // Use concat to create a copy of this array, preventing external modification return this._fileExtensions.concat(); }; - + /** * Adds a file extension to this language. * Private for now since dependent code would need to by kept in sync with such changes. - * In case we ever open this up, we should think about whether we want to make this - * configurable by the user. If so, the user has to be able to override these calls. + * See https://github.com/adobe/brackets/issues/2966 for plans to make this public. * @param {!string} extension A file extension used by this language - * @return {Language} This language * @private */ Language.prototype._addFileExtension = function (extension) { - extension = extension.toLowerCase(); + extension = _normalizeFileExtension(extension); + if (this._fileExtensions.indexOf(extension) === -1) { this._fileExtensions.push(extension); - var language = _fileExtensionsToLanguageMap[extension]; + var language = _fileExtensionToLanguageMap[extension]; if (language) { console.warn("Cannot register file extension \"" + extension + "\" for " + this.name + ", it already belongs to " + language.name); } else { - _fileExtensionsToLanguageMap[extension] = this; + _fileExtensionToLanguageMap[extension] = this; } } - - return this; }; /** - * Sets the prefix and suffix to use for blocks comments in this language. - * @param {!string} prefix Prefix string to use for block comments (i.e. "") - * @return {Language} This language - * @private + * Returns whether the line comment syntax is defined for this language. + * @return {boolean} Whether line comments are supported */ - Language.prototype.setBlockComment = function (prefix, suffix) { - _validateNonEmptyString(prefix, "prefix"); - _validateNonEmptyString(suffix, "suffix"); - - this.blockComment = { prefix: prefix, suffix: suffix }; - - return this; + Language.prototype.hasLineCommentSyntax = function () { + return Boolean(this._lineCommentSyntax); + }; + + /** + * Returns the prefix to use for line comments. + * @return {string} The prefix + */ + Language.prototype.getLineCommentPrefix = function () { + return this._lineCommentSyntax && this._lineCommentSyntax.prefix; }; /** * Sets the prefix to use for line comments in this language. * @param {!string} prefix Prefix string to use for block comments (i.e. "//") - * @return {Language} This language - * @private */ - Language.prototype.setLineComment = function (prefix) { + Language.prototype.setLineCommentSyntax = function (prefix) { _validateNonEmptyString(prefix, "prefix"); - this.lineComment = { prefix: prefix }; + this._lineCommentSyntax = { prefix: prefix }; + }; + + /** + * Returns whether the block comment syntax is defined for this language. + * @return {boolean} Whether block comments are supported + */ + Language.prototype.hasBlockCommentSyntax = function () { + return Boolean(this._blockCommentSyntax); + }; + + /** + * Returns the prefix to use for block comments. + * @return {string} The prefix + */ + Language.prototype.getBlockCommentPrefix = function () { + return this._blockCommentSyntax && this._blockCommentSyntax.prefix; + }; + + /** + * Returns the suffix to use for block comments. + * @return {string} The suffix + */ + Language.prototype.getBlockCommentSuffix = function () { + return this._blockCommentSyntax && this._blockCommentSyntax.suffix; + }; + + /** + * Sets the prefix and suffix to use for blocks comments in this language. + * @param {!string} prefix Prefix string to use for block comments (e.g. "") + */ + Language.prototype.setBlockCommentSyntax = function (prefix, suffix) { + _validateNonEmptyString(prefix, "prefix"); + _validateNonEmptyString(suffix, "suffix"); - return this; + this._blockCommentSyntax = { prefix: prefix, suffix: suffix }; }; /** @@ -369,7 +443,7 @@ define(function (require, exports, module) { * @return {Language} This language if it uses the mode, or whatever {@link LanguageManager#_getLanguageForMode} returns */ Language.prototype.getLanguageForMode = function (mode) { - if (mode === this.mode) { + if (mode === this._mode) { return this; } @@ -381,23 +455,20 @@ define(function (require, exports, module) { * Used to disambiguate modes used by multiple languages. * @param {!string} mode The mode to associate the language with * @param {!Language} language The language to associate with the mode - * @return {Language} This language * @private */ Language.prototype._setLanguageForMode = function (mode, language) { - if (mode === this.mode && language !== this) { + if (mode === this._mode && language !== this) { throw new Error("A language must always map its mode to itself"); } this._modeToLanguageMap[mode] = language; - - return this; }; /** * Defines a language. * - * @param {!string} id Unique identifier for this language, use only letters a-z and _ inbetween (i.e. "cpp", "foo_bar") + * @param {!string} id Unique identifier for this language, use only letters a-z, numbers and _ inbetween (i.e. "cpp", "foo_bar") * @param {!Object} definition An object describing the language * @param {!string} definition.name Human-readable name of the language, as it's commonly referred to (i.e. "C++") * @param {Array.} definition.fileExtensions List of file extensions used by this language (i.e. ["php", "php3"]) @@ -405,49 +476,67 @@ define(function (require, exports, module) { * @param {string} definition.lineComment Line comment prefix (i.e. "//") * @param {string|Array.} definition.mode CodeMirror mode (i.e. "htmlmixed"), optionally with a MIME mode defined by that mode ["clike", "text/x-c++src"] * Unless the mode is located in thirdparty/CodeMirror2/mode//.js, you need to first load it yourself. - * {@link Language#modeReady} is a promise that resolves when a mode has been loaded and set. - * It will not resolve when no mode is specified. * - * @return {Language} The new language + * @return {$.Promise} A promise object that will be resolved with a Language object **/ function defineLanguage(id, definition) { - var language = new Language(id, definition.name); + var result = new $.Deferred(); - var fileExtensions = definition.fileExtensions, - i; - if (fileExtensions) { - for (i = 0; i < fileExtensions.length; i++) { - language._addFileExtension(fileExtensions[i]); - } + if (_pendingLanguages[id]) { + result.reject("Language \"" + id + "\" is waiting to be resolved."); + return result.promise(); } + var language = new Language(id, definition.name), + fileExtensions = definition.fileExtensions, + i; + var blockComment = definition.blockComment; if (blockComment) { - language.setBlockComment(blockComment[0], blockComment[1]); + language.setBlockCommentSyntax(blockComment[0], blockComment[1]); } var lineComment = definition.lineComment; if (lineComment) { - language.setLineComment(lineComment); + language.setLineCommentSyntax(lineComment); } - var mode = definition.mode; - if (mode) { - language._setMode(mode); - } else { - language._modeReady.reject(); - } + // track languages that are currently loading + _pendingLanguages[id] = language; - return language; + language._loadAndSetMode(definition.mode).done(function () { + // register language file extensions after mode has loaded + if (fileExtensions) { + for (i = 0; i < fileExtensions.length; i++) { + language._addFileExtension(fileExtensions[i]); + } + } + + // globally associate mode to language + _setLanguageForMode(language.getMode(), language); + + // finally, store lanuage to _language map + _languages[id] = language; + + result.resolve(language); + }).fail(function (error) { + console.error(error); + result.reject(error); + }).always(function () { + // delete from pending languages after success and failure + delete _pendingLanguages[id]; + }); + + return result.promise(); } // Prevent modes from being overwritten by extensions _patchCodeMirror(); - // Define a custom MIME mode here because JSON files must not contain regular expressions - // Also, all other modes so far were strings, so we spare us the trouble of allowing - // more complex mode values. + // Define a custom MIME mode here instead of putting it directly into languages.json + // because JSON files must not contain regular expressions. Also, all other modes so + // far were strings, so we spare us the trouble of allowing more complex mode values. CodeMirror.defineMIME("text/x-brackets-html", { "name": "htmlmixed", "scriptTypes": [{"matches": /\/x-handlebars-template|\/x-mustache/i, @@ -455,23 +544,29 @@ define(function (require, exports, module) { }); // Load the default languages - $.each(JSON.parse(_defaultLanguagesJSON), defineLanguage); + _defaultLanguagesJSON = JSON.parse(_defaultLanguagesJSON); + _ready = Async.doInParallel(Object.keys(_defaultLanguagesJSON), function (key) { + return defineLanguage(key, _defaultLanguagesJSON[key]); + }, false); // Get the object for HTML - var html = getLanguage("html"); - - // The htmlmixed mode uses the xml mode internally for the HTML parts, so we map it to HTML - html._setLanguageForMode("xml", html); - - // Currently we override the above mentioned "xml" in TokenUtils.getModeAt, instead returning "html". - // When the CSSInlineEditor and the hint providers are no longer based on modes, this can be changed. - // But for now, we need to associate this madeup "html" mode with our HTML language object. - _setLanguageForMode("html", html); - - // The fallback language for unknown modes and file extensions - _fallbackLanguage = getLanguage("unknown"); + _ready.always(function () { + var html = getLanguage("html"); + + // The htmlmixed mode uses the xml mode internally for the HTML parts, so we map it to HTML + html._setLanguageForMode("xml", html); + + // Currently we override the above mentioned "xml" in TokenUtils.getModeAt, instead returning "html". + // When the CSSInlineEditor and the hint providers are no longer based on modes, this can be changed. + // But for now, we need to associate this madeup "html" mode with our HTML language object. + _setLanguageForMode("html", html); + + // The fallback language for unknown modes and file extensions + _fallbackLanguage = getLanguage("unknown"); + }); // Public methods + exports.ready = _ready; exports.defineLanguage = defineLanguage; exports.getLanguage = getLanguage; exports.getLanguageForFileExtension = getLanguageForFileExtension; diff --git a/src/language/languages.json b/src/language/languages.json index c03589de4a3..ba71509fad7 100644 --- a/src/language/languages.json +++ b/src/language/languages.json @@ -1,6 +1,7 @@ { "unknown": { - "name": "Text" + "name": "Text", + "mode": ["null", "text/plain"] }, "css": { diff --git a/test/spec/Editor-test.js b/test/spec/Editor-test.js index 7af1f5fc81c..83b8e4ca77d 100644 --- a/test/spec/Editor-test.js +++ b/test/spec/Editor-test.js @@ -47,21 +47,21 @@ define(function (require, exports, module) { return expected === null; } - return actual.name === expected; + return actual === expected; } function expectModeAndLang(editor, lang) { expect(editor.getModeForSelection()).toSpecifyModeNamed(lang.mode); - expect(editor.getLanguageForSelection().name).toBe(lang.langName); + expect(editor.getLanguageForSelection().getName()).toBe(lang.langName); } describe("Editor", function () { var defaultContent = 'Brackets is going to be awesome!\n'; var myDocument, myEditor; - function createTestEditor(content, mode) { + function createTestEditor(content, languageId) { // create dummy Document and Editor - var mocks = SpecRunnerUtils.createMockEditor(content, mode); + var mocks = SpecRunnerUtils.createMockEditor(content, languageId); myDocument = mocks.doc; myEditor = mocks.editor; } @@ -117,26 +117,28 @@ define(function (require, exports, module) { it("should switch to the HTML mode for files ending in .html", function () { // verify editor content - var mode = LanguageManager.getLanguageForFileExtension("file:///only/testing/the/path.html").mode; + var mode = LanguageManager.getLanguageForFileExtension("file:///only/testing/the/path.html").getMode(); expect(mode).toSpecifyModeNamed("text/x-brackets-html"); }); it("should switch modes even if the url has a query string", function () { // verify editor content - var mode = LanguageManager.getLanguageForFileExtension("http://only.org/testing/the/path.css?v=2").mode; + var mode = LanguageManager.getLanguageForFileExtension("http://only.org/testing/the/path.css?v=2").getMode(); expect(mode).toSpecifyModeNamed(langNames.css.mode); }); it("should accept just a file name too", function () { // verify editor content - var mode = LanguageManager.getLanguageForFileExtension("path.js").mode; + var mode = LanguageManager.getLanguageForFileExtension("path.js").getMode(); expect(mode).toSpecifyModeNamed(langNames.javascript.mode); }); - it("should default to plaintext for unknown file extensions", function () { + it("should default to plain text for unknown file extensions", function () { // verify editor content - var mode = LanguageManager.getLanguageForFileExtension("test.foo").mode; - expect(mode).toBe(undefined); + var mode = LanguageManager.getLanguageForFileExtension("test.foo").getMode(); + + // "unknown" mode uses it's MIME type instead + expect(mode).toBe("text/plain"); }); }); diff --git a/test/spec/EditorCommandHandlers-test.js b/test/spec/EditorCommandHandlers-test.js index 640041314a2..74495c0fef5 100644 --- a/test/spec/EditorCommandHandlers-test.js +++ b/test/spec/EditorCommandHandlers-test.js @@ -46,12 +46,12 @@ define(function (require, exports, module) { var myDocument, myEditor; - function setupFullEditor(content, mode) { + function setupFullEditor(content, languageId) { content = content || defaultContent; - mode = mode || "javascript"; + languageId = languageId || "javascript"; // create dummy Document and Editor - var mocks = SpecRunnerUtils.createMockEditor(content, mode); + var mocks = SpecRunnerUtils.createMockEditor(content, languageId); myDocument = mocks.doc; myEditor = mocks.editor; diff --git a/test/spec/LanguageManager-test.js b/test/spec/LanguageManager-test.js index b27e2e1965c..628a7e904df 100644 --- a/test/spec/LanguageManager-test.js +++ b/test/spec/LanguageManager-test.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, describe, CodeMirror, jasmine, beforeEach, afterEach, it, runs, waitsFor, expect, waitsForDone, waitsForFail */ +/*global define, $, describe, CodeMirror, jasmine, beforeEach, afterEach, it, runs, waitsFor, expect, waitsForDone, waitsForFail, spyOn */ define(function (require, exports, module) { 'use strict'; @@ -36,6 +36,10 @@ define(function (require, exports, module) { describe("LanguageManager", function () { + beforeEach(function () { + waitsForDone(LanguageManager.ready, "LanguageManager ready", 10000); + }); + function defineLanguage(definition) { var def = $.extend({}, definition); @@ -57,29 +61,23 @@ define(function (require, exports, module) { expect(LanguageManager.getLanguage(expected.id)).toBe(actual); } - expect(actual.id).toBe(expected.id); - expect(actual.name).toBe(expected.name); - + expect(actual.getId()).toBe(expected.id); + expect(actual.getName()).toBe(expected.name); expect(actual.getFileExtensions()).toEqual(expected.fileExtensions || []); if (expected.blockComment) { - expect(expected.blockComment.prefix).toBe(actual.blockComment.prefix); - expect(expected.blockComment.suffix).toBe(actual.blockComment.suffix); + expect(actual.hasBlockCommentSyntax()).toBe(true); + expect(actual.getBlockCommentPrefix()).toBe(expected.blockComment.prefix); + expect(actual.getBlockCommentSuffix()).toBe(expected.blockComment.suffix); } else { - expect(actual.blockComment).toBe(undefined); + expect(actual.hasBlockCommentSyntax()).toBe(false); } if (expected.lineComment) { - expect(expected.lineComment.prefix).toBe(actual.lineComment.prefix); - } else { - expect(actual.lineComment).toBe(undefined); - } - - // using async waitsFor is ok if it's the last block in a spec - if (expected.mode) { - waitsForDone(actual.modeReady, '"' + expected.mode + '" mode loading', 10000); + expect(actual.hasLineCommentSyntax()).toBe(true); + expect(actual.getLineCommentPrefix()).toBe(expected.lineComment.prefix); } else { - waitsForFail(actual.modeReady, '"' + expected.mode + '" should not load', 10000); + expect(actual.hasLineCommentSyntax()).toBe(false); } } @@ -110,14 +108,14 @@ define(function (require, exports, module) { describe("LanguageManager API", function () { - it("should map modes to languages", function () { + it("should map identifiers to languages", function () { var html = LanguageManager.getLanguage("html"); expect(html).not.toBe(null); expect(LanguageManager.getLanguage("DoesNotExist")).toBe(undefined); }); - it("should map extensions to languages", function () { + it("should map file extensions to languages", function () { var html = LanguageManager.getLanguage("html"), unknown = LanguageManager.getLanguage("unknown"); @@ -131,16 +129,25 @@ define(function (require, exports, module) { describe("defineLanguage", function () { it("should create a basic language", function () { - var def = { id: "one", name: "One" }, - lang = defineLanguage(def); + var language, + promise, + def = { id: "one", name: "One", mode: ["null", "text/plain"] }; - validateLanguage(def, lang); + // mode already exists, this test is completely synchronous + promise = defineLanguage(def).done(function (lang) { + language = lang; + }); + + expect(promise.isResolved()).toBeTruthy(); + + validateLanguage(def, language); }); it("should throw errors for invalid language id values", function () { - expect(function () { defineLanguage({ id: null }); }).toThrow(new Error("Language ID must be a string")); - expect(function () { defineLanguage({ id: "123" }); }).toThrow(new Error('Invalid language ID "123": Only groups of letters a-z are allowed, separated by _ (i.e. "cpp" or "foo_bar")')); - expect(function () { defineLanguage({ id: "html" }); }).toThrow(new Error('Language "html" is already defined')); + expect(function () { defineLanguage({ id: null }); }).toThrow(new Error("Language ID must be a string")); + expect(function () { defineLanguage({ id: "HTML5" }); }).toThrow(new Error("Invalid language ID \"HTML5\": Only groups of lower case letters and numbers are allowed, separated by underscores.")); + expect(function () { defineLanguage({ id: "_underscore" }); }).toThrow(new Error("Invalid language ID \"_underscore\": Only groups of lower case letters and numbers are allowed, separated by underscores.")); + expect(function () { defineLanguage({ id: "html" }); }).toThrow(new Error('Language "html" is already defined')); }); it("should throw errors for invalid language name values", function () { @@ -148,26 +155,57 @@ define(function (require, exports, module) { expect(function () { defineLanguage({ id: "three", name: "" }); }).toThrow(new Error("name must not be empty")); }); + it("should log errors for missing mode value", function () { + expect(function () { defineLanguage({ id: "four", name: "Four" }); }).toThrow(new Error("mode must be a string")); + expect(function () { defineLanguage({ id: "five", name: "Five", mode: "" }); }).toThrow(new Error("mode must not be empty")); + }); + it("should create a language with file extensions and a mode", function () { - var def = { id: "pascal", name: "Pascal", fileExtensions: ["pas", "p"], mode: "pascal" }, - lang = defineLanguage(def); + var def = { id: "pascal", name: "Pascal", fileExtensions: ["pas", "p"], mode: "pascal" }, + language; - expect(LanguageManager.getLanguageForFileExtension("file.p")).toBe(lang); + runs(function () { + defineLanguage(def).done(function (lang) { + language = lang; + }); + }); + + waitsFor(function () { + return Boolean(language); + }, "The language should be resolved", 50); - validateLanguage(def, lang); + runs(function () { + expect(LanguageManager.getLanguageForFileExtension("file.p")).toBe(language); + validateLanguage(def, language); + }); }); it("should allow multiple languages to use the same mode", function () { - var xmlBefore = LanguageManager.getLanguage("xml"), + var xmlBefore, def = { id: "wix", name: "WiX", fileExtensions: ["wix"], mode: "xml" }, - lang = defineLanguage(def), - xmlAfter = LanguageManager.getLanguage("xml"); + lang, + xmlAfter; - expect(xmlBefore).toBe(xmlAfter); - expect(LanguageManager.getLanguageForFileExtension("file.wix")).toBe(lang); - expect(LanguageManager.getLanguageForFileExtension("file.xml")).toBe(xmlAfter); + runs(function () { + xmlBefore = LanguageManager.getLanguage("xml"); + + defineLanguage(def).done(function (language) { + lang = language; + xmlAfter = LanguageManager.getLanguage("xml"); + }); + }); + + waitsFor(function () { + return Boolean(lang); + }, "The language should be resolved", 50); - validateLanguage(def, lang); + runs(function () { + expect(xmlBefore).toBe(xmlAfter); + expect(LanguageManager.getLanguageForFileExtension("file.wix")).toBe(lang); + expect(LanguageManager.getLanguageForFileExtension("file.xml")).toBe(xmlAfter); + + validateLanguage(def, lang); + }); }); // FIXME: Add internal LanguageManager._reset() @@ -175,44 +213,66 @@ define(function (require, exports, module) { it("should return an error if a language is already defined", function () { var def = { id: "pascal", name: "Pascal", fileExtensions: ["pas", "p"], mode: "pascal" }; - expect(function () { defineLanguage(def); }).toThrow(new Error('Language "pascal" is already defined')); + runs(function () { + expect(function () { defineLanguage(def); }).toThrow(new Error('Language "pascal" is already defined')); + }); }); it("should validate comment prefix/suffix", function () { - var def = { id: "coldfusion", name: "ColdFusion", fileExtensions: ["cfml", "cfm"], mode: "xml" }, - lang = defineLanguage(def); - - expect(function () { lang.setLineComment(""); }).toThrow(new Error("prefix must not be empty")); - expect(function () { lang.setBlockComment(""); }).toThrow(new Error("prefix must not be empty")); + var def = { id: "coldfusion", name: "ColdFusion", fileExtensions: ["cfml", "cfm"], mode: "xml" }, + language; - def.lineComment = { - prefix: "//" - }; - def.blockComment = { - prefix: "" - }; + runs(function () { + defineLanguage(def).done(function (lang) { + language = lang; + }); + }); - lang.setLineComment(def.lineComment.prefix); - lang.setBlockComment(def.blockComment.prefix, def.blockComment.suffix); + waitsFor(function () { + return Boolean(language); + }, "The language should be resolved", 50); - validateLanguage(def, lang); + runs(function () { + expect(function () { language.setLineCommentSyntax(""); }).toThrow(new Error("prefix must not be empty")); + expect(function () { language.setBlockCommentSyntax(""); }).toThrow(new Error("prefix must not be empty")); + + def.lineComment = { + prefix: "//" + }; + def.blockComment = { + prefix: "" + }; + + language.setLineCommentSyntax(def.lineComment.prefix); + language.setBlockCommentSyntax(def.blockComment.prefix, def.blockComment.suffix); + + validateLanguage(def, language); + }); }); it("should load a built-in CodeMirror mode", function () { - var id = "erlang"; + var id = "erlang", + def = { id: id, name: "erlang", fileExtensions: ["erlang"], mode: "erlang" }, + language; runs(function () { // erlang is not defined in the default set of languages in languages.json expect(CodeMirror.modes[id]).toBe(undefined); - var def = { id: id, name: "erlang", fileExtensions: ["erlang"], mode: "erlang" }, - lang = defineLanguage(def); - - expect(LanguageManager.getLanguageForFileExtension("file.erlang")).toBe(lang); - - validateLanguage(def, lang); + defineLanguage(def).done(function (lang) { + language = lang; + }); + }); + + waitsFor(function () { + return Boolean(language); + }, "The language should be resolved", 50); + + runs(function () { + expect(LanguageManager.getLanguageForFileExtension("file.erlang")).toBe(language); + validateLanguage(def, language); }); runs(function () {