Skip to content
This repository has been archived by the owner on Sep 6, 2021. It is now read-only.

Fix #5137 async linting #6530

Merged
merged 22 commits into from
Apr 8, 2014
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
835b3af
Bare async provider implementation.
busykai Jan 3, 2014
db33c0f
Merge remote-tracking branch 'origin/fix-5137' into fix-5137-async-li…
busykai Jan 12, 2014
e730595
Fix @param specification error.
busykai Jan 12, 2014
d9deaa1
Change documentation and JSDoc.
busykai Jan 15, 2014
b91ec3f
Put timeout on provider execution.
busykai Jan 15, 2014
08e0392
Change to record notation (understood by CC).
busykai Jan 15, 2014
03c9ed3
Fix typo in the docs.
busykai Jan 15, 2014
5ec5534
Resolve with null on timeout. Lint.
busykai Jan 16, 2014
9fc0425
Add unit tests.
busykai Jan 16, 2014
62933ce
Merge remote-tracking branch 'upstream/master' into fix-5137-async-li…
busykai Feb 26, 2014
d684e93
Merge remote-tracking branch 'upstream/master' into fix-5137-async-li…
busykai Mar 17, 2014
0c091df
Address code review comments.
busykai Mar 17, 2014
fe7d342
Increase timeout to 1000ms and make it a pref.
busykai Mar 17, 2014
a329181
Add test case for race condition.
busykai Mar 27, 2014
7435877
Address nits and documentation review outcome.
busykai Mar 27, 2014
fecb543
Resolve with an error on timeout/failure.
busykai Mar 30, 2014
563263d
Merge branch 'master' into fix-5137-async-linting
busykai Mar 30, 2014
6f5e030
Keep the order of the providers when reporting.
busykai Mar 31, 2014
c786e69
Check for current promise instead of document.
busykai Mar 31, 2014
a075ab4
Remove left-over documentation sentence.
busykai Apr 2, 2014
a46b9df
Address review comments.
busykai Apr 4, 2014
c3411bb
Merge remote-tracking branch 'upstream/master' into fix-5137-async-li…
busykai Apr 5, 2014
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 52 additions & 24 deletions src/language/CodeInspection.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@


/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4 */
/*global define, $, Mustache, brackets */
/*global define, $, Mustache, brackets, setTimeout */

/**
* Manages linters and other code inspections on a per-language basis. Provides a UI and status indicator for
Expand Down Expand Up @@ -120,7 +120,7 @@ define(function (require, exports, module) {

/**
* @private
* @type {Object.<languageId:string, Array.<{name:string, scanFile:function(string, string):Object}>>}
* @type {{languageId:string, Array.<{name:string, scanFileAsync:?function(string, string):!{$.Promise}, scanFile:function(string, string):Object}>}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a '?' next to the other scanFile() now too, right? (Same for the docs below on getProvidersForPath(), inspectFile(), updatePanelTitleAndStatusBar()).

This is a breaking API change btw -- we need to make sure to file bugs on extensions that using getProvidersForPath() ASAP since they must issue updates before the sprint ships.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually on second thought, it's not really breaking if the caller is just passing the results to inspectFile(). There might actually be zero extensions affected by this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right! scanFile is optional now. There's no way to specify that either one must be implemented, except for plain text documentation.

*/
var _providers = {};

