diff --git a/.github/workflows/dart-2-4.yml b/.github/workflows/dart-2-4.yml new file mode 100644 index 0000000000..65b047df7d --- /dev/null +++ b/.github/workflows/dart-2-4.yml @@ -0,0 +1,18 @@ +name: Dart 2.4 + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + container: + image: google/dart:2.4 + + steps: + - uses: actions/checkout@v1 + - name: Install dependencies + run: pub get + - name: Run tests + run: pub run test diff --git a/.github/workflows/dart-latest.yml b/.github/workflows/dart-latest.yml new file mode 100644 index 0000000000..47b6c19f44 --- /dev/null +++ b/.github/workflows/dart-latest.yml @@ -0,0 +1,18 @@ +name: Dart latest + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + container: + image: google/dart:latest + + steps: + - uses: actions/checkout@v1 + - name: Install dependencies + run: pub get + - name: Run tests + run: pub run test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..c914039970 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 1.0.0 +- Initial release diff --git a/README.md b/README.md index 0815b9bca6..2144de129c 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# dart-metrics \ No newline at end of file +# Dart metrics + +Command line tool which helps to improve code quality diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000000..ef3ba330d4 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:pedantic/analysis_options.yaml + +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: false diff --git a/bin/metrics.dart b/bin/metrics.dart new file mode 100644 index 0000000000..0597a18fe6 --- /dev/null +++ b/bin/metrics.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:glob/glob.dart'; +import 'package:metrics/metrics_analyzer.dart'; +import 'package:metrics/reporters.dart'; + +void main(List args) { + const helpFlagName = 'help'; + const reporterOptionName = 'reporter'; + const cyclomaticComplexityThreshold = 'cyclomatic-complexity'; + const linesOfCodeThreshold = 'lines-of-code'; + const verboseName = 'verbose'; + const ignoredFilesName = 'ignore-files'; + const rootFolderName = 'root-folder'; + + var showUsage = false; + + final parser = ArgParser() + ..addFlag(helpFlagName, abbr: 'h', help: 'Print this usage information.', negatable: false) + ..addOption(reporterOptionName, + abbr: 'r', + help: 'The format of the output of the analysis', + valueHelp: 'console', + allowed: ['console', 'json', 'html'], + defaultsTo: 'console') + ..addOption(cyclomaticComplexityThreshold, + help: 'Cyclomatic complexity threshold', valueHelp: '20', defaultsTo: '20') + ..addOption(linesOfCodeThreshold, help: 'Lines of code threshold', valueHelp: '50', defaultsTo: '50') + ..addOption(rootFolderName, help: 'Root folder', valueHelp: './', defaultsTo: Directory.current.path) + ..addOption(ignoredFilesName, + help: 'Filepaths in Glob syntax to be ignored', + valueHelp: '{/**.g.dart,/**.template.dart}', + defaultsTo: '{/**.g.dart,/**.template.dart}') + ..addFlag(verboseName, negatable: false); + + ArgResults arguments; + + try { + arguments = parser.parse(args); + } on FormatException catch (_) { + showUsage = true; + } + + if (arguments[helpFlagName] as bool || arguments.rest.length != 1) { + showUsage = true; + } + + if (showUsage) { + print('Usage: dartanalyzer [options...] '); + print(parser.usage); + return; + } + + final rootFolder = arguments[rootFolderName] as String; + var dartFilePaths = Glob('${arguments.rest.first}**.dart') + .listSync(root: rootFolder, followLinks: false) + .whereType() + .map((entity) => entity.path); + + final ignoreFilesPattern = arguments[ignoredFilesName] as Object; + if (ignoreFilesPattern is String && ignoreFilesPattern.isNotEmpty) { + final ignoreFilesGlob = Glob(ignoreFilesPattern); + dartFilePaths = dartFilePaths.where((path) => !ignoreFilesGlob.matches(path)); + } + + final recorder = MetricsAnalysisRecorder(); + final analyzer = MetricsAnalyzer(recorder); + final runner = MetricsAnalysisRunner(recorder, analyzer, dartFilePaths, rootFolder: rootFolder)..run(); + + final config = Config( + cyclomaticComplexityWarningLevel: int.parse(arguments[cyclomaticComplexityThreshold] as String), + linesOfCodeWarningLevel: int.parse(arguments[linesOfCodeThreshold] as String)); + + switch (arguments[reporterOptionName] as String) { + case 'console': + ConsoleReporter(reportConfig: config, reportAll: arguments[verboseName] as bool).report(runner.results()); + break; + case 'json': + JsonReporter(reportConfig: config).report(runner.results()); + break; + case 'html': + HtmlReporter(reportConfig: config).report(runner.results()); + break; + default: + throw ArgumentError.value(arguments[reporterOptionName], reporterOptionName); + } +} diff --git a/lib/metrics_analyzer.dart b/lib/metrics_analyzer.dart new file mode 100644 index 0000000000..26a6d0a36f --- /dev/null +++ b/lib/metrics_analyzer.dart @@ -0,0 +1,4 @@ +export 'package:metrics/src/metrics_analysis_recorder.dart'; +export 'package:metrics/src/metrics_analysis_runner.dart'; +export 'package:metrics/src/metrics_analyzer.dart'; +export 'package:metrics/src/models/config.dart'; diff --git a/lib/reporters.dart b/lib/reporters.dart new file mode 100644 index 0000000000..cdb3322d88 --- /dev/null +++ b/lib/reporters.dart @@ -0,0 +1,4 @@ +export 'package:metrics/src/reporters/console_reporter.dart'; +export 'package:metrics/src/reporters/html_reporter.dart'; +export 'package:metrics/src/reporters/json_reporter.dart'; +export 'package:metrics/src/reporters/reporter.dart'; diff --git a/lib/src/cyclomatic_complexity/control_flow_ast_visitor.dart b/lib/src/cyclomatic_complexity/control_flow_ast_visitor.dart new file mode 100644 index 0000000000..57ae65743c --- /dev/null +++ b/lib/src/cyclomatic_complexity/control_flow_ast_visitor.dart @@ -0,0 +1,107 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/syntactic_entity.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; +import 'package:metrics/src/cyclomatic_complexity/cyclomatic_config.dart'; + +class ControlFlowAstVisitor extends RecursiveAstVisitor { + final CyclomaticConfig _config; + final LineInfo _lineInfo; + + final _complexityLines = {}; + + ControlFlowAstVisitor(this._config, this._lineInfo); + + Map get complexityLines => _complexityLines; + + @override + void visitAssertStatement(AssertStatement node) { + _increaseComplexity('assertStatement', node); + super.visitAssertStatement(node); + } + + @override + void visitBlockFunctionBody(BlockFunctionBody node) { + _collectFunctionBodyData(node.block.leftBracket.next, node.block.rightBracket); + super.visitBlockFunctionBody(node); + } + + @override + void visitCatchClause(CatchClause node) { + _increaseComplexity('catchClause', node); + super.visitCatchClause(node); + } + + @override + void visitConditionalExpression(ConditionalExpression node) { + _increaseComplexity('conditionalExpression', node); + super.visitConditionalExpression(node); + } + + @override + void visitExpressionFunctionBody(ExpressionFunctionBody node) { + _collectFunctionBodyData(node.expression.beginToken.previous, node.expression.endToken.next); + node.visitChildren(this); + } + + @override + void visitForStatement(ForStatement node) { + _increaseComplexity('forStatement', node); + super.visitForStatement(node); + } + + @override + void visitIfStatement(IfStatement node) { + _increaseComplexity('ifStatement', node); + super.visitIfStatement(node); + } + + @override + void visitSwitchCase(SwitchCase node) { + _increaseComplexity('switchCase', node); + super.visitSwitchCase(node); + } + + @override + void visitSwitchDefault(SwitchDefault node) { + _increaseComplexity('switchDefault', node); + super.visitSwitchDefault(node); + } + + @override + void visitWhileStatement(WhileStatement node) { + _increaseComplexity('whileStatement', node); + super.visitWhileStatement(node); + } + + @override + void visitYieldStatement(YieldStatement node) { + _increaseComplexity('yieldStatement', node); + super.visitYieldStatement(node); + } + + void _collectFunctionBodyData(Token firstToken, Token lastToken) { + const tokenTypes = [ + TokenType.AMPERSAND_AMPERSAND, + TokenType.BAR_BAR, + TokenType.QUESTION_PERIOD, + TokenType.QUESTION_QUESTION, + TokenType.QUESTION_QUESTION_EQ + ]; + + var token = firstToken; + while (token != lastToken) { + if (token.matchesAny(tokenTypes)) { + _increaseComplexity('blockFunctionBody', token); + } + token = token.next; + } + } + + void _increaseComplexity(String flowType, SyntacticEntity entity) { + final entityComplexity = _config.complexityByControlFlowType(flowType); + final entityLineNumber = _lineInfo.getLocation(entity.offset).lineNumber; + _complexityLines[entityLineNumber] = (_complexityLines[entityLineNumber] ?? 0) + entityComplexity; + } +} diff --git a/lib/src/cyclomatic_complexity/cyclomatic_config.dart b/lib/src/cyclomatic_complexity/cyclomatic_config.dart new file mode 100644 index 0000000000..0b523337b6 --- /dev/null +++ b/lib/src/cyclomatic_complexity/cyclomatic_config.dart @@ -0,0 +1,34 @@ +import 'package:meta/meta.dart'; + +@immutable +class CyclomaticConfig { + static const Iterable _options = [ + 'assertStatement', + 'blockFunctionBody', + 'catchClause', + 'conditionalExpression', + 'forEachStatement', + 'forStatement', + 'ifStatement', + 'switchDefault', + 'switchCase', + 'whileStatement', + 'yieldStatement', + ]; + + final Map _addedComplexityByControlFlowType; + + int complexityByControlFlowType(String type) { + if (!_options.contains(type)) { + throw ArgumentError.value(type); + } + + return _addedComplexityByControlFlowType[type] ?? 0; + } + + CyclomaticConfig({Iterable complexity}) + : _addedComplexityByControlFlowType = + Map.fromIterables(_options, complexity ?? _options.map((_) => 1)); +} + +final CyclomaticConfig defaultCyclomaticConfig = CyclomaticConfig(); diff --git a/lib/src/cyclomatic_complexity/models/scoped_declaration.dart b/lib/src/cyclomatic_complexity/models/scoped_declaration.dart new file mode 100644 index 0000000000..53a7dda18d --- /dev/null +++ b/lib/src/cyclomatic_complexity/models/scoped_declaration.dart @@ -0,0 +1,10 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:meta/meta.dart'; + +@immutable +class ScopedDeclaration { + final Declaration declaration; + final NamedCompilationUnitMember enclosingClass; + + const ScopedDeclaration(this.declaration, this.enclosingClass); +} diff --git a/lib/src/halstead_volume/halstead_volume_ast_visitor.dart b/lib/src/halstead_volume/halstead_volume_ast_visitor.dart new file mode 100644 index 0000000000..9d8321b1a2 --- /dev/null +++ b/lib/src/halstead_volume/halstead_volume_ast_visitor.dart @@ -0,0 +1,36 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +class HalsteadVolumeAstVisitor extends RecursiveAstVisitor { + final _operators = {}; + final _operands = {}; + + Map get operators => _operators; + Map get operands => _operands; + + @override + void visitBlockFunctionBody(BlockFunctionBody node) { + _analyzeFunctionBodyData(node.block.leftBracket.next, node.block.rightBracket); + super.visitBlockFunctionBody(node); + } + + @override + void visitExpressionFunctionBody(ExpressionFunctionBody node) { + _analyzeFunctionBodyData(node.expression.beginToken.previous, node.expression.endToken.next); + node.visitChildren(this); + } + + void _analyzeFunctionBodyData(Token firstToken, Token lastToken) { + var token = firstToken; + while (token != lastToken) { + if (token.isOperator) { + _operators[token.type.name] = (_operators[token.type.name] ?? 0) + 1; + } + if (token.isIdentifier) { + _operands[token.lexeme] = (_operands[token.lexeme] ?? 0) + 1; + } + token = token.next; + } + } +} diff --git a/lib/src/lines_of_code/function_body_ast_visitor.dart b/lib/src/lines_of_code/function_body_ast_visitor.dart new file mode 100644 index 0000000000..55adc5e524 --- /dev/null +++ b/lib/src/lines_of_code/function_body_ast_visitor.dart @@ -0,0 +1,34 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; + +class FunctionBodyAstVisitor extends RecursiveAstVisitor { + final LineInfo _lineInfo; + + final _linesWithCode = {}; + + FunctionBodyAstVisitor(this._lineInfo); + + Iterable get linesWithCode => _linesWithCode; + + @override + void visitBlockFunctionBody(BlockFunctionBody node) { + _collectFunctionBodyData(node.block.leftBracket.next, node.block.rightBracket); + super.visitBlockFunctionBody(node); + } + + @override + void visitExpressionFunctionBody(ExpressionFunctionBody node) { + _collectFunctionBodyData(node.expression.beginToken.previous, node.expression.endToken.next); + node.visitChildren(this); + } + + void _collectFunctionBodyData(Token firstToken, Token lastToken) { + var token = firstToken; + while (token != lastToken) { + _linesWithCode.add(_lineInfo.getLocation(token.offset).lineNumber); + token = token.next; + } + } +} diff --git a/lib/src/metrics_analysis_recorder.dart b/lib/src/metrics_analysis_recorder.dart new file mode 100644 index 0000000000..aae2a66492 --- /dev/null +++ b/lib/src/metrics_analysis_recorder.dart @@ -0,0 +1,46 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:metrics/src/models/component_record.dart'; +import 'package:metrics/src/models/function_record.dart'; +import 'package:path/path.dart' as path; + +class MetricsAnalysisRecorder { + String _fileGroupPath; + String _relativeGroupPath; + Map _groupRecords; + + final _records = []; + Iterable records() => _records; + + void startRecordFile(String filePath, String rootDirectory) { + if (filePath == null) { + throw ArgumentError.notNull('filePath'); + } + if (_fileGroupPath != null) { + throw StateError( + "Can't start a file group while another one is started. Use `endRecordFile` to close the opened one."); + } + + _fileGroupPath = filePath; + _relativeGroupPath = rootDirectory != null ? path.relative(filePath, from: rootDirectory) : filePath; + _groupRecords = {}; + } + + void endRecordFile() { + _records.add(ComponentRecord( + fullPath: _fileGroupPath, relativePath: _relativeGroupPath, records: BuiltMap.from(_groupRecords))); + _relativeGroupPath = null; + _fileGroupPath = null; + _groupRecords = null; + } + + void record(String recordName, FunctionRecord report) { + if (recordName == null) { + throw ArgumentError.notNull('recordName'); + } + if (_groupRecords == null) { + throw StateError('No record groups have been started. Use `startRecordFile` before `record`'); + } + + _groupRecords[recordName] = report; + } +} diff --git a/lib/src/metrics_analysis_runner.dart b/lib/src/metrics_analysis_runner.dart new file mode 100644 index 0000000000..59ebf8cd33 --- /dev/null +++ b/lib/src/metrics_analysis_runner.dart @@ -0,0 +1,21 @@ +import 'package:metrics/src/metrics_analysis_recorder.dart'; +import 'package:metrics/src/metrics_analyzer.dart'; +import 'package:metrics/src/models/component_record.dart'; + +class MetricsAnalysisRunner { + final MetricsAnalysisRecorder _recorder; + final MetricsAnalyzer _analyzer; + final Iterable _filePaths; + final String _rootFolder; + + MetricsAnalysisRunner(this._recorder, this._analyzer, this._filePaths, {String rootFolder}) + : _rootFolder = rootFolder; + + Iterable results() => _recorder.records(); + + void run() { + for (final file in _filePaths) { + _analyzer.runAnalysis(file, _rootFolder); + } + } +} diff --git a/lib/src/metrics_analyzer.dart b/lib/src/metrics_analyzer.dart new file mode 100644 index 0000000000..e819d586cf --- /dev/null +++ b/lib/src/metrics_analyzer.dart @@ -0,0 +1,61 @@ +import 'package:analyzer/analyzer.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:metrics/src/cyclomatic_complexity/control_flow_ast_visitor.dart'; +import 'package:metrics/src/cyclomatic_complexity/cyclomatic_config.dart'; +import 'package:metrics/src/cyclomatic_complexity/models/scoped_declaration.dart'; +import 'package:metrics/src/halstead_volume/halstead_volume_ast_visitor.dart'; +import 'package:metrics/src/lines_of_code/function_body_ast_visitor.dart'; +import 'package:metrics/src/metrics_analysis_recorder.dart'; +import 'package:metrics/src/models/function_record.dart'; +import 'package:metrics/src/scope_ast_visitor.dart'; + +String getQualifiedName(ScopedDeclaration dec) { + final declaration = dec.declaration; + + if (declaration is FunctionDeclaration) { + return declaration.name.toString(); + } else if (declaration is MethodDeclaration) { + return '${dec.enclosingClass.name}.${declaration.name}'; + } + + return null; +} + +class MetricsAnalyzer { + final MetricsAnalysisRecorder _recorder; + + MetricsAnalyzer(this._recorder); + + void runAnalysis(String filePath, String rootFolder) { + final visitor = ScopeAstVisitor(); + final compilationUnit = parseDartFile(filePath, suppressErrors: true)..visitChildren(visitor); + if (visitor.declarations.isNotEmpty) { + _recorder.startRecordFile(filePath, rootFolder); + + for (final scopedDeclaration in visitor.declarations) { + final controlFlowAstVisitor = ControlFlowAstVisitor(defaultCyclomaticConfig, compilationUnit.lineInfo); + final functionBodyAstVisitor = FunctionBodyAstVisitor(compilationUnit.lineInfo); + final halsteadVolumeAstVisitor = HalsteadVolumeAstVisitor(); + + scopedDeclaration.declaration.visitChildren(controlFlowAstVisitor); + scopedDeclaration.declaration.visitChildren(functionBodyAstVisitor); + scopedDeclaration.declaration.visitChildren(halsteadVolumeAstVisitor); + + _recorder.record( + getQualifiedName(scopedDeclaration), + FunctionRecord( + firstLine: compilationUnit.lineInfo + .getLocation(scopedDeclaration.declaration.firstTokenAfterCommentAndMetadata.offset) + .lineNumber, + lastLine: compilationUnit.lineInfo.getLocation(scopedDeclaration.declaration.endToken.end).lineNumber, + cyclomaticLinesComplexity: BuiltMap.from(controlFlowAstVisitor.complexityLines), + linesWithCode: functionBodyAstVisitor.linesWithCode, + operators: BuiltMap.from(halsteadVolumeAstVisitor.operators), + operands: BuiltMap.from(halsteadVolumeAstVisitor.operands))); + } + + _recorder.endRecordFile(); + } + } +} diff --git a/lib/src/models/component_record.dart b/lib/src/models/component_record.dart new file mode 100644 index 0000000000..238cc1c7cb --- /dev/null +++ b/lib/src/models/component_record.dart @@ -0,0 +1,13 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:meta/meta.dart'; +import 'package:metrics/src/models/function_record.dart'; + +@immutable +class ComponentRecord { + final String fullPath; + final String relativePath; + + final BuiltMap records; + + const ComponentRecord({@required this.fullPath, @required this.relativePath, @required this.records}); +} diff --git a/lib/src/models/component_report.dart b/lib/src/models/component_report.dart new file mode 100644 index 0000000000..bfbebf3900 --- /dev/null +++ b/lib/src/models/component_report.dart @@ -0,0 +1,21 @@ +import 'package:meta/meta.dart'; + +@immutable +class ComponentReport { + final double averageMaintainabilityIndex; + final int totalMaintainabilityIndexViolations; + + final int totalCyclomaticComplexity; + final int totalCyclomaticComplexityViolations; + + final int totalLinesOfCode; + final int totalLinesOfCodeViolations; + + const ComponentReport( + {@required this.averageMaintainabilityIndex, + @required this.totalMaintainabilityIndexViolations, + @required this.totalCyclomaticComplexity, + @required this.totalCyclomaticComplexityViolations, + @required this.totalLinesOfCode, + @required this.totalLinesOfCodeViolations}); +} diff --git a/lib/src/models/config.dart b/lib/src/models/config.dart new file mode 100644 index 0000000000..f809bc306a --- /dev/null +++ b/lib/src/models/config.dart @@ -0,0 +1,9 @@ +import 'package:meta/meta.dart'; + +@immutable +class Config { + final int cyclomaticComplexityWarningLevel; + final int linesOfCodeWarningLevel; + + const Config({@required this.cyclomaticComplexityWarningLevel, @required this.linesOfCodeWarningLevel}); +} diff --git a/lib/src/models/function_record.dart b/lib/src/models/function_record.dart new file mode 100644 index 0000000000..e5740d2aeb --- /dev/null +++ b/lib/src/models/function_record.dart @@ -0,0 +1,23 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:meta/meta.dart'; + +@immutable +class FunctionRecord { + final int firstLine; + final int lastLine; + + final BuiltMap cyclomaticLinesComplexity; + + final Iterable linesWithCode; + + final BuiltMap operators; + final BuiltMap operands; + + const FunctionRecord( + {@required this.firstLine, + @required this.lastLine, + @required this.cyclomaticLinesComplexity, + @required this.linesWithCode, + @required this.operators, + @required this.operands}); +} diff --git a/lib/src/models/function_report.dart b/lib/src/models/function_report.dart new file mode 100644 index 0000000000..14ccb3bd42 --- /dev/null +++ b/lib/src/models/function_report.dart @@ -0,0 +1,22 @@ +import 'package:meta/meta.dart'; +import 'package:metrics/src/models/violation_level.dart'; + +@immutable +class FunctionReport { + final int cyclomaticComplexity; + final ViolationLevel cyclomaticComplexityViolationLevel; + + final int linesOfCode; + final ViolationLevel linesOfCodeViolationLevel; + + final double maintainabilityIndex; + final ViolationLevel maintainabilityIndexViolationLevel; + + const FunctionReport( + {@required this.cyclomaticComplexity, + @required this.cyclomaticComplexityViolationLevel, + @required this.linesOfCode, + @required this.linesOfCodeViolationLevel, + @required this.maintainabilityIndex, + @required this.maintainabilityIndexViolationLevel}); +} diff --git a/lib/src/models/violation_level.dart b/lib/src/models/violation_level.dart new file mode 100644 index 0000000000..e9c784670c --- /dev/null +++ b/lib/src/models/violation_level.dart @@ -0,0 +1,20 @@ +class ViolationLevel { + static const ViolationLevel none = ViolationLevel._('None'); + static const ViolationLevel noted = ViolationLevel._('Noted'); + static const ViolationLevel warning = ViolationLevel._('Warning'); + static const ViolationLevel alarm = ViolationLevel._('Alarm'); + + static const Iterable values = [ + ViolationLevel.none, + ViolationLevel.noted, + ViolationLevel.warning, + ViolationLevel.alarm + ]; + + final String _name; + + const ViolationLevel._(this._name); + + @override + String toString() => _name; +} diff --git a/lib/src/reporters/console_reporter.dart b/lib/src/reporters/console_reporter.dart new file mode 100644 index 0000000000..39a6ff88f1 --- /dev/null +++ b/lib/src/reporters/console_reporter.dart @@ -0,0 +1,75 @@ +import 'package:ansicolor/ansicolor.dart'; +import 'package:meta/meta.dart'; +import 'package:metrics/src/models/component_record.dart'; +import 'package:metrics/src/models/config.dart'; +import 'package:metrics/src/models/violation_level.dart'; +import 'package:metrics/src/reporters/reporter.dart'; +import 'package:metrics/src/reporters/utility_selector.dart'; + +class ConsoleReporter implements Reporter { + final Config reportConfig; + final bool reportAll; + + final _redPen = AnsiPen()..red(); + final _yellowPen = AnsiPen()..yellow(); + final _bluePen = AnsiPen()..blue(); + + ConsoleReporter({@required this.reportConfig, this.reportAll = false}); + + @override + void report(Iterable records) { + if (records?.isEmpty ?? true) { + return; + } + + for (final analysisRecord in records) { + final lines = []; + + analysisRecord.records.forEach((source, functionReport) { + final report = UtilitySelector.functionReport(functionReport, reportConfig); + + switch (report.cyclomaticComplexityViolationLevel) { + case ViolationLevel.alarm: + lines.add('${_redPen('ALARM')} $source - complexity: ${_redPen('${report.cyclomaticComplexity}')}'); + break; + case ViolationLevel.warning: + lines.add('${_yellowPen('WARNING')} $source - complexity: ${_yellowPen('${report.cyclomaticComplexity}')}'); + break; + case ViolationLevel.noted: + lines.add('${_bluePen('NOTED')} $source - complexity: ${_yellowPen('${report.cyclomaticComplexity}')}'); + break; + case ViolationLevel.none: + if (reportAll) { + lines.add(' $source - complexity: ${report.cyclomaticComplexity}'); + } + break; + } + }); + + if (lines.isNotEmpty || reportAll) { + final report = UtilitySelector.analysisReport(analysisRecord, reportConfig); + + var consoleRecord = analysisRecord.relativePath; + consoleRecord += ' - complexity: ${report.totalCyclomaticComplexity}'; + if (report.totalCyclomaticComplexityViolations > 0) { + consoleRecord += ' complexity violations: ${_yellowPen('${report.totalCyclomaticComplexityViolations}')}'; + } + consoleRecord += ' lines of code: ${report.totalLinesOfCode}'; + + print(consoleRecord); + lines.forEach(print); + print(''); + } + } + + final report = UtilitySelector.analysisReportForRecords(records, reportConfig); + + var packageTotalRecord = 'Total complexity: ${report.totalCyclomaticComplexity}'; + if (report.totalCyclomaticComplexityViolations > 0) { + packageTotalRecord += ' complexity violations: ${_yellowPen('${report.totalCyclomaticComplexityViolations}')}'; + } + packageTotalRecord += ' lines of code: ${report.totalLinesOfCode}'; + + print(packageTotalRecord); + } +} diff --git a/lib/src/reporters/html_reporter.dart b/lib/src/reporters/html_reporter.dart new file mode 100644 index 0000000000..0aa8a3516f --- /dev/null +++ b/lib/src/reporters/html_reporter.dart @@ -0,0 +1,421 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:html/dom.dart'; +import 'package:meta/meta.dart'; +import 'package:metrics/src/models/component_record.dart'; +import 'package:metrics/src/models/config.dart'; +import 'package:metrics/src/models/violation_level.dart'; +import 'package:metrics/src/reporters/reporter.dart'; +import 'package:metrics/src/reporters/utility_selector.dart'; +import 'package:path/path.dart' as path; +import 'package:resource/resource.dart'; + +const _violationLevelFunctionStyle = { + ViolationLevel.alarm: 'metrics-source-code__text--attention-complexity', + ViolationLevel.warning: 'metrics-source-code__text--warning-complexity', + ViolationLevel.noted: 'metrics-source-code__text--noted-complexity', + ViolationLevel.none: 'metrics-source-code__text--normal-complexity', +}; + +const _violationLevelLineStyle = { + ViolationLevel.alarm: 'metrics-source-code__line--attention-complexity', + ViolationLevel.warning: 'metrics-source-code__line--warning-complexity', + ViolationLevel.noted: 'metrics-source-code__line--noted-complexity', + ViolationLevel.none: 'metrics-source-code__line--normal-complexity', +}; + +const _cyclomaticComplexity = 'Cyclomatic complexity'; +const _cyclomaticComplexityWithViolations = 'Cyclomatic complexity / violations'; +const _linesOfCode = 'Lines of code'; +const _linesOfCodeWithViolations = 'Lines of code / violations'; +const _maintainabilityIndex = 'Maintainability index'; +const _maintainabilityIndexWithViolations = 'Maintainability index / violations'; + +@immutable +class ReportTableRecord { + final String title; + final String link; + + final int cyclomaticComplexity; + final int cyclomaticComplexityViolations; + + final int linesOfCode; + final int linesOfCodeViolations; + + final double maintainabilityIndex; + final int maintainabilityIndexViolations; + + const ReportTableRecord( + {@required this.title, + @required this.link, + @required this.cyclomaticComplexity, + @required this.cyclomaticComplexityViolations, + @required this.linesOfCode, + @required this.linesOfCodeViolations, + @required this.maintainabilityIndex, + @required this.maintainabilityIndexViolations}); +} + +class HtmlReporter implements Reporter { + final Config reportConfig; + final String reportFolder; + + HtmlReporter({this.reportConfig, this.reportFolder = 'metrics'}); + + @override + void report(Iterable records) { + if (records?.isEmpty ?? true) { + return; + } + + _createReportDirectory(reportFolder); + _copyResources(reportFolder); + for (final record in records) { + _generateSourceReport(reportFolder, record); + } + _generateFoldersReports(reportFolder, records); + } + + void _createReportDirectory(String directoryName) { + final reportDirectory = Directory(directoryName); + if (reportDirectory.existsSync()) { + reportDirectory.deleteSync(recursive: true); + } + reportDirectory.createSync(recursive: true); + } + + void _copyResources(String reportFolder) { + const baseStylesheet = 'base'; + + const res = Resource('package:metrics/src/reporters/html_resources/base.css'); + res.readAsString().then((content) { + File(path.setExtension(path.join(reportFolder, baseStylesheet), '.css')) + ..createSync(recursive: true) + ..writeAsStringSync(content); + }); + } + + Element _generateTable(String title, Iterable records) { + final sortedRecords = records.toList()..sort((a, b) => a.title.compareTo(b.title)); + + final totalComplexity = sortedRecords.fold(0, (prevValue, record) => prevValue + record.cyclomaticComplexity); + final totalComplexityViolations = + sortedRecords.fold(0, (prevValue, record) => prevValue + record.cyclomaticComplexityViolations); + final totalLinesOfCode = sortedRecords.fold(0, (prevValue, record) => prevValue + record.linesOfCode); + final totalLinesOfCodeViolations = + sortedRecords.fold(0, (prevValue, record) => prevValue + record.linesOfCodeViolations); + final averageMaintainabilityIndex = + sortedRecords.fold(0, (prevValue, record) => prevValue + record.maintainabilityIndex) / + sortedRecords.length; + final totalMaintainabilityIndexViolations = + sortedRecords.fold(0, (prevValue, record) => prevValue + record.maintainabilityIndexViolations); + + final withCyclomaticComplexityViolations = totalComplexityViolations > 0; + final withLinesOfCodeViolations = totalLinesOfCodeViolations > 0; + final withMaintainabilityIndexViolations = totalMaintainabilityIndexViolations > 0; + + final tableContent = Element.tag('tbody'); + for (final record in sortedRecords) { + final recordHaveCyclomaticComplexityViolations = record.cyclomaticComplexityViolations > 0; + final recordHaveLinesOfCodeViolations = record.linesOfCodeViolations > 0; + final recordHaveMaintainabilityIndexViolations = record.maintainabilityIndexViolations > 0; + + tableContent.append(Element.tag('tr') + ..append(Element.tag('td') + ..append(Element.tag('a') + ..attributes['href'] = record.link + ..text = record.title)) + ..append(Element.tag('td') + ..text = recordHaveCyclomaticComplexityViolations + ? '${record.cyclomaticComplexity} / ${record.cyclomaticComplexityViolations}' + : '${record.cyclomaticComplexity}' + ..classes.add(recordHaveCyclomaticComplexityViolations ? 'with-violations' : '')) + ..append(Element.tag('td') + ..text = recordHaveLinesOfCodeViolations + ? '${record.linesOfCode} / ${record.linesOfCodeViolations}' + : '${record.linesOfCode}' + ..classes.add(recordHaveLinesOfCodeViolations ? 'with-violations' : '')) + ..append(Element.tag('td') + ..text = recordHaveMaintainabilityIndexViolations + ? '${record.maintainabilityIndex.toInt()} / ${record.maintainabilityIndexViolations}' + : '${record.maintainabilityIndex.toInt()}' + ..classes.add(recordHaveMaintainabilityIndexViolations ? 'with-violations' : ''))); + } + + final cyclomaticComplexityTitle = + withCyclomaticComplexityViolations ? _cyclomaticComplexityWithViolations : _cyclomaticComplexity; + final linesOfCodeTitle = withLinesOfCodeViolations ? _linesOfCodeWithViolations : _linesOfCode; + final maintainabilityIndexTitle = + withMaintainabilityIndexViolations ? _maintainabilityIndexWithViolations : _maintainabilityIndex; + + final table = Element.tag('table') + ..classes.add('metrics-total-table') + ..append(Element.tag('thead') + ..append(Element.tag('tr') + ..append(Element.tag('th')..text = title) + ..append(Element.tag('th')..text = cyclomaticComplexityTitle) + ..append(Element.tag('th')..text = linesOfCodeTitle) + ..append(Element.tag('th')..text = maintainabilityIndexTitle))) + ..append(tableContent); + + return Element.tag('div') + ..classes.add('metric-wrapper') + ..append(table) + ..append(Element.tag('div') + ..classes.add('metrics-totals') + ..append(_generateTotalMetrics( + cyclomaticComplexityTitle, + withCyclomaticComplexityViolations ? '$totalComplexity / $totalComplexityViolations' : '$totalComplexity', + withCyclomaticComplexityViolations)) + ..append(_generateTotalMetrics( + linesOfCodeTitle, + withLinesOfCodeViolations ? '$totalLinesOfCode / $totalLinesOfCodeViolations' : '$totalLinesOfCode', + withLinesOfCodeViolations)) + ..append(_generateTotalMetrics( + maintainabilityIndexTitle, + withMaintainabilityIndexViolations + ? '${averageMaintainabilityIndex.toInt()} / $totalMaintainabilityIndexViolations' + : '${averageMaintainabilityIndex.toInt()}', + withMaintainabilityIndexViolations))); + } + + void _generateFoldersReports(String reportDirectory, Iterable records) { + final folders = records.map((record) => path.dirname(record.relativePath)).toSet(); + + for (final folder in folders) { + _generateFolderReport( + reportDirectory, folder, records.where((record) => path.dirname(record.relativePath) == folder)); + } + + final tableRecords = folders.map((folder) { + final report = UtilitySelector.analysisReportForRecords( + records.where((record) => path.dirname(record.relativePath) == folder), reportConfig); + return ReportTableRecord( + title: folder, + link: path.join(folder, 'index.html'), + cyclomaticComplexity: report.totalCyclomaticComplexity, + cyclomaticComplexityViolations: report.totalCyclomaticComplexityViolations, + linesOfCode: report.totalLinesOfCode, + linesOfCodeViolations: report.totalLinesOfCodeViolations, + maintainabilityIndex: report.averageMaintainabilityIndex, + maintainabilityIndexViolations: report.totalMaintainabilityIndexViolations); + }); + + final html = Element.tag('html') + ..attributes['lang'] = 'en' + ..append(Element.tag('head') + ..append(Element.tag('title')..text = 'Metrics report') + ..append(Element.tag('meta')..attributes['charset'] = 'utf-8') + ..append(Element.tag('link') + ..attributes['rel'] = 'stylesheet' + ..attributes['href'] = 'base.css')) + ..append(Element.tag('body') + ..append(Element.tag('h1') + ..classes.add('metric-header') + ..text = 'All files') + ..append(_generateTable('Directory', tableRecords))); + + final htmlDocument = Document()..append(html); + + File(path.join(reportDirectory, 'index.html')) + ..createSync(recursive: true) + ..writeAsStringSync(htmlDocument.outerHtml.replaceAll('&nbsp;', ' ')); + } + + void _generateFolderReport(String reportDirectory, String folder, Iterable records) { + final tableRecords = records.map((record) { + final report = UtilitySelector.analysisReport(record, reportConfig); + final fileName = path.basename(record.relativePath); + + return ReportTableRecord( + title: fileName, + link: path.setExtension(fileName, '.html'), + cyclomaticComplexity: report.totalCyclomaticComplexity, + cyclomaticComplexityViolations: report.totalCyclomaticComplexityViolations, + linesOfCode: report.totalLinesOfCode, + linesOfCodeViolations: report.totalLinesOfCodeViolations, + maintainabilityIndex: report.averageMaintainabilityIndex, + maintainabilityIndexViolations: report.totalMaintainabilityIndexViolations); + }); + + final html = Element.tag('html') + ..attributes['lang'] = 'en' + ..append(Element.tag('head') + ..append(Element.tag('title')..text = 'Metrics report for $folder') + ..append(Element.tag('meta')..attributes['charset'] = 'utf-8') + ..append(Element.tag('link') + ..attributes['rel'] = 'stylesheet' + ..attributes['href'] = path.relative('base.css', from: folder))) + ..append(Element.tag('body') + ..append(Element.tag('h1') + ..classes.add('metric-header') + ..append(Element.tag('a') + ..attributes['href'] = path.relative('index.html', from: folder) + ..text = 'All files') + ..append(Element.tag('span')..text = ' : ') + ..append(Element.tag('span')..text = folder)) + ..append(_generateTable('File', tableRecords))); + + final htmlDocument = Document()..append(html); + + File(path.join(reportDirectory, folder, 'index.html')) + ..createSync(recursive: true) + ..writeAsStringSync(htmlDocument.outerHtml.replaceAll('&nbsp;', ' ')); + } + + void _generateSourceReport(String reportDirectory, ComponentRecord record) { + final sourceFileContent = File(record.fullPath).readAsStringSync(); + final sourceFileLines = LineSplitter.split(sourceFileContent); + + final linesIndices = Element.tag('td')..classes.add('metrics-source-code__number-lines'); + for (var i = 1; i <= sourceFileLines.length; ++i) { + linesIndices + ..append(Element.tag('a')..attributes['name'] = 'L$i') + ..append(Element.tag('a') + ..attributes['href'] = '#L$i' + ..text = '$i') + ..append(Element.tag('br')); + } + + final cyclomaticValues = Element.tag('td')..classes.add('metrics-source-code__complexity'); + for (var i = 1; i <= sourceFileLines.length; ++i) { + final functionReport = record.records.values.firstWhere( + (functionReport) => functionReport.firstLine <= i && functionReport.lastLine >= i, + orElse: () => null); + + final complexityValueElement = Element.tag('div')..classes.add('metrics-source-code__text'); + + var line = ' '; + if (functionReport != null) { + final report = UtilitySelector.functionReport(functionReport, reportConfig); + + if (functionReport.firstLine == i) { + line = 'ⓘ'; + + complexityValueElement.attributes['class'] = + '${complexityValueElement.attributes['class']} metrics-source-code__text--with-icon'.trim(); + + complexityValueElement.attributes['title'] = 'Function stats:' + '\n${_cyclomaticComplexity.toLowerCase()}: ${report.cyclomaticComplexity}' + '\n${_cyclomaticComplexity.toLowerCase()} violation level: ${report.cyclomaticComplexityViolationLevel.toString().toLowerCase()}' + '\n${_linesOfCode.toLowerCase()}: ${report.linesOfCode}' + '\n${_linesOfCode.toLowerCase()} violation level: ${report.linesOfCodeViolationLevel.toString().toLowerCase()}' + '\n${_maintainabilityIndex.toLowerCase()}: ${report.maintainabilityIndex.toInt()}' + '\n${_maintainabilityIndex.toLowerCase()} violation level: ${report.maintainabilityIndexViolationLevel.toString().toLowerCase()}'; + } + + final lineWithComplexityIncrement = functionReport.cyclomaticLinesComplexity.containsKey(i); + if (lineWithComplexityIncrement) { + line = '$line +${functionReport.cyclomaticLinesComplexity[i]}'.trim(); + } + +/* uncomment this block if you need check lines with code + final lineWithCode = functionReport.linesWithCode.contains(i); + if (lineWithCode) { + line += ' c'; + } +*/ + final functionViolationLevel = UtilitySelector.functionViolationLevel(report); + + final lineViolationStyle = lineWithComplexityIncrement + ? _violationLevelLineStyle[functionViolationLevel] + : _violationLevelFunctionStyle[functionViolationLevel]; + + complexityValueElement.classes.add(lineViolationStyle ?? ''); + } + complexityValueElement.text = line.replaceAll(' ', ' '); + + cyclomaticValues.append(complexityValueElement); + } + + final codeBlock = Element.tag('td') + ..classes.add('metrics-source-code__code') + ..append(Element.tag('pre') + ..classes.add('prettyprint lang-dart') + ..text = sourceFileContent); + + final report = UtilitySelector.analysisReport(record, reportConfig); + + final withCyclomaticComplexityViolations = report.totalCyclomaticComplexityViolations > 0; + final withLinesOfCodeViolations = report.totalLinesOfCodeViolations > 0; + final totalMaintainabilityIndexViolations = report.totalMaintainabilityIndexViolations > 0; + + final body = Element.tag('body') + ..append(Element.tag('h1') + ..classes.add('metric-header') + ..append(Element.tag('a') + ..attributes['href'] = path.relative('index.html', from: path.dirname(record.relativePath)) + ..text = 'All files') + ..append(Element.tag('span')..text = ' : ') + ..append(Element.tag('a') + ..attributes['href'] = 'index.html' + ..text = path.dirname(record.relativePath)) + ..append(Element.tag('span')..text = '/${path.basename(record.relativePath)}')) + ..append(_generateTotalMetrics( + withCyclomaticComplexityViolations ? _cyclomaticComplexityWithViolations : _cyclomaticComplexity, + withCyclomaticComplexityViolations + ? '${report.totalCyclomaticComplexity} / ${report.totalCyclomaticComplexityViolations}' + : '${report.totalCyclomaticComplexity}', + withCyclomaticComplexityViolations)) + ..append(_generateTotalMetrics( + withLinesOfCodeViolations ? _linesOfCodeWithViolations : _linesOfCode, + withLinesOfCodeViolations + ? '${report.totalLinesOfCode} / ${report.totalLinesOfCodeViolations}' + : '${report.totalLinesOfCode}', + withLinesOfCodeViolations)) + ..append(_generateTotalMetrics( + totalMaintainabilityIndexViolations ? _maintainabilityIndexWithViolations : _maintainabilityIndex, + totalMaintainabilityIndexViolations + ? '${report.averageMaintainabilityIndex.toInt()} / ${report.totalMaintainabilityIndexViolations}' + : '${report.averageMaintainabilityIndex.toInt()}', + totalMaintainabilityIndexViolations)) + ..append(Element.tag('pre') + ..append(Element.tag('table') + ..classes.add('metrics-source-code') + ..append(Element.tag('thead') + ..classes.add('metrics-source-code__header') + ..append(Element.tag('tr') + ..append(Element.tag('td')..classes.add('metrics-source-code__number-lines')) + ..append(Element.tag('td') + ..classes.add('metrics-source-code__complexity') + ..text = 'Complexity') + ..append(Element.tag('td') + ..classes.add('metrics-source-code__code') + ..text = 'Source code'))) + ..append(Element.tag('tbody') + ..classes.add('metrics-source-code__body') + ..append(Element.tag('tr')..append(linesIndices)..append(cyclomaticValues)..append(codeBlock))))) + ..append(Element.tag('script') + ..attributes['src'] = 'https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.min.js') + ..append(Element.tag('script') + ..attributes['src'] = 'https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/lang-dart.min.js'); + + final head = Element.tag('head') + ..append(Element.tag('title')..text = 'Metrics report for ${record.relativePath}') + ..append(Element.tag('meta')..attributes['charset'] = 'utf-8') + ..append(Element.tag('link') + ..attributes['rel'] = 'stylesheet' + ..attributes['href'] = path.relative('base.css', from: path.dirname(record.relativePath))); + + final html = Element.tag('html') + ..attributes['lang'] = 'en' + ..append(head) + ..append(body); + + final htmlDocument = Document()..append(html); + + File(path.setExtension(path.join(reportDirectory, record.relativePath), '.html')) + ..createSync(recursive: true) + ..writeAsStringSync(htmlDocument.outerHtml.replaceAll('&nbsp;', ' ')); + } + + Element _generateTotalMetrics(String name, String value, bool violations) => Element.tag('div') + ..classes.add(!violations ? 'metrics-total' : 'metrics-total metrics-total--violations') + ..append(Element.tag('span') + ..classes.add('metrics-total__label') + ..text = '$name : ') + ..append(Element.tag('span') + ..classes.add('metrics-total__count') + ..text = value); +} diff --git a/lib/src/reporters/html_resources/base.css b/lib/src/reporters/html_resources/base.css new file mode 100644 index 0000000000..fcdb45af7f --- /dev/null +++ b/lib/src/reporters/html_resources/base.css @@ -0,0 +1,312 @@ +/* Start ============== NORMALIZE =============== */ + +/*! based on normalize.css v5.0.0 | MIT License | github.com/necolas */ + +/* +* 1. Change the default font family in all browsers (opinionated). +* 2. Prevent adjustments of font size after orientation changes in +* IE on Windows Phone and in iOS. +*/ + +/* Document ========================================================================== */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections ========================================================================== */ + +/* +* Remove the margin in all browsers (opinionated). +*/ +body { + margin: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; +} + +p { + margin: 0; +} + +/* Grouping content ========================================================================== */ + +/* +* 1. Add the correct box sizing in Firefox. +* 2. Show the overflow in Edge and IE. +*/ +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/* Text-level semantics ========================================================================== */ + +/* +* Remove gaps in links underline in iOS 8+ and Safari 8+. +*/ +a { + -webkit-text-decoration-skip: objects; + outline: none; +} + +/* +* Remove the outline on focused links when they are also active or hovered +* in all browsers (opinionated). +*/ +a:active, +a:hover { + outline-width: 0; +} + +/* +* Prevent text blurriness in Firefox on macOS +* link: https://stackoverflow.com/a/44898465 +*/ +@supports (-moz-appearance: meterbar) and (background-blend-mode: difference, normal) { + + body { + -moz-osx-font-smoothing: grayscale; + } +} + +/* ============== NORMALIZE =============== End */ + +/* Start ============== BASE =============== */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + padding: 24px; + font-family: "Open sans", "Lucida grande", "Segoe UI", Arial, Verdana, Tahoma, sans-serif; + font-size: 14px; +} + +table { + width: 100%; + margin: 0; + border-spacing: 0; + font-size: inherit; +} + +table td { + vertical-align: top; +} + +pre, +code { + margin: 0; + font-family: "Courier New", monospace; + font-size: 1em; +} + +/* ============== BASE =============== END */ + +/* Start ============== CUSTOM =============== */ +a { + text-decoration: none; + color: #4488ff; +} + +a:hover { + opacity: .7; +} + +.metric-header { + margin-bottom: 24px; + font-size: 18px; +} + +.metric-wrapper { + display: flex; + justify-content: space-between; +} + +.metrics-total-table { + width: calc(100% - 360px); + border-top: 1px solid rgba(0,0,0,0.32); + border-left: 1px solid rgba(0,0,0,0.32); +} + +.metrics-total-table th, +.metrics-total-table td { + padding: 8px; + border-right: 1px solid rgba(0,0,0,0.32); +} + +.metrics-total-table td { + border-bottom: 1px solid rgba(0,0,0,0.32); +} + +.metrics-total-table th { + border-bottom: 2px solid rgba(0,0,0,0.32); +} + +.metrics-total-table tr th:first-child, +.metrics-total-table tr td:first-child { + text-align: left; + min-width: 128px; +} + +.metrics-total-table tr td { + word-break: break-all; +} + +.metrics-total-table tr th:not(:first-child) { + width: 128px; +} + +.metrics-total-table tr td:not(:first-child) { + text-align: center; +} + +.metrics-totals { + width: 352px; +} + +.metrics-total { + padding: 8px 12px; + background-color: rgba(106, 200, 236, 0.35); +} + +.metrics-total--violations, +.with-violations { + background-color: rgba(252, 142, 0, 0.35); +} + +.metrics-total + .metrics-total { + margin-top: 8px; +} + +.metrics-total__count { + font-weight: bold; +} + +.metrics-source-code { + margin-top: 24px; + border: 1px solid #ccc; +} + +.metrics-source-code__header td { + padding: 8px; + font-family: "Open sans", "Lucida grande", "Segoe UI", Arial, Verdana, Tahoma, sans-serif; + font-weight: 800; + font-size: 12px; + letter-spacing: .083em; + text-transform: uppercase; + border-bottom: 1px solid #ccc; +} + +.metrics-source-code__number-lines { + width: 48px; + border-right: 1px solid #ccc; + text-align: right +} + +.metrics-source-code__number-lines a { + padding: 0 4px; + text-decoration: none; + color: #4488ff; +} + +.metrics-source-code__number-lines a:hover { + color: #2fd5d9; +} + +.metrics-source-code__number-lines a:focus-within { + background-color: #4488ff; + color: #fff; +} + +.metrics-source-code__body .metrics-source-code__number-lines { + padding: 8px 4px; +} + +.metrics-source-code__complexity { + width: 112px; + padding: 8px 0; + border-right: 1px solid #ccc; +} + +pre.prettyprint.prettyprint { + border: 0; + padding: 0; + text-overflow: ellipsis; + overflow: hidden; +} + +.metrics-source-code__text { + padding: 0 8px; +} + +.metrics-source-code__text::after { + content: ""; + position: absolute; + left: calc(112px + 48px + 16px + 8px); + width: calc(100% - 112px - 48px - 16px - 32px); + height: 16px; + background-color: inherit; + z-index: -1; +} + +.metrics-source-code__code { + position: relative; + padding: 8px; + line-height: 16px; +} + +.metrics-source-code__line--normal-complexity { + background-color: #33bb1159; +} + +.metrics-source-code__line--noted-complexity { + background-color: #59c5ff59; +} + +.metrics-source-code__line--warning-complexity { + background-color: #fc8e0059; +} + +.metrics-source-code__line--attention-complexity { + background-color: #f1586659; +} + +.metrics-source-code__text--normal-complexity { + background-color: #33bb1126; +} + +.metrics-source-code__text--noted-complexity { + background-color: #59c5ff26; +} + +.metrics-source-code__text--warning-complexity { + background-color: #fc8e0026; +} + +.metrics-source-code__text--attention-complexity { + background-color: #f1586626; +} + +.metrics-source-code__text--with-icon { + display: flex; + align-items: center; + height: 16px; + font-size: 12px; +} + +/* ============== CUSTOM =============== END */ + diff --git a/lib/src/reporters/json_reporter.dart b/lib/src/reporters/json_reporter.dart new file mode 100644 index 0000000000..b6ec9856c9 --- /dev/null +++ b/lib/src/reporters/json_reporter.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:meta/meta.dart'; +import 'package:metrics/src/models/component_record.dart'; +import 'package:metrics/src/models/config.dart'; +import 'package:metrics/src/reporters/reporter.dart'; +import 'package:metrics/src/reporters/utility_selector.dart'; + +class JsonReporter implements Reporter { + final Config reportConfig; + + JsonReporter({@required this.reportConfig}); + + @override + void report(Iterable records) { + if (records?.isEmpty ?? true) { + return; + } + + print(json.encode(records.map(_analysisRecordToJson).toList())); + } + + Map _analysisRecordToJson(ComponentRecord record) => { + 'source': record.relativePath, + 'records': record.records.asMap().map((key, value) { + final report = UtilitySelector.functionReport(value, reportConfig); + return MapEntry(key, { + 'cyclomatic-complexity': report.cyclomaticComplexity, + 'cyclomatic-complexity-violation-level': report.cyclomaticComplexityViolationLevel.toString().toLowerCase(), + 'lines-of-code': report.linesOfCode, + }); + }) + }; +} diff --git a/lib/src/reporters/reporter.dart b/lib/src/reporters/reporter.dart new file mode 100644 index 0000000000..2a09e27ecf --- /dev/null +++ b/lib/src/reporters/reporter.dart @@ -0,0 +1,6 @@ +import 'package:metrics/src/models/component_record.dart'; + +// ignore: one_member_abstracts +abstract class Reporter { + void report(Iterable records); +} diff --git a/lib/src/reporters/utility_selector.dart b/lib/src/reporters/utility_selector.dart new file mode 100644 index 0000000000..dffcc90368 --- /dev/null +++ b/lib/src/reporters/utility_selector.dart @@ -0,0 +1,163 @@ +import 'dart:math'; + +import 'package:metrics/src/models/component_record.dart'; +import 'package:metrics/src/models/component_report.dart'; +import 'package:metrics/src/models/config.dart'; +import 'package:metrics/src/models/function_record.dart'; +import 'package:metrics/src/models/function_report.dart'; +import 'package:metrics/src/models/violation_level.dart'; + +double log2(num a) => log(a) / ln2; + +class UtilitySelector { + static ComponentReport analysisReportForRecords(Iterable records, Config config) { + final report = records.fold( + const ComponentReport( + averageMaintainabilityIndex: 0, + totalMaintainabilityIndexViolations: 0, + totalCyclomaticComplexity: 0, + totalCyclomaticComplexityViolations: 0, + totalLinesOfCode: 0, + totalLinesOfCodeViolations: 0), (prevValue, record) { + final report = analysisReport(record, config); + + return ComponentReport( + averageMaintainabilityIndex: prevValue.averageMaintainabilityIndex + report.averageMaintainabilityIndex, + totalMaintainabilityIndexViolations: + prevValue.totalMaintainabilityIndexViolations + report.totalMaintainabilityIndexViolations, + totalCyclomaticComplexity: prevValue.totalCyclomaticComplexity + report.totalCyclomaticComplexity, + totalCyclomaticComplexityViolations: + prevValue.totalCyclomaticComplexityViolations + report.totalCyclomaticComplexityViolations, + totalLinesOfCode: prevValue.totalLinesOfCode + report.totalLinesOfCode, + totalLinesOfCodeViolations: prevValue.totalLinesOfCodeViolations + report.totalLinesOfCodeViolations); + }); + + return ComponentReport( + averageMaintainabilityIndex: report.averageMaintainabilityIndex / records.length, + totalMaintainabilityIndexViolations: report.totalMaintainabilityIndexViolations, + totalCyclomaticComplexity: report.totalCyclomaticComplexity, + totalCyclomaticComplexityViolations: report.totalCyclomaticComplexityViolations, + totalLinesOfCode: report.totalLinesOfCode, + totalLinesOfCodeViolations: report.totalLinesOfCodeViolations); + } + + static ComponentReport analysisReport(ComponentRecord record, Config config) { + var averageMaintainabilityIndex = 0.0; + var totalMaintainabilityIndexViolations = 0; + var totalCyclomaticComplexity = 0; + var totalCyclomaticComplexityViolations = 0; + var totalLinesOfCode = 0; + var totalLinesOfCodeViolations = 0; + + for (final record in record.records.values) { + final report = functionReport(record, config); + + averageMaintainabilityIndex += report.maintainabilityIndex; + if (report.maintainabilityIndexViolationLevel == ViolationLevel.warning || + report.maintainabilityIndexViolationLevel == ViolationLevel.alarm) { + ++totalMaintainabilityIndexViolations; + } + + totalCyclomaticComplexity += report.cyclomaticComplexity; + if (report.cyclomaticComplexity >= config.cyclomaticComplexityWarningLevel) { + ++totalCyclomaticComplexityViolations; + } + + totalLinesOfCode += report.linesOfCode; + if (report.linesOfCode >= config.linesOfCodeWarningLevel) { + ++totalLinesOfCodeViolations; + } + } + + return ComponentReport( + averageMaintainabilityIndex: averageMaintainabilityIndex / record.records.values.length, + totalMaintainabilityIndexViolations: totalMaintainabilityIndexViolations, + totalCyclomaticComplexity: totalCyclomaticComplexity, + totalCyclomaticComplexityViolations: totalCyclomaticComplexityViolations, + totalLinesOfCode: totalLinesOfCode, + totalLinesOfCodeViolations: totalLinesOfCodeViolations); + } + + static FunctionReport functionReport(FunctionRecord function, Config config) { + final cyclomaticComplexity = + function.cyclomaticLinesComplexity.values.fold(0, (prevValue, nextValue) => prevValue + nextValue) + 1; + + final linesOfCode = function.linesWithCode.length; + + // Total number of occurrences of operators. + final totalNumberOfOccurrencesOfOperators = + function.operators.values.fold(0, (prevValue, nextValue) => prevValue + nextValue); + + // Total number of occurrences of operands + final totalNumberOfOccurrencesOfOperands = + function.operands.values.fold(0, (prevValue, nextValue) => prevValue + nextValue); + + // Number of distinct operators. + final numberOfDistinctOperators = function.operators.keys.length; + + // Number of distinct operands. + final numberOfDistinctOperands = function.operands.keys.length; + + // Halstead Program Length – The total number of operator occurrences and the total number of operand occurrences. + final halsteadProgramLength = totalNumberOfOccurrencesOfOperators + totalNumberOfOccurrencesOfOperands; + + // Halstead Vocabulary – The total number of unique operator and unique operand occurrences. + final halsteadVocabulary = numberOfDistinctOperators + numberOfDistinctOperands; + + // Program Volume – Proportional to program size, represents the size, in bits, of space necessary for storing the program. This parameter is dependent on specific algorithm implementation. + final halsteadVolume = halsteadProgramLength * log2(max(1, halsteadVocabulary)); + + final maintainabilityIndex = max( + 0, + (171 - 5.2 * log(max(1, halsteadVolume)) - 0.23 * cyclomaticComplexity - 16.2 * log(max(1, linesOfCode))) * + 100 / + 171) + .toDouble(); + + return FunctionReport( + cyclomaticComplexity: cyclomaticComplexity, + cyclomaticComplexityViolationLevel: + _violationLevel(cyclomaticComplexity, config.cyclomaticComplexityWarningLevel), + linesOfCode: linesOfCode, + linesOfCodeViolationLevel: _violationLevel(linesOfCode, config.linesOfCodeWarningLevel), + maintainabilityIndex: maintainabilityIndex, + maintainabilityIndexViolationLevel: _maintainabilityIndexViolationLevel(maintainabilityIndex)); + } + + static ViolationLevel functionViolationLevel(FunctionReport report) { + final values = ViolationLevel.values.toList(); + + final cyclomaticComplexityViolationLevelIndex = values.indexOf(report.cyclomaticComplexityViolationLevel); + final linesOfCodeViolationLevelIndex = values.indexOf(report.linesOfCodeViolationLevel); + final maintainabilityIndexViolationLevelIndex = values.indexOf(report.maintainabilityIndexViolationLevel); + + final highestLevelIndex = max(max(cyclomaticComplexityViolationLevelIndex, linesOfCodeViolationLevelIndex), + maintainabilityIndexViolationLevelIndex); + + return values.elementAt(highestLevelIndex); + } + + static ViolationLevel _violationLevel(int value, int warningLevel) { + if (value >= warningLevel * 2) { + return ViolationLevel.alarm; + } else if (value >= warningLevel) { + return ViolationLevel.warning; + } else if (value >= (warningLevel / 2).floor()) { + return ViolationLevel.noted; + } + + return ViolationLevel.none; + } + + static ViolationLevel _maintainabilityIndexViolationLevel(double index) { + if (index < 10) { + return ViolationLevel.alarm; + } else if (index < 20) { + return ViolationLevel.warning; + } else if (index < 40) { + return ViolationLevel.noted; + } + + return ViolationLevel.none; + } +} diff --git a/lib/src/scope_ast_visitor.dart b/lib/src/scope_ast_visitor.dart new file mode 100644 index 0000000000..b7fb6baa24 --- /dev/null +++ b/lib/src/scope_ast_visitor.dart @@ -0,0 +1,43 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:metrics/src/cyclomatic_complexity/models/scoped_declaration.dart'; + +class ScopeAstVisitor extends RecursiveAstVisitor { + final _declarations = []; + + ClassOrMixinDeclaration _enclosingClass; + + Iterable get declarations => _declarations; + + @override + void visitClassDeclaration(ClassDeclaration node) { + _enclosingClass = node; + super.visitClassDeclaration(node); + _enclosingClass = null; + } + + @override + void visitFunctionDeclaration(FunctionDeclaration node) { + _registerDeclaration(node); + super.visitFunctionDeclaration(node); + } + + @override + void visitMethodDeclaration(MethodDeclaration node) { + if (node.body is! EmptyFunctionBody) { + _registerDeclaration(node); + } + super.visitMethodDeclaration(node); + } + + @override + void visitMixinDeclaration(MixinDeclaration node) { + _enclosingClass = node; + super.visitMixinDeclaration(node); + _enclosingClass = null; + } + + void _registerDeclaration(Declaration node) { + _declarations.add(ScopedDeclaration(node, _enclosingClass)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000000..18192dbd23 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,27 @@ +name: metrics +description: Command line tool which helps to improve code quality +version: 1.0.0 +homepage: https://github.com/wrike/metrics +author: Dmitry Krutskih + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + analyzer: '>=0.38.0 <0.39.0' + ansicolor: ^1.0.0 + args: ^1.5.0 + built_collection: ^4.2.0 + glob: ^1.2.0 + html: '>=0.13.0 < 1.0.0' + intl: ^0.15.2 + meta: ^1.1.0 + path: ^1.6.0 + resource: ^2.1.0 + +dev_dependencies: + pedantic: ^1.9.0 + test: ^1.9.0 + +executables: + metrics: diff --git a/test/scope_ast_visitor_test.dart b/test/scope_ast_visitor_test.dart new file mode 100644 index 0000000000..7ee94814a0 --- /dev/null +++ b/test/scope_ast_visitor_test.dart @@ -0,0 +1,23 @@ +@TestOn('vm') +import 'package:analyzer/analyzer.dart'; +import 'package:metrics/src/scope_ast_visitor.dart'; +import 'package:test/test.dart'; + +void main() { + group('analyze file with ', () { + test('abstract class', () { + final visitor = ScopeAstVisitor(); + parseDartFile('./test/scope_ast_visitor_test/sample_abstract_class.dart').visitChildren(visitor); + + expect(visitor.declarations, isEmpty); + }); + test('mixin', () { + final visitor = ScopeAstVisitor(); + parseDartFile('./test/scope_ast_visitor_test/sample_mixin.dart').visitChildren(visitor); + + expect(visitor.declarations.length, equals(1)); + expect(visitor.declarations.first.declaration, isNotNull); + expect(visitor.declarations.first.enclosingClass, isNotNull); + }); + }); +} diff --git a/test/scope_ast_visitor_test/sample_abstract_class.dart b/test/scope_ast_visitor_test/sample_abstract_class.dart new file mode 100644 index 0000000000..88265bd98a --- /dev/null +++ b/test/scope_ast_visitor_test/sample_abstract_class.dart @@ -0,0 +1,5 @@ +abstract class Foo { + void bar1(Iterable records); + + void bar2(Iterable records); +} diff --git a/test/scope_ast_visitor_test/sample_mixin.dart b/test/scope_ast_visitor_test/sample_mixin.dart new file mode 100644 index 0000000000..8d055cd816 --- /dev/null +++ b/test/scope_ast_visitor_test/sample_mixin.dart @@ -0,0 +1,3 @@ +mixin ValuesMapping { + V findValueByKey(Map map, K key) => map[key]; +}