How to implement a Dart CLI using fpdart

Sandro Maglione

Sandro Maglione

Functional programming

Learn how to implement a CLI app using dart with fpdart:

  • Learn how a CLI app works in dart
  • Learn how to organize and implement a dart app using pattern matching and dependency injection
  • Learn how to use all the features of fpdart to make your app safe and maintainable

Define requirements

We are going to implement a CLI app that scans a dart project to find all unused files.

A file is considered unused if it is not imported in any other file.

  • Read files in the project (Directory.list)
  • Extract all imports from each file
  • Compute which files are not imported and therefore unused

How a CLI app works in dart

The main function in dart accepts a List<String> as parameter.

List<String> (arguments) represents the parameters provided when running the command on the terminal.

For example to run the example app in this article you execute the following command:

dart run bin/dart_cli_with_fpdart.dart -o cli_options.yaml

In this example the List<String> will be [-o, cli_options.yaml].

A CLI app reads this configuration arguments and executes the program based on them.

Here -o specifies the location of the options file as cli_options.yaml. The app will therefore search for a file called cli_options.yaml to read the configuration.

For more details read the official Dart documentation: Write command-line apps

List implementation steps

We can define a list of required steps that the app will need to execute.

Defining each step helps to understand where to start with the implementation.

It also aligns with the fpdart model: chaining a series of operations each based on the output of the previous one.

The steps are the following:

  1. Parsing CLI arguments
  2. Extract package name from pubspec.yaml
  3. List all .dart files in the project directory
  4. Read all imports from each file
  5. Extract all used and unused files based on imports

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Parsing CLI arguments

We define the first interface called ArgumentsParser:

arguments_parser.dart
abstract final class ArgumentsParser {
  const ArgumentsParser();
  IOEither<CliError, CliOptions> parse(List<String> arguments);
}

This class contains a parse method:

  • Take the list of arguments from main as input
  • Parse them and return parsed options when successful (CliOptions) or a CliError otherwise

We use IOEither from fpdart:

  • IO: A sync operation (parsing)
  • Either: An operation that may fail

CLI options

A CLI usually provides a way to define a configuration from a file.

In this example the app has 2 options:

  • pubspec_path: Location of the pubspec.yaml file (to read name of the package)
  • entry: Location of the app entry file

entry is necessary because an entry file has no imports by definition. We want to avoid reporting the entry file as unused.

These options can be defined inside a yaml file, similar to pubspec.yaml:

cli_options.yaml
pubspec_path: "./pubspec.yaml"
entry: "main"

We therefore define a CLIOptions class responsible to collect and store the options:

cli_options.dart
final class CliOptions {
  final String pubspecPath;
  final String entry;
 
  const CliOptions._({
    required this.pubspecPath,
    required this.entry,
  });
 
  factory CliOptions.init(dynamic optionsPath) {
    final options = File(optionsPath ?? "cli_options.yaml");
 
    if (options.existsSync()) {
      final fileContent = options.readAsStringSync();
      final yamlContent = loadYaml(fileContent);
      final pubspecPath = yamlContent?['pubspec_path'] ?? "pubspec.yaml";
      final entry = "${yamlContent?['entry'] ?? "main"}.dart";
      return CliOptions._(pubspecPath: pubspecPath, entry: entry);
    } else {
      if (optionsPath != null) {
        stderr.writeln(
            'Warning: $optionsPath invalid, fallback to default options');
      }
 
      return CliOptions._(
        pubspecPath: "pubspec.yaml",
        entry: "main.dart",
      );
    }
  }
}

CliOptions.init extracts the options from the yaml file. It fallbacks to the defaults when the options file is missing.

CLI errors

All the errors are defined as a sealed class called CliError:

cli_error.dart
sealed class CliError {
  const CliError();
}

Marking the class as sealed allows to pattern match on all possible errors and report a clear error message in case of issues.

The final implementation for error reporting is the following:

