diff --git a/packages/cli_tools/README_completion.md b/packages/cli_tools/README_completion.md index a97ed72..61aed72 100644 --- a/packages/cli_tools/README_completion.md +++ b/packages/cli_tools/README_completion.md @@ -8,10 +8,17 @@ the completion shell script. Two tools are currently supported. The shell script needs to be installed by end users to enable completion. -### Enable experimental feature +### Enable the feature + +Enable this feature by constructing `BetterCommandRunner` +with the flag `enableCompletionCommand` set to `true`. + +### Example implementation + +See a complete example command implementation that uses this feature in +the `example` folder: +[command_completion_example.dart](example/command_completion_example.dart) -Enable this experimental feature by constructing `BetterCommandRunner` -with the flag `experimentalCompletionCommand` set to `true`. ## Using the tool `carapace` @@ -71,11 +78,13 @@ For more information and installing in other shells, see: https://carapace-sh.github.io/carapace-bin/setup.html -### Distribution +### Installation End users will need to install `carapace` and copy the Yaml file to the proper location, even if the Yaml file is distributed with the command. +See also [Distribution](#distribution) below. + ## Using the tool `completely` @@ -147,7 +156,95 @@ autoload -Uz +X compinit && compinit autoload -Uz +X bashcompinit && bashcompinit ``` -### Distribution +### Installation + +For end users, the generated bash script can be installed directly in their +`~/.local/share/bash-completion/completions/`. + + +## Distribution + +Two sub-commands are provided to help command developers to distribute the +completion capability to their end users. + +### Embedding the completions scripts in the command package / executable + +The `completion embed` sub-command is intended for the command developer rather +than the end users. It embeds a script in the command source code, so that +it later can be installed by end users with `completion install`. + +Usage: + +```sh +$ my-command completion embed --help +Embed a command line completion script in the command source code + +Usage: example completion embed [arguments] +-h, --help Print this usage information. +-t, --target (mandatory) The target tool format + + [completely] Use the `completely` tool (https://github.com/bashly-framework/completely) + [carapace] Use the `carapace` tool (https://carapace.sh/) + +-f, --script-file Read the script file to embed from a file instead of stdin +-o, --output-file The Dart file name to write ("-" for stdout) + (defaults to "completion_script_.dart") +-d, --output-dir Override the directory to write the Dart source file to + (defaults to "Directory: '/Users/christer/dev/cli_tools/packages/cli_tools/lib/src'") +``` + +### For end-users: Installing the completion script + +End users run the install command: + +```sh +$ my-command completion install --help +Install a command line completion script + +Usage: example completion install [arguments] +-h, --help Print this usage information. +-t, --target (mandatory) The target tool format + + [completely] Use the `completely` tool (https://github.com/bashly-framework/completely) + [carapace] Use the `carapace` tool (https://carapace.sh/) + +-e, --exec-name Override the name of the executable +-d, --write-dir Override the directory to write the script to +``` + +Examples: + +```sh +my-command completion install -t completely +``` +or +```sh +my-command completion install -t carapace +source <(carapace example) # for bash; for others see https://carapace.sh/setup.html +``` + +### Generation examples: -For end users, the generated bash script can be distributed as a file for them -to install directly in their `~/.local/share/bash-completion/completions/`. +In order to regenerate the completion scripts, run these commands for each +target. + +(Use the `embed` sub-command's `--output-dir` option to specify a different +directory for the generated source files, `lib/src/` is the default.) + +Completely: +```sh +my-command completion generate -t completely | completely generate - example.bash +my-command completion embed -t completely -f example.bash -d example/ +``` + +Carapace: +```sh +my-command completion generate -t carapace -f example.yaml +my-command completion embed -t carapace -f example.yaml -d example/ +``` + +Or as one-liners: +```sh +my-command completion generate -t completely | completely generate - - | my-command completion embed -t completely +my-command completion generate -t carapace | my-command completion embed -t carapace +``` diff --git a/packages/cli_tools/example/command_completion_example.dart b/packages/cli_tools/example/command_completion_example.dart new file mode 100644 index 0000000..4be4716 --- /dev/null +++ b/packages/cli_tools/example/command_completion_example.dart @@ -0,0 +1,54 @@ +import 'dart:io' show exitCode; + +import 'package:cli_tools/better_command_runner.dart'; +import 'package:config/config.dart'; + +import 'completion_script_carapace.dart'; +import 'completion_script_completely.dart'; + +/// Example of using [BetterCommandRunner] with command line completion. +/// +/// Run this example program like so: +/// ```sh +/// dart example/command_completion_example.dart completion install -t completely +/// ``` +/// or +/// ```sh +/// dart example/command_completion_example.dart completion install -t carapace +/// source <(carapace example) # for bash, for others see https://carapace.sh/setup.html +/// ``` +/// +/// In order to regenerate the completion scripts, run these commands for each +/// target. +/// +/// See also [README_completion.md]. +/// +/// Completely: +/// ```sh +/// dart example/command_completion_example.dart completion generate -t completely | completely generate - example.bash +/// dart example/command_completion_example.dart completion embed -t completely -f example.bash -d example/ +/// ``` +/// +/// Carapace: +/// ```sh +/// dart example/command_completion_example.dart completion generate -t carapace -f example.yaml +/// dart example/command_completion_example.dart completion embed -t carapace -f example.yaml -d example/ +/// ``` +Future main(final List args) async { + final commandRunner = BetterCommandRunner( + 'example', + 'Example CLI command', + enableCompletionCommand: true, + embeddedCompletions: [ + completionScriptCompletely, + completionScriptCarapace, + ], + ); + + try { + await commandRunner.run(args); + } on UsageException catch (e) { + print(e); + exitCode = 1; + } +} diff --git a/packages/cli_tools/example/completion_script_carapace.dart b/packages/cli_tools/example/completion_script_carapace.dart new file mode 100644 index 0000000..2bc74f5 --- /dev/null +++ b/packages/cli_tools/example/completion_script_carapace.dart @@ -0,0 +1,65 @@ +/// This file is auto-generated. +library; + +import 'package:cli_tools/better_command_runner.dart' show CompletionTarget; + +const String _completionScript = r''' +# yaml-language-server: $schema=https://carapace.sh/schemas/command.json +name: example +persistentFlags: + -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose. + -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet. + +commands: + - name: completion + + commands: + - name: generate + flags: + -t, --target=!: The target tool format + -e, --exec-name=: Override the name of the executable + -f, --file=: Write the specification to a file instead of stdout + completion: + flag: + target: ["completely", "carapace"] + file: ["$files"] + + - name: embed + flags: + -t, --target=!: The target tool format + -f, --script-file=: The script file to embed + -o, --output-file=: The Dart file name to write + -d, --output-dir=: Override the directory to write the Dart source file to + completion: + flag: + target: ["completely", "carapace"] + output-dir: ["$directories"] + + - name: install + flags: + -t, --target=!: The target tool format + -e, --exec-name=: Override the name of the executable + -d, --write-dir=: Override the directory to write the script to + completion: + flag: + target: ["completely", "carapace"] + write-dir: ["$directories"] + + - name: install + flags: + -t, --target=!: The target tool format + -e, --exec-name=: Override the name of the executable + -d, --write-dir=: Override the directory to write the script to + completion: + flag: + target: ["completely", "carapace"] + write-dir: ["$directories"] + + +'''; + +/// Embedded script for command line completion for `carapace`. +const completionScriptCarapace = ( + target: CompletionTarget.carapace, + script: _completionScript, +); diff --git a/packages/cli_tools/example/completion_script_completely.dart b/packages/cli_tools/example/completion_script_completely.dart new file mode 100644 index 0000000..fd54c3e --- /dev/null +++ b/packages/cli_tools/example/completion_script_completely.dart @@ -0,0 +1,139 @@ +/// This file is auto-generated. +library; + +import 'package:cli_tools/better_command_runner.dart' show CompletionTarget; + +const String _completionScript = r''' +# example completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_example_completions_filter() { + local words="$1" + local cur=${COMP_WORDS[COMP_CWORD]} + local result=() + + # words the user already typed (excluding the command itself) + local used=() + if ((COMP_CWORD > 1)); then + used=("${COMP_WORDS[@]:1:$((COMP_CWORD - 1))}") + fi + + if [[ "${cur:0:1}" == "-" ]]; then + # Completing an option: offer everything (including options) + echo "$words" + + else + # Completing a non-option: offer only non-options, + # and don't re-offer ones already used earlier in the line. + for word in $words; do + [[ "${word:0:1}" == "-" ]] && continue + + local seen=0 + for u in "${used[@]}"; do + if [[ "$u" == "$word" ]]; then + seen=1 + break + fi + done + ((!seen)) && result+=("$word") + done + + echo "${result[*]}" + fi +} + +_example_completions() { + local cur=${COMP_WORDS[COMP_CWORD]} + local compwords=() + if ((COMP_CWORD > 0)); then + compwords=("${COMP_WORDS[@]:1:$((COMP_CWORD - 1))}") + fi + local compline="${compwords[*]}" + + COMPREPLY=() + + case "$compline" in + 'completion install'*'--write-dir') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A directory -- "$cur") + ;; + + 'completion embed'*'--output-dir') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A directory -- "$cur") + ;; + + 'completion generate'*'--target') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completely carapace")" -- "$cur") + ;; + + 'completion install'*'--target') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completely carapace")" -- "$cur") + ;; + + 'completion generate'*'--file') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A file -- "$cur") + ;; + + 'completion embed'*'--target') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completely carapace")" -- "$cur") + ;; + + 'completion generate'*'-t') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completely carapace")" -- "$cur") + ;; + + 'completion generate'*'-f') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A file -- "$cur") + ;; + + 'completion install'*'-d') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A directory -- "$cur") + ;; + + 'completion install'*'-t') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completely carapace")" -- "$cur") + ;; + + 'completion embed'*'-t') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completely carapace")" -- "$cur") + ;; + + 'completion embed'*'-d') + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A directory -- "$cur") + ;; + + 'completion generate'*) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "--quiet -q --verbose -v --target -t --exec-name -e --file -f")" -- "$cur") + ;; + + 'completion install'*) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "--quiet -q --verbose -v --target -t --exec-name -e --write-dir -d")" -- "$cur") + ;; + + 'completion embed'*) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "--quiet -q --verbose -v --target -t --script-file -f --output-file -o --output-dir -d")" -- "$cur") + ;; + + 'completion'*) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "generate embed install --quiet -q --verbose -v")" -- "$cur") + ;; + + *) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_example_completions_filter "completion --quiet -q --verbose -v")" -- "$cur") + ;; + + esac +} && + complete -F _example_completions example + +# ex: filetype=sh + +'''; + +/// Embedded script for command line completion for `completely`. +const completionScriptCompletely = ( + target: CompletionTarget.completely, + script: _completionScript, +); diff --git a/packages/cli_tools/lib/better_command_runner.dart b/packages/cli_tools/lib/better_command_runner.dart index 4f3586b..dcdcf2e 100644 --- a/packages/cli_tools/lib/better_command_runner.dart +++ b/packages/cli_tools/lib/better_command_runner.dart @@ -1,3 +1,4 @@ export 'src/better_command_runner/better_command.dart'; export 'src/better_command_runner/better_command_runner.dart'; +export 'src/better_command_runner/completion/completion.dart'; export 'src/better_command_runner/exit_exception.dart'; diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index 70ef694..bfd7c38 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -6,6 +6,7 @@ import 'package:args/command_runner.dart'; import 'package:config/config.dart'; import 'completion/completion_command.dart'; +import 'completion/completion_target.dart' show CompletionScript; /// A function type for executing code before running a command. typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); @@ -148,7 +149,8 @@ class BetterCommandRunner final OnBeforeRunCommand? onBeforeRunCommand, final OnAnalyticsEvent? onAnalyticsEvent, final int? wrapTextColumn, - final bool experimentalCompletionCommand = false, + final bool enableCompletionCommand = false, + final Iterable? embeddedCompletions, final List? globalOptions, final Map? env, }) : _messageOutput = messageOutput, @@ -174,8 +176,10 @@ class BetterCommandRunner } prepareOptionsForParsing(_globalOptions, argParser); - if (experimentalCompletionCommand) { - addCommand(CompletionCommand()); + if (enableCompletionCommand) { + addCommand( + CompletionCommand(embeddedCompletions: embeddedCompletions), + ); } } diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion.dart new file mode 100644 index 0000000..f3d1d76 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion.dart @@ -0,0 +1 @@ +export 'completion_target.dart'; diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart index 6bcbf83..bf6c1d2 100644 --- a/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart @@ -3,7 +3,9 @@ import 'dart:async'; import 'package:config/config.dart'; import '../better_command.dart'; +import 'completion_embed_command.dart'; import 'completion_generate_command.dart'; +import 'completion_install_command.dart'; import 'completion_target.dart'; abstract final class CompletionOptions { @@ -12,6 +14,11 @@ abstract final class CompletionOptions { argName: 'target', argAbbrev: 't', helpText: 'The target tool format', + allowedHelp: { + 'completely': + 'Use the `completely` tool (https://github.com/bashly-framework/completely)', + 'carapace': 'Use the `carapace` tool (https://carapace.sh/)', + }, mandatory: true, ); static const execNameOption = StringOption( @@ -22,8 +29,16 @@ abstract final class CompletionOptions { } class CompletionCommand extends BetterCommand { - CompletionCommand() { + CompletionCommand({ + final Iterable? embeddedCompletions, + }) { addSubcommand(CompletionGenerateCommand()); + addSubcommand(CompletionEmbedCommand()); + if (embeddedCompletions != null) { + addSubcommand(CompletionInstallCommand( + embeddedCompletions: embeddedCompletions, + )); + } } @override diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart new file mode 100644 index 0000000..5d758d6 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart @@ -0,0 +1,151 @@ +import 'dart:convert' show utf8; +import 'dart:io' show Platform, Directory, File, stdin, stderr, IOSink, stdout; + +import 'package:config/config.dart'; +import 'package:path/path.dart' as p; +import 'package:super_string/super_string.dart'; + +import '../better_command.dart'; +import '../better_command_runner.dart' show StandardGlobalOption; +import 'completion_command.dart' show CompletionOptions; + +enum CompletionEmbedOption implements OptionDefinition { + target(CompletionOptions.targetOption), + scriptFileName(StringOption( + argName: 'script-file', + argAbbrev: 'f', + helpText: 'Read the script to embed from a file instead of stdin', + )), + outFileName(StringOption( + argName: 'output-file', + argAbbrev: 'o', + helpText: 'The Dart file name to write ("-" for stdout)', + fromCustom: _outputSourceFileName, + customValidator: _validateSourceFileName, + defaultsTo: 'completion_script_.dart', + )), + outDir(DirOption( + argName: 'output-dir', + argAbbrev: 'd', + helpText: 'Override the directory to write the Dart source file to', + mode: PathExistMode.mustExist, + fromDefault: _defaultWriteDir, + )); + + const CompletionEmbedOption(this.option); + + @override + final ConfigOptionBase option; +} + +/// Finds the package root directory above current script/executable containing +/// a pubspec.yaml. Returns null if no such directory is found. +Directory? _findPackageRoot() { + final String scriptDir = Platform.script.resolve('.').toFilePath(); + + Directory dir = Directory(scriptDir); + while (dir.path != dir.parent.path) { + if (File(p.join(dir.path, 'pubspec.yaml')).existsSync()) { + return dir; + } + dir = dir.parent; + } + return null; +} + +Directory _defaultWriteDir() { + final dir = _findPackageRoot(); + if (dir == null) { + return Directory.current; + } + return Directory(p.join(dir.path, 'lib', 'src')); +} + +String _outputSourceFileName(final Configuration cfg) { + final target = cfg.value(CompletionEmbedOption.target); + return 'completion_script_${target.name}.dart'; +} + +void _validateSourceFileName(final String value) { + if (value == '-') { + return; + } + if (value.contains('/') || value.contains('\\')) { + throw FormatException('Dart file name "$value" cannot contain a path'); + } + if (!value.endsWith('.dart')) { + throw FormatException('Dart file name "$value" does not end with ".dart"'); + } +} + +class CompletionEmbedCommand + extends BetterCommand { + CompletionEmbedCommand() : super(options: CompletionEmbedOption.values); + + @override + String get name => 'embed'; + + @override + String get description => + 'Embed a command line completion script in the command source code'; + + @override + bool get hidden => true; + + @override + Future runWithConfig( + final Configuration commandConfig) async { + final target = commandConfig.value(CompletionEmbedOption.target); + final scriptFileName = + commandConfig.optionalValue(CompletionEmbedOption.scriptFileName); + final writeFile = commandConfig.value(CompletionEmbedOption.outFileName); + final writeDir = commandConfig.value(CompletionEmbedOption.outDir); + + final String scriptContent; + if (scriptFileName == null) { + scriptContent = await stdin.transform(utf8.decoder).join(); + } else { + final scriptFile = File(scriptFileName); + scriptContent = scriptFile.readAsStringSync(); + } + + final outputContent = """ +/// This file is auto-generated. +library; + +import 'package:cli_tools/better_command_runner.dart' show CompletionTarget; + +const String _completionScript = r''' +$scriptContent +'''; + +/// Embedded script for command line completion for `${target.name}`. +const completionScript${target.name.capitalize()} = ( + target: $target, + script: _completionScript, +); +"""; + + final File? embeddedFile = + writeFile == '-' ? null : File(p.join(writeDir.path, writeFile)); + + final IOSink out = embeddedFile?.openWrite() ?? stdout; + + out.write(outputContent); + + if (embeddedFile != null) { + await out.flush(); + await out.close(); + } + + if (runner?.globalConfiguration.findValueOf( + argName: StandardGlobalOption.verbose.option.argName) == + true) { + final out = + embeddedFile == null ? ' to stdout' : ': ${embeddedFile.path}'; + stderr.writeln('Wrote embedded script$out'); + } + + return null as T; + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart index bbe243d..cebcf1f 100644 --- a/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart @@ -1,8 +1,9 @@ -import 'dart:io' show IOSink, stdout; +import 'dart:io' show IOSink, stdout, stderr; import 'package:config/config.dart'; import '../better_command.dart'; +import '../better_command_runner.dart' show StandardGlobalOption; import 'carapace_generator.dart'; import 'completely_generator.dart'; import 'completion_command.dart' show CompletionOptions; @@ -44,7 +45,7 @@ class CompletionGenerateCommand final betterRunner = runner; if (betterRunner == null) { - throw Exception('BetterCommandRunner not set in the completion command'); + throw StateError('BetterCommandRunner not set'); } final usage = UsageRepresentation.compile( @@ -67,6 +68,13 @@ class CompletionGenerateCommand await out.flush(); await out.close(); } + + if (betterRunner.globalConfiguration.findValueOf( + argName: StandardGlobalOption.verbose.option.argName) == + true) { + final out = file == null ? ' to stdout' : ': ${file.path}'; + stderr.writeln('Generated specification$out'); + } return null as T; } } diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart new file mode 100644 index 0000000..e5271b7 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart @@ -0,0 +1,133 @@ +import 'dart:io' show Platform, File, stderr, Directory; + +import 'package:config/config.dart'; +import 'package:path/path.dart' as p; + +import '../better_command.dart'; +import '../better_command_runner.dart' show StandardGlobalOption; +import '../exit_exception.dart'; +import 'completion_command.dart' show CompletionOptions; +import 'completion_target.dart'; + +enum CompletionInstallOption implements OptionDefinition { + target(CompletionOptions.targetOption), + execName(CompletionOptions.execNameOption), + writeDir(DirOption( + argName: 'write-dir', + argAbbrev: 'd', + helpText: 'Override the directory to write the script to', + mode: PathExistMode.mustExist, + )); + + const CompletionInstallOption(this.option); + + @override + final ConfigOptionBase option; +} + +class CompletionInstallCommand + extends BetterCommand { + final Map _embeddedCompletions; + + CompletionInstallCommand({ + required final Iterable embeddedCompletions, + }) : _embeddedCompletions = Map.fromEntries(embeddedCompletions.map( + (final e) => MapEntry(e.target, e.script), + )), + super(options: CompletionInstallOption.values); + + @override + String get name => 'install'; + + @override + String get description => 'Install a command line completion script'; + + @override + Future runWithConfig( + final Configuration commandConfig) async { + final target = commandConfig.value(CompletionInstallOption.target); + final execName = + commandConfig.optionalValue(CompletionInstallOption.execName); + final writeDir = + commandConfig.optionalValue(CompletionInstallOption.writeDir); + + final betterRunner = runner; + if (betterRunner == null) { + throw StateError('BetterCommandRunner not set'); + } + + final scriptContent = _embeddedCompletions[target]; + if (scriptContent == null) { + stderr.writeln('No embedded script found for target: $target'); + throw ExitException.error(); + } + + final executableName = execName ?? betterRunner.executableName; + + final writeFileName = switch (target) { + CompletionTarget.completely => '$executableName.bash', + CompletionTarget.carapace => '$executableName.yaml', + }; + final writeDirPath = writeDir?.path ?? + switch (target) { + CompletionTarget.completely => p.join( + _getHomeDir(), + '.local', + 'share', + 'bash-completion', + 'completions', + ), + CompletionTarget.carapace => p.join( + _getUserConfigDir(), + 'carapace', + 'specs', + ), + }; + final writeFilePath = p.join(writeDirPath, writeFileName); + + await Directory(writeDirPath).create(recursive: true); + final out = File(writeFilePath).openWrite(); + out.write(scriptContent); + await out.flush(); + await out.close(); + + if (betterRunner.globalConfiguration.findValueOf( + argName: StandardGlobalOption.verbose.option.argName) == + true) { + stderr.writeln('Installed script: $writeFilePath'); + } + return null as T; + } + + /// Returns the user configuration directory for the current platform. + /// See also: https://specifications.freedesktop.org/basedir-spec/ + static String _getUserConfigDir() { + if (Platform.environment['XDG_CONFIG_HOME'] case final String configHome) { + return configHome; + } + if (Platform.isWindows) { + final appData = Platform.environment['APPDATA']; + if (appData != null && appData.isNotEmpty) { + return appData; + } + final userProfile = Platform.environment['USERPROFILE']; + if (userProfile != null && userProfile.isNotEmpty) { + return p.join(userProfile, 'AppData', 'Roaming'); + } + throw Exception('APPDATA environment variable is not set'); + } else if (Platform.isMacOS) { + return '${_getHomeDir()}/Library/Application Support'; + } else if (Platform.isLinux) { + return '${_getHomeDir()}/.config'; + } + throw Exception('Unsupported platform: ${Platform.operatingSystem}'); + } + + static String _getHomeDir() { + final homeDir = Platform.environment['HOME']; + if (homeDir == null) { + throw Exception('HOME environment variable is not set'); + } + return homeDir; + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart index 212c716..f850246 100644 --- a/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart @@ -2,3 +2,5 @@ enum CompletionTarget { completely, carapace, } + +typedef CompletionScript = ({CompletionTarget target, String script}); diff --git a/packages/cli_tools/test/better_command_runner/completion_test.dart b/packages/cli_tools/test/better_command_runner/completion_test.dart index 3bc5c49..822ee56 100644 --- a/packages/cli_tools/test/better_command_runner/completion_test.dart +++ b/packages/cli_tools/test/better_command_runner/completion_test.dart @@ -5,7 +5,7 @@ import 'package:test_descriptor/test_descriptor.dart' as d; void main() { test( - 'Given a BetterCommandRunner without enabling experimental completion feature' + 'Given a BetterCommandRunner without enabling completion feature' ' when running base command with --help flag' ' then global usage does not include "completion" command', () async { final infos = []; @@ -29,9 +29,7 @@ void main() { ); }); - group( - 'Given a BetterCommandRunner with experimental completion feature enabled', - () { + group('Given a BetterCommandRunner with completion feature enabled', () { final infos = []; final messageOutput = MessageOutput( usageLogger: (final u) => infos.add(u), @@ -41,7 +39,7 @@ void main() { 'test', 'test project', messageOutput: messageOutput, - experimentalCompletionCommand: true, + enableCompletionCommand: true, ); setUp(() { @@ -128,20 +126,7 @@ void main() { stringContainsInOrder([ 'other-exec-name:', ' - completion', - ' - --quiet', - ' - -q', - ' - --verbose', - ' - -v', 'other-exec-name completion generate*--target:', - ' - completely', - ' - carapace', - 'other-exec-name completion generate*-t:', - ' - completely', - ' - carapace', - 'other-exec-name completion generate*--file:', - ' - ', - 'other-exec-name completion generate*-f:', - ' - ', ])); await expectLater(spec.validate(), completes); }); @@ -172,14 +157,16 @@ void main() { ' -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet.', 'commands:', ' - name: completion', - ' flags:', - ' -t, --target=!: The target tool format', - ' -e, --exec-name=: Override the name of the executable', - ' -f, --file=: Write the specification to a file instead of stdout', - ' completion:', - ' flag:', - ' target: ["completely", "carapace"]', - r' file: ["$files"]', + ' commands:', + ' - name: generate', + ' flags:', + ' -t, --target=!: The target tool format', + ' -e, --exec-name=: Override the name of the executable', + ' -f, --file=: Write the specification to a file instead of stdout', + ' completion:', + ' flag:', + ' target: ["completely", "carapace"]', + r' file: ["$files"]', ])); await expectLater(spec.validate(), completes); }); @@ -206,19 +193,229 @@ void main() { stringContainsInOrder([ r'# yaml-language-server: $schema=https://carapace.sh/schemas/command.json', 'name: other-exec-name', - 'persistentFlags:', - ' -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose.', - ' -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet.', - 'commands:', - ' - name: completion', - ' flags:', - ' -t, --target=!: The target tool format', - ' -e, --exec-name=: Override the name of the executable', - ' -f, --file=: Write the specification to a file instead of stdout', - ' completion:', - ' flag:', - ' target: ["completely", "carapace"]', - r' file: ["$files"]', + ])); + await expectLater(spec.validate(), completes); + }); + + test( + 'when running subcommand "completion embed -t completely" ' + 'then the proper embedded script dart file is written', () async { + await d.dir('test-dir', [ + d.file('test.bash', r''' +# example completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_example_completions_filter() { +} + ''') + ]).create(); + final dirPath = p.join(d.sandbox, 'test-dir'); + final bashFilePath = p.join(dirPath, 'test.bash'); + + await runner.run([ + 'completion', + 'embed', + '-t', + 'completely', + '-f', + bashFilePath, + '-d', + dirPath, + ]); + + final filePath = p.join(dirPath, 'completion_script_completely.dart'); + final spec = d.file( + filePath, + stringContainsInOrder([ + '/// This file is auto-generated.', + 'library;', + "import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;", + 'const String _completionScript = r', + r''' +# example completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_example_completions_filter() { +} +''', + r''' +/// Embedded script for command line completion for `completely`. +const completionScriptCompletely = ( + target: CompletionTarget.completely, + script: _completionScript, +); +''', + ])); + await expectLater(spec.validate(), completes); + }); + + test( + 'when running subcommand "completion embed -t carapace" ' + 'then the proper embedded script dart file is written', () async { + await d.dir('test-dir', [ + d.file('test.yaml', r''' +# yaml-language-server: $schema=https://carapace.sh/schemas/command.json +name: test +persistentFlags: + -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose. + -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet. + ''') + ]).create(); + final dirPath = p.join(d.sandbox, 'test-dir'); + final yamlFilePath = p.join(dirPath, 'test.yaml'); + + await runner.run([ + 'completion', + 'embed', + '-t', + 'carapace', + '-f', + yamlFilePath, + '-d', + dirPath, + ]); + + final filePath = p.join(dirPath, 'completion_script_carapace.dart'); + final spec = d.file( + filePath, + stringContainsInOrder([ + '/// This file is auto-generated.', + 'library;', + "import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;", + 'const String _completionScript = r', + r''' +# yaml-language-server: $schema=https://carapace.sh/schemas/command.json +name: test +persistentFlags: + -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose. + -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet. +''', + r''' +/// Embedded script for command line completion for `carapace`. +const completionScriptCarapace = ( + target: CompletionTarget.carapace, + script: _completionScript, +); +''', + ])); + await expectLater(spec.validate(), completes); + }); + }); + + group( + 'Given a BetterCommandRunner with completion feature enabled' + ' and embedded completions', () { + final infos = []; + final messageOutput = MessageOutput( + usageLogger: (final u) => infos.add(u), + ); + + const completelyCompletionScript = r''' +# example completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_example_completions_filter() { +} +'''; + const carapaceCompletionScript = r''' +# yaml-language-server: $schema=https://carapace.sh/schemas/command.json +name: test +persistentFlags: + -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose. + -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet. +'''; + const completionScriptCompletely = ( + target: CompletionTarget.completely, + script: completelyCompletionScript, + ); + const completionScriptCarapace = ( + target: CompletionTarget.carapace, + script: carapaceCompletionScript, + ); + + final runner = BetterCommandRunner( + 'test', + 'test project', + messageOutput: messageOutput, + enableCompletionCommand: true, + embeddedCompletions: [ + completionScriptCompletely, + completionScriptCarapace, + ], + ); + + setUp(() { + infos.clear(); + }); + + test( + 'when running subcommand "completion install -t completely -d " ' + 'then the proper script file is written', () async { + await d.dir('test-dir').create(); + final dirPath = p.join(d.sandbox, 'test-dir'); + + await runner.run([ + 'completion', + 'install', + '-t', + 'completely', + '-d', + dirPath, + ]); + + final filePath = p.join(dirPath, 'test.bash'); + final spec = d.file( + filePath, + stringContainsInOrder([ + r''' +# example completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_example_completions_filter() { +} +''', + ])); + await expectLater(spec.validate(), completes); + }); + + test( + 'when running subcommand "completion install -t carapace -d " ' + 'then the proper script file is written', () async { + await d.dir('test-dir').create(); + final dirPath = p.join(d.sandbox, 'test-dir'); + + await runner.run([ + 'completion', + 'install', + '-t', + 'carapace', + '-d', + dirPath, + ]); + + final filePath = p.join(dirPath, 'test.yaml'); + final spec = d.file( + filePath, + stringContainsInOrder([ + r''' +# yaml-language-server: $schema=https://carapace.sh/schemas/command.json +name: test +persistentFlags: + -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose. + -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet. +''', ])); await expectLater(spec.validate(), completes); });