Writing Command-Line Utilities with Dart

If there's one thing computers are good for, it's automating repetitive, tedious startup or maintenance tasks that waste developers' time and, in the worst cases, serve as a deterrent to starting a project at all. Writing a few small scripts to handle the boilerplate and scaffolding can go a long way toward eliminating barriers to productivity. For modern web developers, some common technologies for adding custom tools to the tool belt are Perl (for old-school scripters) and Node.js (using JavaScript).

Dart is a newer language, originally developed by Google but now an ECMA standard, for building web, server, command-line, and mobile apps. It's a scalable, object-oriented programming language, with robust libraries and runtimes included out of the box. In this tutorial, we'll go over how you can get set up to write your own command-line utilities with Dart.

Check out the Dart Language Tour for a crash course in the language, or take a look at the FAQ for a higher-level overview. If you know JavaScript, Java, PHP, ActionScript, C/C++, or another "curly brace" language, you'll find Dart to be familiar.

Environment Setup

There are many editors that support Dart development, but in keeping with the Dart team's announcement that Jetbrains WebStorm will be the preferred editor going forward, we'll use that one. WebStorm is a commercial product, but you can download and use it free for 30 days.

  1. Download the correct Dart SDK for your system. For this tutorial, you will not need Dartium (a special build of Chromium with an embedded Dart VM). Then unzip the Dart SDK and place the dart-sdk folder anywhere on your system. On Windows, I prefer C:\Program Files\dart\dart-sdk. (Note: To automate this step, Windows users can use the Dart for Windows installer, and Windows, OSX, or Linux users can use Dart-Up.)
  2. If you're not already a WebStorm user, download and install WebStorm.
  3. Run WebStorm. When asked for a license key, either provide a valid key or indicate you'd like to Evaluate for free for 30 days.
  4. From WebStorm's welcome screen, choose Create New Project, then choose Dart as the project type.
  5. In the Location blank, replace untitled with the name of your new project. I'm using boilerplate.
  6. Input the path to the Dart SDK. If you've entered the path correctly, WebStorm will display the SDK version.
  7. Important: Make sure Generate sample content is checked.
  8. Select Console Application and click Create.

Dart SDK and VM

To run a command-line app, you need the Dart VM (virtual machine), which comes in the Dart SDK download. If you followed the instructions for setting up WebStorm, you already have everything you need.

System Path

For the first part of this tutorial, you'll run your command-line application using Pub, which is Dart's tool for managing packages, assets, and dependencies, and also includes commands for creating, developing, deploying, and executing Dart applications. To make life easy for yourself, you'll want to add dart-sdk/bin to your system's PATH environment variable so you can access Pub from anywhere in your terminal.

On your system's command line, use the appropriate command for your operating system, inserting the path to your Dart SDK where indicated.

Windows (assuming the C: drive):

> set PATH=%PATH%;C:\<dart-sdk directory>\bin

Mac OS:

$ export PATH=$PATH:<dart-sdk directory>/bin

Linux:

$ export PATH=${PATH}:<dart-sdk directory>/bin

Hello, World

Your project in WebStorm should have started with two files open in the editor: pubspec.yaml and main.dart (if not, open them now by double-clicking each of them in the Project panel). Dart uses your pubspec file to manage your project's dependencies, and main.dart will be your primary code file.

To keep things simple, remove all of the pre-generated code from your main.dart file and replace it with this:

void main(List<String> arguments) {
  print("Hello, World!");
}

All Dart programs start execution with the main() function. In this code, we make use of Dart's optional type annotations to make things clearer and improve tooling. void indicates that main() will return nothing (null), and List<String> arguments tells the Dart VM that if there are any command-line arguments passed to our application, they should be accessible in arguments, which is a List (Dart's array type) of elements of type String. The body of the function has just one line, which synchronously prints the string "Hello, World!" to the system console.