final errorMessage = switch (cliError) {
  InvalidArgumentsError() => "Invalid CLI arguments",
 
  LoadYamlOptionsError(yamlLoaderError: final yamlLoaderError) =>
    "Error while loading yaml configuration: $yamlLoaderError",
 
  MissingPackageNameError(path: final path) =>
    "Missing package name in pubspec.yaml at path '$path'",
 
  ReadFilesError() => "Error while reading project files",
  
  ReadFileImportsError() => "Error while decoding file imports",
};

CLI arguments parsing

The args package allows to parse raw command-line arguments (List<String>) into a set of options and values.

We use it to define a concrete implementation for ArgumentsParser:

arguments_parser.dart
final class ArgumentsParserImpl extends ArgumentsParser {
  static const _options = "options";
 
  final ArgParser _argParser;
  const ArgumentsParserImpl(this._argParser);
 
  @override
  IOEither<CliError, CliOptions> parse(List<String> arguments) =>
      IOEither.tryCatch(
        () {
          final parser = _argParser..addOption(_options, abbr: 'o');
 
          final argResults = parser.parse(arguments);
          final optionsPath = argResults[_options];
 
          return CliOptions.init(optionsPath);
        },
        InvalidArgumentsError.new,
      );
}

We use IOEither.tryCatch to execute the parsing and collect any error (throw) and covert it to InvalidArgumentsError.

Extract package name from pubspec.yaml

Same as before we define an interface called ConfigReader:

config_reader.dart
abstract final class ConfigReader {
  const ConfigReader();
  TaskEither<CliError, String> packageName(CliOptions cliOptions);
}

Notice how the packageName method takes the result of the previous step as input (CliOptions).

packageName returns a TaskEither instead of IOEither.

The Either part is the same (operation that may fail). What changes is that now the operation is asynchronous (async), therefore we need to use Task instead of IO.

Parsing yaml file

An intermediate step to read the package name from pubspec.yaml is to be able to parse a yaml file.

We define a new interface called YamlLoader:

yaml_loader.dart
abstract final class YamlLoader {
  const YamlLoader();
  TaskEither<YamlLoaderError, dynamic> loadFromPath(String path);
}

Important: Notice how we are defining all the operations as abstract class.

This pattern allows to focus on the requirements (methods and their signature) and leave the implementation details aside.

It also allows to make the app more composable, since we can swap different implementations.

We use the yaml package to parse a yaml file. Here is the concrete implementation of YamlLoader:

yaml_loader.dart
final class YamlLoaderImpl implements YamlLoader {
  @override
  TaskEither<YamlLoaderError, dynamic> loadFromPath(String path) =>
      TaskEither.Do(
        (_) async {
          final file = await _(
            IO(
              () => File(path),
            ).toTaskEither<YamlLoaderError>().flatMap(
                  (file) => TaskEither<YamlLoaderError, File>(
                    () async => (await file.exists())
                        ? Either.right(file)
                        : Either.left(MissingFile(path)),
                  ),
                ),
          );
 
          final fileContent = await _(
            TaskEither.tryCatch(
              file.readAsString,
              (error, stackTrace) => ReadingFileAsStringError(),
            ),
          );
 
          return _(
            TaskEither.tryCatch(
              () async => loadYaml(fileContent),
              ParsingFailed.new,
            ),
          );
        },
      );
}

Read package name from pubspec.yaml

We can now use YamlLoader to read the pubspec.yaml file and extract the package name:

config_reader.dart
final class ConfigReaderImpl implements ConfigReader {
  final YamlLoader _yamlLoader;
  const ConfigReaderImpl(this._yamlLoader);
 
  @override
  TaskEither<CliError, String> packageName(CliOptions cliOptions) =>
      TaskEither.Do(
        (_) async {
          final yamlContent = await _(
            _yamlLoader
                .loadFromPath(cliOptions.pubspecPath)
                .mapLeft(LoadYamlOptionsError.new),
          );
 
          return _(
            TaskEither.tryCatch(
              () async => yamlContent["name"],
              (error, stackTrace) =>
                  MissingPackageNameError(cliOptions.pubspecPath),
            ),
          );
        },
      );
}

YamlLoader is now a dependency of ConfigReaderImpl. Passing an instance of YamlLoader in the constructor is called Dependency Injection.

