Manages linters and other code inspections on a per-language basis. Provides a UI and status indicator for the resulting errors/warnings.
Currently, inspection providers are only invoked on the current file and only when it is opened, switched to, or saved. But in the future, inspectors may be invoked as part of a global scan, at intervals while typing, etc. Currently, results are only displayed in a bottom panel list and in a status bar icon. But in the future, results may also be displayed inline in the editor (as gutter markers, squiggly underlines, etc.). In the future, support may also be added for error/warning providers that cannot process a single file at a time (e.g. a full-project compiler).
Constants for the preferences defined in this file.
var PREF_ENABLED = "enabled",
PREF_COLLAPSED = "collapsed",
PREF_ASYNC_TIMEOUT = "asyncTimeout",
PREF_PREFER_PROVIDERS = "prefer",
PREF_PREFERRED_ONLY = "usePreferredOnly";
var prefs = PreferencesManager.getExtensionPrefs("linting");
When collapsed, the errors panel is closed but the status bar icon is kept up to date.
var _collapsed = false;
Promise of the returned by the last call to inspectFile or null if linting is disabled. Used to prevent any stale promises to cause updates of the UI.
var _currentPromise = null;
Returns an array of the IDs of providers registered for a specific language
function getProviderIDsForLanguage(languageId) {
if (!_providers[languageId]) {
return [];
}
return _providers[languageId].map(function (provider) {
return provider.name;
});
}
Returns a list of providers registered for given languageId through register function
function getProvidersForLanguageId(languageId) {
var result = [];
if (_providers[languageId]) {
result = result.concat(_providers[languageId]);
}
if (_providers['*']) {
result = result.concat(_providers['*']);
}
return result;
}
Returns a list of provider for given file path, if available. Decision is made depending on the file extension.
function getProvidersForPath(filePath) {
var language = LanguageManager.getLanguageForPath(filePath).getId(),
context = PreferencesManager._buildContext(filePath, language),
installedProviders = getProvidersForLanguageId(language),
preferredProviders,
prefPreferredProviderNames = prefs.get(PREF_PREFER_PROVIDERS, context),
prefPreferredOnly = prefs.get(PREF_PREFERRED_ONLY, context),
providers;
if (prefPreferredProviderNames && prefPreferredProviderNames.length) {
if (typeof prefPreferredProviderNames === "string") {
prefPreferredProviderNames = [prefPreferredProviderNames];
}
preferredProviders = prefPreferredProviderNames.reduce(function (result, key) {
var provider = _.find(installedProviders, {name: key});
if (provider) {
result.push(provider);
}
return result;
}, []);
if (prefPreferredOnly) {
providers = preferredProviders;
} else {
providers = _.union(preferredProviders, installedProviders);
}
} else {
providers = installedProviders;
}
return providers;
}
Command to go to the first Problem
function handleGotoFirstProblem() {
run();
if (_gotoEnabled) {
$problemsPanel.find("tr:not(.inspector-section)").first().trigger("click");
}
}
// Register command handlers
CommandManager.register(Strings.CMD_VIEW_TOGGLE_INSPECTION, Commands.VIEW_TOGGLE_INSPECTION, toggleEnabled);
CommandManager.register(Strings.CMD_GOTO_FIRST_PROBLEM, Commands.NAVIGATE_GOTO_FIRST_PROBLEM, handleGotoFirstProblem);
// Register preferences
prefs.definePreference(PREF_ENABLED, "boolean", brackets.config["linting.enabled_by_default"], {
description: Strings.DESCRIPTION_LINTING_ENABLED
})
.on("change", function (e, data) {
toggleEnabled(prefs.get(PREF_ENABLED), true);
});
prefs.definePreference(PREF_COLLAPSED, "boolean", false, {
description: Strings.DESCRIPTION_LINTING_COLLAPSED
})
.on("change", function (e, data) {
toggleCollapsed(prefs.get(PREF_COLLAPSED), true);
});
prefs.definePreference(PREF_ASYNC_TIMEOUT, "number", 10000, {
description: Strings.DESCRIPTION_ASYNC_TIMEOUT
});
prefs.definePreference(PREF_PREFER_PROVIDERS, "array", [], {
description: Strings.DESCRIPTION_LINTING_PREFER,
valueType: "string"
});
prefs.definePreference(PREF_PREFERRED_ONLY, "boolean", false, {
description: Strings.DESCRIPTION_USE_PREFERED_ONLY
});
// Initialize items dependent on HTML DOM
AppInit.htmlReady(function () {
// Create bottom panel to list error details
var panelHtml = Mustache.render(PanelTemplate, Strings);
WorkspaceManager.createBottomPanel("errors", $(panelHtml), 100);
$problemsPanel = $("#problems-panel");
var $selectedRow;
$problemsPanelTable = $problemsPanel.find(".table-container")
.on("click", "tr", function (e) {
if ($selectedRow) {
$selectedRow.removeClass("selected");
}
$selectedRow = $(e.currentTarget);
$selectedRow.addClass("selected");
// This is a inspector title row, expand/collapse on click
if ($selectedRow.hasClass("inspector-section")) {
var $triangle = $(".disclosure-triangle", $selectedRow);
var isExpanded = $triangle.hasClass("expanded");
// Clicking the inspector title section header collapses/expands result rows
if (isExpanded) {
$selectedRow.nextUntil(".inspector-section").addClass("forced-hidden");
} else {
$selectedRow.nextUntil(".inspector-section").removeClass("forced-hidden");
}
$triangle.toggleClass("expanded");
var providerName = $selectedRow.find("input[type='hidden']").val();
prefs.set(providerName + ".collapsed", !isExpanded);
prefs.save();
} else {
// This is a problem marker row, show the result on click
// Grab the required position data
var lineTd = $selectedRow.find(".line-number");
var line = parseInt(lineTd.text(), 10) - 1; // convert friendlyLine back to pos.line
// if there is no line number available, don't do anything
if (!isNaN(line)) {
var character = lineTd.data("character");
var editor = EditorManager.getCurrentFullEditor();
editor.setCursorPos(line, character, true);
MainViewManager.focusActivePane();
}
}
});
$("#problems-panel .close").click(function () {
toggleCollapsed(true);
});
// Status bar indicator - icon & tooltip updated by run()
var statusIconHtml = Mustache.render("<div id=\"status-inspection\"> </div>", Strings);
StatusBar.addIndicator(INDICATOR_ID, $(statusIconHtml), true, "", "", "status-indent");
$("#status-inspection").click(function () {
// Clicking indicator toggles error panel, if any errors in current file
if (_hasErrors) {
toggleCollapsed();
}
});
// Set initial UI state
toggleEnabled(prefs.get(PREF_ENABLED), true);
toggleCollapsed(prefs.get(PREF_COLLAPSED), true);
});
// Testing
exports._unregisterAll = _unregisterAll;
exports._PREF_ASYNC_TIMEOUT = PREF_ASYNC_TIMEOUT;
exports._PREF_PREFER_PROVIDERS = PREF_PREFER_PROVIDERS;
exports._PREF_PREFERRED_ONLY = PREF_PREFERRED_ONLY;
// Public API
exports.register = register;
exports.Type = Type;
exports.toggleEnabled = toggleEnabled;
exports.inspectFile = inspectFile;
exports.requestRun = run;
exports.getProvidersForPath = getProvidersForPath;
exports.getProviderIDsForLanguage = getProviderIDsForLanguage;
});
Runs a file inspection over passed file. Uses the given list of providers if specified, otherwise uses the set of providers that are registered for the file's language. This method doesn't update the Brackets UI, just provides inspection results. These results will reflect any unsaved changes present in the file if currently open.
The Promise yields an array of provider-result pair objects (the result is the return value of the provider's scanFile() - see register() for details). The result object may be null if there were no errors from that provider. If there are no providers registered for this file, the Promise yields null instead.
function inspectFile(file, providerList) {
var response = new $.Deferred(),
results = [];
providerList = providerList || getProvidersForPath(file.fullPath);
if (!providerList.length) {
response.resolve(null);
return response.promise();
}
DocumentManager.getDocumentText(file)
.done(function (fileText) {
var perfTimerInspector = PerfUtils.markStart("CodeInspection:\t" + file.fullPath),
masterPromise;
masterPromise = Async.doInParallel(providerList, function (provider) {
var perfTimerProvider = PerfUtils.markStart("CodeInspection '" + provider.name + "':\t" + file.fullPath),
runPromise = new $.Deferred();
runPromise.done(function (scanResult) {
results.push({provider: provider, result: scanResult});
});
if (provider.scanFileAsync) {
window.setTimeout(function () {
// timeout error
var errTimeout = {
pos: { line: -1, col: 0},
message: StringUtils.format(Strings.LINTER_TIMED_OUT, provider.name, prefs.get(PREF_ASYNC_TIMEOUT)),
type: Type.ERROR
};
runPromise.resolve({errors: [errTimeout]});
}, prefs.get(PREF_ASYNC_TIMEOUT));
provider.scanFileAsync(fileText, file.fullPath)
.done(function (scanResult) {
PerfUtils.addMeasurement(perfTimerProvider);
runPromise.resolve(scanResult);
})
.fail(function (err) {
PerfUtils.finalizeMeasurement(perfTimerProvider);
var errError = {
pos: {line: -1, col: 0},
message: StringUtils.format(Strings.LINTER_FAILED, provider.name, err),
type: Type.ERROR
};
console.error("[CodeInspection] Provider " + provider.name + " (async) failed: " + err);
runPromise.resolve({errors: [errError]});
});
} else {
try {
var scanResult = provider.scanFile(fileText, file.fullPath);
PerfUtils.addMeasurement(perfTimerProvider);
runPromise.resolve(scanResult);
} catch (err) {
PerfUtils.finalizeMeasurement(perfTimerProvider);
var errError = {
pos: {line: -1, col: 0},
message: StringUtils.format(Strings.LINTER_FAILED, provider.name, err),
type: Type.ERROR
};
console.error("[CodeInspection] Provider " + provider.name + " (sync) threw an error: " + err);
runPromise.resolve({errors: [errError]});
}
}
return runPromise.promise();
}, false);
masterPromise.then(function () {
// sync async may have pushed results in different order, restore the original order
results.sort(function (a, b) {
return providerList.indexOf(a.provider) - providerList.indexOf(b.provider);
});
PerfUtils.addMeasurement(perfTimerInspector);
response.resolve(results);
});
})
.fail(function (err) {
console.error("[CodeInspection] Could not read file for inspection: " + file.fullPath);
response.reject(err);
});
return response.promise();
}
The provider is passed the text of the file and its fullPath. Providers should not assume that the file is open (i.e. DocumentManager.getOpenDocumentForPath() may return null) or that the file on disk matches the text given (file may have unsaved changes).
Registering any provider for the "javascript" language automatically unregisters the built-in Brackets JSLint provider. This is a temporary convenience until UI exists for disabling registered providers.
Providers implement scanFile() if results are available synchronously, or scanFileAsync() if results may require an async wait (if both are implemented, scanFile() is ignored). scanFileAsync() returns a {$.Promise} object resolved with the same type of value as scanFile() is expected to return. Rejecting the promise is treated as an internal error in the provider.
function register(languageId, provider) {
if (!_providers[languageId]) {
_providers[languageId] = [];
} else {
// Check if provider with same name exists for the given language
// If yes, remove the provider before inserting the most recently loaded one
var indexOfProvider = _.findIndex(_providers[languageId], function(entry) { return entry.name === provider.name; });
if (indexOfProvider !== -1) {
_providers[languageId].splice(indexOfProvider, 1);
}
}
_providers[languageId].push(provider);
run(); // in case a file of this type is open currently
}
Run inspector applicable to current document. Updates status bar indicator and refreshes error list in bottom panel. Does not run if inspection is disabled or if a providerName is given and does not match the current doc's provider name.
function run() {
if (!_enabled) {
_hasErrors = false;
_currentPromise = null;
Resizer.hide($problemsPanel);
StatusBar.updateIndicator(INDICATOR_ID, true, "inspection-disabled", Strings.LINT_DISABLED);
setGotoEnabled(false);
return;
}
var currentDoc = DocumentManager.getCurrentDocument(),
providerList = currentDoc && getProvidersForPath(currentDoc.file.fullPath);
if (providerList && providerList.length) {
var numProblems = 0;
var aborted = false;
var allErrors = [];
var html;
var providersReportingProblems = [];
// run all the providers registered for this file type
(_currentPromise = inspectFile(currentDoc.file, providerList)).then(function (results) {
// check if promise has not changed while inspectFile was running
if (this !== _currentPromise) {
return;
}
// how many errors in total?
var errors = results.reduce(function (a, item) { return a + (item.result ? item.result.errors.length : 0); }, 0);
_hasErrors = Boolean(errors);
if (!errors) {
Resizer.hide($problemsPanel);
var message = Strings.NO_ERRORS_MULTIPLE_PROVIDER;
if (providerList.length === 1) {
message = StringUtils.format(Strings.NO_ERRORS, providerList[0].name);
}
StatusBar.updateIndicator(INDICATOR_ID, true, "inspection-valid", message);
setGotoEnabled(false);
return;
}
var perfTimerDOM = PerfUtils.markStart("ProblemsPanel render:\t" + currentDoc.file.fullPath);
// Augment error objects with additional fields needed by Mustache template
results.forEach(function (inspectionResult) {
var provider = inspectionResult.provider;
var isExpanded = prefs.get(provider.name + ".collapsed") !== false;
if (inspectionResult.result) {
inspectionResult.result.errors.forEach(function (error) {
// some inspectors don't always provide a line number or report a negative line number
if (!isNaN(error.pos.line) &&
(error.pos.line + 1) > 0 &&
(error.codeSnippet = currentDoc.getLine(error.pos.line)) !== undefined) {
error.friendlyLine = error.pos.line + 1;
error.codeSnippet = error.codeSnippet.substr(0, 175); // limit snippet width
}
if (error.type !== Type.META) {
numProblems++;
}
// Hide the errors when the provider is collapsed.
error.display = isExpanded ? "" : "forced-hidden";
});
// if the code inspector was unable to process the whole file, we keep track to show a different status
if (inspectionResult.result.aborted) {
aborted = true;
}
if (inspectionResult.result.errors.length) {
allErrors.push({
isExpanded: isExpanded,
providerName: provider.name,
results: inspectionResult.result.errors
});
providersReportingProblems.push(provider);
}
}
});
// Update results table
html = Mustache.render(ResultsTemplate, {reportList: allErrors});
$problemsPanelTable
.empty()
.append(html)
.scrollTop(0); // otherwise scroll pos from previous contents is remembered
if (!_collapsed) {
Resizer.show($problemsPanel);
}
updatePanelTitleAndStatusBar(numProblems, providersReportingProblems, aborted);
setGotoEnabled(true);
PerfUtils.addMeasurement(perfTimerDOM);
});
} else {
// No provider for current file
_hasErrors = false;
_currentPromise = null;
Resizer.hide($problemsPanel);
var language = currentDoc && LanguageManager.getLanguageForPath(currentDoc.file.fullPath);
if (language) {
StatusBar.updateIndicator(INDICATOR_ID, true, "inspection-disabled", StringUtils.format(Strings.NO_LINT_AVAILABLE, language.getName()));
} else {
StatusBar.updateIndicator(INDICATOR_ID, true, "inspection-disabled", Strings.NOTHING_TO_LINT);
}
setGotoEnabled(false);
}
}
Enable or disable the "Go to First Error" command
function setGotoEnabled(gotoEnabled) {
CommandManager.get(Commands.NAVIGATE_GOTO_FIRST_PROBLEM).setEnabled(gotoEnabled);
_gotoEnabled = gotoEnabled;
}
function _unregisterAll() {
_providers = {};
}
Toggle the collapsed state for the panel. This explicitly collapses the panel (as opposed to the auto collapse due to files with no errors & filetypes with no provider). When explicitly collapsed, the panel will not reopen automatically on switch files or save.
function toggleCollapsed(collapsed, doNotSave) {
if (collapsed === undefined) {
collapsed = !_collapsed;
}
if (collapsed === _collapsed) {
return;
}
_collapsed = collapsed;
if (!doNotSave) {
prefs.set(PREF_COLLAPSED, _collapsed);
prefs.save();
}
if (_collapsed) {
Resizer.hide($problemsPanel);
} else {
if (_hasErrors) {
Resizer.show($problemsPanel);
}
}
}
Enable or disable all inspection.
function toggleEnabled(enabled, doNotSave) {
if (enabled === undefined) {
enabled = !_enabled;
}
// Take no action when there is no change.
if (enabled === _enabled) {
return;
}
_enabled = enabled;
CommandManager.get(Commands.VIEW_TOGGLE_INSPECTION).setChecked(_enabled);
updateListeners();
if (!doNotSave) {
prefs.set(PREF_ENABLED, _enabled);
prefs.save();
}
// run immediately
run();
}
Update DocumentManager listeners.
function updateListeners() {
if (_enabled) {
// register our event listeners
MainViewManager
.on("currentFileChange.codeInspection", function () {
run();
});
DocumentManager
.on("currentDocumentLanguageChanged.codeInspection", function () {
run();
})
.on("documentSaved.codeInspection documentRefreshed.codeInspection", function (event, document) {
if (document === DocumentManager.getCurrentDocument()) {
run();
}
});
} else {
DocumentManager.off(".codeInspection");
MainViewManager.off(".codeInspection");
}
}
Update the title of the problem panel and the tooltip of the status bar icon. The title and the tooltip will change based on the number of problems reported and how many provider reported problems.
function updatePanelTitleAndStatusBar(numProblems, providersReportingProblems, aborted) {
var message, tooltip;
if (providersReportingProblems.length === 1) {
// don't show a header if there is only one provider available for this file type
$problemsPanelTable.find(".inspector-section").hide();
$problemsPanelTable.find("tr").removeClass("forced-hidden");
if (numProblems === 1 && !aborted) {
message = StringUtils.format(Strings.SINGLE_ERROR, providersReportingProblems[0].name);
} else {
if (aborted) {
numProblems += "+";
}
message = StringUtils.format(Strings.MULTIPLE_ERRORS, providersReportingProblems[0].name, numProblems);
}
} else if (providersReportingProblems.length > 1) {
$problemsPanelTable.find(".inspector-section").show();
if (aborted) {
numProblems += "+";
}
message = StringUtils.format(Strings.ERRORS_PANEL_TITLE_MULTIPLE, numProblems);
} else {
return;
}
$problemsPanel.find(".title").text(message);
tooltip = StringUtils.format(Strings.STATUSBAR_CODE_INSPECTION_TOOLTIP, message);
StatusBar.updateIndicator(INDICATOR_ID, true, "inspection-errors", tooltip);
}