Run the code in main.dart by right-clicking the file in the Project panel and selecting Run, or you can use the hotkey Shift+F10 if main.dart is selected. Console output will appear in the Run panel, which displays by default in the lower portion of the WebStorm interface.

Hello, World!

Pub Global

Dart's Pub program is a versatile tool. You can use the global command to activate Dart packages, which allows you to use Pub in the system terminal to run scripts from that package's bin directory. (Note: A package is Dart's largest unit of modularity for sharing code, and every Dart application with a pubspec.yaml file in its root directory is also a package.) It's possible to activate a Dart package housed on pub.dartlang.org or from a Git repository, but for this tutorial you'll activate the package right from the local machine.

pub global activate --source path <path to Dart project>

Enter the above command on your system's terminal, inserting the absolute or relative path to your boilerplate project. WebStorm provides a convenient way to access a terminal with its Terminal panel. You can bring up that panel with the application's menu: View->Tool Windows->Terminal. Alternatively, use the hotkey Alt+F12. If you've correctly updated your system path to include the Dart SDK, Pub will be globally accessible in your terminal.

Pub's response should look something like:

Activated boilerplate 0.0.1 at path "<path to Dart project>".

That done, you can now use Pub run in combination with Pub global to run the main.dart script from anywhere, using a command with the form:

pub global run <package>:<script>

An example with the project's values filled in:

pub global run boilerplate:main

If everything is working, you will see your program's output in the terminal.

Hello, World!

If you make changes to the code in main.dart and save the file, those changes will be reflected the next time your run your script with Pub global.

To deactivate a package:

pub global deactivate <package>

To see a list of currently activated packages:

pub global list

Parsing Command-Line Arguments

What if you're tired of saying hello to the world, and you'd rather greet your good friend Jim instead? For that, you need command-line arguments. For example:

pub global run boilerplate:main --name Jim

If you run that now, you'll still get the old greeting, and your argument, Jim, will be ignored.

Acquire Dependencies

The Dart team has thoughtfully provided us with a package for parsing command-line arguments into key/value pairs. It supports the common GNU and POSIX style options.

Open the file pubspec.yaml. Dart uses your pubspec file to manage your project's dependencies. Replace the pre-generated dependencies section in pubspec.yaml with one that looks like this (remove any # characters, which indicate a comment):

dependencies:
  args: '>=0.13.2 <0.14.0'

If you're using WebStorm, be sure to click the Get Dependencies link that shows up in the upper right of your editor window when pubspec.yaml is open. This will download the code your project depends upon.

Parse the Arguments

Replace the contents of your main.dart file with the following code, which includes everything you need to handle a command-line argument called "name":

import 'package:args/args.dart';

ArgResults argResults;

void main(List<String> arguments) {
  final ArgParser argParser = new ArgParser()
    ..addOption('name', abbr: 'n', defaultsTo: 'World');

  argResults = argParser.parse(arguments);

  final String name = argResults['name'];

  print("Hello, $name!");
}

A lot more is going on now, so we'll go over each part of the new code.

import 'package:args/args.dart';

You need access to two classes from the args package: ArgParser and ArgResults, so you import the library using the import keyword.

ArgResults argResults;

Making use of Dart's optional type annotations, you declare a top-level variable of type ArgResults. An instance of ArgParser will do all the command-line argument parsing for you, and it will return the results as an instance of ArgResults. As a top-level variable, argResults will be accessible to all of the code in the file.

final ArgParser argParser = new ArgParser()
  ..addOption('name', abbr: 'n', defaultsTo: 'World');

Within the main() function, you create a new instance of ArgParser and tell it what your program's expected options are. argParser is of type ArgParser. It is declared to be final because the reference should never be reassigned after this initialization. If you mistakenly try to reinitialize argParser later, the Dart analyzer will issue a warning.

Dart's cascade operator (..) allows you to perform a series of operations on the members of a single object, in this case argParser. The equivalent without the cascade operator would look like this:

argParser.addOption('name', abbr: 'n', defaultsTo: 'World');

The call to addOption() tells the parser that your program is expecting an argument aliased as "name". abbr is an optional named parameter you can use to define an abbreviation for "name". Using defaultsTo, you can provide a default value for those times when the user does not provide his or her own. With the "name" option defined, it's possible to specify its value as "Jim" on the command line using any of the following formats:

--name=Jim
--name Jim
-nJim
-n Jim

Next, the parser does its work.

argResults = argParser.parse(arguments);

The main() function collects command-line arguments as a List (array) of string elements called arguments. You pass arguments to argParser's parse() function, and it returns the parsed results as an instance of ArgResults.

final String name = argResults['name'];

You access the "name" option with argResults['name']. If no valid value was passed in, argResults['name'] will have the default value of "World".

print("Hello, $name!");

Finally, you use print() to send a string to the console. With Dart's string interpolation feature, the value of name is inserted into the string without having to use inefficient and unsightly concatenation routines.

Try It

To get your app to greet Jim using the abbreviated command-line argument, enter this into your terminal:

pub global run boilerplate:main -n Jim

Help!

ArgParser let's you quickly and easily provide help to your users. To add help support to your app, modify the first statement in main() to look like this:

final ArgParser argParser = new ArgParser()
    ..addOption('name', abbr: 'n', defaultsTo: 'World',
      help: "Who would you like to greet?")
    ..addFlag('help', abbr: 'h', negatable: false,
      help: "Displays this help information.");

With these changes, you're providing a string of help text for each of the two command-line options via the optional named parameter "help". The "help" option (available to a user as --help or -h) is defined by the addFlag() method. A flag is a special type of command-line option that's represented as a Boolean value rather than a string. If the flag is present on the command line, its value is true. If you allow a flag to be negatable, the user can explicitly set it to false with a "no-" prefix: --no-help.

To handle the "help" flag, add this code directly before the print() call:

  if (argResults['help']) {
    print("""
** HELP **
${argParser.usage}
    """);
  }

At first glance, this code might look a little strange. argResults['help'] resolves to true if the "help" flag was set, and false otherwise. If the user wants help, the app uses print() to send argParser's formatted help information to the console, available through the usage property. Dart supports triple-quote syntax to construct a multi-line string. This works similarly to HTML's <pre> tag, which explains the lack of indentation. The value of argParser.usage is inserted using string interpolation.

Run the program again with the following terminal command:

pub global run boilerplate:main --help

And the result:

** HELP **  
-n, --name    Who would you like to greet?
              (defaults to "World")

-h, --help    Displays this help information.

Hello, World!

For the sake of brevity, we will omit help support for the remainder of this tutorial, but you should normally include it for production apps.

Writing a File

Being able to greet anyone (or anything) on the system console is very cool, but what if you want to do something useful? Often, that will involve accessing the local file system. This next version of the project will generate the basic boilerplate code for an HTML file, taking a required title and optional file name as arguments.

Replace the contents of your main.dart file with the following code:

import 'dart:io';
import 'package:args/args.dart';

ArgResults argResults;

void main(List<String> arguments) {
  final ArgParser argParser = new ArgParser()
    ..addOption('title', abbr: 't',
      help: "The title will be inserted into the <title> tag.")
    ..addOption('filename', abbr: 'f', defaultsTo: 'index.html',
      help: "Optional. Output file name. (Default: index.html)");

  argResults = argParser.parse(arguments);

  final String title = argResults['title'];

  if (title == null) {
    handleError("Missing required argument: title");
  }
  else {
    final String filename = argResults['filename'];
    final String output = """<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>$title</title>
</head>
<body>

</body>
</html>
    """;

    new File(filename).writeAsStringSync(output);
    stdout.writeln("File saved: $filename");
  }
}

void handleError(String msg) {
  stderr.writeln(msg);
  exitCode = 2;
}

There's a bit more code here, but we will only examine the new stuff.

import 'dart:io';

To access all the file system goodies, you need to import the standard Dart I/O library. No adjustment to pubspec.yaml is necessary here, as this library is part of Dart's core.

if (title == null) {
  handleError("Missing required argument: title");
}

title will be null if the user fails to provide it. We've decided title should be a required value, so if it's missing, you pull the plug, calling the helper function handleError().

void handleError(String msg) {
  stderr.writeln(msg);
  exitCode = 2;
}

The handleError() function asynchronously writes an error message to the stderr stream and sets the global exitCode to 2. stderr usually ends up writing to the console. When the program exits, the host OS examines the exitCode, and a value of 2 indicates the program exited in an error state.

For a more complete discussion about exit codes and using the standard input, output, and error streams, see the Writing Command-Line Apps tutorial on Dart's official site.

If the title was provided, the else block runs. Once again, you use the triple quotes to create a multi-line string and insert the title with string interpolation ($title).

new File(filename).writeAsStringSync(output);

To create or overwrite a file, you instantiate a new File object, passing its constructor either the default "index.html" or the file name provided as a command-line argument. All of Dart's file operations more typically execute asynchronously, but to keep things simple, we're using the special synchronous version of writeAsString() here.

stdout.writeln("File saved: $filename");

In earlier examples, you used print() to output strings synchronously to the console. The writeln() method of stdout does much the same, but asynchronously, in keeping with Dart best practices.

Try It

To try out the new code, type something like this into your terminal:

pub global run boilerplate:main -t "My Awesome Site"

This will create a file with the default name of "index.html" in the current directory. Note that you have to use quotes around the title because it's comprised of multiple words.

A Truly Global Installation

By this time, you might be asking yourself, "Why can't I just run my Dart script on the command line without all this Pub nonsense?" You can!

Executables

Any Dart script in your project's bin directory can be registered as an executable in two steps.

First, add a new section to your project's pubspec.yaml file. Note that sections can be added in any order, so you're safe tacking this on at the end.

executables:
  boilerplate: main

In this entry, boilerplate is the name you want your global executable to have, and main is the Dart script in your bin directory that contains the code to run.

Much like the dependencies section, you can add several entries to the executables section. If you have more than one script that you'd like to be globally accessible, stack them up here, but don't forget to give them different executable names. For our purposes, registering main.dart will suffice.

Activate

This second step will look very familiar, but you need to do it again now that your project has an executables section in its pubspec. Run this script (again) in your terminal:

pub global activate --source path <path to Dart project>

The output should include a few messages about the activation status of your project and, importantly, Installed boilerplate executable. That message means that Pub has created a shortcut called boilerplate in its own cache directory.

Warning! If you haven't gone through these steps before, you're probably also seeing a warning telling you that Pub's cache directory is not in your system path. Refer back to this tutorial's Environment Setup section for details about how you can update the system path on your OS, but instead of adding the Dart SDK path, you'll add the path to Pub's cache directory, which is also revealed in the warning.

Try It

If your path was updated correctly, you'll be able to run your app from the command line like this:

boilerplate -t MyTitle

Better, right? Remember, it's also possible to activate a Dart script from pub.dartlang.org or a Git repository. See the Pub Global docs for more on that.

Put a Mustache On It

String interpolation is a great feature for building up text-based boilerplate and filling out templates, but when you need even more power, nothing beats a full templating engine like the popular Mustache. You can use any of several Dart packages to add Mustache or maybe Markdown support to your scripts.

I'll leave it as an exercise for the reader to implement, but here is a short list of packages to get you started:

Conclusion

Clean, simple semantics, great tools, comprehensive core libraries, and a vibrant ecosystem make Dart a good choice for your next command-line utility script. Dart's Pub tool makes it easy to manage your app's dependencies and make your script globally accessible on your system. The language makes text manipulation convenient with built-in support for interpolation, multi-line strings, and automatic concatenation of adjacent strings. Easy, asynchronous or synchronous file and console I/O round out the list of features that makes Dart my favorite language for automating basic tasks.