diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 0000000..da387f9 --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,95 @@ +name: Dart CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dart-lang/setup-dart@v1 + - name: Dart version + run: | + dart --version + uname -a + - name: Install dependencies + run: dart pub get + - name: Upgrade dependencies + run: dart pub upgrade + - name: dart format + run: dart format -o none --set-exit-if-changed . + - name: dart analyze + run: dart analyze --fatal-infos --fatal-warnings . + - name: dependency_validator + run: dart run dependency_validator + - name: dart pub publish --dry-run + run: dart pub publish --dry-run + + + test_linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dart-lang/setup-dart@v1 + - name: Dart version + run: | + dart --version + uname -a + - name: Install dependencies + run: dart pub get + - name: Upgrade dependencies + run: dart pub upgrade + - name: Run tests + run: dart run test --platform vm --coverage=./coverage + - name: Generate coverage report + run: | + dart pub global activate coverage + dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --report-on=lib --lcov -o ./coverage/lcov.info -i ./coverage +# - name: Upload coverage to Codecov +# uses: codecov/codecov-action@v3 +# env: +# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +# with: +# directory: ./coverage/ +# flags: unittests +# env_vars: OS,DART +# fail_ci_if_error: true +# verbose: true + + test_windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: dart-lang/setup-dart@v1 + - name: Dart version + run: | + dart --version + uname -a + - name: Install dependencies + run: dart pub get + - name: Upgrade dependencies + run: dart pub upgrade + - name: Run tests + run: dart run test --platform vm + + + test_macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - uses: dart-lang/setup-dart@v1 + - name: Dart version + run: | + dart --version + uname -a + - name: Install dependencies + run: dart pub get + - name: Upgrade dependencies + run: dart pub upgrade + - name: Run tests + run: dart run test --platform vm + diff --git a/.gitignore b/.gitignore index 102eaf4..dd07ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ build/ # If you're building an application, you may want to check-in your pubspec.lock pubspec.lock + +# Coverage files +coverage/ + +.DS_Store diff --git a/AUTHORS b/AUTHORS index b4661b6..4aedb8c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,6 @@ # Below is a list of people and organizations that have contributed # to the project. Names should be added to the list like so: # -# Name/Organization +# Name/Organization -Jacob MacDonald +Graciliano M. Passos: gmpassos @ GitHub diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf0abc..79807be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 1.2.0 + +- CLI: + - Using `ascii_art_tree` to show the output tree, with styles `dots` (original) and `elegant`. + - Added options: + - `--regexp`: to use `RegExp` to match the target import. + - `--all`: to find all the import paths. + - `--quiet`: for a quiet output (only displays found paths). + - `--strip`: strips the search root directory from displayed import paths. + - `--format`: Defines the style for the output tree (elegant, dots, json). + - `--fast`: to enable a fast import parser. + - Improved help with examples. +- Added support for conditional imports. +- Added public libraries to facilitate integration with other packages. +- Updated `README.md` to show CLI and Library usage. + # 1.1.1 - Add explicit executables config to the pubspec.yaml. diff --git a/README.md b/README.md index 05f571a..9e1f370 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,98 @@ -A tool to find the shortest import path from one dart file to another. +# import_path -## Usage +[![pub package](https://img.shields.io/pub/v/import_path.svg?logo=dart&logoColor=00b9fc)](https://pub.dartlang.org/packages/import_path) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![Dart CI](https://github.com/jakemac53/import_path/actions/workflows/dart.yml/badge.svg?branch=master)](https://github.com/jakemac53/import_path/actions/workflows/dart.yml) +[![GitHub Tag](https://img.shields.io/github/v/tag/jakemac53/import_path?logo=git&logoColor=white)](https://github.com/jakemac53/import_path/releases) +[![Last Commits](https://img.shields.io/github/last-commit/jakemac53/import_path?logo=git&logoColor=white)](https://github.com/jakemac53/import_path/commits/master) +[![Pull Requests](https://img.shields.io/github/issues-pr/jakemac53/import_path?logo=github&logoColor=white)](https://github.com/jakemac53/import_path/pulls) +[![Code size](https://img.shields.io/github/languages/code-size/jakemac53/import_path?logo=github&logoColor=white)](https://github.com/jakemac53/import_path) +[![License](https://img.shields.io/github/license/jakemac53/import_path?logo=open-source-initiative&logoColor=green)](https://github.com/jakemac53/import_path/blob/master/LICENSE) + +A tool to find the shortest import path or listing +all import paths between two Dart files. +It also supports the use of `RegExp` to match imports. + +## CLI Usage First, globally activate the package: -`dart pub global activate import_path` +```shell +dart pub global activate import_path +``` Then run it, the first argument is the library or application that you want to start searching from, and the second argument is the import you want to search for. -`import_path ` +```shell +import_path +``` Files should be specified as dart import uris, so relative or absolute file paths, as well as `package:` and `dart:` uris are supported. -## Example +## Examples From the root of this package, you can do: -``` +```shell pub global activate import_path + import_path bin/import_path.dart package:analyzer/dart/ast/ast.dart ``` + +To find all the `dart:io` imports from a `web/main.dart`: + +```shell +import_path web/main.dart dart:io --all +``` + +Search for all the imports for "dart:io" and "dart:html" using `RegExp`: + +```shell +import_path web/main.dart "dart:(io|html)" --regexp --all +``` +For help or more usage examples: + +```shell +import_path --help +``` + +## Library Usage + +You can also use the class `ImportPath` from your code: + +```dart +import 'package:import_path/import_path.dart'; + +void main(List args) async { + var strip = args.any((a) => a == '--strip' || a == '-s'); + + var importPath = ImportPath( + Uri.base.resolve('bin/import_path.dart'), + 'package:analyzer/dart/ast/ast.dart', + strip: strip, + ); + + await importPath.execute(); +} +``` + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/jakemac53/import_path/issues + +## Authors + +- Jacob MacDonald: [jakemac53][github_jakemac53]. +- Graciliano M. Passos: [gmpassos][github_gmpassos]. + +[github_jakemac53]: https://github.com/jakemac53 +[github_gmpassos]: https://github.com/gmpassos + +## License + +Dart free & open-source [license](https://github.com/jakemac53/import_path/blob/master/LICENSE). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# 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 + +# 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/bin/import_path.dart b/bin/import_path.dart index a355e56..41cdc8f 100644 --- a/bin/import_path.dart +++ b/bin/import_path.dart @@ -2,105 +2,105 @@ // All rights reserved. Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -import 'dart:collection'; -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:package_config/package_config.dart'; -import 'package:path/path.dart' as p; - -// Assigned early on in `main`. -late PackageConfig packageConfig; - -main(List args) async { - if (args.length != 2) { - print(''' -Expected exactly two Dart files as arguments, a file to start -searching from and an import to search for. +import 'package:args/args.dart'; +import 'package:import_path/import_path.dart'; + +void _showHelp(ArgParser argsParser) { + var usage = argsParser.usage + .replaceAllMapped(RegExp(r'(^|\n)'), (m) => '${m.group(1)} '); + + print(''' +╔═════════════════════╗ +║ import_path - CLI ║ +╚═════════════════════╝ + +USAGE: + + import_path %startSearchFile %targetImport -s -q --all + +OPTIONS: + +$usage + +EXAMPLES: + + # Search for the shortest import path of `dart:io` in a `web` directory: + import_path web/main.dart dart:io + + # Search all the import paths of a deferred library, + # stripping the search root directory from the output: + import_path web/main.dart web/lib/deferred_lib.dart --all -s + + # For a quiet output (no headers or warnings, only displays found paths): + import_path web/main.dart dart:io -q + + # Search for all the imports for "dart:io" and "dart:html" using `RegExp`: + import_path web/main.dart "dart:(io|html)" --regexp --all '''); +} + +void main(List args) async { + var argsParser = ArgParser(); + + argsParser.addFlag('help', + abbr: 'h', negatable: false, help: "Show usage information"); + + argsParser.addFlag('all', + abbr: 'a', negatable: false, help: "Searches for all the import paths."); + + argsParser.addFlag('strip', + abbr: 's', + negatable: false, + help: "Strips the search root directory from displayed import paths."); + + argsParser.addFlag('regexp', + abbr: 'r', + negatable: false, + help: "Parses `%targetImport` as a `RegExp`."); + + argsParser.addFlag('fast', + abbr: 'z', + negatable: false, + help: + "Uses a fast Dart parser (only parses the import directives at the top)."); + + argsParser.addFlag('quiet', + abbr: 'q', + negatable: false, + help: "Quiet output (only displays found paths)."); + + argsParser.addOption('format', + abbr: 'f', + allowed: ['elegant', 'dots', 'json'], + defaultsTo: 'elegant', + help: "The output format"); + + var argsResult = argsParser.parse(args); + + var help = argsResult.arguments.isEmpty || argsResult['help']; + if (help) { + _showHelp(argsParser); return; } - var from = Uri.base.resolve(args[0]); - var importToFind = Uri.base.resolve(args[1]); - packageConfig = (await findPackageConfig(Directory.current))!; - - var root = from.scheme == 'package' ? packageConfig.resolve(from)! : from; - var queue = Queue()..add(root); - - // Contains the closest parent to the root of the app for a given uri. - var parents = {root.toString(): null}; - while (queue.isNotEmpty) { - var parent = queue.removeFirst(); - var newImports = _importsFor(parent) - .where((uri) => !parents.containsKey(uri.toString())); - queue.addAll(newImports); - for (var import in newImports) { - parents[import.toString()] = parent.toString(); - if (importToFind == import) { - _printImportPath(import.toString(), parents, root.toString()); - return; - } - } - } - print('Unable to find an import path from $from to $importToFind'); -} + var regexp = argsResult['regexp'] as bool; + var findAll = argsResult['all'] as bool; + var quiet = argsResult['quiet'] as bool; + var strip = argsResult['strip'] as bool; + var fast = argsResult['fast'] as bool; -final generatedDir = p.join('.dart_tool/build/generated'); - -List _importsFor(Uri uri) { - if (uri.scheme == 'dart') return []; - - var file = File((uri.scheme == 'package' ? packageConfig.resolve(uri) : uri)! - .toFilePath()); - // Check the generated dir for package:build - if (!file.existsSync()) { - var package = uri.scheme == 'package' - ? packageConfig[uri.pathSegments.first] - : packageConfig.packageOf(uri); - if (package == null) { - print('Warning: unable to read file at $uri, skipping it'); - return []; - } - var path = uri.scheme == 'package' - ? p.joinAll(uri.pathSegments.skip(1)) - : p.relative(uri.path, from: package.root.path); - file = File(p.join(generatedDir, package.name, path)); - if (!file.existsSync()) { - print('Warning: unable to read file at $uri, skipping it'); - return []; - } - } - var contents = file.readAsStringSync(); - - var parsed = parseString(content: contents, throwIfDiagnostics: false); - return parsed.unit.directives - .whereType() - .where((directive) { - if (directive.uri.stringValue == null) { - print('Empty uri content: ${directive.uri}'); - } - return directive.uri.stringValue != null; - }) - .map((directive) => uri.resolve(directive.uri.stringValue!)) - .toList(); -} + var format = argsResult['format'] as String; -void _printImportPath( - String import, Map parents, String root) { - var path = []; - String? next = import; - path.add(next); - while (next != root && next != null) { - next = parents[next]; - if (next != null) { - path.add(next); - } - } - var spacer = ''; - for (var import in path.reversed) { - print('$spacer$import'); - spacer += '..'; - } + var style = parseImportPathStyle(format) ?? ImportPathStyle.elegant; + + var from = Uri.base.resolve(argsResult.rest[0]); + + var importToFindArg = argsResult.rest[1]; + var importToFind = + regexp ? RegExp(importToFindArg) : Uri.base.resolve(importToFindArg); + + var importPath = ImportPath(from, importToFind, + findAll: findAll, quiet: quiet, strip: strip, fastParser: fast); + + await importPath.execute(style: style); } diff --git a/example/example.dart b/example/example.dart index 5973e3d..a49c6fc 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,11 +1,27 @@ -import 'dart:io'; - -void main() async { - var result = await Process.run('pub', [ - 'run', - 'import_path', - 'bin/import_path.dart', - 'package:analyzer/dart/ast/ast.dart' - ]); - print(result.stdout); +import 'package:import_path/import_path.dart'; + +void main(List args) async { + var strip = args.any((a) => a == '--strip' || a == '-s'); + + var importPath = ImportPath( + Uri.base.resolve('bin/import_path.dart'), + 'package:analyzer/dart/ast/ast.dart', + strip: strip, + ); + + await importPath.execute(); } + +///////////////////////////////////// +// OUTPUT: with argument `--strip` // +///////////////////////////////////// +// » Search entry point: file:///workspace/import_path/bin/import_path.dart +// » Stripping search root from displayed imports: file:///workspace/import_path/ +// » Searching for the shortest import path for `package:analyzer/dart/ast/ast.dart`... +// » Search finished [total time: 299 ms, resolve paths time: 5 ms] +// +// bin/import_path.dart +// └─┬─ package:import_path/import_path.dart +// └─┬─ package:import_path/src/import_path_parser.dart +// └──> package:analyzer/dart/ast/ast.dart +// diff --git a/lib/import_path.dart b/lib/import_path.dart new file mode 100644 index 0000000..e794a63 --- /dev/null +++ b/lib/import_path.dart @@ -0,0 +1,14 @@ +/// Import Path search library. +library import_path; + +export 'src/import_path_base.dart' + show + ImportPathStyle, + parseImportPathStyle, + ImportPathStyleExtension, + ImportToFind, + ImportToFindURI, + ImportToFindRegExp, + ImportPath; +export 'src/import_path_parser.dart' show ImportParser; +export 'src/import_path_scanner.dart' show ImportPathScanner; diff --git a/lib/src/import_path_base.dart b/lib/src/import_path_base.dart new file mode 100644 index 0000000..607b3f4 --- /dev/null +++ b/lib/src/import_path_base.dart @@ -0,0 +1,276 @@ +// Copyright (c) 2020, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Based on the original work of: +// - Jacob MacDonald: jakemac53 on GitHub +// +// Conversion to library: +// - Graciliano M. Passos: gmpassos @ GitHub +// + +import 'dart:convert' show JsonEncoder; +import 'dart:io'; + +import 'package:ascii_art_tree/ascii_art_tree.dart'; +import 'package:graph_explorer/graph_explorer.dart'; +import 'package:path/path.dart' as p; + +import 'import_path_parser.dart'; +import 'import_path_scanner.dart'; + +typedef MessagePrinter = void Function(Object? m); + +/// Base class [ImportPath], [ImportPathScanner] and [ImportParser]. +abstract class ImportWidget { + /// If `true`, we won't call [printMessage]. + final bool quiet; + + /// The function to print messages/text. Default: [print]. + /// Called by [printMessage]. + MessagePrinter messagePrinter; + + ImportWidget({this.quiet = false, this.messagePrinter = print}); + + /// Prints a message/text. + /// - See [messagePrinter]. + void printMessage(Object? m) => messagePrinter(m); +} + +/// The import to find using [ImportPath]. +/// - See [ImportToFindURI] and [ImportToFindRegExp]. +abstract class ImportToFind extends NodeMatcher { + /// Resolves [o] (an [Uri], [String], [RegExp] or [ImportToFind]) + /// and returns an [ImportToFind]. + /// - If [o] is a [String] it should be a valid [Uri]. + /// - See [ImportToFindURI] and [ImportToFindRegExp]. + factory ImportToFind.from(Object o) { + if (o is ImportToFind) { + return o; + } else if (o is Uri) { + return ImportToFindURI(o); + } else if (o is RegExp) { + return ImportToFindRegExp(o); + } else if (o is String) { + return ImportToFindURI(Uri.parse(o)); + } else { + throw ArgumentError("Can't resolve: $o"); + } + } + + ImportToFind(); + + @override + bool matchesValue(Uri value) => matches(value); + + /// Returns `true` if [importUri] matches the import to find. + bool matches(Uri importUri); +} + +/// An [Uri] implementation of [ImportToFind]. +class ImportToFindURI extends ImportToFind { + final Uri uri; + + ImportToFindURI(this.uri); + + @override + bool matches(Uri importUri) => uri == importUri; + + @override + String toString() => uri.toString(); +} + +/// A [RegExp] implementation of [ImportToFind]. +class ImportToFindRegExp extends ImportToFind { + final RegExp regExp; + + ImportToFindRegExp(this.regExp); + + @override + bool matches(Uri importUri) => regExp.hasMatch(importUri.toString()); + + @override + String toString() => regExp.toString(); +} + +/// The [ImportPath] output style. +enum ImportPathStyle { + dots, + elegant, + json, +} + +ImportPathStyle? parseImportPathStyle(String s) { + s = s.toLowerCase().trim(); + + switch (s) { + case 'dots': + return ImportPathStyle.dots; + case 'elegant': + return ImportPathStyle.elegant; + case 'json': + return ImportPathStyle.json; + default: + return null; + } +} + +extension ImportPathStyleExtension on ImportPathStyle { + ASCIIArtTreeStyle? get asASCIIArtTreeStyle { + switch (this) { + case ImportPathStyle.dots: + return ASCIIArtTreeStyle.dots; + case ImportPathStyle.elegant: + return ASCIIArtTreeStyle.elegant; + default: + return null; + } + } +} + +/// Import Path search tool. +class ImportPath extends ImportWidget { + /// The entry point to start the search. + final Uri from; + + /// The import to find. Can be an [Uri] or a [RegExp]. + /// See [ImportToFind.from]. + final ImportToFind importToFind; + + /// If `true` searches for all import matches. + /// See [ImportPathScanner.findAll]. + final bool findAll; + + /// If `true` remove from paths the [searchRoot]. + final bool strip; + + /// If `true`, it will use a fast parser that attempts to + /// parse only the import section of Dart files. Default: `false`. + /// See [ImportPathScanner.fastParser]. + final bool fastParser; + + /// If `true`, it will also scan imports that depend on an `if` resolution. Default: `true`. + /// See [ImportPathScanner.includeConditionalImports]. + final bool includeConditionalImports; + + /// The search root to strip from the displayed import paths. + /// - If `searchRoot` is not provided at construction it's resolved + /// using [from] parent directory (see [resolveSearchRoot]). + late String searchRoot; + + ImportPath(this.from, Object importToFind, + {this.findAll = false, + bool quiet = false, + this.strip = false, + this.fastParser = false, + this.includeConditionalImports = true, + String? searchRoot, + MessagePrinter messagePrinter = print}) + : importToFind = ImportToFind.from(importToFind), + super(quiet: quiet, messagePrinter: messagePrinter) { + this.searchRoot = searchRoot ?? resolveSearchRoot(); + } + + /// This list contains common Dart root directories. + /// These names are preserved by [resolveSearchRoot] to prevent + /// them from being stripped. Default: `'web', 'bin', 'src', 'test', 'example'` + List commonRootDirectories = ['web', 'bin', 'src', 'test', 'example']; + + /// Resolves the [searchRoot] using [from] parent. + /// See [commonRootDirectories]. + String resolveSearchRoot() { + var rootPath = p.dirname(from.path); + var rootDirName = p.split(rootPath).last; + + if (commonRootDirectories.contains(rootDirName)) { + var rootPath2 = p.dirname(rootPath); + if (rootPath2.isNotEmpty) { + rootPath = rootPath2; + } + } + + var rootUri = from.replace(path: rootPath).toString(); + return rootUri.endsWith('/') ? rootUri : '$rootUri/'; + } + + /// Return the search root to [strip] from the displayed import paths. + /// If [strip] is `false` returns `null`. + /// See [searchRoot]. + String? get _stripSearchRoot => strip ? searchRoot : null; + + /// Executes the import search and prints the results. + /// - [style] defines the output format. Default: [ImportPathStyle.elegant] + /// - See [search], [printMessage] and [ASCIIArtTree]. + Future execute( + {ImportPathStyle style = ImportPathStyle.elegant, + Directory? packageDirectory}) async { + var tree = await search( + style: style.asASCIIArtTreeStyle ?? ASCIIArtTreeStyle.elegant, + packageDirectory: packageDirectory); + + if (tree != null) { + if (!quiet) { + printMessage(''); + } + + if (style == ImportPathStyle.json) { + var j = JsonEncoder.withIndent(' ').convert(tree.toJson()); + printMessage(j); + } else { + var treeText = tree.generate(); + printMessage(treeText); + } + } + + if (!quiet) { + if (tree == null) { + printMessage( + '» Unable to find an import path from $from to $importToFind'); + } else { + var totalFoundPaths = tree.totalLeaves; + if (totalFoundPaths > 1) { + printMessage( + '» Found $totalFoundPaths import paths from $from to $importToFind\n'); + } + } + } + + return tree; + } + + /// Performs the imports search and returns the tree. + /// - [style] defines [ASCIIArtTree] style. Default: [ASCIIArtTreeStyle.elegant] + /// - See [searchPaths] and [ASCIIArtTree]. + Future search( + {ASCIIArtTreeStyle style = ASCIIArtTreeStyle.elegant, + Directory? packageDirectory}) async { + var foundPaths = await searchPaths(packageDirectory: packageDirectory); + if (foundPaths.isEmpty) return null; + + var asciiArtTree = ASCIIArtTree.fromPaths( + foundPaths, + stripPrefix: _stripSearchRoot, + style: style, + ); + + return asciiArtTree; + } + + /// Performs the imports search and returns the found import paths. + /// - [packageDirectory] is used to resolve . Default: [Directory.current]. + /// - Uses [ImportPathScanner]. + Future>> searchPaths({Directory? packageDirectory}) async { + var importPathScanner = ImportPathScanner( + findAll: findAll, + fastParser: fastParser, + messagePrinter: messagePrinter, + quiet: quiet, + includeConditionalImports: includeConditionalImports); + + var foundPaths = await importPathScanner.searchPaths(from, importToFind, + stripSearchRoot: _stripSearchRoot, packageDirectory: packageDirectory); + + var foundPathsStr = foundPaths.toListOfStringPaths(); + return foundPathsStr; + } +} diff --git a/lib/src/import_path_parser.dart b/lib/src/import_path_parser.dart new file mode 100644 index 0000000..ba78c0c --- /dev/null +++ b/lib/src/import_path_parser.dart @@ -0,0 +1,207 @@ +// Copyright (c) 2020, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Based on the original work of: +// - Jacob MacDonald: jakemac53 on GitHub +// +// Faster parser by: +// - Graciliano M. Passos: gmpassos @ GitHub +// + +import 'dart:collection'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:package_config/package_config.dart'; +import 'package:path/path.dart' as p; + +import 'import_path_base.dart'; + +/// Dart import parser. +/// Parses for import directives in Dart files. +class ImportParser extends ImportWidget { + /// The Dart package [Directory], root of [PackageConfig]. + final Directory packageDirectory; + + /// If `true`, it will include the imports that + /// depend on an `if` resolution. Default: `true`. + final bool includeConditionalImports; + + /// If `true`, it will use a fast parser that attempts to + /// parse only the import section of Dart files. Default: `false`. + /// + /// The faster parser is usually 2-3 times faster. + /// To perform fast parsing, it first tries to detect the last import line + /// using a simple [RegExp] match, then parses the file only up to the last + /// import. If it fails, it falls back to full-file parsing. + final bool fastParser; + + final PackageConfig _packageConfig; + final String _generatedDir; + + ImportParser(this.packageDirectory, this._packageConfig, + {this.includeConditionalImports = true, + this.fastParser = false, + bool quiet = false, + MessagePrinter messagePrinter = print}) + : _generatedDir = p.join('.dart_tool/build/generated'), + super(quiet: quiet, messagePrinter: messagePrinter); + + static Future from(Directory packageDirectory, + {bool includeConditionalImports = true, + bool fastParser = false, + bool quiet = false, + MessagePrinter messagePrinter = print}) async { + var packageConfig = (await findPackageConfig(packageDirectory))!; + return ImportParser(packageDirectory, packageConfig, + includeConditionalImports: includeConditionalImports, + fastParser: fastParser, + quiet: quiet, + messagePrinter: messagePrinter); + } + + /// Resolves an [uri] from [packageDirectory]. + Uri resolveUri(Uri uri) => + uri.scheme == 'package' ? _packageConfig.resolve(uri)! : uri; + + final Map> _importsCache = {}; + + /// Disposes the internal imports cache. + void disposeCache() { + _importsCache.clear(); + } + + /// Returns the imports for [uri]. + /// - If [cached] is `true` will use the internal cache of resolved imports. + List importsFor(Uri uri, {bool cached = true}) { + if (cached) { + return UnmodifiableListView(_importsCache[uri] ??= _importsForImpl(uri)); + } else { + return _importsForImpl(uri); + } + } + + List _importsForImpl(Uri uri) { + if (uri.scheme == 'dart') return []; + + final isSchemePackage = uri.scheme == 'package'; + + var filePath = + (isSchemePackage ? _packageConfig.resolve(uri) : uri)?.toFilePath(); + + if (filePath == null) { + if (!quiet) { + printMessage('» [WARNING] Unable to resolve Uri $uri, skipping it'); + } + return []; + } + + var file = File(filePath); + + // Check the [_generatedDir] for package:build + if (!file.existsSync()) { + var package = isSchemePackage + ? _packageConfig[uri.pathSegments.first] + : _packageConfig.packageOf(uri); + + if (package == null) { + if (!quiet) { + printMessage('» [WARNING] Unable to read file at $uri, skipping it'); + } + return []; + } + + var path = isSchemePackage + ? p.joinAll(uri.pathSegments.skip(1)) + : p.relative(uri.path, from: package.root.path); + + file = File(p.join(_generatedDir, package.name, path)); + if (!file.existsSync()) { + if (!quiet) { + printMessage('» [WARNING] Unable to read file at $uri, skipping it'); + } + return []; + } + } + + var content = file.readAsStringSync(); + + var importDirectives = _parseImportDirectives(content, quiet); + var importsUris = _filterImportDirectiveUris(importDirectives, uri); + + return importsUris; + } + + List _filterImportDirectiveUris( + Iterable importDirectives, Uri uri) => + importDirectives + .expand((directive) { + var mainUri = directive.uri.stringValue!; + + if (includeConditionalImports && + directive.configurations.isNotEmpty) { + var conditional = directive.configurations + .map((e) => e.uri.stringValue) + .whereType() + .toList(); + + var multiple = [mainUri, ...conditional]; + return multiple; + } else { + return [mainUri]; + } + }) + .map((directiveUriStr) => uri.resolve(directiveUriStr)) + .toList(); + + Iterable _parseImportDirectives( + String content, bool quiet) { + if (fastParser) { + var header = _extractHeader(content); + if (header != null) { + var headerParsed = + parseString(content: header, throwIfDiagnostics: false); + if (headerParsed.errors.isEmpty) { + return _filterImportDirectives(headerParsed, quiet); + } + } + } + + var parsed = parseString(content: content, throwIfDiagnostics: false); + return _filterImportDirectives(parsed, quiet); + } + + Iterable _filterImportDirectives( + ParseStringResult parsed, bool quiet) { + var importDirectives = parsed.unit.directives + .whereType() + .where((directive) { + var uriNull = directive.uri.stringValue == null; + if (uriNull && !quiet) { + printMessage('Empty uri content: ${directive.uri}'); + } + return !uriNull; + }); + + return importDirectives; + } + + static final _regExpImport = RegExp( + r'''(?:^|\n)[ \t]*(?:import|export)\s*['"][^\r\n'"]+?['"]\s*.*?;''', + dotAll: true); + + String? _extractHeader(String content) { + var importMatches = + _regExpImport.allMatches(content).toList(growable: false); + + if (importMatches.isEmpty) return null; + + var headEndIdx = importMatches.last.end; + var header = content.substring(0, headEndIdx); + + return header.isNotEmpty ? header : null; + } +} diff --git a/lib/src/import_path_scanner.dart b/lib/src/import_path_scanner.dart new file mode 100644 index 0000000..3055d1e --- /dev/null +++ b/lib/src/import_path_scanner.dart @@ -0,0 +1,94 @@ +// Copyright (c) 2020, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Based on the original work of (shortest path): +// - Jacob MacDonald: jakemac53 on GitHub +// +// Resolve all import paths and optimizations by: +// - Graciliano M. Passos: gmpassos @ GitHub +// + +import 'dart:io'; + +import 'package:graph_explorer/graph_explorer.dart'; + +import 'import_path_base.dart'; +import 'import_path_parser.dart'; + +/// Import Path Scanner tool. +class ImportPathScanner extends ImportWidget { + /// If `true` searches for all import matches. + final bool findAll; + + /// If `true`, it will use a fast parser that attempts to + /// parse only the import section of Dart files. Default: `false`. + /// See [ImportParser.fastParser]. + final bool fastParser; + + /// If `true`, it will also scan imports that depend on an `if` resolution. Default: `true`. + /// See [ImportParser.includeConditionalImports]. + final bool includeConditionalImports; + + ImportPathScanner( + {this.findAll = false, + bool quiet = false, + this.fastParser = false, + this.includeConditionalImports = true, + MessagePrinter messagePrinter = print}) + : super(quiet: quiet, messagePrinter: messagePrinter); + + Future>>> searchPaths(Uri from, ImportToFind importToFind, + {Directory? packageDirectory, String? stripSearchRoot}) async { + packageDirectory ??= Directory.current; + + final importParser = await ImportParser.from(packageDirectory, + includeConditionalImports: includeConditionalImports, + fastParser: fastParser, + quiet: quiet, + messagePrinter: messagePrinter); + + var scanner = GraphScanner(findAll: findAll); + + if (!quiet) { + printMessage('» Search entry point: $from'); + + if (stripSearchRoot != null) { + printMessage( + '» Stripping search root from displayed imports: $stripSearchRoot'); + } + + var msgSearching = fastParser ? 'Fast searching' : 'Searching'; + + if (findAll) { + printMessage( + '» $msgSearching for all import paths for `$importToFind`...'); + } else { + printMessage( + '» $msgSearching for the shortest import path for `$importToFind`...'); + } + } + + var result = await scanner.scanPathsFrom( + from, + importToFind, + outputsProvider: (graph, node) => importParser + .importsFor(node.value) + .map((uri) => graph.node(uri)) + .toList(), + maxExpansion: 100, + ); + + var paths = result.paths; + if (!findAll) { + paths = paths.shortestPaths(); + } + + if (!quiet) { + printMessage( + "» Search finished [total time: ${result.time.inMilliseconds} ms, resolve paths time: ${result.resolvePathsTime.inMilliseconds} ms]"); + } + + return paths; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9425146..a342fd4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,24 @@ name: import_path -version: 1.1.1 -description: A tool to find the shortest import path from one dart file to another. +version: 1.2.0 +description: A tool to find the shortest import path or listing all import paths between two Dart files. It also supports the use of `RegExp` to match imports. homepage: https://github.com/jakemac53/import_path environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.14.0 <4.0.0' dependencies: analyzer: '>=3.0.0 <5.0.0' - package_config: ^2.0.0 - path: ^1.8.0 + package_config: ^2.1.0 + path: ^1.8.3 + args: ^2.4.2 + ascii_art_tree: ^1.0.6 + graph_explorer: ^1.0.1 + +dev_dependencies: + lints: ^1.0.1 + dependency_validator: ^3.2.2 + test: ^1.21.4 + coverage: ^1.2.0 executables: import_path: import_path diff --git a/run_coverage.sh b/run_coverage.sh new file mode 100755 index 0000000..fd9a349 --- /dev/null +++ b/run_coverage.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +dart run test --coverage=./coverage -x build + +dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --report-on=lib --lcov -o ./coverage/lcov.info -i ./coverage + +genhtml -o ./coverage/report ./coverage/lcov.info +open ./coverage/report/index.html diff --git a/test/import_path_test.dart b/test/import_path_test.dart new file mode 100644 index 0000000..5348e1c --- /dev/null +++ b/test/import_path_test.dart @@ -0,0 +1,190 @@ +import 'dart:convert' show JsonEncoder; +import 'dart:io'; + +import 'package:import_path/import_path.dart'; +import 'package:path/path.dart' as pack_path; +import 'package:test/test.dart'; + +void main() { + group('ImportPath[shortest]', () { + test( + 'strip: false ; dots: false ; quiet: false', + () => doSearchTest( + strip: false, dots: false, quiet: false, expectedTreeText: r''' +file://.../import_path.dart + └─┬─ package:import_path/import_path.dart + └─┬─ package:import_path/src/import_path_parser.dart + └──> package:analyzer/dart/ast/ast.dart +''')); + + test( + 'strip: false ; dots: false ; quiet: true', + () => doSearchTest( + strip: false, dots: false, quiet: true, expectedTreeText: r''' +file://.../import_path.dart + └─┬─ package:import_path/import_path.dart + └─┬─ package:import_path/src/import_path_parser.dart + └──> package:analyzer/dart/ast/ast.dart +''')); + + test( + 'strip: false ; dots: true ; quiet: false', + () => doSearchTest( + strip: false, dots: true, quiet: false, expectedTreeText: r''' +file://.../import_path.dart +..package:import_path/import_path.dart +....package:import_path/src/import_path_parser.dart +......package:analyzer/dart/ast/ast.dart +''')); + + test( + 'strip: true ; dots: true ; quiet: false', + () => doSearchTest( + strip: true, dots: true, quiet: false, expectedTreeText: r''' +bin/import_path.dart +..package:import_path/import_path.dart +....package:import_path/src/import_path_parser.dart +......package:analyzer/dart/ast/ast.dart +''')); + }); + + group('ImportPath[all]', () { + test( + 'strip: true ; dots: true ; quiet: false', + () => doSearchTest( + strip: true, + dots: true, + quiet: false, + all: true, + expectedTreeText: RegExp(r'''^bin/import_path.dart +\..package:import_path/import_path.dart +\....package:import_path/src/import_path_parser.dart +\......package:analyzer/dart/ast/ast.dart +.*? +\.....\.+package:analyzer/dart/ast/ast.dart\s*''', dotAll: true))); + }); +} + +Future doSearchTest( + {required bool strip, + required bool dots, + required bool quiet, + bool all = false, + required Pattern expectedTreeText}) async { + var output = []; + + var importPath = ImportPath( + _resolveFileUri('bin/import_path.dart'), + 'package:analyzer/dart/ast/ast.dart', + strip: strip, + quiet: quiet, + findAll: all, + messagePrinter: (m) { + output.add(m); + print(m); + }, + ); + + var style = dots ? ImportPathStyle.dots : ImportPathStyle.elegant; + + var tree = await importPath.execute(style: style); + expect(tree, isNotNull); + + var outputIdx = 0; + if (!quiet) { + expect(output[outputIdx++], startsWith('» Search entry point:')); + if (strip) { + expect(output[outputIdx++], + startsWith('» Stripping search root from displayed imports:')); + } + + if (all) { + expect( + output[outputIdx++], + equals( + '» Searching for all import paths for `package:analyzer/dart/ast/ast.dart`...')); + } else { + expect( + output[outputIdx++], + equals( + '» Searching for the shortest import path for `package:analyzer/dart/ast/ast.dart`...')); + } + + expect( + output[outputIdx++], + matches(RegExp( + r'» Search finished \[total time: \d+ ms, resolve paths time: \d+ ms\]'))); + expect(output[outputIdx++], equals('')); + } + + var treeText = output[outputIdx++] + .toString() + .replaceAll(RegExp(r'file://.*?/import_path/bin'), 'file://...'); + + expect(treeText, matches(expectedTreeText)); + + if (all) { + expect( + output[outputIdx++], matches(RegExp(r'» Found \d+ import paths from'))); + } + + expect(output.length, equals(outputIdx)); + + { + var output2 = []; + + var importPath2 = ImportPath( + _resolveFileUri('bin/import_path.dart'), + 'package:analyzer/dart/ast/ast.dart', + strip: strip, + quiet: true, + findAll: all, + messagePrinter: (m) => output2.add(m), + ); + + var tree2 = await importPath2.execute(style: ImportPathStyle.json); + + expect( + output2, + equals([ + JsonEncoder.withIndent(' ').convert(tree2?.toJson()), + ])); + } + + { + var output3 = []; + + var importPath3 = ImportPath( + _resolveFileUri('bin/import_path.dart'), + 'package:analyzer/dart/ast/ast.dart', + strip: strip, + quiet: false, + findAll: all, + fastParser: true, + messagePrinter: (m) => output3.add(m), + ); + + var tree3 = await importPath3.execute(style: style); + + expect(tree3?.generate(), equals(tree?.generate())); + + expect(output3, contains(startsWith("» Fast searching "))); + } +} + +Uri _resolveFileUri(String targetFilePath) { + var possiblePaths = [ + './', + '../', + './import_path', + ]; + + for (var p in possiblePaths) { + var file = File(pack_path.join(p, targetFilePath)); + if (file.existsSync()) { + return file.absolute.uri; + } + } + + return Uri.base.resolve(targetFilePath); +}