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:
- Parsing CLI arguments
- Extract package name from
pubspec.yaml
- List all
.dart
files in the project directory - Read all imports from each file
- Extract all used and unused files based on imports
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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
arguments
frommain
as input - Parse them and return parsed options when successful (
CliOptions
) or aCliError
otherwise
We use
IOEither
fromfpdart
:
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.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
:
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.init
extracts the options from theyaml
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
:
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
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
:
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),
),
);
},
);
}
YamlLoader
is now a dependency ofConfigReaderImpl
. Passing an instance ofYamlLoader
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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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.
listFilesLibDir
returns a Record. The record contains the list of files in the projectfileList
and 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];
}
ImportMatch
usesequatable
to allow comparing two instances based on theirpath
.
Reading files and imports
I report here the full implementation of FileReader
:
- Use
Directory.list
to extract all the files from thelib
folder (Directory("lib")
) - For each file with a
.dart
extension call the internal_readImports
function _readImports
reads the file and extract all theimport
at 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
fpdart
app 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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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
.Do
constructor 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
Do
constructor 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
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
:
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!
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.