You can read more about how this works here: How to implement Dependency Injection in Flutter

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Extract all files and imports

The next step is reading all the files inside the project and their imports:

file_reader.dart
abstract final class FileReader {
  const FileReader();
  TaskEither<
      CliError,
      ({
        List<ImportMatch> fileList,
        HashSet<ImportMatch> importSet,
      })> listFilesLibDir(
    String packageName,
    ImportMatch entry,
  );
}

Same as before we define an abstract class that contains a listFilesLibDir method.

listFilesLibDir returns a Record. The record contains the list of files in the project fileList and a set of all the imports for each file importSet.

You can read more about records here: Records and Pattern Matching in dart - Complete Guide

Collecting all the files path

We define an ImportMatch class responsible to store the path of each file:

final class ImportMatch extends Equatable {
  final String path;
  const ImportMatch(this.path);
 
  factory ImportMatch.relative(File file) => ImportMatch(
        file.path.replaceFirst("lib/", ""),
      );
 
  @override
  String toString() {
    return path;
  }
 
  @override
  List<Object?> get props => [path];
}

ImportMatch uses equatable to allow comparing two instances based on their path.

Reading files and imports

I report here the full implementation of FileReader:

  • Use Directory.list to extract all the files from the lib folder (Directory("lib"))
  • For each file with a .dart extension call the internal _readImports function
  • _readImports reads the file and extract all the import at the top of the file using RegExp
final class FileReaderImpl implements FileReader {
  static final _importRegex = RegExp(r"""^import ['"](?<path>.+)['"];$""");
 
  @override
  TaskEither<
      CliError,
      ({
        List<ImportMatch> fileList,
        HashSet<ImportMatch> importSet,
      })> listFilesLibDir(
    String packageName,
    ImportMatch entry,
  ) =>
      TaskEither.tryCatch(
        () async {
          final dir = Directory("lib");
 
          final fileList = <ImportMatch>[];
          final importSet = HashSet<ImportMatch>();
 
          final dirList = dir.list(recursive: true);
          await for (final file in dirList) {
            if (file is File && file.uri._fileExtension == "dart") {
              importSet.addAll(await _readImports(file, packageName));
 
              final importMatch = ImportMatch.relative(file);
              if (importMatch != entry) {
                fileList.add(importMatch);
              }
            }
          }
 
          return (fileList: fileList, importSet: importSet);
        },
        ReadFilesError.new,
      );
 
  Future<List<ImportMatch>> _readImports(File file, String packageName) async {
    final projectPackage = "package:$packageName/";
 
    final linesStream = file
        .openRead()
        .transform(
          utf8.decoder,
        )
        .transform(
          LineSplitter(),
        );
 
    final importList = <ImportMatch>[];
 
    await for (final line in linesStream) {
      if (line.isEmpty) continue;
 
      final path = _importRegex.firstMatch(line)?.namedGroup("path");
 
      /// `package:` refers to `lib`
      if (path != null) {
        if (path.startsWith(projectPackage)) {
          importList.add(
            ImportMatch(
              path.replaceFirst(projectPackage, ""),
            ),
          );
        }
      } else {
        break; // Assume all imports are declared first
      }
    }
 
    return importList;
  }
}

Define the full program: ReaderTaskEither

We are now ready to compose all the steps using fpdart.

In every fpdart app you will likely use ReaderTaskEither

ReaderTaskEither<E, L, R> accepts 3 parameters:

  • E: Dependencies required to execute the program
  • L: Errors
  • R: Success value

We already defined the CliError class for the L parameter.

The success value R can be defined as a record FileUsage:

typedef FileUsage = ({
  Iterable<ImportMatch> unused,
  Iterable<ImportMatch> used,
  ImportMatch entry,
});

Define dependencies

The E parameter in ReaderTaskEither expects all the dependencies.

We collect them all in a single class called MainLayer:

main_layer.dart
abstract final class MainLayer {
  const MainLayer();
  ArgumentsParser get argumentsParser;
  ConfigReader get configReader;
  FileReader get fileReader;
}