Expand Down Expand Up @@ -148,7 +148,7 @@ define(function (require, exports, module) {
* Decision is made depending on the file extension.
*
* @param {!string} filePath
* @return ?{Array.<{name:string, scanFile:function(string, string):?{errors:!Array, aborted:boolean}}>} provider
* @return ?{Array.<{name:string, scanFileAsync:?function(string, string):!{$.Promise}, scanFile:function(string, string):?{errors:!Array, aborted:boolean}}>} provider
*/
function getProvidersForPath(filePath) {
return _providers[LanguageManager.getLanguageForPath(filePath).getId()];
Expand All @@ -160,13 +160,20 @@ define(function (require, exports, module) {
* 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.
*
* If a provider implements scanFileAsync, then it will be used to scan the file. Otherwise, scanFile,
* a synchronous version of the function will be used. Provider must never reject a promise and resolve it
* with null in case the results cannot be retrieved for whatever reason.
*
* A code inspection provider's scanFileAsync must return a {$.Promise} object which must be resolved with
* ?{errors:!Array, aborted:boolean}}.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like these docs would make more sense to put on register() -- from the perspective of inspectFile() callers, all this stuff is basically an implementation detail.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove this piece from here. For the sake of maintainability.

*
* 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.
*
* @param {!File} file File that will be inspected for errors.
* @param {?Array.<{name:string, scanFile:function(string, string):?{errors:!Array, aborted:boolean}}>} providerList
* @param {?Array.<{name:string, scanFileAsync:?function(string, string):!{$.Promise}, scanFile:function(string, string):?{errors:!Array, aborted:boolean}}>} providerList
* @return {$.Promise} a jQuery promise that will be resolved with ?Array.<{provider:Object, result: ?{errors:!Array, aborted:boolean}}>
*/
function inspectFile(file, providerList) {
Expand All @@ -179,29 +186,50 @@ define(function (require, exports, module) {
response.resolve(null);
return response.promise();
}

DocumentManager.getDocumentText(file)
.done(function (fileText) {
var perfTimerInspector = PerfUtils.markStart("CodeInspection:\t" + file.fullPath);

providerList.forEach(function (provider) {
var perfTimerProvider = PerfUtils.markStart("CodeInspection '" + provider.name + "':\t" + file.fullPath);

try {
var scanResult = provider.scanFile(fileText, file.fullPath);
var perfTimerInspector = PerfUtils.markStart("CodeInspection:\t" + file.fullPath),
masterPromise;

masterPromise = Async.doInParallel(providerList, function (provider) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doInParallel() means the order of the providers in the results table will be nondeterministic. So this should either be sorted before display (e.g. alphabetically by provider name), or it should use doSequentially() so the order is stable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added a test case for it also.

var perfTimerProvider = PerfUtils.markStart("CodeInspection '" + provider.name + "':\t" + file.fullPath),
runPromise = new $.Deferred();

runPromise.then(function (scanResult) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: our normal style is to use done() (which is slightly more efficient than then() in jQuery's implementation, also)

results.push({provider: provider, result: scanResult});
} catch (err) {
console.error("[CodeInspection] Provider " + provider.name + " threw an error: " + err);
response.reject(err);
return;
});

if (provider.scanFileAsync) {
setTimeout(function () { runPromise.resolve(null); }, 500);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if 500ms is enough if you have, for example, a web service and a certain latency. Perhaps we should increase this a little bit, even if this will never be sufficient if the linting takes more time to finish.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I forgot to comment on this. I will make it a preference.

What about 1 second for default value?

provider.scanFileAsync(fileText, file.fullPath)
.then(function (scanResult) {
PerfUtils.addMeasurement(perfTimerProvider);
runPromise.resolve(scanResult);
})
.fail(function (err) {
console.error("[CodeInspection] Provider " + provider.name + " (async) failed: " + err);
runPromise.resolve(null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we should reject() in cases where providers fail to return results -- that's what the code in master does. If you want to make sure we still display all the other providers' results are still displayed, just change the masterPromise.then() to masterPromise.always() and pass failFast = false to doInParallel() (which you're alreayd doing implicitly).

});
} else {
try {
var scanResult = provider.scanFile(fileText, file.fullPath);
PerfUtils.addMeasurement(perfTimerProvider);
runPromise.resolve(scanResult);
} catch (err) {
console.error("[CodeInspection] Provider " + provider.name + " (sync) threw an error: " + err);
runPromise.resolve(null);
}
}
return runPromise.promise();

PerfUtils.addMeasurement(perfTimerProvider);
}, false);

masterPromise.then(function () {
PerfUtils.addMeasurement(perfTimerInspector);
response.resolve(results);
});

PerfUtils.addMeasurement(perfTimerInspector);

response.resolve(results);
})
.fail(function (err) {
console.error("[CodeInspection] Could not read file for inspection: " + file.fullPath);
Expand All @@ -216,7 +244,7 @@ define(function (require, exports, module) {
* change based on the number of problems reported and how many provider reported problems.
*
* @param {Number} numProblems - total number of problems across all providers
* @param {Array.<{name:string, scanFile:function(string, string):Object}>} providersReportingProblems - providers that reported problems
* @param {Array.<{name:string, scanFileAsync:?function(string, string):!{$.Promise}, scanFile:function(string, string):Object}>} providersReportingProblems - providers that reported problems
* @param {boolean} aborted - true if any provider returned a result with the 'aborted' flag set
*/
function updatePanelTitleAndStatusBar(numProblems, providersReportingProblems, aborted) {
Expand Down Expand Up @@ -382,7 +410,7 @@ define(function (require, exports, module) {
* registered providers.
*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a sentence that makes it clear, that the scanFileAsync always has precedence over scanFile iof both of them would have been provided. I think this is an edge case, but we make it clear right from the beginning.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow I added this note to inspectFile() documentation. :) Will add clarification here as well.

* @param {string} languageId
* @param {{name:string, scanFile:function(string, string):?{errors:!Array, aborted:boolean}} provider
* @param {{name:string, scanFileAsync:?function(string, string):!{$.Promise}, scanFile:function(string, string):?{errors:!Array, aborted:boolean}}} provider
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first I wasn't sure I liked having there be two different functions for sync vs. async, but I've come around to liking this approach. For one thing, whenever we get around to introducing real time linting as you type, this gives us a nice migration path -- we can automatically opt in all legacy sync linters, while leaving async linters to opt in manually only if they feel like they're generally fast enough.

*
* Each error is: { pos:{line,ch}, endPos:?{line,ch}, message:string, type:?Type }
* If type is unspecified, Type.WARNING is assumed.
Expand All @@ -391,15 +419,15 @@ define(function (require, exports, module) {
if (!_providers[languageId]) {
_providers[languageId] = [];
}

if (languageId === "javascript") {
// This is a special case to enable extension provider to replace the JSLint provider
// in favor of their own implementation
_.remove(_providers[languageId], function (registeredProvider) {
return registeredProvider.name === "JSLint";
});
}

_providers[languageId].push(provider);

run(); // in case a file of this type is open currently
Expand Down
148 changes: 148 additions & 0 deletions test/spec/CodeInspection-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ define(function (require, exports, module) {

return provider;
}

function createAsyncCodeInspector(name, result, scanTime, syncImpl) {
var provider = {
name: name,
scanFileAsync: function () {
var deferred = new $.Deferred();
setTimeout(function () {
deferred.resolve(result);
}, scanTime);
return deferred.promise();
}
};
spyOn(provider, "scanFileAsync").andCallThrough();

if (syncImpl === true) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

little nit: if (syncImpl) { should be sufficient here.

provider.scanFile = function () {
return result;
};
spyOn(provider, "scanFile").andCallThrough();
}

return provider;
}

function successfulLintResult() {
return {errors: []};
Expand Down Expand Up @@ -250,6 +273,131 @@ define(function (require, exports, module) {
expect(expectedResult).toBeNull();
});
});

it("should run asynchoronous implementation when both available in the provider", function () {
var provider = createAsyncCodeInspector("javascript async linter with sync impl", failLintResult(), 200, true);
CodeInspection.register("javascript", provider);

runs(function () {
var promise = CodeInspection.inspectFile(simpleJavascriptFileEntry);

waitsForDone(promise, "file linting", 5000);
});

runs(function () {
expect(provider.scanFileAsync).toHaveBeenCalled();
expect(provider.scanFile).not.toHaveBeenCalled();
});

});

it("should timeout on a provider that takes too long", function () {
var provider = createAsyncCodeInspector("javascript async linter with sync impl", failLintResult(), 1000, true),
result;
CodeInspection.register("javascript", provider);

runs(function () {
var promise = CodeInspection.inspectFile(simpleJavascriptFileEntry);
promise.done(function (r) {
result = r;
});

waitsForDone(promise, "file linting", 550);
});

runs(function () {
expect(provider.scanFileAsync).toHaveBeenCalled();
expect(result).toBeDefined();
expect(result[0].provider).toEqual(provider);
expect(result[0].errors).toBeFalsy();
});

});

it("should run two asynchronous providers and a synchronous one", function () {
var asyncProvider1 = createAsyncCodeInspector("javascript async linter 1", failLintResult(), 200, true),
asyncProvider2 = createAsyncCodeInspector("javascript async linter 2", successfulLintResult(), 300, false),
syncProvider3 = createCodeInspector("javascript sync linter 3", failLintResult()),
result;
CodeInspection.register("javascript", asyncProvider1);
CodeInspection.register("javascript", asyncProvider2);
CodeInspection.register("javascript", syncProvider3);

runs(function () {
var promise = CodeInspection.inspectFile(simpleJavascriptFileEntry);
promise.done(function (r) {
result = r;
});

waitsForDone(promise, "file linting", 5000);
});

runs(function () {
var i;
expect(result.length).toEqual(3);

for (i = 0; i < result.length; i++) {
switch (result[i].provider.name) {
case asyncProvider1.name:
expect(asyncProvider1.scanFile).not.toHaveBeenCalled();
expect(asyncProvider2.scanFileAsync).toHaveBeenCalled();
break;
case asyncProvider2.name:
expect(asyncProvider2.scanFileAsync).toHaveBeenCalled();
break;
case syncProvider3.name:
expect(syncProvider3.scanFile).toHaveBeenCalled();
break;
default:
expect(true).toBe(false);
break;
}
}
});

});

it("should return results for 3 providers when 2 completes and 1 times out", function () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we remove the async timeouts I think you could pretty quickly turn tests like this into cases like "2 complete and 1 fails" (for the case where the provider itself rejects its promise - see my comment on docs above)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

increased timeout instead.

var asyncProvider1 = createAsyncCodeInspector("javascript async linter 1", failLintResult(), 200, true),
asyncProvider2 = createAsyncCodeInspector("javascript async linter 2", failLintResult(), 1000, false),
syncProvider3 = createCodeInspector("javascript sync linter 3", failLintResult()),
result;
CodeInspection.register("javascript", asyncProvider1);
CodeInspection.register("javascript", asyncProvider2);
CodeInspection.register("javascript", syncProvider3);

runs(function () {
var promise = CodeInspection.inspectFile(simpleJavascriptFileEntry);
promise.done(function (r) {
result = r;
});

waitsForDone(promise, "file linting", 5000);
});

runs(function () {
var i;
expect(result.length).toEqual(3);

for (i = 0; i < result.length; i++) {
switch (result[i].provider.name) {
case asyncProvider1.name:
case syncProvider3.name:
expect(result[i].result).toBeDefined();
expect(result[i].result).not.toBeNull();
break;
case asyncProvider2.name:
expect(result[i].result).toBeDefined();
expect(result[i].result).toBeNull();
break;
default:
expect(true).toBe(false);
break;
}
}
});
});

});

describe("Code Inspection UI", function () {
Expand Down