From 33356993abcbc702037db1846b27172573cbba1a Mon Sep 17 00:00:00 2001 From: Taiwo Eniku Date: Wed, 5 Nov 2025 21:50:15 +0100 Subject: [PATCH 1/7] chore: init analyzer package --- analysis_options.yaml | 67 +++++- packages/analysis_parser_cli/.gitignore | 3 + packages/analysis_parser_cli/CHANGELOG.md | 3 + packages/analysis_parser_cli/README.md | 2 + .../analysis_parser_cli/analysis_options.yaml | 35 +++ .../bin/analysis_parser_cli.dart | 5 + .../lib/analysis_parser_cli.dart | 33 +++ .../analysis_parser_cli/lib/models/file.dart | 78 +++++++ .../lib/parser/analysis_line_parser.dart | 211 ++++++++++++++++++ .../lib/parser/analysis_parser.dart | 115 ++++++++++ .../lib/parser/config.dart | 22 ++ .../lib/parser/parser.dart | 51 +++++ .../analysis_parser_cli/lib/utils/enums.dart | 20 ++ .../lib/utils/extensions.dart | 7 + .../analysis_parser_cli/lib/utils/helper.dart | 56 +++++ packages/analysis_parser_cli/pubspec.yaml | 22 ++ .../test/analysis_parser_cli_test.dart | 8 + .../parser/analysis_line_parser_test.dart | 140 ++++++++++++ 18 files changed, 875 insertions(+), 3 deletions(-) create mode 100644 packages/analysis_parser_cli/.gitignore create mode 100644 packages/analysis_parser_cli/CHANGELOG.md create mode 100644 packages/analysis_parser_cli/README.md create mode 100644 packages/analysis_parser_cli/analysis_options.yaml create mode 100644 packages/analysis_parser_cli/bin/analysis_parser_cli.dart create mode 100644 packages/analysis_parser_cli/lib/analysis_parser_cli.dart create mode 100644 packages/analysis_parser_cli/lib/models/file.dart create mode 100644 packages/analysis_parser_cli/lib/parser/analysis_line_parser.dart create mode 100644 packages/analysis_parser_cli/lib/parser/analysis_parser.dart create mode 100644 packages/analysis_parser_cli/lib/parser/config.dart create mode 100644 packages/analysis_parser_cli/lib/parser/parser.dart create mode 100644 packages/analysis_parser_cli/lib/utils/enums.dart create mode 100644 packages/analysis_parser_cli/lib/utils/extensions.dart create mode 100644 packages/analysis_parser_cli/lib/utils/helper.dart create mode 100644 packages/analysis_parser_cli/pubspec.yaml create mode 100644 packages/analysis_parser_cli/test/analysis_parser_cli_test.dart create mode 100644 packages/analysis_parser_cli/test/parser/analysis_line_parser_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index dee8927..c3e3dd7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -15,9 +15,70 @@ include: package:lints/recommended.yaml # Uncomment the following section to specify additional rules. -# linter: -# rules: -# - camel_case_types +linter: + rules: + # Core Lint Rules + - avoid_print + - avoid_single_cascade_in_expression_statements + - camel_case_types + - constant_identifier_names + - curly_braces_in_flow_control_structures + - directives_ordering + - always_use_package_imports + - file_names + - prefer_final_fields + - prefer_is_not_empty + - sort_constructors_first + - type_annotate_public_apis + + # Flutter Specific Lint Rules + - avoid_redundant_argument_values + - avoid_unnecessary_containers + - avoid_web_libraries_in_flutter + - prefer_const_constructors + - prefer_const_literals_to_create_immutables + - prefer_final_locals + - sized_box_for_whitespace + - unnecessary_const + - use_key_in_widget_constructors + + # Style and Convention + - always_declare_return_types + - annotate_overrides + - avoid_annotating_with_dynamic + - avoid_empty_else + - avoid_init_to_null + - avoid_return_types_on_setters + - avoid_unused_constructor_parameters + - await_only_futures + - empty_catches + - prefer_typing_uninitialized_variables + + # Documentation and Comments + - slash_for_doc_comments + - comment_references + + # Code Efficiency and Performance + - avoid_function_literals_in_foreach_calls + - avoid_types_on_closure_parameters + - prefer_collection_literals + - unnecessary_new + - unnecessary_lambdas + - unnecessary_getters_setters + - use_function_type_syntax_for_parameters + - prefer_spread_collections + + # Error Handling + - avoid_catches_without_on_clauses + + # Best Practices + - avoid_positional_boolean_parameters + - use_string_buffers + - use_rethrow_when_possible + + # Miscellaneous + - avoid_returning_this + - use_full_hex_values_for_flutter_colors # analyzer: # exclude: diff --git a/packages/analysis_parser_cli/.gitignore b/packages/analysis_parser_cli/.gitignore new file mode 100644 index 0000000..3a85790 --- /dev/null +++ b/packages/analysis_parser_cli/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/packages/analysis_parser_cli/CHANGELOG.md b/packages/analysis_parser_cli/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/analysis_parser_cli/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/analysis_parser_cli/README.md b/packages/analysis_parser_cli/README.md new file mode 100644 index 0000000..3816eca --- /dev/null +++ b/packages/analysis_parser_cli/README.md @@ -0,0 +1,2 @@ +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/packages/analysis_parser_cli/analysis_options.yaml b/packages/analysis_parser_cli/analysis_options.yaml new file mode 100644 index 0000000..8bcc791 --- /dev/null +++ b/packages/analysis_parser_cli/analysis_options.yaml @@ -0,0 +1,35 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + + +formatter: + trailing_commas: preserve + page_width: 300 + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/analysis_parser_cli/bin/analysis_parser_cli.dart b/packages/analysis_parser_cli/bin/analysis_parser_cli.dart new file mode 100644 index 0000000..322312d --- /dev/null +++ b/packages/analysis_parser_cli/bin/analysis_parser_cli.dart @@ -0,0 +1,5 @@ +import 'package:analysis_parser_cli/analysis_parser_cli.dart' as analysis_parser_cli; + +Future main(List arguments) async { + await analysis_parser_cli.AnalysisParserCli().run(arguments); +} diff --git a/packages/analysis_parser_cli/lib/analysis_parser_cli.dart b/packages/analysis_parser_cli/lib/analysis_parser_cli.dart new file mode 100644 index 0000000..b742abe --- /dev/null +++ b/packages/analysis_parser_cli/lib/analysis_parser_cli.dart @@ -0,0 +1,33 @@ +import 'package:analysis_parser_cli/parser/analysis_parser.dart'; +import 'package:analysis_parser_cli/parser/config.dart'; +import 'package:analysis_parser_cli/parser/parser.dart'; +import 'package:analysis_parser_cli/utils/helper.dart'; +import 'package:args/args.dart'; +import 'package:file/local.dart'; + +class AnalysisParserCli { + final projectPath = LocalFileSystem().currentDirectory.path; + final argsParser = ArgParser(); + final projectDir = AnalysisCLIHelpers.projectPathKey; + final outputDir = AnalysisCLIHelpers.outputDirKey; + + Future run(List arguments) async { + argsParser + ..addOption(outputDir, abbr: outputDir.split('').first, defaultsTo: projectPath) + ..addOption(projectDir, abbr: projectDir.split('').first, defaultsTo: projectPath); + + final args = argsParser.parse(arguments); + final workingDirectory = args[projectDir]!; + final outputDirectory = args[outputDir]!; + final AnalyzerConfig config = AnalyzerConfig.dart(workingDirectory: workingDirectory); + final Parser parser = AnalysisParser(config: config); + + final files = await parser.parse(); + final results = AnalysisCLIHelpers.generateAnalysisMap(files); + final result = await AnalysisCLIHelpers.writeAnalysisMapToJson(results, '$outputDirectory/.analysis.json'); + print(result); + print(files.length); + print(parser.totalAnalysisFound); + return result; + } +} diff --git a/packages/analysis_parser_cli/lib/models/file.dart b/packages/analysis_parser_cli/lib/models/file.dart new file mode 100644 index 0000000..4dba3fc --- /dev/null +++ b/packages/analysis_parser_cli/lib/models/file.dart @@ -0,0 +1,78 @@ +import 'package:analysis_parser_cli/utils/enums.dart'; + +final class AnalysisFile { + const AnalysisFile({ + required this.path, + this.lines = const [], + }); + + final String path; + final List lines; + + AnalysisFile copyWith({String? path, List? lines}) { + return AnalysisFile( + path: path ?? this.path, + lines: lines ?? this.lines, + ); + } + + @override + bool operator ==(Object other) { + return other is AnalysisFile && other.path == path; + } + + Map toJson() { + return { + 'file': path, + 'lines': lines.map((e) => e.toJson()).toList(), + }; + } + + @override + String toString() => 'File{path: $path, lines: $lines}'; + + @override + int get hashCode => path.hashCode; + +} + +final class AnalysisLine { + const AnalysisLine({ + required this.message, + required this.lineNumber, + required this.columnNumber, + required this.type, + this.rule, + }); + + final String message; + final int lineNumber; + final int columnNumber; + final AnalysisType type; + final String? rule; + + AnalysisLine copyWith({String? message, int? lineNumber, int? columnNumber, AnalysisType? type, String? rule}) { + return AnalysisLine( + message: message ?? this.message, + lineNumber: lineNumber ?? this.lineNumber, + columnNumber: columnNumber ?? this.columnNumber, + type: type ?? this.type, + rule: rule ?? this.rule, + ); + } + + Map toJson() { + return { + 'line': lineNumber, + 'column': columnNumber, + 'type': type.name, + 'message': message, + if (rule != null) 'rule': rule, + }; + } + + @override + String toString() { + return 'Line{message: "$message", type: $type, line: $lineNumber, column: $columnNumber, rule: $rule}'; + } +} diff --git a/packages/analysis_parser_cli/lib/parser/analysis_line_parser.dart b/packages/analysis_parser_cli/lib/parser/analysis_line_parser.dart new file mode 100644 index 0000000..f81f5f5 --- /dev/null +++ b/packages/analysis_parser_cli/lib/parser/analysis_line_parser.dart @@ -0,0 +1,211 @@ +import 'package:analysis_parser_cli/models/file.dart'; +import 'package:analysis_parser_cli/utils/enums.dart'; + +/// Base class for all per-line analyzer output parsers. +/// +/// An [AnalysisLineParser] is responsible for interpreting a single line +/// of analyzer output and deciding whether it represents: +/// +/// * a new file start (`parseFileName`) +/// * a completed parsed analysis entry (`parseLine`) +/// * or neither (returns `null`) +/// +/// Implementations may optionally support multiline parsing if a single +/// analysis entry spans multiple lines (e.g. Java, Python). +/// +/// The parser itself does **not** handle file grouping or state — that is +/// the responsibility of [Parser]. This class is only concerned with +/// understanding what a *single line* means. +/// +/// Example use inside a stream parser: +/// +/// ```dart +/// final file = lineParser.parseFileName(line); +/// final entry = lineParser.parseLine(line); +/// ``` +/// +/// If you need to buffer lines before producing a parsed entry, override +/// [supportsMultiline] and manage internal state until `parseLine` can return +/// a completed `AnalysisLine`. +abstract class AnalysisLineParser { + /// Returns a file path if the given line indicates a new file section, + /// otherwise returns `null`. + /// + /// Example output: + /// ``` + /// lib/foo.dart:10:3 • Something happened + /// ``` + String? parseFileName(String line) => null; + + /// Parses a single analyzer output line and returns an [AnalysisLine] + /// when the line contains a complete parsed item. Returns `null` if the + /// line is either irrelevant or incomplete (for multiline parsers). + AnalysisLine? parseLine(String line); + + /// Whether this parser requires multiple lines to form a full entry. + /// + /// If `true`, the caller should continue feeding lines until `parseLine` + /// returns a non-null value. + bool get supportsMultiline => false; + + + AnalysisLine? flush() => null; +} + +/// Parses output from `dart analyze`. +/// +/// Example: +/// `warning • lib/foo.dart:10:3 • Unused import • unused_import` +class DartAnalysisLineParser extends AnalysisLineParser { + final _pattern = RegExp( + r'^(info|warning|error|hint)\s+-\s+(.+?):(\d+):(\d+)\s+-\s+(.*?)(?:\s+-\s+([\w\-_]+))?$', + ); + + RegExpMatch? _match(String line) => _pattern.firstMatch(stripAnsi(line.trim())); + + String stripAnsi(String input) => input.replaceAll(RegExp(r'\x1B\[[0-9;]*m'), ''); + + @override + String? parseFileName(String line) => _match(line)?.group(2); + + @override + AnalysisLine? parseLine(String line) { + final m = _match(line); + if (m == null) return null; + + return AnalysisLine( + type: AnalysisType.fromString(m.group(1)), + lineNumber: int.tryParse(m.group(3) ?? '') ?? 0, + columnNumber: int.tryParse(m.group(4) ?? '') ?? 0, + message: m.group(5) ?? '', + rule: m.group(6), + ); + } +} + +//Python +class PythonAnalysisLineParser extends AnalysisLineParser { + // Matches: file:line:col: CODE: message (rule) + final _singlePattern = RegExp( + r'^(.+?):(\d+):(\d+):\s+([A-Z]\d+):\s+(.*?)(?:\s+\((.+)\))?$', + ); + + // Pylint module header, marks context but isn't an issue line + final _moduleHeader = RegExp(r'^\*{5,}\s+Module\s+(.+)$'); + + // Used to detect file boundary + @override + String? parseFileName(String line) { + final m = _singlePattern.firstMatch(line); + return m?.group(1); + } + + @override + bool get supportsMultiline => true; + + @override + AnalysisLine? parseLine(String line) { + // Skip pylint headers + if (_moduleHeader.hasMatch(line)) return null; + + final m = _singlePattern.firstMatch(line); + if (m == null) return null; + + final type = _mapCodeToType(m.group(4)!); + return AnalysisLine( + type: type, + message: m.group(5) ?? '', + lineNumber: int.parse(m.group(2)!), + columnNumber: int.parse(m.group(3)!), + rule: m.group(6), + ); + } + + AnalysisType _mapCodeToType(String code) { + switch (code[0]) { + case 'E': + case 'F': + return AnalysisType.error; + case 'W': + return AnalysisType.warning; + case 'C': + case 'R': + return AnalysisType.info; + default: + return AnalysisType.hint; + } + } +} + + +//JS +class JsAnalysisLineParser extends AnalysisLineParser { + /// Matches file headers like: + /// src/app.js + /// ./lib/index.ts + /// packages/core/src/util/helper.jsx + /// + /// Supports: dots, slashes, dashes, @scoped folders, spaces. + final _filePattern = RegExp( + r'^[^:*?"<>|]+\.(js|jsx|ts|tsx)$', + ); + + /// Matches ESLint issue lines like: + /// " 10:5 error Unexpected console statement no-console" + final _issuePattern = RegExp( + r'^\s*(\d+):(\d+)\s+(error|warning)\s+(.*?)\s{2,}([\w\-/@]+)$', + ); + + AnalysisLine? _pendingIssue; + + @override + bool get supportsMultiline => true; + + @override + String? parseFileName(String line) { + if (_filePattern.hasMatch(line.trim())) { + return line.trim(); + } + return null; + } + + @override + AnalysisLine? parseLine(String line) { + final match = _issuePattern.firstMatch(line); + + // ✅ If this line starts a new issue + if (match != null) { + // If we had a pending multiline issue, flush it first + final flushed = _pendingIssue; + _pendingIssue = AnalysisLine( + type: match.group(3) == 'error' + ? AnalysisType.error + : AnalysisType.warning, + message: '${match.group(1)}:${match.group(2)} ${match.group(3)} ${match.group(4)} ${match.group(5)}', + lineNumber: int.parse(match.group(1)!), + columnNumber: int.parse(match.group(2)!), + rule: match.group(5), + ); + return flushed; + } + + // ✅ Continuation of previous issue (indented, not blank, not file header) + if (_pendingIssue != null && line.trimLeft() != line) { + _pendingIssue = _pendingIssue!.copyWith( + message: '${_pendingIssue!.message}\n${line.trim()}', + ); + return null; + } + + // ✅ No match, no continuation + return null; + } + + /// Call this when switching files or finishing parsing + @override + AnalysisLine? flush() { + final flushed = _pendingIssue; + _pendingIssue = null; + return flushed; + } +} diff --git a/packages/analysis_parser_cli/lib/parser/analysis_parser.dart b/packages/analysis_parser_cli/lib/parser/analysis_parser.dart new file mode 100644 index 0000000..a787dfb --- /dev/null +++ b/packages/analysis_parser_cli/lib/parser/analysis_parser.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:analysis_parser_cli/models/file.dart'; +import 'package:analysis_parser_cli/parser/parser.dart'; +import 'package:analysis_parser_cli/utils/extensions.dart'; +import 'package:collection/collection.dart'; +import 'package:process/process.dart'; + + +/// Parses analyzer output line-by-line and groups results by file. +/// +/// This parser streams stdout from the analyzer process, detects when +/// a new file begins, and buffers the lines belonging to that file +/// until either a file switch occurs or the stream ends. +/// +/// Typical usage: +/// +/// ```dart +/// final parser = AnalysisParser( +/// config: AnalysisConfig(args: ['--fatal-infos']), +/// projectDir: './', +/// ); +/// +/// final results = await parser.parse(); +/// for (final file in results) { +/// print('${file.path} → ${file.lines.length} issues'); +/// } +/// ``` +class AnalysisParser extends Parser { + AnalysisParser({required super.config}); + + /// Tracks the file currently being parsed (as reported by the line parser). + String? _currentFile; + + /// Buffered lines belonging to the current file. + final List _currentFileLines = []; + + /// Final collection of parsed files. Uses a [Set] to avoid duplicates. + final Set files = {}; + + /// Pushes the currently buffered file and its lines into [files]. + /// + /// If the file already exists in the set, the new lines are merged. + /// Clears the internal buffer after flushing. + void _flushCurrentFile() { + if (_currentFile == null || _currentFileLines.isEmpty) return; + + final existing = files.firstWhereOrNull((f) => f.path == _currentFile); + + if (existing != null) { + final merged = existing.copyWith( + lines: [...existing.lines, ..._currentFileLines], + ); + files + ..remove(existing) + ..add(merged); + } else { + files.add( + AnalysisFile(path: _currentFile!, lines: [..._currentFileLines]), + ); + } + + totalAnalysisFound += _currentFileLines.length; + _currentFileLines.clear(); + } + + /// Runs the analyzer command, streams its output, and returns parsed results. + /// + /// Returns a list of [AnalysisFile] objects grouped by file path. + /// If the analyzer exits with a non-zero exit code and produces stderr, + /// the process terminates and prints the collected error output. + @override + Future> parse() async { + final process = await Process.instance.start( + commands.first, + [...commands.sublist(1), ...config.arguments], + workingDirectory: config.workingDirectory, + ); + + final errors = []; + + final stdoutDone = process.stdout.splitForEach(_parseLine); + final stderrDone = process.stderr.splitForEach(errors.add); + + await Future.wait([stdoutDone, stderrDone]); + + //Flush final file + _flushCurrentFile(); + + final exitCode = await process.exitCode; + + if (exitCode != 0 && errors.isNotEmpty) { + stderr.writeln(errors.join('\n')); + exit(exitCode); + } + + return files.toList(); + } + + /// Parses a single analyzer line, detects file switches, and buffers content. + void _parseLine(String line) { + final detectedFile = lineParser.parseFileName(line); + + if (detectedFile != null && detectedFile != _currentFile) { + _flushCurrentFile(); + _currentFile = detectedFile; + } + + final parsed = lineParser.parseLine(line); + if (parsed != null) { + _currentFileLines.add(parsed); + } + } +} diff --git a/packages/analysis_parser_cli/lib/parser/config.dart b/packages/analysis_parser_cli/lib/parser/config.dart new file mode 100644 index 0000000..9ee4f98 --- /dev/null +++ b/packages/analysis_parser_cli/lib/parser/config.dart @@ -0,0 +1,22 @@ +import 'package:analysis_parser_cli/parser/analysis_line_parser.dart'; + +class AnalyzerConfig { + final String command; + final AnalysisLineParser parser; + final List arguments; + final String workingDirectory; + AnalyzerConfig({ + required this.command, + required this.parser, + this.arguments = const [], + this.workingDirectory = '.', + }); + + factory AnalyzerConfig.dart({String workingDirectory = '.'}) { + return AnalyzerConfig( + command: 'dart analyze "$workingDirectory"', + parser: DartAnalysisLineParser(), + workingDirectory: workingDirectory, + ); + } +} diff --git a/packages/analysis_parser_cli/lib/parser/parser.dart b/packages/analysis_parser_cli/lib/parser/parser.dart new file mode 100644 index 0000000..d91b804 --- /dev/null +++ b/packages/analysis_parser_cli/lib/parser/parser.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:analysis_parser_cli/models/file.dart'; +import 'package:analysis_parser_cli/parser/analysis_line_parser.dart'; +import 'package:analysis_parser_cli/parser/config.dart'; + +/// Base class for all analyzer output parsers. +/// +/// A [Parser] is responsible for executing an analyzer command, +/// streaming its output, and returning a list of parsed results. +/// +/// Concrete implementations (e.g. `AnalysisParser`) handle the +/// process I/O and file grouping logic, while the `lineParser` +/// handles the language-specific line matching. +/// +/// Example: +/// ```dart +/// final parser = AnalysisParser(config: AnalyzerConfig(command: 'dart analyze')); +/// final files = await parser.parse(); +/// print('Found ${parser.totalAnalysisFound} issues'); +/// ``` +abstract class Parser { + Parser({required this.config}); + + /// Total number of parsed analysis results across all files. + int totalAnalysisFound = 0; + + /// Configuration containing the analyzer command and line parser. + final AnalyzerConfig config; + + /// Splits a command string into executable + arguments. + /// + /// Supports quoted segments, allowing paths with spaces: + /// `"flutter analyze --write=out.json" "/Users/me/My Project/lib"` + final RegExp _regex = RegExp(r'''(['"])(.*?)\1|(\S+)'''); + + /// The parsed command as a list. Example: + /// + /// `'dart analyze lib'` → `['dart', 'analyze', 'lib']` + List get commands { + return _regex.allMatches(config.command).map((m) { + return m.group(2) ?? m.group(3) ?? ''; + }).toList(); + } + + /// Language-specific parser used to interpret individual lines. + AnalysisLineParser get lineParser => config.parser; + + /// Starts the parsing process and returns analyzed files. + FutureOr> parse(); +} diff --git a/packages/analysis_parser_cli/lib/utils/enums.dart b/packages/analysis_parser_cli/lib/utils/enums.dart new file mode 100644 index 0000000..2d8fffc --- /dev/null +++ b/packages/analysis_parser_cli/lib/utils/enums.dart @@ -0,0 +1,20 @@ +enum AnalysisType { + error('error'), + warning('warn'), + info('information'), + hint('hint'); + + final String alias; + const AnalysisType(this.alias); + + static AnalysisType fromString(String? type) { + return AnalysisType.values.firstWhere( + (e) { + final matchName = e.name.toLowerCase() == type?.toLowerCase(); + final matchAlias = e.alias.toLowerCase() == type?.toLowerCase(); + return matchName || matchAlias; + }, + orElse: () => AnalysisType.error, + ); + } +} diff --git a/packages/analysis_parser_cli/lib/utils/extensions.dart b/packages/analysis_parser_cli/lib/utils/extensions.dart new file mode 100644 index 0000000..5a0106b --- /dev/null +++ b/packages/analysis_parser_cli/lib/utils/extensions.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +extension StreamListOfInt on Stream> { + Future splitForEach(void Function(String) action) { + return transform(utf8.decoder).transform(const LineSplitter()).forEach(action); + } +} \ No newline at end of file diff --git a/packages/analysis_parser_cli/lib/utils/helper.dart b/packages/analysis_parser_cli/lib/utils/helper.dart new file mode 100644 index 0000000..fecf610 --- /dev/null +++ b/packages/analysis_parser_cli/lib/utils/helper.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:analysis_parser_cli/models/file.dart'; + +class AnalysisCLIHelpers { + + static const projectPathKey = 'project-dir'; + static const outputDirKey = 'output-dir'; + + static Map> generateAnalysisMap(List analysisFiles) { + final Map> analysisMap = {}; + for (var file in analysisFiles) { + final lines = {}; + for (var line in file.lines) { + final lineNumber = line.lineNumber.toString(); + lines.update(lineNumber, (existing) { + return _mergeLineMaps(existing, line.toJson()); + }, ifAbsent: line.toJson); + } + analysisMap[file.path] = lines; + } + return analysisMap; + } + + static Map _mergeLineMaps(Map map1, Map map2) { + List mergeValues(dynamic v1, dynamic v2) { + final list1 = v1 is List ? v1.cast() : [v1 as T]; + final list2 = v2 is List ? v2.cast() : [v2 as T]; + return [...list1, ...list2]; + } + + return { + 'line': map1['line'], + 'message': mergeValues(map1['message'], map2['message']), + 'type': mergeValues(map1['type'], map2['type']), + 'column': mergeValues(map1['column'], map2['column']), + 'rule': mergeValues(map1['rule'], map2['rule']), + }; + } + + static Future writeAnalysisMapToJson(Map> analysisMap, String filePath) async { + try { + String jsonString = json.encode(analysisMap); + File file = File(filePath); + if (await file.exists()) { + await file.delete(); // Deletes the file if it exists + } + await file.create(recursive: true); + await file.writeAsString(jsonString); + return file.path; + } catch (e) { + print('Unable to write to file: $e'); + exit(-1); + } + } +} diff --git a/packages/analysis_parser_cli/pubspec.yaml b/packages/analysis_parser_cli/pubspec.yaml new file mode 100644 index 0000000..65c122b --- /dev/null +++ b/packages/analysis_parser_cli/pubspec.yaml @@ -0,0 +1,22 @@ +name: analysis_parser_cli +description: A sample command-line application. +version: 1.0.0 +publish_to: none +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ^3.8.1 + +# Add regular dependencies here. +dependencies: + # path: ^1.8.0 + args: '^2.4.2' + file: ^7.0.0 + collection: + process: + git: + url: https://github.com/kannel-outis/process.git + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 diff --git a/packages/analysis_parser_cli/test/analysis_parser_cli_test.dart b/packages/analysis_parser_cli/test/analysis_parser_cli_test.dart new file mode 100644 index 0000000..7e63c2c --- /dev/null +++ b/packages/analysis_parser_cli/test/analysis_parser_cli_test.dart @@ -0,0 +1,8 @@ +// import 'package:analysis_parser_cli/analysis_parser_cli.dart'; +// import 'package:test/test.dart'; + +// void main() { +// test('calculate', () { +// expect(calculate(), 42); +// }); +// } diff --git a/packages/analysis_parser_cli/test/parser/analysis_line_parser_test.dart b/packages/analysis_parser_cli/test/parser/analysis_line_parser_test.dart new file mode 100644 index 0000000..5cf1c10 --- /dev/null +++ b/packages/analysis_parser_cli/test/parser/analysis_line_parser_test.dart @@ -0,0 +1,140 @@ +import 'package:analysis_parser_cli/models/file.dart'; +import 'package:analysis_parser_cli/parser/analysis_line_parser.dart'; +import 'package:analysis_parser_cli/utils/enums.dart'; +import 'package:test/test.dart'; + +void main() { + test('analysis line parser ...', () async { + final parser = PythonAnalysisLineParser(); + final line = + " error - packages/analysis_parser_cli/test/analysis_parser_cli_test.dart:6:12 - The function 'calculate' isn't defined. Try importing the library that defines 'calculate', correcting the name to the name of an existing function, or defining a function named 'calculate'. - undefined_function"; + final result = parser.parseLine(line); + print(result?.lineNumber); + print(result?.columnNumber); + print(result?.message); + print(result?.rule); + print(result?.type); + print(parser.parseFileName(line)); + }); + group('PythonAnalysisLineParser', () { + final parser = PythonAnalysisLineParser(); + + test('parses a simple pylint single-line issue', () { + const line = "project/module.py:12:4: E1101: Module 'os' has no 'pathjoin' member (no-member)"; + final result = parser.parseLine(line); + + expect(result, isNotNull); + expect(result!.lineNumber, 12); + expect(result.columnNumber, 4); + expect(result.message, "Module 'os' has no 'pathjoin' member"); + expect(result.rule, "no-member"); + expect(result.type, AnalysisType.error); + expect(parser.parseFileName(line), "project/module.py"); + }); + + test('parses another file after module header', () { + const block = ''' +************* Module my_package.core.utils +my_package/core/utils.py:35:4: W0612: Unused variable 'x' (unused-variable) + def build_path(parts): + ^^^^^^^^^^^^^^^^^^^^^ +'''; + + final lines = block.split('\n'); + + final results = lines.map(parser.parseLine).whereType().toList(); + + expect(results.length, 1); + final r = results.first; + expect(r.lineNumber, 35); + expect(r.columnNumber, 4); + expect(r.message, "Unused variable 'x'"); + expect(r.rule, "unused-variable"); + expect(r.type, AnalysisType.warning); + expect(parser.parseFileName(lines[1]), "my_package/core/utils.py"); + }); + + test('ignores non-matching lines and pointer lines', () { + const line = " ^^^^^^^^^^^^^^^"; + final result = parser.parseLine(line); + expect(result, isNull); + }); + + test('supports multiline output', () { + expect(parser.supportsMultiline, isTrue); + }); + }); + + group('JsAnalysisLineParser', () { + late JsAnalysisLineParser parser; + + setUp(() { + parser = JsAnalysisLineParser(); + }); + + test('parses ESLint multiline issues correctly', () { + const input = ''' +src/app.js + 10:5 error Unexpected console statement no-console + This is an extra context line + And another one + +src/utils/helpers.ts + 2:3 warning "x" is defined but never used no-unused-vars + 5:1 error Missing return type on function @typescript-eslint/explicit-function-return-type +'''; + + final lines = input.split('\n'); + final files = []; + + String? currentFile; + final currentLines = []; + + for (final line in lines) { + final newFile = parser.parseFileName(line); + + if (newFile != null) { + final flushed = parser.flush(); + if (flushed != null) currentLines.add(flushed); + + if (currentFile != null) { + files.add(AnalysisFile(path: currentFile, lines: List.of(currentLines))); + currentLines.clear(); + } + currentFile = newFile; + continue; + } + + final parsed = parser.parseLine(line); + if (parsed != null) { + currentLines.add(parsed); + } + } + + // Final flush + final lastFlushed = parser.flush(); + if (lastFlushed != null) currentLines.add(lastFlushed); + if (currentFile != null) { + files.add(AnalysisFile(path: currentFile, lines: List.of(currentLines))); + } + + // ✅ Assertions + expect(files.length, 2); + + final file1 = files[0]; + expect(file1.path, 'src/app.js'); + expect(file1.lines.length, 1); + expect( + file1.lines.first.message, + '10:5 error Unexpected console statement no-console\n' + 'This is an extra context line\n' + 'And another one', + ); + + final file2 = files[1]; + expect(file2.lines.length, 2); + expect(file2.lines.first.rule, 'no-unused-vars'); + expect(file2.lines.last.rule, '@typescript-eslint/explicit-function-return-type'); + }); + }); +} From e43dc2a8a50780b4593c01bbcd0581ec2553de82 Mon Sep 17 00:00:00 2001 From: Taiwo Eniku Date: Sat, 8 Nov 2025 00:56:57 +0100 Subject: [PATCH 2/7] chore: added analysis config --- analysis_options.yaml | 2 +- lib/commands/comands.dart | 112 +++++++++++++++++- lib/commands/runner.dart | 1 + lib/utils/config.dart | 41 +++++++ packages/analysis_parser_cli/.gitignore | 1 + .../lib/analysis_parser_cli.dart | 7 +- .../analysis_parser_cli/lib/utils/helper.dart | 2 + test/commands/comands_test.dart | 5 + test/utils/config_test.dart | 4 +- 9 files changed, 169 insertions(+), 6 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index c3e3dd7..27fd5e7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -69,7 +69,7 @@ linter: - prefer_spread_collections # Error Handling - - avoid_catches_without_on_clauses + # - avoid_catches_without_on_clauses # Best Practices - avoid_positional_boolean_parameters diff --git a/lib/commands/comands.dart b/lib/commands/comands.dart index b58a4bf..61720af 100644 --- a/lib/commands/comands.dart +++ b/lib/commands/comands.dart @@ -20,6 +20,8 @@ const _outputDir = 'output-dir'; const _projectPathKey = 'projectPath'; const _configFile = 'config'; const _reportFormat = 'report-format'; +const _languageKey = 'lang'; +const _analysisParserFileKey = 'analysisParserFile'; //TODO(kanniel-outis): move to git parser package folder @@ -120,6 +122,7 @@ class LcovCliCommand extends Command { ..addOption(_projectPathKey, abbr: _projectPathKey.split('').first, help: 'Path to the project root directory containing the source code for coverage analysis') ..addOption(_gitParserFileKey, abbr: _gitParserFileKey.split('').first, help: 'Path to the git parser file containing git change analysis results') ..addOption(_reportType, abbr: _reportType.split('').first, defaultsTo: 'html', help: 'Format of the output report (html, json, or console)') + ..addOption(_analysisParserFileKey, abbr: _analysisParserFileKey.split('').first, help: 'Path to the analysis results file (e.g., `coverage/.analyzer.json`)') ..addOption(_configFile, abbr: _configFile.split('').first, help: 'Path to the config file containing configuration options for the analysis tool'); } catch (e) { Logger.error(e); @@ -143,6 +146,7 @@ class LcovCliCommand extends Command { final projectPath = config.projectPath; final gitParserFile = config.gitParserFile; final reportFormat = config.reportFormat; + final analysisParserFile = config.analysisParserFile; final result = await Process.instance.start( 'dart', [ @@ -170,7 +174,75 @@ class LcovCliCommand extends Command { if (reportFormat != null) ...[ '--reportType', reportFormat, - ] + ], + if (analysisParserFile != null) ...[ + '--analysisParserFile', + analysisParserFile, + ], + ], + runInShell: true, + ); + result.stdout.transform(utf8.decoder).listen(stdout.write); + result.stderr.transform(utf8.decoder).listen(stderr.write); + return await result.exitCode; + } catch (e) { + Logger.error(e); + exit(-1); + } + } +} +class AnalyzerCommand extends Command { + AnalyzerCommand() { + addArgParser(); + } + + void addArgParser() { + try { + argParser + ..addOption(_outputDir, abbr: _outputDir.split('').first, help: 'Path to the output directory where the processed analysis report will be saved. result is processed to json file') + ..addOption(_projectPathKey, abbr: _projectPathKey.split('').first, help: 'Path to the project root directory containing the source code for code analysis') + ..addOption(_languageKey, abbr: _languageKey.split('').first.toUpperCase(), help: 'Language of the project') + ..addOption(_configFile, abbr: _configFile.split('').first, help: 'Path to the configuration file'); + } catch (e) { + Logger.error(e); + exit(-1); + } + } + + @override + String get description => 'Analyzer CLI Command Line Tool - Runs static code analysis and retrieves results'; + + @override + String get name => 'analyze'; + + @override + FutureOr? run() async { + try { + final config = Config.analyzerParserFromArgs(argResults); + final configFile = config.config; + final outputDir = config.output; + final projectPath = config.projectPath; + final language = config.language; + final result = await Process.instance.start( + 'dart', + [ + path.join(Utils.root, 'packages', 'analysis_parser_cli', 'bin', 'analysis_parser_cli.dart'), + if (outputDir != null) ...[ + '--output-dir', + outputDir, + ], + if (projectPath != null) ...[ + '--project-dir', + projectPath, + ], + if (configFile != null) ...[ + '--config', + configFile, + ], + if (language != null) ...[ + '--lang', + language, + ], ], runInShell: true, ); @@ -203,6 +275,8 @@ class MainRunnerCommand extends Command { ..addOption(_gitParserFileKey, abbr: _gitParserFileKey.split('').first, help: 'Path to the git parser file containing the results of git change analysis') ..addOption(_outputDir, help: 'Path to the output directory where the analysis results will be saved.') ..addOption(_reportFormat, abbr: _reportFormat.split('').first, defaultsTo: 'html', help: 'Format of the output report (html, json, or console)') + ..addOption(_languageKey, abbr: _languageKey.split('').first.toUpperCase(), defaultsTo: 'dart', help: 'Language of the project') + ..addOption(_analysisParserFileKey, abbr: _analysisParserFileKey.split('').first, help: 'Path to the analysis results file (e.g., `coverage/.analyzer.json`)') ..addOption(_configFile, abbr: _configFile.split('').first, help: 'Path to the config file containing configuration options for the analysis tool'); } catch (e) { Logger.error(e); @@ -270,10 +344,34 @@ class MainRunnerCommand extends Command { if (config.reportFormat != null) ...[ '--reportType', config.reportFormat!, - ] + ], + if (config.analysisParserFile != null) ...[ + '--analysisParserFile', + config.analysisParserFile!, + ], ]; - await main.run(['git', ...gitOptions]); + final analyzerOptions = [ + if (config.output != null) ...[ + '--output-dir', + config.output!, + ], + if (config.projectPath != null) ...[ + '--project-dir', + config.projectPath!, + ], + if (config.config != null) ...[ + '--config', + config.config!, + ], + if (config.language != null) ...[ + '--lang', + config.language!, + ], + ]; + + final invoke = Future.wait([main.run(['git', ...gitOptions]), main.run(['analyze', ...analyzerOptions])]); + await _runSafely(invoke); await main.run(['lcov', ...lcovOptions]); Logger.success('Analysis completed successfully.'); return 0; @@ -282,4 +380,12 @@ class MainRunnerCommand extends Command { exit(-1); } } + + Future _runSafely(Future invoke) async { + try { + await invoke; + } catch (e) { + Logger.error(e); + } + } } diff --git a/lib/commands/runner.dart b/lib/commands/runner.dart index bec781a..bd8e8cb 100644 --- a/lib/commands/runner.dart +++ b/lib/commands/runner.dart @@ -13,6 +13,7 @@ class CoverOpsRunner extends CommandRunner { } void init() { + addCommand(AnalyzerCommand()); addCommand(GitCliCommand()); addCommand(LcovCliCommand()); addCommand(MainRunnerCommand(this)); diff --git a/lib/utils/config.dart b/lib/utils/config.dart index e5433cc..438d028 100644 --- a/lib/utils/config.dart +++ b/lib/utils/config.dart @@ -19,6 +19,8 @@ const _configFile = 'config'; const _reportFormat = 'report-format'; const _reportType = 'reportType'; const _projectDir = 'project-dir'; +const _analysisParserFileKey = 'analysisParserFile'; +const _languageKey = 'lang'; /// Stores configuration options for the CoverOps CLI tool. /// @@ -35,6 +37,9 @@ class Config { /// Path to the Git analysis results file (e.g., `coverage/.gitparser.json`). final String? gitParserFile; + /// Path to the analysis results file (e.g., `coverage/.analysis.json`). + final String? analysisParserFile; + /// Target branch for Git comparison (e.g., `main`). final String? targetBranch; @@ -53,6 +58,12 @@ class Config { /// Report format (e.g., `html`, `json`, `console`). final String? reportFormat; + /// Language of the project. + final String? language; + + /// Path to the configuration file. + final String? config; + /// Creates a [Config] instance with the specified options. /// /// All parameters are optional and can be null if not specified. @@ -61,22 +72,27 @@ class Config { /// - `lcovFile` Path to the LCOV coverage file. /// - `jsonCoverage` Path to the JSON coverage file. /// - `gitParserFile` Path to the Git analysis results file. + /// - `analyzerParserFile` Path to the analyzer results file. /// - `targetBranch` Target branch for Git comparison. /// - `targetBranchFallback` Fallback branch if target is unavailable. /// - `sourceBranch` Source branch with changes. /// - `output` Output directory for reports. /// - `projectPath` Project root directory. /// - `reportFormat` Report format (e.g., `html`, `json`, `console`). + /// - `language` Language of the project. Config({ this.lcovFile, this.jsonCoverage, this.gitParserFile, + this.analysisParserFile, this.targetBranch, this.targetBranchFallback, this.sourceBranch, this.output, this.projectPath, + this.language, this.reportFormat, + this.config, }); /// Creates a [Config] instance from command-line arguments. @@ -95,12 +111,15 @@ class Config { lcovFile: fileConfig?.lcovFile ?? args?[_lcovFileKey], jsonCoverage: fileConfig?.jsonCoverage ?? args?[_jsonCoverageKey], gitParserFile: fileConfig?.gitParserFile ?? args?[_gitParserFileKey], + analysisParserFile: fileConfig?.analysisParserFile ?? args?[_analysisParserFileKey], targetBranch: fileConfig?.targetBranch ?? args?[_targetBranchKey], targetBranchFallback: fileConfig?.targetBranchFallback ?? args?[_targetBranchFallbackKey], sourceBranch: fileConfig?.sourceBranch ?? args?[_sourceBranchKey], output: fileConfig?.output ?? args?[_output] ?? args?[_outputDir], projectPath: fileConfig?.projectPath ?? args?[_projectPathKey], reportFormat: fileConfig?.reportFormat ?? args?[_reportFormat], + language: fileConfig?.language ?? args?[_languageKey], + config: configPath, ); } @@ -141,12 +160,33 @@ class Config { lcovFile: fileConfig?.lcovFile ?? args?[_lcovFileKey], jsonCoverage: fileConfig?.jsonCoverage ?? args?[_jsonCoverageKey], gitParserFile: fileConfig?.gitParserFile ?? args?[_gitParserFileKey], + analysisParserFile: fileConfig?.analysisParserFile ?? args?[_analysisParserFileKey], output: fileConfig?.output ?? args?[_output], projectPath: fileConfig?.projectPath ?? args?[_projectPathKey], reportFormat: fileConfig?.reportFormat ?? args?[_reportType], ); } + /// Creates a [Config] instance for analyzer parsing from command-line arguments. + /// + /// This factory constructor merges settings from [args] with those from a JSON + /// configuration file specified by the `--config` flag. File-based settings + /// take precedence for keys defined in the JSON file. + /// + /// Parameters: + /// - `args` The parsed command-line arguments from [ArgResults]. + /// returns A [Config] instance with merged settings for analyzer parsing. + factory Config.analyzerParserFromArgs(ArgResults? args) { + final configPath = args?[_configFile] as String?; + final fileConfig = _FileConfig(configFilePath: configPath).getConfig(); + return Config( + output: fileConfig?.output ?? args?[_outputDir] ?? args?[_output], + projectPath: fileConfig?.projectPath ?? args?[_projectPathKey], + language: fileConfig?.language ?? args?[_languageKey], + config: configPath, + ); + } + /// Parses a JSON file into a map. /// /// Reads the content of [file] synchronously and decodes it as JSON. Returns an @@ -209,6 +249,7 @@ class _FileConfig extends Config { output: args[_output], projectPath: args[_projectPathKey], reportFormat: (args[_reportFormat.toCamelCase] as List?)?.join(','), + analysisParserFile: args[_analysisParserFileKey], ); } catch (e) { Logger.error(e); diff --git a/packages/analysis_parser_cli/.gitignore b/packages/analysis_parser_cli/.gitignore index 3a85790..fa128cd 100644 --- a/packages/analysis_parser_cli/.gitignore +++ b/packages/analysis_parser_cli/.gitignore @@ -1,3 +1,4 @@ # https://dart.dev/guides/libraries/private-files # Created by `dart pub` .dart_tool/ +/coverage/ diff --git a/packages/analysis_parser_cli/lib/analysis_parser_cli.dart b/packages/analysis_parser_cli/lib/analysis_parser_cli.dart index b742abe..9e9afbf 100644 --- a/packages/analysis_parser_cli/lib/analysis_parser_cli.dart +++ b/packages/analysis_parser_cli/lib/analysis_parser_cli.dart @@ -10,15 +10,20 @@ class AnalysisParserCli { final argsParser = ArgParser(); final projectDir = AnalysisCLIHelpers.projectPathKey; final outputDir = AnalysisCLIHelpers.outputDirKey; + final language = AnalysisCLIHelpers.languageKey; + final configFile = AnalysisCLIHelpers.configFileKey; Future run(List arguments) async { argsParser ..addOption(outputDir, abbr: outputDir.split('').first, defaultsTo: projectPath) - ..addOption(projectDir, abbr: projectDir.split('').first, defaultsTo: projectPath); + ..addOption(projectDir, abbr: projectDir.split('').first, defaultsTo: projectPath) + ..addOption(language, abbr: language.split('').first.toUpperCase(), defaultsTo: 'dart') + ..addOption(configFile, abbr: configFile.split('').first); final args = argsParser.parse(arguments); final workingDirectory = args[projectDir]!; final outputDirectory = args[outputDir]!; + //TODO: get config from lang arg final AnalyzerConfig config = AnalyzerConfig.dart(workingDirectory: workingDirectory); final Parser parser = AnalysisParser(config: config); diff --git a/packages/analysis_parser_cli/lib/utils/helper.dart b/packages/analysis_parser_cli/lib/utils/helper.dart index fecf610..9a7f998 100644 --- a/packages/analysis_parser_cli/lib/utils/helper.dart +++ b/packages/analysis_parser_cli/lib/utils/helper.dart @@ -6,6 +6,8 @@ class AnalysisCLIHelpers { static const projectPathKey = 'project-dir'; static const outputDirKey = 'output-dir'; + static const languageKey = 'lang'; + static const configFileKey = 'config'; static Map> generateAnalysisMap(List analysisFiles) { final Map> analysisMap = {}; diff --git a/test/commands/comands_test.dart b/test/commands/comands_test.dart index 96459db..5576218 100644 --- a/test/commands/comands_test.dart +++ b/test/commands/comands_test.dart @@ -81,6 +81,7 @@ void main() { final mainCommand = MainRunnerCommand(runner); runner.addCommand(GitCliCommand()); runner.addCommand(LcovCliCommand()); + runner.addCommand(AnalyzerCommand()); runner.addCommand(mainCommand); // Simulate output from both commands @@ -106,6 +107,10 @@ void main() { 'build/output/git.json', '--report-format', 'html,json', + '--analyzerParserFile', + 'build/output/git.json', + '--dialect', + 'dart', ]); expect(result, 0); diff --git a/test/utils/config_test.dart b/test/utils/config_test.dart index 1d487be..4474486 100644 --- a/test/utils/config_test.dart +++ b/test/utils/config_test.dart @@ -23,7 +23,9 @@ void main() { ..addOption('config') ..addOption('reportType') ..addOption('project-dir') - ..addOption('report-format'); + ..addOption('report-format') + ..addOption('analyzerParserFile') + ..addOption('dialect'); }); test('creates Config with null values when no args provided', () { From d5469f3c4c2361395b002843848b9a666c7eb711 Mon Sep 17 00:00:00 2001 From: Taiwo Eniku Date: Sat, 8 Nov 2025 01:06:25 +0100 Subject: [PATCH 3/7] chore:push test --- lib/commands/comands.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/commands/comands.dart b/lib/commands/comands.dart index 61720af..590b7eb 100644 --- a/lib/commands/comands.dart +++ b/lib/commands/comands.dart @@ -373,7 +373,7 @@ class MainRunnerCommand extends Command { final invoke = Future.wait([main.run(['git', ...gitOptions]), main.run(['analyze', ...analyzerOptions])]); await _runSafely(invoke); await main.run(['lcov', ...lcovOptions]); - Logger.success('Analysis completed successfully.'); + Logger.success('Analysis completed successfully.'); return 0; } catch (e) { Logger.error(e); From 857c9cf9cfa2cb5c36484b876d9d3d1e499c0265 Mon Sep 17 00:00:00 2001 From: Taiwo Eniku Date: Sat, 8 Nov 2025 01:11:17 +0100 Subject: [PATCH 4/7] chore: push test --- lib/commands/comands.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/commands/comands.dart b/lib/commands/comands.dart index 590b7eb..61720af 100644 --- a/lib/commands/comands.dart +++ b/lib/commands/comands.dart @@ -373,7 +373,7 @@ class MainRunnerCommand extends Command { final invoke = Future.wait([main.run(['git', ...gitOptions]), main.run(['analyze', ...analyzerOptions])]); await _runSafely(invoke); await main.run(['lcov', ...lcovOptions]); - Logger.success('Analysis completed successfully.'); + Logger.success('Analysis completed successfully.'); return 0; } catch (e) { Logger.error(e); From bec67453200356ee8fabf65d9ef437dfc2e391e7 Mon Sep 17 00:00:00 2001 From: kannel-outis Date: Sat, 8 Nov 2025 01:16:07 +0100 Subject: [PATCH 5/7] chore: push test --- lib/commands/comands.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/commands/comands.dart b/lib/commands/comands.dart index 61720af..999c409 100644 --- a/lib/commands/comands.dart +++ b/lib/commands/comands.dart @@ -191,6 +191,8 @@ class LcovCliCommand extends Command { } } } + +//TODO: might move class AnalyzerCommand extends Command { AnalyzerCommand() { addArgParser(); From 0c926ddacb9e4644da1fb855fc0e3ff51f446342 Mon Sep 17 00:00:00 2001 From: kannel-outis Date: Sat, 8 Nov 2025 01:19:47 +0100 Subject: [PATCH 6/7] chore: update tests --- test/commands/comands_test.dart | 6 +++--- test/utils/config_test.dart | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/commands/comands_test.dart b/test/commands/comands_test.dart index 5576218..49160b3 100644 --- a/test/commands/comands_test.dart +++ b/test/commands/comands_test.dart @@ -107,9 +107,9 @@ void main() { 'build/output/git.json', '--report-format', 'html,json', - '--analyzerParserFile', - 'build/output/git.json', - '--dialect', + '--analysisParserFile', + 'build/output/analysis.json', + '--lang', 'dart', ]); diff --git a/test/utils/config_test.dart b/test/utils/config_test.dart index 4474486..fddb74d 100644 --- a/test/utils/config_test.dart +++ b/test/utils/config_test.dart @@ -24,8 +24,8 @@ void main() { ..addOption('reportType') ..addOption('project-dir') ..addOption('report-format') - ..addOption('analyzerParserFile') - ..addOption('dialect'); + ..addOption('analysisParserFile') + ..addOption('lang'); }); test('creates Config with null values when no args provided', () { From e927f0d159a0cbaf7971d841f2cbe054f1172b04 Mon Sep 17 00:00:00 2001 From: kannel-outis Date: Sat, 8 Nov 2025 14:22:02 +0100 Subject: [PATCH 7/7] chore: update lcov_cli --- analysis_options.yaml | 3 + .../lib/analysis_parser_cli.dart | 2 +- .../analysis_parser_cli/lib/utils/helper.dart | 4 +- packages/lcov_cli/analysis_options.yaml | 3 + .../generators/syntax/syntax_decorator.dart | 12 +- packages/lcov_cli/lib/models/line.dart | 37 +++ .../parsers/code_coverage_file_parser.dart | 19 +- .../lib/parsers/line_data_json_parser.dart | 113 ++++++++ .../lcov_cli/lib/parsers/line_parser.dart | 19 +- packages/lcov_cli/lib/run.dart | 19 +- packages/lcov_cli/lib/utils/arg_settings.dart | 11 + .../code_coverage_file_parser_test.dart | 37 ++- .../parsers/line_data_json_parser_test.dart | 258 ++++++++++++++++++ 13 files changed, 511 insertions(+), 26 deletions(-) create mode 100644 packages/lcov_cli/lib/parsers/line_data_json_parser.dart create mode 100644 packages/lcov_cli/test/parsers/line_data_json_parser_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 27fd5e7..8945343 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -15,6 +15,9 @@ include: package:lints/recommended.yaml # Uncomment the following section to specify additional rules. +formatter: + page_width: 300 + linter: rules: # Core Lint Rules diff --git a/packages/analysis_parser_cli/lib/analysis_parser_cli.dart b/packages/analysis_parser_cli/lib/analysis_parser_cli.dart index 9e9afbf..fec140b 100644 --- a/packages/analysis_parser_cli/lib/analysis_parser_cli.dart +++ b/packages/analysis_parser_cli/lib/analysis_parser_cli.dart @@ -28,7 +28,7 @@ class AnalysisParserCli { final Parser parser = AnalysisParser(config: config); final files = await parser.parse(); - final results = AnalysisCLIHelpers.generateAnalysisMap(files); + final results = AnalysisCLIHelpers.generateAnalysisMap(files, workingDirectory); final result = await AnalysisCLIHelpers.writeAnalysisMapToJson(results, '$outputDirectory/.analysis.json'); print(result); print(files.length); diff --git a/packages/analysis_parser_cli/lib/utils/helper.dart b/packages/analysis_parser_cli/lib/utils/helper.dart index 9a7f998..9d3b463 100644 --- a/packages/analysis_parser_cli/lib/utils/helper.dart +++ b/packages/analysis_parser_cli/lib/utils/helper.dart @@ -9,7 +9,7 @@ class AnalysisCLIHelpers { static const languageKey = 'lang'; static const configFileKey = 'config'; - static Map> generateAnalysisMap(List analysisFiles) { + static Map> generateAnalysisMap(List analysisFiles, String rootPath) { final Map> analysisMap = {}; for (var file in analysisFiles) { final lines = {}; @@ -19,7 +19,7 @@ class AnalysisCLIHelpers { return _mergeLineMaps(existing, line.toJson()); }, ifAbsent: line.toJson); } - analysisMap[file.path] = lines; + analysisMap['$rootPath/${file.path}'] = lines; } return analysisMap; } diff --git a/packages/lcov_cli/analysis_options.yaml b/packages/lcov_cli/analysis_options.yaml index dee8927..380d107 100644 --- a/packages/lcov_cli/analysis_options.yaml +++ b/packages/lcov_cli/analysis_options.yaml @@ -13,6 +13,9 @@ include: package:lints/recommended.yaml +formatter: + page_width: 300 + # Uncomment the following section to specify additional rules. # linter: diff --git a/packages/lcov_cli/lib/generators/syntax/syntax_decorator.dart b/packages/lcov_cli/lib/generators/syntax/syntax_decorator.dart index c7a261a..f4a8d36 100644 --- a/packages/lcov_cli/lib/generators/syntax/syntax_decorator.dart +++ b/packages/lcov_cli/lib/generators/syntax/syntax_decorator.dart @@ -30,7 +30,7 @@ class SyntaxDecorator { /// 4. Preserving unmatched content with default styling String applyRules() { final buffer = StringBuffer(); - final styledSpans = <_StyledSpan>[]; + final styledSpans = <_StyledSpan>[]; //TODO: return list of _StyledSpan final matchedPositions = List.filled(content.length, false); @@ -56,6 +56,7 @@ class SyntaxDecorator { match.start, match.end, SpanTag(attributes: {'class': rule.name}, content: cleanContent(match.group(0)!)).build(), + cleanContent(match.group(0)!), ), ); } @@ -71,7 +72,7 @@ class SyntaxDecorator { SpanTag(attributes: {'class': 'default-style'}, content: cleanContent(unmatched)).build(), ); } - buffer.write(span.content); + buffer.write(span.htmlContent); currentIndex = span.end; } @@ -96,10 +97,13 @@ class _StyledSpan { final int end; /// The styled HTML content for this span. - final String content; + final String htmlContent; + + /// The raw content for this span. + final String rawContent; /// Creates a new [_StyledSpan] instance. - _StyledSpan(this.start, this.end, this.content); + _StyledSpan(this.start, this.end, this.htmlContent, this.rawContent); } /// A decorator class for applying syntax highlighting to test code lines. diff --git a/packages/lcov_cli/lib/models/line.dart b/packages/lcov_cli/lib/models/line.dart index c4b3d5b..1a3f05d 100644 --- a/packages/lcov_cli/lib/models/line.dart +++ b/packages/lcov_cli/lib/models/line.dart @@ -6,6 +6,7 @@ class Line { this.isModified = false, this.isLineHit = false, //is line covered this.canHitLine = true, // can line be covered, e.g imports cant be covered so they shiuld be noted + this.qualityAnalysisIssues, }); final int lineNumber; @@ -14,6 +15,7 @@ class Line { final bool isLineHit; final bool canHitLine; final bool isModified; + final QualityAnalysisIssues? qualityAnalysisIssues; Line copyWith({ int? lineNumber, @@ -22,6 +24,7 @@ class Line { bool? isModified, bool? isLineHit, bool? canHitLine, + QualityAnalysisIssues? qualityAnalysisIssues, }) { return Line( lineNumber: lineNumber ?? this.lineNumber, @@ -30,14 +33,29 @@ class Line { isModified: isModified ?? this.isModified, isLineHit: isLineHit ?? this.isLineHit, canHitLine: canHitLine ?? this.canHitLine, + qualityAnalysisIssues: qualityAnalysisIssues ?? this.qualityAnalysisIssues, ); } + bool get hasQualityIssues => qualityAnalysisIssues != null; + @override String toString() { return 'LcovLine{lineNumber: $lineNumber, lineContent: $lineContent, isLineCovered: $isLineHit}'; } } + +final class QualityAnalysisIssues { + final String message; + final String type; + final String? rule; + + QualityAnalysisIssues({ + required this.message, + required this.type, + this.rule, + }); +} class CoverageLine extends Line { CoverageLine({ required super.lineNumber, @@ -65,3 +83,22 @@ class FileLine extends Line { FileLine({required super.lineNumber, required super.lineContent}); } + +class QualityLine extends Line { + QualityLine({ + required super.lineNumber, + required this.message, + required this.type, + this.rule, + }) : super( + qualityAnalysisIssues: QualityAnalysisIssues( + message: message, + type: type, + rule: rule, + ), + ); + + final String message; + final String type; + final String? rule; +} diff --git a/packages/lcov_cli/lib/parsers/code_coverage_file_parser.dart b/packages/lcov_cli/lib/parsers/code_coverage_file_parser.dart index 0d7cc22..cd649e5 100644 --- a/packages/lcov_cli/lib/parsers/code_coverage_file_parser.dart +++ b/packages/lcov_cli/lib/parsers/code_coverage_file_parser.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:lcov_cli/lcov_cli.dart'; import 'package:lcov_cli/models/code_file.dart'; import 'package:lcov_cli/models/lcov_file_group.dart'; import 'package:lcov_cli/models/line.dart'; @@ -14,7 +15,10 @@ import 'package:lcov_cli/parsers/line_parser.dart'; /// It extends the LineParser class to inherit parsing behavior. class CodeCoverageFileParser extends LineParser { // List of code files that have been modified (if any). - final List? modifiedCodeFiles; + final Map>? modifiedCodeFiles; + + // List of code files that have analysis code quality issues (if any). + final Map>? analysisCodeQualityIssues; // List of code files containing coverage information. final List coverageCodeFiles; @@ -23,7 +27,8 @@ class CodeCoverageFileParser extends LineParser { /// /// [modifiedCodeFiles]: A list of files that have been modified (can be null). /// [coverageCodeFiles]: A list of files with code coverage data. - CodeCoverageFileParser({required this.modifiedCodeFiles, required this.coverageCodeFiles}); + /// [analysisCodeQualityIssues]: A list of files with analysis code quality issues (can be null). + CodeCoverageFileParser({this.analysisCodeQualityIssues, this.modifiedCodeFiles, required this.coverageCodeFiles}); /// Parses the lines of code in the provided coverage files and modified files (if any). /// @@ -49,9 +54,6 @@ class CodeCoverageFileParser extends LineParser { ); }).toList(); - // Map of modified files by file path (if any modified files are provided). - final modifiedFilesByPath = {for (var file in modifiedCodeFiles ?? []) file.path: file}; - // Map of coverage files by file path. final coverageFilesByPath = {for (var file in coverageCodeFiles) getLcovFilePath(file.path): file}; @@ -63,7 +65,8 @@ class CodeCoverageFileParser extends LineParser { final coverageLinesByNumber = {for (var line in coverageFilesByPath[group.filePath]?.codeLines ?? []) line.lineNumber: line}; // Map modified lines by line number (if the file has been modified). - final modifiedLinesByNumber = {for (var line in modifiedFilesByPath[group.filePath]?.codeLines ?? []) line.lineNumber: line}; + final modifiedLinesByNumber = modifiedCodeFiles?[group.filePath]; + final analysisLinesByNumber = analysisCodeQualityIssues?[group.filePath]; for (var i = 0; i < group.content.length; i++) { final index = i + 1; // Line number (1-based index). @@ -76,9 +79,11 @@ class CodeCoverageFileParser extends LineParser { canHitLine: coverageLinesByNumber[index]?.canHitLine ?? false, hitCount: coverageLinesByNumber[index]?.hitCount ?? 0, isLineHit: coverageLinesByNumber[index]?.isLineHit ?? false, - isModified: modifiedLinesByNumber[index]?.isModified ?? false, + isModified: modifiedLinesByNumber?[index]?.isModified ?? false, + qualityAnalysisIssues: analysisLinesByNumber?[index]?.qualityAnalysisIssues, ), ); + } codeFiles.add(CodeFile(path: group.filePath, codeLines: fileLines)); diff --git a/packages/lcov_cli/lib/parsers/line_data_json_parser.dart b/packages/lcov_cli/lib/parsers/line_data_json_parser.dart new file mode 100644 index 0000000..fd79692 --- /dev/null +++ b/packages/lcov_cli/lib/parsers/line_data_json_parser.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:lcov_cli/models/line.dart'; +import 'package:lcov_cli/parsers/line_parser.dart'; + +/// A specialized parser for processing JSON files containing line-level data. +/// +/// This abstract parser extends [DynamicLineParser] to produce a structured map +/// of file paths to line information. Each file maps line numbers to [Line] objects, +/// which represent the state or quality of individual lines. +/// +/// Use the factory constructors to create specific implementations: +/// - [LineDataJsonParser.modified] returns a parser for modified lines ([ModifiedLineDataParser]). +/// - [LineDataJsonParser.quality] returns a parser for code quality data ([QualityLineDataParser]). +abstract class LineDataJsonParser extends DynamicLineParser>> { + /// The JSON file being parsed. + final File jsonFile; + + /// Creates an instance of [LineDataJsonParser]. + /// + /// Parameters: + /// - [jsonFile]: The JSON file containing line-level data. + LineDataJsonParser._(this.jsonFile); + + /// Creates a parser for modified lines. + factory LineDataJsonParser.modified(File jsonFile) = _ModifiedLineDataParser; + + /// Creates a parser for code quality data. + factory LineDataJsonParser.quality(File jsonFile) = _QualityLineDataParser; + + /// Parses the JSON file and returns a nested map representing line data. + /// + /// The outer map's keys are file paths, and the inner map's keys are line numbers. + /// The inner map's values are [Line] objects representing the state or quality + /// of each line. + /// + /// Parameters: + /// - [rootPath]: Optional root path for resolving relative file paths. Not used + /// in this implementation. + /// + /// Returns a [Future] containing a [Map] of file paths to line maps. + @override + Future>> parsedLines([String? rootPath]) async { + final jsonString = await jsonFile.readAsString(); + final dynamic jsonData = jsonDecode(jsonString); + final Map> result = {}; + + if (jsonData is Map) { + jsonData.forEach((key, value) { + if (value is Map) { + final Map innerMap = {}; + value.forEach((innerKey, innerValue) { + if (innerValue is int || innerValue is Map) { + final intKey = int.tryParse(innerKey); + if (intKey != null) { + innerMap[intKey] = resolve(intKey, innerValue); + } + } + }); + result[key] = innerMap; + } + }); + } + return result; + } + + /// Resolves a line number and raw data into a [Line] object. + /// + /// This method must be implemented by subclasses to convert raw JSON data + /// into the appropriate [Line] subclass. + /// + /// Parameters: + /// - [lineNumber]: The line number being processed. + /// - [data]: Raw JSON data for this line (integer for modified lines, or a + /// map for quality lines). + /// + /// Returns a [Line] object representing this line's state or quality. + Line resolve(int lineNumber, dynamic data); +} + +/// A parser for tracking modified lines in a JSON file. +class _ModifiedLineDataParser extends LineDataJsonParser { + _ModifiedLineDataParser(super.jsonFile) : super._(); + + @override + Line resolve(int lineNumber, data) { + return GitLine(lineNumber: lineNumber, hasLineChanged: data > 0); + } +} + +/// A parser for tracking code quality issues in a JSON file. +class _QualityLineDataParser extends LineDataJsonParser { + _QualityLineDataParser(super.jsonFile) : super._(); + + @override + Line resolve(int lineNumber, data) { + final type = data['type']; + final message = data['message']; + final rule = data['rule']; + return QualityLine( + lineNumber: lineNumber, + message: _convertToString(message), + type: _convertToString(type), + rule: _convertToString(rule), + ); + } + + String _convertToString(dynamic value) { + if (value is List) return value.join('\n'); + return value?.toString() ?? ''; + } +} diff --git a/packages/lcov_cli/lib/parsers/line_parser.dart b/packages/lcov_cli/lib/parsers/line_parser.dart index ca73b7c..3343c97 100644 --- a/packages/lcov_cli/lib/parsers/line_parser.dart +++ b/packages/lcov_cli/lib/parsers/line_parser.dart @@ -36,4 +36,21 @@ abstract class LineParser { /// /// Returns a list of [CodeFile] objects containing the parsed coverage data. FutureOr> parsedLines([String? rootPath]); -} \ No newline at end of file +} + +/// A base class for parsing code coverage files with dynamic return types. +/// +/// This abstract class provides a common interface for parsing different types of +/// code coverage files (e.g., LCOV, JSON) and returning results of type [T]. +abstract class DynamicLineParser { + /// Creates a new [DynamicLineParser] instance. + const DynamicLineParser(); + + /// Parses the file and returns coverage data of type [T]. + /// + /// Parameters: + /// - [rootPath]: Optional root path to use for resolving relative file paths. + /// + /// Returns a value of type [T] containing the parsed coverage data. + FutureOr parsedLines([String? rootPath]); +} diff --git a/packages/lcov_cli/lib/run.dart b/packages/lcov_cli/lib/run.dart index 390a58b..3aa1445 100644 --- a/packages/lcov_cli/lib/run.dart +++ b/packages/lcov_cli/lib/run.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:lcov_cli/lcov_cli.dart'; import 'package:lcov_cli/parsers/code_coverage_file_parser.dart'; -import 'package:lcov_cli/parsers/json_parser.dart'; +import 'package:lcov_cli/parsers/line_data_json_parser.dart'; class LcovCli { Future run(List args) async { @@ -26,7 +26,8 @@ class LcovCli { Directory(outputDir.orEmpty), settings.parserType, parsedProjectDir.path, - settings.gitParserJsonFile?.path, + settings.gitParserJsonFile, + settings.analysisResultsFile, settings.reportTypes, ); stopwatch.stop(); @@ -39,19 +40,25 @@ class LcovCli { Directory outputDir, ParserType type, String? rootPath, - String? gitparserFile, + File? gitparserFile, + File? analysisResultsFile, List reportTypes, ) async { - LineParser? gitJsonParser; + DynamicLineParser? gitJsonParser; + DynamicLineParser? analysisJsonParser; if (gitparserFile != null) { - gitJsonParser = JsonFileLineParser(File(gitparserFile)); + gitJsonParser = LineDataJsonParser.modified(gitparserFile); + } + if (analysisResultsFile != null) { + analysisJsonParser = LineDataJsonParser.quality(analysisResultsFile); } final LineParser lcovLineParser = LineParser.fromType(type, file); final lcovLines = await lcovLineParser.parsedLines(rootPath); final totalCodeCoverageParser = CodeCoverageFileParser( coverageCodeFiles: lcovLines, - modifiedCodeFiles: gitJsonParser != null ? await gitJsonParser.parsedLines(rootPath) : null, + modifiedCodeFiles: await gitJsonParser?.parsedLines(rootPath), + analysisCodeQualityIssues: await analysisJsonParser?.parsedLines(rootPath), ); final codeFiles = await totalCodeCoverageParser.parsedLines(rootPath); for (var report in reportTypes) { diff --git a/packages/lcov_cli/lib/utils/arg_settings.dart b/packages/lcov_cli/lib/utils/arg_settings.dart index 0140e4b..5ca97e8 100644 --- a/packages/lcov_cli/lib/utils/arg_settings.dart +++ b/packages/lcov_cli/lib/utils/arg_settings.dart @@ -11,6 +11,7 @@ class ArgumentSettings { final bool isFlutterProject; final String? gitParserFile; final List reportTypes; + final String? analysisParserFile; ArgumentSettings({ this.lcovFile, @@ -20,6 +21,7 @@ class ArgumentSettings { this.gitParserFile, this.isFlutterProject = false, required this.reportTypes, + this.analysisParserFile, }); static ArgParser _parser = ArgParser(); @@ -33,6 +35,7 @@ class ArgumentSettings { final rootProjectPathKey = 'projectPath'; final gitParserFileKey = 'gitParserFile'; final reportType = 'reportType'; + final analysisParserFileKey = 'analysisParserFile'; _parser ..addOption(lcovFileKey, abbr: lcovFileKey.split('').first, help: 'Path to the LCOV file') @@ -40,7 +43,9 @@ class ArgumentSettings { ..addOption(outputKey, abbr: outputKey.split('').first, help: 'Path to the output directory') ..addOption(rootProjectPathKey, abbr: rootProjectPathKey.split('').first, help: 'Path to the project') ..addOption(gitParserFileKey, abbr: gitParserFileKey.split('').first, help: 'Path to the git parser file') + ..addOption(analysisParserFileKey, abbr: analysisParserFileKey.split('').first, help: 'Path to the analysis results file (e.g., `coverage/.analyzer.json`)') ..addOption(reportType, abbr: reportType.split('').first, defaultsTo: 'html', help: 'Type of report to generate (html, json, console). multiple can be passed seperated by comma') + ..addOption(flutterProjectKey, abbr: flutterProjectKey.split('').first, defaultsTo: 'false', help: 'Whether or not this is a Flutter project'); final results = _parser.parse(args); @@ -54,6 +59,7 @@ class ArgumentSettings { isFlutterProject: results[flutterProjectKey] == 'true', gitParserFile: results[gitParserFileKey] as String?, reportTypes: ReportType.fromString(results[reportType] as String?), + analysisParserFile: results[analysisParserFileKey] as String?, ); } @@ -71,6 +77,11 @@ class ArgumentSettings { if (gitParserFile != null) return File(gitParserFile!); return null; } + + File? get analysisResultsFile { + if (analysisParserFile != null) return File(analysisParserFile!); + return null; + } ParserType get parserType { if (lcovFile != null) return ParserType.lcov; diff --git a/packages/lcov_cli/test/parsers/code_coverage_file_parser_test.dart b/packages/lcov_cli/test/parsers/code_coverage_file_parser_test.dart index d1f993c..ffd6940 100644 --- a/packages/lcov_cli/test/parsers/code_coverage_file_parser_test.dart +++ b/packages/lcov_cli/test/parsers/code_coverage_file_parser_test.dart @@ -38,14 +38,39 @@ void main() { test('parsedLines correctly merges coverage and modified files data', () async { final parser = CodeCoverageFileParser( - modifiedCodeFiles: [ + modifiedCodeFiles: { + testFile.path: { + 2: Line(lineNumber: 2, lineContent: 'line2', isModified: true), + } + }, + coverageCodeFiles: [ CodeFile( - path: testFile.path, + path: 'test.dart', codeLines: [ - Line(lineNumber: 2, lineContent: 'line2', isModified: true), + Line(lineNumber: 1, lineContent: 'line1', canHitLine: true, hitCount: 1), + Line(lineNumber: 2, lineContent: 'line2', canHitLine: true, hitCount: 0), ], ), ], + ); + + final result = await parser.parsedLines(tempDir.path); + expect(result[0].codeLines[1].isModified, isTrue); + expect(result[0].codeLines[1].hitCount, equals(0)); + }); + + test('parsedLines correctly merges coverage and quality analysis issues data', () async { + final parser = CodeCoverageFileParser( + analysisCodeQualityIssues: { + testFile.path: { + 2: QualityLine( + lineNumber: 2, + message: 'Missing a catch clause', + type: 'error', + rule: 'some_rule', + ), + } + }, coverageCodeFiles: [ CodeFile( path: 'test.dart', @@ -58,8 +83,10 @@ void main() { ); final result = await parser.parsedLines(tempDir.path); - expect(result[0].codeLines[1].isModified, isTrue); - expect(result[0].codeLines[1].hitCount, equals(0)); + expect(result[0].codeLines[1].hasQualityIssues, isTrue); + expect(result[0].codeLines[1].qualityAnalysisIssues?.message, equals('Missing a catch clause')); + expect(result[0].codeLines[1].qualityAnalysisIssues?.type, equals('error')); + expect(result[0].codeLines[1].qualityAnalysisIssues?.rule, equals('some_rule')); }); test('parsedLines handles non-existent files gracefully', () async { diff --git a/packages/lcov_cli/test/parsers/line_data_json_parser_test.dart b/packages/lcov_cli/test/parsers/line_data_json_parser_test.dart new file mode 100644 index 0000000..76e2f63 --- /dev/null +++ b/packages/lcov_cli/test/parsers/line_data_json_parser_test.dart @@ -0,0 +1,258 @@ +import 'dart:io'; +import 'package:lcov_cli/parsers/line_data_json_parser.dart'; +import 'package:test/test.dart'; +import 'package:lcov_cli/models/line.dart'; + +void main() { + late LineDataJsonParser parser; + late Directory tempDir; + + group('LineDataJsonParser.quality Test', () { + late File qualityAnalysisFile; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp(); + qualityAnalysisFile = File('${tempDir.path}/test.json'); + parser = LineDataJsonParser.quality(qualityAnalysisFile); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test( + 'Given a JSON file containing a single quality issue ' + 'When the parser processes it ' + 'Then it should return one QualityLine with the correct details', + () async { + await qualityAnalysisFile.writeAsString(''' + { + "test/commands/comands_test.dart": { + "127": { + "line": 127, + "column": 3, + "type": "info", + "message": "Constructor declarations should be before non-constructor declarations. Try moving the constructor declaration before all other members.", + "rule": "sort_constructors_first" + } + } + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(1)); + expect(result['test/commands/comands_test.dart']?.entries.length, equals(1)); + expect(result['test/commands/comands_test.dart']?.entries.single.value, isA()); + expect(result['test/commands/comands_test.dart']?.entries.single.value.lineNumber, equals(127)); + expect(result['test/commands/comands_test.dart']?.entries.single.value.qualityAnalysisIssues?.message, equals('Constructor declarations should be before non-constructor declarations. Try moving the constructor declaration before all other members.')); + expect(result['test/commands/comands_test.dart']?.entries.single.value.qualityAnalysisIssues?.type, equals('info')); + expect(result['test/commands/comands_test.dart']?.entries.single.value.qualityAnalysisIssues?.rule, equals('sort_constructors_first')); + }, + ); + + test( + 'Given a JSON file containing multiple messages for a single line ' + 'When the parser processes it ' + 'Then it should concatenate all messages, types, and rules into a single QualityLine', + () async { + await qualityAnalysisFile.writeAsString(''' + { + "test/commands/comands_test.dart": { + "167": { + "line": 167, + "message": [ + "Missing type annotation on a public API. Try adding a type annotation.", + "The method 'noSuchMethod' should have a return type but doesn't. Try adding a return type to the method." + ], + "type": [ + "info", + "info" + ], + "column": [ + 3, + 3 + ], + "rule": [ + "type_annotate_public_apis", + "always_declare_return_types" + ] + } + } + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(1)); + expect(result['test/commands/comands_test.dart']?.entries.length, equals(1)); + expect(result['test/commands/comands_test.dart']?.entries.single.value, isA()); + expect(result['test/commands/comands_test.dart']?.entries.single.value.lineNumber, equals(167)); + expect( + result['test/commands/comands_test.dart']?.entries.single.value.qualityAnalysisIssues?.message, equals('Missing type annotation on a public API. Try adding a type annotation.\nThe method \'noSuchMethod\' should have a return type but doesn\'t. Try adding a return type to the method.')); + expect(result['test/commands/comands_test.dart']?.entries.single.value.qualityAnalysisIssues?.type, equals('info\ninfo')); + expect(result['test/commands/comands_test.dart']?.entries.single.value.qualityAnalysisIssues?.rule, equals('type_annotate_public_apis\nalways_declare_return_types')); + }, + ); + + test( + 'Given a JSON file containing multiple lines with single and multiple messages ' + 'When the parser processes it ' + 'Then it should return all QualityLines with the correct merged content for each line', + () async { + await qualityAnalysisFile.writeAsString(''' + { + "test/commands/comands_test.dart": { + "127": { + "line": 127, + "column": 3, + "type": "info", + "message": "Constructor declarations should be before non-constructor declarations", + "rule": "sort_constructors_first" + }, + "155": { + "line": 155, + "column": 3, + "type": "info", + "message": "Constructor declarations should be before non-constructor declarations", + "rule": "sort_constructors_first" + }, + "167": { + "line": 167, + "message": [ + "Missing type annotation on a public API. Try adding a type annotation.", + "The method 'noSuchMethod' should have a return type but doesn't. Try adding a return type to the method." + ], + "type": [ + "info", + "info" + ], + "column": [ + 3, + 3 + ], + "rule": [ + "type_annotate_public_apis", + "always_declare_return_types" + ] + } + } + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(1)); + expect(result['test/commands/comands_test.dart']?.entries.length, equals(3)); + expect(result['test/commands/comands_test.dart']?[127]?.qualityAnalysisIssues?.message, equals('Constructor declarations should be before non-constructor declarations')); + expect(result['test/commands/comands_test.dart']?[155]?.qualityAnalysisIssues?.message, equals('Constructor declarations should be before non-constructor declarations')); + expect(result['test/commands/comands_test.dart']?[167]?.qualityAnalysisIssues?.message, equals('Missing type annotation on a public API. Try adding a type annotation.\nThe method \'noSuchMethod\' should have a return type but doesn\'t. Try adding a return type to the method.')); + expect(result['test/commands/comands_test.dart']?[167]?.qualityAnalysisIssues?.type, equals('info\ninfo')); + expect(result['test/commands/comands_test.dart']?[167]?.qualityAnalysisIssues?.rule, equals('type_annotate_public_apis\nalways_declare_return_types')); + }, + ); + }); + + group('LineDataJsonParser.modified Test', () { + late File modifiedFile; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp(); + modifiedFile = File('${tempDir.path}/test.json'); + parser = LineDataJsonParser.modified(modifiedFile); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test( + 'Given a valid JSON file with multiple files and line change indicators ' + 'When the parser processes it ' + 'Then it should correctly identify modified and unmodified lines for each file', + () async { + await modifiedFile.writeAsString(''' + { + "lib/file1.dart": {"1": 1, "2": 0, "3": 1}, + "lib/file2.dart": {"1": 0, "2": 1, "3": 0} + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(2)); + expect(result['lib/file1.dart']?.entries.length, equals(3)); + expect((result['lib/file1.dart']?[1] as GitLine).hasLineChanged, isTrue); + expect((result['lib/file1.dart']?[2] as GitLine).hasLineChanged, isFalse); + expect((result['lib/file1.dart']?[3] as GitLine).hasLineChanged, isTrue); + }); + + test( + 'Given an empty JSON file ' + 'When the parser processes it ' + 'Then it should return an empty result', + () async { + await modifiedFile.writeAsString('{}'); + + final result = await parser.parsedLines(); + + expect(result, isEmpty); + }); + + test( + 'Given a JSON file containing invalid line numbers ' + 'When the parser processes it ' + 'Then it should skip invalid entries and parse only valid lines', + () async { + await modifiedFile.writeAsString(''' + { + "lib/file1.dart": {"invalid": 1, "2": 0} + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(1)); + expect(result['lib/file1.dart']?.entries.length, equals(1)); + expect(result['lib/file1.dart']?[2]?.lineNumber, equals(2)); + }); + + test( + 'Given a JSON file with non-integer change values ' + 'When the parser processes it ' + 'Then it should ignore invalid entries and include only valid numeric changes', + () async { + await modifiedFile.writeAsString(''' + { + "lib/file1.dart": {"1": "changed", "2": null, "3": 1} + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(1)); + expect(result['lib/file1.dart']?.entries.length, equals(1)); + expect(result['lib/file1.dart']?[1], isNull); + expect(result['lib/file1.dart']?[2], isNull); + expect(result['lib/file1.dart']?[3], isA()); + expect(result['lib/file1.dart']?[3]?.isModified, true); + expect(result['lib/file1.dart']?[3]?.lineNumber, 3); + }); + + test( + 'Given a JSON file with a malformed structure ' + 'When the parser processes it ' + 'Then it should skip invalid entries and parse only properly formatted sections', + () async { + await modifiedFile.writeAsString(''' + { + "lib/file1.dart": "not a map", + "lib/file2.dart": {"1": 1} + } + '''); + + final result = await parser.parsedLines(); + + expect(result.length, equals(1)); + expect(result['lib/file2.dart']?.entries.length, equals(1)); + }); + }); +}