MainLayer collects all the top-level dependencies of the app. These are all the interfaces that we defined and implemented previously.

main_layer.dart
final class AppMainLayer implements MainLayer {
  @override
  final ArgumentsParser argumentsParser;
 
  @override
  final ConfigReader configReader;
 
  @override
  final FileReader fileReader;
 
  const AppMainLayer({
    required this.argumentsParser,
    required this.configReader,
    required this.fileReader,
  });
}

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

program implementation

The final program is a single instance of ReaderTaskEither.

It takes arguments as input and return ReaderTaskEither<MainLayer, CliError, FileUsage>:

main.dart
ReaderTaskEither<MainLayer, CliError, FileUsage> program(List<String> arguments) => // ...

We use the .Do constructor of ReaderTaskEither

The implementation is a linear series of steps that executes each of the functions we defined above:

  • Use ReaderTaskEither.ask() to extract an instance of MainLayer
  • Use ReaderTaskEither.from* to convert each step to a ReaderTaskEither
  • The Do constructor will automatically collect all possible errors
  • Using the _ function we can extract the success value for each function
main.dart
ReaderTaskEither<MainLayer, CliError, FileUsage> program(List<String> arguments) =>
    ReaderTaskEither<MainLayer, CliError, FileUsage>.Do(
      (_) async {
        final layer = await _(ReaderTaskEither.ask());
 
        final cliOptions = await _(
          ReaderTaskEither.fromIOEither(
            layer.argumentsParser.parse(arguments),
          ),
        );
 
        final packageName = await _(
          ReaderTaskEither.fromTaskEither(
            layer.configReader.packageName(cliOptions),
          ),
        );
 
        final entry = ImportMatch(cliOptions.entry);
        final readFile = await _(
          ReaderTaskEither.fromTaskEither(
            layer.fileReader.listFilesLibDir(packageName, entry),
          ),
        );
 
        final fileUsage = readFile.fileList.partition(
          (projectFile) => readFile.importSet.contains(projectFile),
        );
 
        return (
          unused: fileUsage.$1,
          used: fileUsage.$2,
          entry: entry,
        );
      },
    );

Execute the CLI and report errors

The very final step is executing the ReaderTaskEither.

We do this inside the main function:

  • Call match to handle both the error and success cases
  • Use pattern matching to report a clear message for each error
  • Use stdout.writeln to communicate the result to the user

We then call run and provide all the dependencies to execute the ReaderTaskEither and return a Future:

dart_cli_with_fpdart.dart
void main(List<String> arguments) async =>
    program(arguments).match<void>((cliError) {
      exitCode = 2;
 
      final errorMessage = switch (cliError) {
        InvalidArgumentsError() => "Invalid CLI arguments",
        LoadYamlOptionsError(yamlLoaderError: final yamlLoaderError) =>
          "Error while loading yaml configuration: $yamlLoaderError",
        MissingPackageNameError(path: final path) =>
          "Missing package name in pubspec.yaml at path '$path'",
        ReadFilesError() => "Error while reading project files",
        ReadFileImportsError() => "Error while decoding file imports",
      };
 
      stderr.writeln(errorMessage);
    }, (result) {
      exitCode = 0;
 
      stdout.writeln();
 
      stdout.writeln("Entry 👉: ${result.entry}");
 
      stdout.writeln();
 
      stdout.writeln("Unused 👎");
      for (final file in result.unused) {
        stdout.writeln("  => $file");
      }
 
      stdout.writeln();
 
      stdout.writeln("Used 👍");
      for (final file in result.used) {
        stdout.writeln("  => $file");
      }
    }).run(
      AppMainLayer(
        argumentsParser: ArgumentsParserImpl(ArgParser()),
        configReader: ConfigReaderImpl(YamlLoaderImpl()),
        fileReader: FileReaderImpl(),
      ),
    );

This is it!

You can then run the app in your terminal to inspect all the unused files:

Example execution of the final CLI app: all the used and unused files are reported. You can go ahead and remove all used files!Example execution of the final CLI app: all the used and unused files are reported. You can go ahead and remove all used files!

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below 👇

Thanks for reading.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.