dartでtodoリストcli「dado」を作成する

この記事は[Dart Advent Calendar 2019](https://qiita.com/advent-calendar/2019/dartlang)の20日目の記事です。
Dart2.6から追加された[dart2native](https://dart.dev/tools/dart2native)を使ったcliツール作成の記事がいくつかありますが、私もcliツールを作成したいと思います。
https://medium.com/dartlang/dart2native-a76c815e6baf


今回はDartの練習のためにも、TODOリストを作成していきます。
mattnさんの https://github.com/mattn/todo をインスパイヤしています:bow:
こちらのtodo cliはGoで書かれています。

# 今回作成するdado

今回はDartのtodoリストということで、`dado`という名前にしています。
![Dec-18-2019 23-20-19.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/3c27e9dd-d7d0-23e9-5787-1b181308ab0e.gif)

<img width="400" alt="スクリーンショット 2019-12-18 23.11.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/f781663d-1a31-f899-9f57-2a14905ae7df.png">


## Usage

```shell
❯ bin/dado -h
Usage: dado.dar
-a, --add       add task to list
-l, --list      watch task list
-d, --done      done task
-u, --undone    undone task
-r, --remove    remove task from list
-c, --clean     clean done tasks from list
-h, --help      prints usage information
```

# 使用したpackage
optionやhelpを簡単に作成するためにも先人が作成したpackageを利用していきます。今回使用するのは以下のpackage。

## build_cli
Dartのビルドシステムを使って、引数からアノテーションで指定したオプションを解析していい感じにcliを作りやすくしてくれるpackageです。
https://pub.dev/packages/build_cli

## shell
dart:ioのラッパーで、Dartのコード内で、シェルスクリプトの実行を書きやすくるpackageです。今回はDartのAPIにファイルを削除するものがなく？シェルスクリプトでrmコマンドを叩くために使用しています。（あったら教えて下さい:bow:）
https://pub.dev/packages/shell

shell packageを使わないファイル削除を追記しました。(2019-12-21)
> 今回はDartのAPIにファイルを削除するものがなく？シェルスクリプトでrmコマンドを叩くために使用しています。（あったら教えて下さい:bow:）

dart:ioに[delete](https://api.dartlang.org/stable/2.7.0/dart-io/FileSystemEntity/delete.html)なるものがありました:bow:
@mono0926 さん ありがとうございます:bow:


shell packageを使用している以下の部分(`done`, `undone`, `remove`, `clean`で使用している)

```dart
  Shell().start('rm', ['file2.txt']);
  newFile.rename('file.txt');
```

をdeleteメソッドを使って書き直すと以下のような感じになります。

```dart
  newFile.rename('file.txt').then((file){
    newFile.delete(recursive: true);
  });
```

## 雛形を作成する
stagehandを利用して、dart cliの雛形を作成していきます。

```shell
$ stagehand dado
$ pub get
$ dart bin/main.dart
Hello world: 42!
```

Hello world: 42! が出ると一旦雛形は完成です。

- [参考]
https://dart.dev/tutorials/server/get-started

## build_cliを適用していきます。
pubspec.yamlを以下のうように設定し、`pub get`します。

```pubspec.yaml
name: dado
description: A sample command-line application.

environment:
  sdk: '>=2.5.0 <3.0.0'

dependencies:
#  path: ^1.6.0
  build_cli_annotations: ^1.0.0

dev_dependencies:
  pedantic: ^1.8.0
  test: ^1.6.0
  build_cli: ^1.0.0
  build_runner: ^1.0.0
```

[end-2-end example](https://github.com/kevmoo/build_cli/tree/master/build_cli/example)を参考にDartCliをFlagOptionなどに対応させて行きます。

```bin/main.dart
import 'dart:io';

import 'package:build_cli_annotations/build_cli_annotations.dart';
import 'package:dado/dado.dart';
import 'package:io/io.dart';
import 'package:io/ansi.dart';

part 'main.g.dart';

@CliOptions()
class Options {
  @CliOption(abbr: 'add', help: 'add task to list')
  String add;

  @CliOption(abbr: 'list', help: 'watch task list')
  bool list;

  @CliOption(abbr: 'done', help: 'done task')
  String done;

  @CliOption(abbr: 'undone', help: 'undone task')
  String undone;

  @CliOption(abbr: 'remove', help: 'remove task from list')
  String remove;

  @CliOption(abbr: 'clean', help: 'clean done tasks from list')
  bool clean;

 @CliOption(abbr: 'h', negatable: false, help: 'Prints usage information.')
  bool help;

  Options();
}

void main(List<String> args) {
 Options options;
 try {
   options = parseOptions(args);
 } on FormatException catch (e) {
    print(red.wrap(e.message));
    print('');
    _printUsage();
    exitCode = ExitCode.usage.code;
    return;
 }

 if (options.help) {
   _printUsage();
   return;
 } else if (options.add != null) {
 } else if (options.list) {
 } else if (options.done != null) {
 } else if (options.undone != null) {
 } else if (options.remove != null) {
 } else if (options.clean) {
 }
}

void _printUsage() {
  print('Usage: dado.dar');
  print(_$parserForOptions.usage);
}
```

```bin/main.g.dart
part of 'main.dart';

Options _$parseOptionsResult(ArgResults result) =>
  Options()
    ..add = result['add'] as String
    ..list = result['list'] as bool
    ..done = result['done'] as String
    ..undone = result['undone'] as String
    ..remove = result['remove'] as String
    ..clean = result['clean'] as bool
    ..help = result['help'] as bool;

ArgParser _$populateOptionsParser(ArgParser parser) => parser
  ..addOption('add', abbr: 'a', help: 'add task to list')
  ..addFlag('list', abbr: 'l', help: 'watch task list', negatable: false)
  ..addOption('done', abbr: 'd', help: 'done task')
  ..addOption('undone', abbr: 'u', help: 'undone task')
  ..addOption('remove', abbr: 'r', help: 'remove task from list')
  ..addFlag('clean', abbr: 'c', help: 'clean done tasks from list', negatable: false)
  ..addFlag('help', abbr: 'h', help: 'prints usage information', negatable: false);

final _$parserForOptions = _$populateOptionsParser(ArgParser());

Options parseOptions(List<String> args) {
  final result = _$parserForOptions.parse(args);
  return _$parseOptionsResult(result);
}
```

一旦、helpメッセージだけ表示できるようになりました。

```
❯ dart bin/main.dart -h
Usage: dado.dar
-a, --add       add task to list
-l, --list      watch task list
-d, --done      done task
-u, --undone    undone task
-r, --remove    remove task from list
-c, --clean     clean done tasks from list
-h, --help      prints usage information
```

他のオプションをしていしても何も起こりません。

```
❯ dart bin/main.dart -a hoge

❯ dart bin/main.dart -l

```

## 各々のオプションを実装
実装すると`bin/main.dart`のコメントの部分にその関数を適用するだけです。

```bin/main.dart
if (options.help) {
   _printUsage();
   return;
 } else if (options.add != null) {
  // addの処理
 } else if (options.list) {
  // listの処理
 } else if (options.done != null) {
  // doneの処理
 } else if (options.undone != null) {
  // undoneの処理
 } else if (options.remove != null) {
  // removeの処理
 } else if (options.clean) {
  // cleanの処理
 }
```

### add
addはタスクをリストに追加する処理です。オプションで指定された文字を受け取り、ストレージとして使用している`file.txt`に書き込みます。

```dart
void add(String task) async {
  var file = File('file.txt');
  var current = await file.readAsString();
  var sink = file.openWrite();
  sink.write(current);
  sink.write(task + '\n');

  await sink.close();
}
```

### list
listはタスク一覧とタスクの状態(done,undone)を見ることができる機能です。
一行ずつ読み込み、`-`がついていればdoneマーク、付いていなければundoneマークを付けて表示します。

```dart
const undoneMark = '\x1b[32m\u2610\x1b[0m';
const doneMark = '\x1b[31m\u2611\x1b[0m';

void list() {
  final file = File('file.txt');
  Stream<List<int>> inputStream = file.openRead();

  inputStream
    .transform(utf8.decoder)
    .transform(LineSplitter())
    .listen((String line) {
      if (line.startsWith('-')) {
        line = line.replaceFirst('-', '');
        print('$doneMark $line');
      } else {
        print('$undoneMark $line');
      }
    },
    onDone: () { },
    onError: (e) { print(e.toString()); });
}
```

### done
doneは`-`が付いていないundoneなtaskをdoneにする処理です。
オプション変数からタスク番号を受け取り、その番号にマッチするタスクに`-`を付けることでdoneとしています。


```dart
void done(int number) {
  File('file2.txt').create(recursive: true);
  var newFile = File('file2.txt');
  var sink = newFile.openWrite();
  final file = File('file.txt');
  Stream<List<int>> inputStream = file.openRead();

  var cnt = 1;
  inputStream
    .transform(utf8.decoder)
    .transform(LineSplitter())
    .listen((String line) {
    if (cnt == number && !line.startsWith('-')) {
      sink.write('-$line\n');
    } else {
      sink.write('$line\n');
    }
    cnt += 1;
  },
    onDone: () { },
    onError: (e) { print(e.toString()); });

  Shell().start('rm', ['file2.txt']);
  newFile.rename('file.txt');
}
```

新しく`file2.txt`というファイルを作成し、後に`file.txt`に置き換えることで実装しています。
undone,remove,cleanも同様の実装をしています。
`Shell().start`で`rm`を実行することによって、ファイル削除をしています。

### undone
undoneは`-`が付いているdoneなtaskをundoneにする処理です。
オプション変数からタスク番号を受け取り、その番号にマッチするタスクに`-`を外すことでdoneとしています。

```dart
void undone(int number) {
  File('file2.txt').create(recursive: true);
  var newFile = File('file2.txt');
  var sink = newFile.openWrite();
  final file = File('file.txt');
  Stream<List<int>> inputStream = file.openRead();

  var cnt = 1;
  inputStream
    .transform(utf8.decoder)
    .transform(LineSplitter())
    .listen((String line) {
    if (cnt == number && line.startsWith('-')) {
      var undoneLine = line.replaceFirst('-', '');
      sink.write('$undoneLine\n');
    } else {
      sink.write('$line\n');
    }
    cnt += 1;
  },
    onDone: () { },
    onError: (e) { print(e.toString()); });

  Shell().start('rm', ['file2.txt']);
  newFile.rename('file.txt');
}
```

### remove
removeは、TODOリスト自体から指定したタスクを削除するオプションです。
一行ずつ読み込み指定された番号のタスクを書き込まないことで削除しています。

```dart
void remove(int number) {
  File('file2.txt').create(recursive: true);
  var newFile = File('file2.txt');
  var sink = newFile.openWrite();
  final file = File('file.txt');
  Stream<List<int>> inputStream = file.openRead();

  var cnt = 1;
  inputStream
    .transform(utf8.decoder)
    .transform(LineSplitter())
    .listen((String line) {
      var match = cnt == number;
      if (!match) {
        sink.write('$line\n');
      }
    cnt += 1;
  },
    onDone: () { },
    onError: (e) { print(e.toString()); });

  Shell().start('rm', ['file2.txt']);
  newFile.rename('file.txt');
}
```

### clean
cleanはdoneなタスクを一括削除します。
一行ずつ読み込み、`-`が付いていれば書き込まないことで実現しています。

```dart

void clean() {
  File('file2.txt').create(recursive: true);
  var newFile = File('file2.txt');
  var sink = newFile.openWrite();
  final file = File('file.txt');
  Stream<List<int>> inputStream = file.openRead();

  inputStream
    .transform(utf8.decoder)
    .transform(LineSplitter())
    .listen((String line) {
    if (!line.startsWith('-')) {
      sink.write('$line\n');
    }
  },
    onDone: () { },
    onError: (e) { print(e.toString()); });

  Shell().start('rm', ['file2.txt']);
  newFile.rename('file.txt');
}
```


## コンパイルして、実行ファイルとして出力する
最後に、`dart2native`コマンドを使用することで実行ファイルとして出力できます。

```
❯ dart2native bin/main.dart -o dado
```
## 最後に
今回実装したdadoのリポジトリです。
https://github.com/TaigaMikami/dado