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
fpdartto 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.yamlIn 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
fpdartmodel: chaining a series of operations each based on the output of the previous one.
The steps are the following:
- Parsing CLI arguments
- Extract package name from
pubspec.yaml - List all
.dartfiles in the project directory - Read all imports from each file
- Extract all used and unused files based on imports
There is more 🤩
Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
Parsing CLI arguments
We define the first interface called ArgumentsParser:
abstract final class ArgumentsParser {
const ArgumentsParser();
IOEither<CliError, CliOptions> parse(List<String> arguments);
}This class contains a parse method:
- Take the list of
argumentsfrommainas input - Parse them and return parsed options when successful (
CliOptions) or aCliErrorotherwise
We use
IOEitherfromfpdart:
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 thepubspec.yamlfile (to read name of the package)entry: Location of the app entry file
entryis 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:
pubspec_path: "./pubspec.yaml"
entry: "main"We therefore define a CLIOptions class responsible to collect and store the options:
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.initextracts the options from theyamlfile. It fallbacks to the defaults when the options file is missing.
CLI errors
All the errors are defined as a sealed class called CliError:
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:
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:
abstract final class ConfigReader {
const ConfigReader();
TaskEither<CliError, String> packageName(CliOptions cliOptions);
}Notice how the
packageNamemethod 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:
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:
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:
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),
),
);
},
);
}
YamlLoaderis now a dependency ofConfigReaderImpl. Passing an instance ofYamlLoaderin 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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
Extract all files and imports
The next step is reading all the files inside the project and their imports:
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.
listFilesLibDirreturns a Record. The record contains the list of files in the projectfileListand a set of all the imports for each fileimportSet.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];
}
ImportMatchusesequatableto allow comparing two instances based on theirpath.
Reading files and imports
I report here the full implementation of FileReader:
- Use
Directory.listto extract all the files from thelibfolder (Directory("lib")) - For each file with a
.dartextension call the internal_readImportsfunction _readImportsreads the file and extract all theimportat the top of the file usingRegExp
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
fpdartapp you will likely useReaderTaskEither
ReaderTaskEither<E, L, R> accepts 3 parameters:
E: Dependencies required to execute the programL: ErrorsR: 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:
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.
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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
program implementation
The final program is a single instance of ReaderTaskEither.
It takes arguments as input and return ReaderTaskEither<MainLayer, CliError, FileUsage>:
ReaderTaskEither<MainLayer, CliError, FileUsage> program(List<String> arguments) => // ...We use the
.Doconstructor ofReaderTaskEither
The implementation is a linear series of steps that executes each of the functions we defined above:
- Use
ReaderTaskEither.ask()to extract an instance ofMainLayer - Use
ReaderTaskEither.from*to convert each step to aReaderTaskEither - The
Doconstructor will automatically collect all possible errors - Using the
_function we can extract the success value for each function
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
matchto handle both the error and success cases - Use pattern matching to report a clear message for each error
- Use
stdout.writelnto communicate the result to the user
We then call run and provide all the dependencies to execute the ReaderTaskEither and return a Future:
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:
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.
