Dartle

Dartle CI pub package

A simple task runner/build system/build library written in Dart.

📚 Dartle Documentation

Purpose

The goal with Dartle is to define a (sometimes large) number of tasks where only a few of them are explicitly invoked by a human. This is accomplished by defining task phases and declaring interdependencies between tasks.

Dartle makes sure that every task that needs to run, but no others, actually run when you ask it to run one or more tasks.

Tasks on the same phase run in parallel, on their own isolates, and if a task fails, all running tasks are immediately cancelled.

What can I do with Dartle?

  • use it as a Dart-based task runner or build system (write tasks in Dart).
  • build Dart projects using DartleDart support.
  • use it as a file system diff tool. Know when things have changed since last cached.
  • create your own build system using Dartle as a library! See jb, for example.

Using Dartle

For example, Dartle's own build (which uses Dartle's own support for Dart) has the following tasks (as shown by running dartle --show-tasks):

======== Showing build information only, no tasks will be executed ========

Tasks declared in this build:

==> Setup Phase:
  * clean
      Deletes the outputs of all other tasks in this build.
  * cleanWorkingDirs [up-to-date]
      Cleanup working dir before builds. Avoids caching generated files.
==> Build Phase:
  * analyzeCode [up-to-date]
      Analyzes Dart source code
  * build [default] [always-runs]
      Runs all enabled tasks.
  * checkImports [up-to-date]
      Checks dart file imports are allowed
  * compileExe
      Compiles Dart executables declared in pubspec. Argument may specify the name(s) of the executable(s) to compile.
  * format [up-to-date]
      Formats all Dart source code.
  * generateDartSources [up-to-date]
      Generates Dart source files
  * runBuildRunner [up-to-date]
      Runs the Dart build_runner tool.
  * runPubGet [up-to-date]
      Runs "pub get" in order to update dependencies.
  * test [out-of-date]
      Runs Dart tests.
==> TearDown Phase:
  No tasks in this phase.

The following tasks were selected to run, in order:

  cleanWorkingDirs
      runPubGet
      generateDartSources
          runBuildRunner
              checkImports
              format
                  analyzeCode
                      test
                          build

Note: Tasks on the same column may run in parallel.

When you invoke, say, dartle analyzeCode, Dartle will make sure that the analyseCode task will run, but also that all its dependencies, generateDartSources, format, checkImports and runPubGet will run first as long as their runCondition requires them to run. If any of these tasks doesn't need to run, it is automatically skipped.

Dartle has several RunConditions to determine when a task is up-to-date or needs to run:

  • RunOnChanges - run task if any inputs/outputs changed since last run.
  • RunAtMostEvery - run task at most every T, where T is a period of time.
  • RunToDelete - run task if any of its outputs exists.

There are also combiners like AndCondition and OrCondition (and you can define your own conditions).

For example, the runPubGet task runs if pubspec.yaml changes OR if it has not been run for one week.

How to use

Add dartle to your dev_dependencies:

dart pub add -d dartle

Write a dartle build file

A basic dartle.dart file can be automatically generated by invoking dartle on a directory where dartle.dart does not exist yet.

dartle.dart

import 'package:dartle/dartle.dart';

final helloTask = Task(hello, argsValidator: const ArgsCount.range(min: 0, max: 1));
final byeTask = Task(bye, dependsOn: const {'hello'});
final cleanTask = createCleanTask(tasks: {helloTask, byeTask});

main(List<String> args) async =>
    run(args, tasks: {helloTask, byeTask, cleanTask}, defaultTasks: {helloTask});

/// To pass an argument to a task, use a ':' prefix, e.g.:
/// dartle hello :joe
hello(List<String> args) =>
    print("Hello ${args.isEmpty ? 'World' : args[0]}!");

/// If no arguments are expected, use `_` as the function parameter.
bye(_) => print("Bye!");

