From 52efc7fb680537bf4bc1e9a147cb27f61f9dbcf2 Mon Sep 17 00:00:00 2001 From: Devon Carew Date: Sun, 6 Mar 2016 12:26:29 -0800 Subject: [PATCH] implement --watch for flutter analyze --- .../lib/src/commands/analyze.dart | 287 +++++++++++++++++- packages/flutter_tools/lib/src/dart/sdk.dart | 8 + packages/flutter_tools/test/analyze_test.dart | 87 ++++++ 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 packages/flutter_tools/lib/src/dart/sdk.dart create mode 100644 packages/flutter_tools/test/analyze_test.dart diff --git a/packages/flutter_tools/lib/src/commands/analyze.dart b/packages/flutter_tools/lib/src/commands/analyze.dart index 242b10dba4..210d06e7e8 100644 --- a/packages/flutter_tools/lib/src/commands/analyze.dart +++ b/packages/flutter_tools/lib/src/commands/analyze.dart @@ -12,10 +12,17 @@ import 'package:path/path.dart' as path; import '../artifacts.dart'; import '../base/process.dart'; +import '../base/utils.dart'; import '../build_configuration.dart'; +import '../dart/sdk.dart'; import '../globals.dart'; import '../runner/flutter_command.dart'; +// TODO(devoncarew): Possible improvements to flutter analyze --watch: +// - Auto-detect new issues introduced by changes and highlight then in the output. +// - Use ANSI codes to improve the display when the terminal supports it (screen +// clearing, cursor position manipulation, bold and faint codes, ...) + bool isDartFile(FileSystemEntity entry) => entry is File && entry.path.endsWith('.dart'); bool isDartTestFile(FileSystemEntity entry) => entry is File && entry.path.endsWith('_test.dart'); bool isDartBenchmarkFile(FileSystemEntity entry) => entry is File && entry.path.endsWith('_bench.dart'); @@ -114,12 +121,23 @@ class AnalyzeCommand extends FlutterCommand { argParser.addFlag('current-package', help: 'Include the lib/main.dart file from the current directory, if any.', defaultsTo: true); argParser.addFlag('preamble', help: 'Display the number of files that will be analyzed.', defaultsTo: true); argParser.addFlag('congratulate', help: 'Show output even when there are no errors, warnings, hints, or lints.', defaultsTo: true); + argParser.addFlag('watch', help: 'Run analysis continuously, watching the filesystem for changes.', negatable: false); } bool get requiresProjectRoot => false; + bool get isFlutterRepo { + return FileSystemEntity.isDirectorySync('examples') && + FileSystemEntity.isDirectorySync('packages') && + FileSystemEntity.isFileSync('bin/flutter'); + } + @override Future runInProject() async { + return argResults['watch'] ? _analyzeWatch() : _analyzeOnce(); + } + + Future _analyzeOnce() async { Stopwatch stopwatch = new Stopwatch()..start(); Set pubSpecDirectories = new HashSet(); List dartFiles = argResults.rest.toList(); @@ -166,7 +184,6 @@ class AnalyzeCommand extends FlutterCommand { } if (argResults['flutter-repo']) { - //examples/*/ as package //examples/layers/*/ as files //dev/manual_tests/*/ as package @@ -457,4 +474,272 @@ linter: printStatus('No analyzer warnings! (ran in ${elapsed}s)'); return 0; } + + Future _analyzeWatch() async { + List directories; + + if (isFlutterRepo) { + directories = []; + directories.addAll(_gatherProjectPaths(path.absolute('examples'))); + directories.addAll(_gatherProjectPaths(path.absolute('packages'))); + printStatus('Analyzing Flutter repository (${directories.length} projects).'); + for (String projectPath in directories) + printTrace(' ${path.relative(projectPath)}'); + printStatus(''); + } else { + directories = [Directory.current.path]; + } + + AnalysisServer server = new AnalysisServer(dartSdkPath, directories); + server.onAnalyzing.listen(_handleAnalysisStatus); + server.onErrors.listen(_handleAnalysisErrors); + + await server.start(); + + int exitCode = await server.onExit; + printStatus('Analysis server exited with code $exitCode.'); + return 0; + } + + bool firstAnalysis = true; + Set analyzedPaths = new Set(); + Map> analysisErrors = >{}; + Stopwatch analysisTimer; + int lastErrorCount = 0; + + void _handleAnalysisStatus(bool isAnalyzing) { + if (isAnalyzing) { + if (firstAnalysis) { + printStatus('Analyzing ${path.basename(Directory.current.path)}...'); + } else { + printStatus(''); + } + + analyzedPaths.clear(); + analysisTimer = new Stopwatch()..start(); + } else { + analysisTimer.stop(); + + // Sort and print errors. + List errors = []; + for (List fileErrors in analysisErrors.values) + errors.addAll(fileErrors); + + errors.sort(); + + for (AnalysisError error in errors) + printStatus(error.toString()); + + // Print an analysis summary. + String errorsMessage; + + int issueCount = errors.length; + int issueDiff = issueCount - lastErrorCount; + lastErrorCount = issueCount; + + // TODO(devoncarew): If there were no issues found, and no change in the + // issue count, do we want to print anything? + if (firstAnalysis) + errorsMessage = '$issueCount ${pluralize('issue', issueCount)} found'; + else if (issueDiff > 0) + errorsMessage = '$issueDiff new ${pluralize('issue', issueDiff)}, $issueCount total'; + else if (issueDiff < 0) + errorsMessage = '${-issueDiff} ${pluralize('issue', -issueDiff)} fixed, $issueCount remaining'; + else if (issueCount != 0) + errorsMessage = 'no new issues, $issueCount total'; + else + errorsMessage = 'no issues found'; + + String files = '${analyzedPaths.length} ${pluralize('file', analyzedPaths.length)}'; + String seconds = (analysisTimer.elapsedMilliseconds / 1000.0).toStringAsFixed(2); + printStatus('$errorsMessage • analyzed $files, $seconds seconds'); + + firstAnalysis = false; + } + } + + void _handleAnalysisErrors(FileAnalysisErrors fileErrors) { + fileErrors.errors.removeWhere(_filterError); + + analyzedPaths.add(fileErrors.file); + analysisErrors[fileErrors.file] = fileErrors.errors; + } + + bool _filterError(AnalysisError error) { + // TODO(devoncarew): Also filter the regex items from `analyzeOnce()`. + + if (error.type == 'TODO') + return true; + + return false; + } + + List _gatherProjectPaths(String rootPath) { + if (FileSystemEntity.isFileSync(path.join(rootPath, 'pubspec.yaml'))) + return [rootPath]; + + return new Directory(rootPath) + .listSync(followLinks: false) + .expand((FileSystemEntity entity) { + return entity is Directory ? _gatherProjectPaths(entity.path) : []; + }); + } +} + +class AnalysisServer { + AnalysisServer(this.sdk, this.directories); + + final String sdk; + final List directories; + + Process _process; + StreamController _analyzingController = new StreamController.broadcast(); + StreamController _errorsController = new StreamController.broadcast(); + + int _id = 0; + + Future start() async { + String snapshot = path.join(sdk, 'bin/snapshots/analysis_server.dart.snapshot'); + List args = [snapshot, '--sdk', sdk]; + + printTrace('dart ${args.join(' ')}'); + _process = await Process.start('dart', args); + _process.exitCode.whenComplete(() => _process = null); + + Stream errorStream = _process.stderr.transform(UTF8.decoder).transform(const LineSplitter()); + errorStream.listen((String error) => printError(error)); + + Stream inStream = _process.stdout.transform(UTF8.decoder).transform(const LineSplitter()); + inStream.listen(_handleServerResponse); + + // Available options (many of these are obsolete): + // enableAsync, enableDeferredLoading, enableEnums, enableNullAwareOperators, + // enableSuperMixins, generateDart2jsHints, generateHints, generateLints + _sendCommand('analysis.updateOptions', { + 'options': { + 'enableSuperMixins': true + } + }); + + _sendCommand('server.setSubscriptions', { + 'subscriptions': ['STATUS'] + }); + + _sendCommand('analysis.setAnalysisRoots', { + 'included': directories, + 'excluded': [] + }); + } + + Stream get onAnalyzing => _analyzingController.stream; + Stream get onErrors => _errorsController.stream; + + Future get onExit => _process.exitCode; + + void _sendCommand(String method, Map params) { + String message = JSON.encode( { + 'id': (++_id).toString(), + 'method': method, + 'params': params + }); + _process.stdin.writeln(message); + printTrace('==> $message'); + } + + void _handleServerResponse(String line) { + printTrace('<== $line'); + + dynamic response = JSON.decode(line); + + if (response is Map) { + if (response['event'] != null) { + String event = response['event']; + dynamic params = response['params']; + + if (params is Map) { + if (event == 'server.status') + _handleStatus(response['params']); + else if (event == 'analysis.errors') + _handleAnalysisIssues(response['params']); + else if (event == 'server.error') + _handleServerError(response['params']); + } + } else if (response['error'] != null) { + printError('Error from the analysis server: ${response['error']['message']}'); + } + } + } + + void _handleStatus(Map statusInfo) { + // {"event":"server.status","params":{"analysis":{"isAnalyzing":true}}} + if (statusInfo['analysis'] != null) { + bool isAnalyzing = statusInfo['analysis']['isAnalyzing']; + _analyzingController.add(isAnalyzing); + } + } + + void _handleServerError(Map errorInfo) { + printError('Error from the analysis server: ${errorInfo['message']}'); + } + + void _handleAnalysisIssues(Map issueInfo) { + // {"event":"analysis.errors","params":{"file":"/Users/.../lib/main.dart","errors":[]}} + String file = issueInfo['file']; + List errors = issueInfo['errors'].map((Map json) => new AnalysisError(json)).toList(); + _errorsController.add(new FileAnalysisErrors(file, errors)); + } + + Future dispose() async => _process?.kill(); +} + +class FileAnalysisErrors { + FileAnalysisErrors(this.file, this.errors); + + final String file; + final List errors; +} + +class AnalysisError implements Comparable { + AnalysisError(this.json); + + static final Map _severityMap = { + 'ERROR': 3, + 'WARNING': 2, + 'INFO': 1 + }; + + // "severity":"INFO","type":"TODO","location":{ + // "file":"/Users/.../lib/test.dart","offset":362,"length":72,"startLine":15,"startColumn":4 + // },"message":"...","hasFix":false} + Map json; + + String get severity => json['severity']; + int get severityLevel => _severityMap[severity] ?? 0; + String get type => json['type']; + String get message => json['message']; + + String get file => json['location']['file']; + int get startLine => json['location']['startLine']; + int get startColumn => json['location']['startColumn']; + int get offset => json['location']['offset']; + + int compareTo(AnalysisError other) { + // Sort in order of file path, error location, severity, and message. + if (file != other.file) + return file.compareTo(other.file); + + if (offset != other.offset) + return offset - other.offset; + + int diff = other.severityLevel - severityLevel; + if (diff != 0) + return diff; + + return message.compareTo(other.message); + } + + String toString() { + String relativePath = path.relative(file); + return '${severity.toLowerCase().padLeft(7)} • $message • $relativePath:$startLine:$startColumn'; + } } diff --git a/packages/flutter_tools/lib/src/dart/sdk.dart b/packages/flutter_tools/lib/src/dart/sdk.dart new file mode 100644 index 0000000000..a44abb8f2a --- /dev/null +++ b/packages/flutter_tools/lib/src/dart/sdk.dart @@ -0,0 +1,8 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +/// Locate the Dart SDK by finding the Dart VM and going up two directories. +String get dartSdkPath => new File(Platform.executable).parent.parent.path; diff --git a/packages/flutter_tools/test/analyze_test.dart b/packages/flutter_tools/test/analyze_test.dart new file mode 100644 index 0000000000..9ff1be262f --- /dev/null +++ b/packages/flutter_tools/test/analyze_test.dart @@ -0,0 +1,87 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_tools/src/base/os.dart'; +import 'package:flutter_tools/src/commands/analyze.dart'; +import 'package:flutter_tools/src/dart/pub.dart'; +import 'package:flutter_tools/src/dart/sdk.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +import 'src/context.dart'; + +main() => defineTests(); + +defineTests() { + AnalysisServer server; + Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('analysis_test'); + }); + + tearDown(() { + tempDir?.deleteSync(recursive: true); + return server?.dispose(); + }); + + group('analyze', () { + testUsingContext('AnalysisServer success', () async { + _createSampleProject(tempDir); + + await pubGet(directory: tempDir.path); + + server = new AnalysisServer(dartSdkPath, [tempDir.path]); + + int errorCount = 0; + Future onDone = server.onAnalyzing.where((bool analyzing) => analyzing == false).first; + server.onErrors.listen((FileAnalysisErrors errors) => errorCount += errors.errors.length); + + await server.start(); + await onDone; + + expect(errorCount, 0); + }, overrides: { + OperatingSystemUtils: os + }); + + testUsingContext('AnalysisServer errors', () async { + _createSampleProject(tempDir, brokenCode: true); + + await pubGet(directory: tempDir.path); + + server = new AnalysisServer(dartSdkPath, [tempDir.path]); + + int errorCount = 0; + Future onDone = server.onAnalyzing.where((bool analyzing) => analyzing == false).first; + server.onErrors.listen((FileAnalysisErrors errors) => errorCount += errors.errors.length); + + await server.start(); + await onDone; + + expect(errorCount, 2); + }, overrides: { + OperatingSystemUtils: os + }); + }); +} + +void _createSampleProject(Directory directory, { bool brokenCode: false }) { + File pubspecFile = new File(path.join(directory.path, 'pubspec.yaml')); + pubspecFile.writeAsStringSync(''' +name: foo_project +'''); + + File dartFile = new File(path.join(directory.path, 'lib', 'main.dart')); + dartFile.parent.createSync(); + dartFile.writeAsStringSync(''' +void main() { + print('hello world'); + ${brokenCode ? 'prints("hello world");' : ''} +} +'''); +}