Notice that the dartle.dart script should be very simple (a basic invocation to Dartle's run, ideally), so it's clear at a glance what the tasks are.

Put any logic you may need to write for tasks (and even the task declarations) in source file inside the dartle-src directory, as that's where Dartle looks for changes to the build (besides dartle.dart and pubspec.*).

Check Dartle's own dartle.dart file for a good example.

Run your build!

In dev mode (while you're setting up your build), use dart to run the build file directly:

dart dartle.dart <tasks>

Notice that all dev_dependencies can be used in your build! And all Dart tools work with it, including the Observatory and debugger, after all this is just plain Dart!

Once you're done with the basics of your build, it is recommended to install Dartle for faster performance.

To install Dartle:

dart pub global activate dartle

Now, you can run your build with the dartle command:

dartle <tasks>

dartle automatically re-compiles the dartle.dart script into an executable if necessary to make builds run so fast they feel instant!

Selecting tasks

If no task is explicitly invoked, Dartle runs the defaultTasks defined in the build, or does nothing if none was defined.

To run specific task(s), give them as arguments when invoking dartle:

dartle hello bye

Output:

2020-02-06 20:53:26.917795 - dartle[main] - INFO - Executing 2 tasks out of a total of 4 tasks: 2 tasks selected, 0 due to dependencies
2020-02-06 20:53:26.918155 - dartle[main] - INFO - Running task 'hello'
Hello World!
2020-02-06 20:53:26.918440 - dartle[main] - INFO - Running task 'bye'
Bye!
✔ Build succeeded in 3 ms

Notice that Dartle will cache resources to make builds run faster. It uses the .dartle_tool/ directory, in the working directory, to manage the cache. You should not commit the .dartle_tool/ directory into source control.

To provide arguments to a task, provide the argument immediately following the task invocation, prefixing it with ::

./dartle hello :Joe

Prints:

2020-02-06 20:55:00.502056 - dartle[main] - INFO - Executing 1 task out of a total of 4 tasks: 1 task selected, 0 due to dependencies
2020-02-06 20:55:00.502270 - dartle[main] - INFO - Running task 'hello'
Hello Joe!
✔ Build succeeded in 1 ms

Declaring tasks

The preferred way to declare a task is by wrapping a top-level function, as shown in the example above.

Basically:

import 'package:dartle/dartle.dart';

final allTasks = {Task(hello)};

main(List<String> args) async => run(args, tasks: allTasks);

hello(_) => print("Hello Dartle!");

This allows the task to run in parallel with other tasks on different Isolates (potentially on different CPU cores).

Notice that because the task may run on an Isolate, it must not depend on any global state. The function will not see changes made from the main Isolate or any other.

If that's not important, a lambda can be used, but in such case the task's name must be provided explicitly (because lambdas have no name):

import 'package:dartle/dartle.dart';

final allTasks = {Task((_) => print("Hello Dartle!"), name: 'hello')};

main(List<String> args) async => run(args, tasks: allTasks);

A Task's function should only take arguments if it declares an ArgsValidator, as shown in the example:

Task(hello, argsValidator: const ArgsCount.range(min: 0, max: 1))

...

hello(List<String> args) => ...

A Task will not be executed if its argsValidator is not satisfied (Dartle will fail the build if that happens).

Incremental Tasks

For a Dartle task to become incremental, it only needs to have an action function that accepts an optional argument containing the ChangeSet since the last build.

For example, the hello function from the previous example would need to be declared as shown below to become an incremental task:

hello(List<String> args, [ChangeSet? changeSet]) async {
    // TODO inspect changes to know what needs to be done
}

Importantly, the ChangeSet must be an optional argument, otherwise the function won't match the signature expected by Dartle.

Task dependencies and run conditions

A Task can depend on other task(s), so that whenever it runs, its dependencies also run (as long as they are not up-to-date).

In the example above, the bye task depends on the hello task:

Task(bye, dependsOn: const {'hello'})

This means that whenever bye runs, hello runs first.

Notice that tasks that have no dependencies between themselves can run at the same time - either on the same Isolate or in separate Isolates (use the -p flag to indicate that tasks may run in different Isolates when possible, i.e. when their action is a top-level function and there's no dependencies with the other tasks).

A task may be skipped if it's up-to-date according to its RunCondition. The example Dart file demonstrates that:

Task(encodeBase64,
  description: 'Encodes input.txt in base64, writing to output.txt',
  runCondition: RunOnChanges(
    inputs: file('input.txt'),
    outputs: file('output.txt'),
  ))

The above task only runs if at least one of these conditions is true:

  • output.txt does not yet exist.
  • either input.txt or output.txt changed since last time this task ran.
  • the -f or --force-tasks flag is used.

If a RunCondition is not provided, the task is always considered out-of-date.

To force all tasks to run, use the -z or --reset-cache flag.

Help

For more help, run dartle -h. Proper documentation is going to be available soon!

Prior Art

Dartle is inspired by Gradle and, loosely, Make and Apache Ant.

Libraries

dartle
A simple build system written in Dart.
dartle_cache
A library exposing the mechanism used by dartle to cache resources and intelligently determine which tasks must run, and which tasks may be skipped.
dartle_dart
Dartle extension for building Dart projects.
dartlex
Dartle auxiliary library that is used to compile a 'dartle.dart' script into a binary executable automatically when it changes.