From 9aca9e9f3782ebd1cb7bb4b8812ab2ca5d0c66dc Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Fri, 27 Feb 2026 23:09:28 -0600 Subject: [PATCH] feat: read yaml, execute commands --- .vscode/settings.json | 8 ++ Cargo.lock | 3 +- Cargo.toml | 1 + README.md | 107 +++++++++++++++- config.sample.fire.yml | 118 +++++++++++++++++ schemas/fire.schema.json | 262 ++++++++++++++++++++++++++++++++++++++ src/completion.rs | 185 +++++++++++++++++++++++++++ src/config.rs | 140 +++++++++++++++++++++ src/execute.rs | 80 ++++++++++++ src/help.rs | 64 ++++++++++ src/lib.rs | 266 +++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 +- src/resolve.rs | 94 ++++++++++++++ zsh_completations | 29 +++-- 14 files changed, 1339 insertions(+), 20 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 config.sample.fire.yml create mode 100644 schemas/fire.schema.json create mode 100644 src/completion.rs create mode 100644 src/config.rs create mode 100644 src/execute.rs create mode 100644 src/help.rs create mode 100644 src/resolve.rs diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ef5f9d4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "yaml.schemas": { + "./schemas/fire.schema.json": [ + "fire.yaml", + "*.fire.yaml", + ] + } +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 03d9e24..5409f06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "bitflags" @@ -46,6 +46,7 @@ name = "fire-cli" version = "0.1.2" dependencies = [ "dirs", + "serde", "serde_yaml", ] diff --git a/Cargo.toml b/Cargo.toml index 9d707ec..e3c9826 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ path = "src/main.rs" [dependencies] dirs = "5.0.1" serde_yaml = "0.9.34" +serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index 5d3cee0..e90be5f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,108 @@ # Fire CLI -Under construction. +A CLI with dynamic completion powered by external configuration. + +## Command Configuration +Fire loads YAML files from the current directory with these patterns: +- `fire.yaml` +- `fire.yml` +- `*.fire.yaml` +- `*.fire.yml` + +Files are merged in this order: +1. `fire.yaml` / `fire.yml` +2. `*.fire.yaml` / `*.fire.yml` (lexicographic order) + +If the same command name appears more than once, the last loaded definition wins. + +Example: + +```yaml +commands: + run: + description: Run npm scripts + exec: npm run + commands: + build: npm run build + test: + description: Run tests + run: npm run test + + lint: npm run lint +``` + +`exec` and `run` are both supported and treated as executable command actions. + +## `config.fire.yaml` Validation in VS Code +The schema is available at [`schemas/fire.schema.json`](./schemas/fire.schema.json). + +You can associate it in two ways: + +### Option 1: Global mapping by file name +In `.vscode/settings.json`: + +```json +{ + "yaml.schemas": { + "./schemas/fire.schema.json": [ + "config.fire.yaml", + "*.fire.yml", + "*.fire.yaml" + ] + } +} +``` + +### Option 2: Per-file mapping with `$schema` +In the first line of your YAML file: + +```yaml +# yaml-language-server: $schema=./schemas/fire.schema.json +``` + +With this, VS Code (YAML extension) validates structure, types, and allowed DSL fields. + +Note: `cli` is a reserved command name at the top-level `commands` map, so `commands.cli` is rejected by the schema. + +Expected validation error (example in VS Code): `Property cli is not allowed.` + +Why: `cli` is reserved for internal CLI behavior and cannot be overridden as a user command at the root `commands` level. + +## Autocomplete Without External Scripts +Fire supports two completion modes: + +- Rich zsh completion (value + description) +- Bash-compatible command completion (`complete -C`) + +### zsh +```zsh +source ./zsh_completations +``` + +### bash +```bash +complete -o nospace -C fire fire +``` + +For `complete -C`, the shell invokes `fire` in completion mode using `COMP_LINE` / `COMP_POINT`. + +Completion output includes command name and `description` when present (`namedescription`) in the `__complete` protocol, which is consumed by the zsh completion script. + +Note: `complete -C` only uses completion values; descriptions are a zsh-native feature through `zsh_completations`. + +## Execution Rules +- `fire ` executes `exec` (or `run`) of the resolved command. +- Nested command resolution is greedy: it matches the deepest valid subcommand path. +- Remaining unmatched tokens are appended as arguments to the final executable command. + +Example: +- `fire run start --host 0.0.0.0` with `run.exec: "npm run"` executes: + - `npm run start --host 0.0.0.0` + +`cli` is reserved for internal CLI management and cannot be overridden by user commands. + +## Run +```bash +cargo build +./target/debug/fire +``` diff --git a/config.sample.fire.yml b/config.sample.fire.yml new file mode 100644 index 0000000..a4a8b14 --- /dev/null +++ b/config.sample.fire.yml @@ -0,0 +1,118 @@ +# yaml-language-server: $schema=./schemas/fire.schema.json +dir: . # default working directory + +env_file: .env # default env file + +namespace: + id: 51170376-ea38-4317-ba49-4e63dfc8a571 # optional + description: Pumpkat + alias: pk + +runtimes: + deno: + sdk: deno + runner: deno + check: deno --version + fallback_runner: docker run --rm -it denoland/deno:latest /bin/bash + paths: + - scripts/*.ts + - scripts/helpers/*.ts + py: + sdk: python + paths: + - scripts/*.py + - scripts/helpers/*.py + +include: + - common.fire.yml + - database.fire.yml + +x-arg-config: &arg-config + # Use {n} to represent the argument index: {{n}} -> {1}, {2}, ... ; #{n} -> #1, #2, ... + # The ... and [] forms must stay literal; they expand to arrays (string[]). + # Examples: ${n} -> ...${n} or [${n}]; {{n}} -> ...{{n}} or [{{n}}]. + placeholder: "{{n}}" # replace ${n} with the index; you can also use $1, $2, $3, ... + on_unused_args: ignore # default is error; ignore skips, warn logs, error fails + +# Example: running `fire hello other args` executes `echo hello world other args...` +commands: + hello: echo hello world + + double: + commands: + hello: + description: "say twice" + exec: + - echo hello world + - echo again + world: + description: "say world twice" + exec: + - echo world + - echo world + + npm: + dir: . # default (relative to project root) + check: npm -v # if this fails, fallback_runner will be used + fallback_runner: docker run --rm -it node:lts-alpine /bin/bash # fallback + exec: npm + + run: + description: | + y si agrego descripción a este? + vamos + exec: npm run --key ${SECRET_KEY:-default_value} + + run2: + before: docker compose ps -q front | grep -q . || docker compose up -d front + exec: compose exec front npm run + + run3: + runner: docker compose exec front /bin/bash # equivalent to run inside the container + env_file: .env.front # override the env file for this command + exec: # multiple commands run in sequence; the last one receives the args + - npm run build + - npm run start + + # fire run5 -> npm run + # fire run5 build -> npm run clean && npm run build + # fire run5 other -> npm run other + run5: + exec: npm run # if it matches subcommands, it will be ignored + commands: + build: npm run clean && npm run build + start: fire build && npm run start + + up: + <<: *arg-config + compute: + arg1: deno:makeHash("{1}", "sha3-256") + description: " " + exec: echo "Hello {1}, your job is {2}" + + computed: + <<: *arg-config + eval: py:sayHello("{1}", "{2}", ...{{n}}) + + vars: + compute: + arg1: deno:getServiceNameById("{1}") + macros: + "{{front}}": docker compose exec front + "{{dynamic}}": docker compose exec {{1}} + exec: "{{front}} npm run" + commands: + npm-version: "{{front}} npm -v" + node-version: "{{front}} node -v" + hello: "{{dynamic}} echo Hello" + hey: + exec: npm run # if it matches subcommands, it will be ignored + commands: + build: npm run clean && npm run build + hey: + description: "say hey!!!" + exec: npm run + start: fire build && npm run start + + web: + delegate: npm-run # built-in delegate that uses package.json scripts for autocompletion diff --git a/schemas/fire.schema.json b/schemas/fire.schema.json new file mode 100644 index 0000000..e063197 --- /dev/null +++ b/schemas/fire.schema.json @@ -0,0 +1,262 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fire-cli.dev/schemas/fire.schema.json", + "title": "Fire Config", + "type": "object", + "additionalProperties": false, + "properties": { + "dir": { + "type": "string", + "description": "Project working directory. Defaults to '.'." + }, + "env_file": { + "type": "string", + "description": "Environment file path loaded before command execution. Defaults to '.env'." + }, + "namespace": { + "$ref": "#/$defs/namespace" + }, + "runtimes": { + "type": "object", + "description": "Runtime definitions keyed by alias (for example: deno, py).", + "additionalProperties": { + "$ref": "#/$defs/runtime" + } + }, + "include": { + "type": "array", + "description": "List of additional Fire config files to merge/include.", + "items": { + "type": "string" + } + }, + "x-arg-config": { + "description": "Reusable argument placeholder behavior, usually consumed via YAML anchors.", + "$ref": "#/$defs/argConfig" + }, + "commands": { + "type": "object", + "description": "Command definitions keyed by command name. The top-level command name `cli` is reserved and cannot be defined (validation error example: 'Property cli is not allowed.').", + "minProperties": 1, + "properties": { + "cli": { + "description": "Reserved command name. `commands.cli` is not allowed at the top level because `cli` is used internally by Fire.", + "not": {} + } + }, + "additionalProperties": { + "$ref": "#/$defs/commandEntry" + } + } + }, + "required": [ + "commands" + ], + "$defs": { + "namespace": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Optional namespace UUID." + }, + "description": { + "type": "string", + "description": "Human-readable namespace label." + }, + "alias": { + "type": "string", + "description": "Short namespace alias." + } + }, + "required": [ + "description", + "alias" + ] + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "sdk": { + "type": "string", + "description": "Runtime SDK identifier used in runtime expressions (for example: deno, python)." + }, + "runner": { + "type": "string", + "description": "Default runner command used to execute runtime logic." + }, + "check": { + "type": "string", + "description": "Health-check command to verify runtime availability." + }, + "fallback_runner": { + "type": "string", + "description": "Fallback runner command used when `check` fails." + }, + "paths": { + "type": "array", + "description": "Glob patterns where runtime scripts/functions are discovered.", + "items": { + "type": "string" + } + } + }, + "required": [ + "sdk" + ] + }, + "argConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "placeholder": { + "type": "string", + "description": "Placeholder template for positional args. Example: '{{n}}', '$n'." + }, + "on_unused_args": { + "type": "string", + "description": "Behavior when extra args are passed and not consumed by placeholders.", + "enum": [ + "ignore", + "warn", + "error" + ] + } + } + }, + "stringOrStringArray": { + "description": "A single command string or a sequence of command strings.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "commandEntry": { + "description": "Command shorthand string or full command object.", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/commandSpec" + } + ] + }, + "commandSpec": { + "type": "object", + "additionalProperties": false, + "properties": { + "<<": { + "description": "YAML merge key used to reuse anchored blocks." + }, + "dir": { + "type": "string", + "description": "Command-specific working directory. Overrides top-level `dir`." + }, + "env_file": { + "type": "string", + "description": "Command-specific environment file. Overrides top-level `env_file`." + }, + "check": { + "type": "string", + "description": "Pre-check command; can be paired with `fallback_runner`." + }, + "runner": { + "type": "string", + "description": "Command runner prefix (for example to run inside a container shell)." + }, + "fallback_runner": { + "type": "string", + "description": "Runner used if `check` fails." + }, + "before": { + "type": "string", + "description": "Command executed before the main action." + }, + "description": { + "type": "string", + "description": "Short user-facing description shown in listings and completion." + }, + "delegate": { + "type": "string", + "description": "Built-in delegate used to generate dynamic subcommands (for example: npm-run)." + }, + "placeholder": { + "type": "string", + "description": "Placeholder template for this command (same semantics as `x-arg-config.placeholder`)." + }, + "on_unused_args": { + "type": "string", + "description": "Behavior for unused args at this command level.", + "enum": [ + "ignore", + "warn", + "error" + ] + }, + "exec": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Shell command(s) to execute. If an array is provided, commands run in order." + }, + "eval": { + "type": "string", + "description": "Runtime expression to evaluate (for example: py:func(\"{1}\"))." + }, + "compute": { + "type": "object", + "description": "Computed variables map (name -> runtime expression), evaluated before execution.", + "additionalProperties": { + "type": "string" + } + }, + "macros": { + "type": "object", + "description": "Macro substitutions map used in `exec` and nested command strings.", + "additionalProperties": { + "type": "string" + } + }, + "commands": { + "type": "object", + "description": "Nested subcommands keyed by subcommand name.", + "additionalProperties": { + "$ref": "#/$defs/commandEntry" + } + } + }, + "anyOf": [ + { + "required": [ + "exec" + ] + }, + { + "required": [ + "eval" + ] + }, + { + "required": [ + "delegate" + ] + }, + { + "required": [ + "commands" + ] + } + ] + } + } +} diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..0739cf8 --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,185 @@ +use std::collections::BTreeMap; + +use crate::config::{CommandEntry, FireConfig}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CompletionSuggestion { + pub(crate) value: String, + pub(crate) description: Option, +} + +pub(crate) fn completion_suggestions( + config: &FireConfig, + words: &[String], +) -> Vec { + if words.is_empty() { + return suggestions_with_prefix("", &config.commands); + } + + let mut available = &config.commands; + + for token in &words[..words.len() - 1] { + let Some(entry) = available.get(token) else { + return Vec::new(); + }; + let Some(subcommands) = entry.subcommands() else { + return Vec::new(); + }; + available = subcommands; + } + + let prefix = words.last().map(String::as_str).unwrap_or(""); + + if let Some(exact) = available.get(prefix) { + if let Some(subcommands) = exact.subcommands() { + return suggestions_with_prefix("", subcommands); + } + return Vec::new(); + } + + suggestions_with_prefix(prefix, available) +} + +pub(crate) fn suggestions_with_prefix( + prefix: &str, + commands: &BTreeMap, +) -> Vec { + commands + .iter() + .filter_map(|(name, entry)| { + if !name.starts_with(prefix) { + return None; + } + let description = entry.description().unwrap_or_default().trim(); + if description.is_empty() { + Some(CompletionSuggestion { + value: name.clone(), + description: None, + }) + } else { + Some(CompletionSuggestion { + value: name.clone(), + description: Some(description.to_string()), + }) + } + }) + .collect() +} + +pub(crate) fn render_with_descriptions(suggestions: &[CompletionSuggestion]) -> Vec { + suggestions + .iter() + .map(|suggestion| match suggestion.description.as_deref() { + Some(description) => format!( + "{}\t{}", + suggestion.value, + first_description_line(description) + ), + None => suggestion.value.clone(), + }) + .collect() +} + +pub(crate) fn render_values_only(suggestions: &[CompletionSuggestion]) -> Vec { + suggestions + .iter() + .map(|suggestion| suggestion.value.clone()) + .collect() +} + +fn first_description_line(description: &str) -> &str { + description.lines().next().unwrap_or("").trim() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::config::{CommandEntry, CommandSpec, FireConfig}; + + use super::*; + + #[test] + fn completion_includes_description() { + let mut map = BTreeMap::new(); + map.insert( + "run".to_string(), + CommandEntry::Spec(CommandSpec { + description: "run scripts".to_string(), + ..CommandSpec::default() + }), + ); + map.insert( + "raw".to_string(), + CommandEntry::Shorthand("echo raw".to_string()), + ); + + let values = suggestions_with_prefix("r", &map); + assert_eq!( + values, + vec![ + CompletionSuggestion { + value: "raw".to_string(), + description: None + }, + CompletionSuggestion { + value: "run".to_string(), + description: Some("run scripts".to_string()) + } + ] + ); + } + + #[test] + fn completion_moves_into_subcommands_on_exact_match() { + let yaml = r#" +commands: + vars: + commands: + npm-version: echo ok + node-version: echo ok +"#; + let config: FireConfig = serde_yaml::from_str(yaml).expect("valid config"); + let words = vec!["vars".to_string()]; + + let values = completion_suggestions(&config, &words); + assert_eq!( + values, + vec![ + CompletionSuggestion { + value: "node-version".to_string(), + description: None + }, + CompletionSuggestion { + value: "npm-version".to_string(), + description: None + } + ] + ); + } + + #[test] + fn completion_returns_empty_for_exact_command_without_subcommands() { + let yaml = r#" +commands: + run: npm run + run2: npm run test +"#; + let config: FireConfig = serde_yaml::from_str(yaml).expect("valid config"); + let words = vec!["run".to_string()]; + + let values = completion_suggestions(&config, &words); + assert!(values.is_empty()); + } + + #[test] + fn render_with_descriptions_uses_only_first_line_of_description() { + let suggestions = vec![CompletionSuggestion { + value: "run".to_string(), + description: Some("run service\nwith custom host".to_string()), + }]; + + let rendered = render_with_descriptions(&suggestions); + assert_eq!(rendered, vec!["run\trun service".to_string()]); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6736b9f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,140 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone, Default)] +pub(crate) struct FireConfig { + #[serde(default)] + pub(crate) commands: BTreeMap, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub(crate) enum CommandEntry { + Shorthand(String), + Spec(CommandSpec), +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub(crate) struct CommandSpec { + #[serde(default)] + pub(crate) description: String, + #[serde(default)] + pub(crate) exec: Option, + #[serde(default)] + pub(crate) run: Option, + #[serde(default)] + pub(crate) commands: BTreeMap, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub(crate) enum CommandAction { + Single(String), + Multiple(Vec), +} + +impl CommandEntry { + pub(crate) fn description(&self) -> Option<&str> { + match self { + CommandEntry::Shorthand(_) => None, + CommandEntry::Spec(spec) => Some(spec.description.as_str()), + } + } + + pub(crate) fn execution_commands(&self) -> Option> { + match self { + CommandEntry::Shorthand(value) => Some(vec![value.clone()]), + CommandEntry::Spec(spec) => spec + .exec + .as_ref() + .or(spec.run.as_ref()) + .map(CommandAction::as_vec), + } + } + + pub(crate) fn subcommands(&self) -> Option<&BTreeMap> { + match self { + CommandEntry::Shorthand(_) => None, + CommandEntry::Spec(spec) => Some(&spec.commands), + } + } +} + +impl CommandAction { + pub(crate) fn as_vec(&self) -> Vec { + match self { + CommandAction::Single(command) => vec![command.clone()], + CommandAction::Multiple(commands) => commands.clone(), + } + } +} + +pub(crate) fn load_config() -> FireConfig { + let files = discover_config_files("."); + if files.is_empty() { + return FireConfig::default(); + } + + let mut merged = FireConfig::default(); + for file in files { + let Ok(text) = fs::read_to_string(&file) else { + eprintln!("[fire] Could not read {}. Skipping.", file.display()); + continue; + }; + + let parsed: FireConfig = match serde_yaml::from_str(&text) { + Ok(value) => value, + Err(err) => { + eprintln!( + "[fire] Invalid YAML in {}: {}. Skipping.", + file.display(), + err + ); + continue; + } + }; + + merged.commands.extend(parsed.commands); + } + + merged +} + +fn discover_config_files(base_dir: impl AsRef) -> Vec { + let mut base_files = Vec::new(); + let mut pattern_files = Vec::new(); + + let Ok(entries) = fs::read_dir(base_dir) else { + return Vec::new(); + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + if matches!(name, "fire.yml" | "fire.yaml") { + base_files.push(path); + continue; + } + + if name.ends_with(".fire.yml") || name.ends_with(".fire.yaml") { + pattern_files.push(path); + } + } + + base_files.sort(); + pattern_files.sort(); + base_files.extend(pattern_files); + base_files +} diff --git a/src/execute.rs b/src/execute.rs new file mode 100644 index 0000000..05dbea5 --- /dev/null +++ b/src/execute.rs @@ -0,0 +1,80 @@ +use std::{process, process::Command}; + +use crate::resolve::ResolvedCommand; + +pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { + let Some(commands_to_run) = resolved.command.execution_commands() else { + eprintln!("[fire] Command path has no executable action."); + if let Some(subcommands) = resolved.command.subcommands() { + eprintln!("[fire] Available subcommands:"); + for (name, entry) in subcommands { + let description = entry.description().unwrap_or_default(); + if description.is_empty() { + eprintln!(" - {name}"); + } else { + eprintln!(" - {name}\t{description}"); + } + } + } + process::exit(1); + }; + + let mut exit_code = 0; + for (index, command) in commands_to_run.iter().enumerate() { + let mut full_command = command.clone(); + if index + 1 == commands_to_run.len() && !resolved.remaining_args.is_empty() { + full_command.push(' '); + full_command.push_str(&join_shell_args(resolved.remaining_args)); + } + + let status = Command::new("sh") + .arg("-c") + .arg(&full_command) + .status() + .unwrap_or_else(|err| { + eprintln!("[fire] Failed to execute `{full_command}`: {err}"); + process::exit(1); + }); + + let code = status.code().unwrap_or(1); + exit_code = code; + if code != 0 { + break; + } + } + + process::exit(exit_code); +} + +fn join_shell_args(args: &[String]) -> String { + args.iter() + .map(String::as_str) + .map(shell_escape) + .collect::>() + .join(" ") +} + +fn shell_escape(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + + if value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '=')) + { + return value.to_string(); + } + + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_single_quote_in_shell_argument() { + assert_eq!(shell_escape("it'ok"), "'it'\"'\"'ok'"); + } +} diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..81ddb0c --- /dev/null +++ b/src/help.rs @@ -0,0 +1,64 @@ +use crate::config::CommandEntry; + +pub(crate) fn print_command_help(command_path: &[String], command: &CommandEntry) { + if command_path.is_empty() { + println!("Fire CLI help"); + } else { + println!("Command: {}", command_path.join(" ")); + } + + let description = command.description().unwrap_or_default().trim(); + if !description.is_empty() { + println!("Description:"); + for line in description.lines() { + println!(" {}", line.trim()); + } + } + + if let Some(subcommands) = command.subcommands() { + if !subcommands.is_empty() { + println!("Subcommands:"); + for (name, entry) in subcommands { + let description = first_description_line(entry.description().unwrap_or_default()); + if description.is_empty() { + println!(" - {name}"); + } else { + println!(" - {name}\t{description}"); + } + } + } + } +} + +fn first_description_line(description: &str) -> &str { + description.lines().next().unwrap_or("").trim() +} + +#[cfg(test)] +mod tests { + use crate::config::FireConfig; + + use super::*; + + #[test] + fn first_description_line_uses_first_line_only() { + assert_eq!(first_description_line("line one\nline two"), "line one"); + } + + #[test] + fn print_help_is_stable_with_command_spec() { + let yaml = r#" +commands: + run: + description: Run scripts + exec: npm run + commands: + start: + description: Start app + exec: npm run start +"#; + let config: FireConfig = serde_yaml::from_str(yaml).expect("valid config"); + let command = config.commands.get("run").expect("run command"); + print_command_help(&["run".to_string()], command); + } +} diff --git a/src/lib.rs b/src/lib.rs index bec086f..44cc768 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,261 @@ -use std::env; +use std::{env, path::Path, process}; + +mod completion; +mod config; +mod execute; +mod help; +mod resolve; + +use completion::{completion_suggestions, render_values_only, render_with_descriptions}; +use config::{load_config, FireConfig}; +use execute::execute_resolved_command; +use help::print_command_help; +use resolve::resolve_command; pub fn setup_cli() { - let args: Vec = env::args().collect(); - dbg!(args); - let path = dirs::home_dir().unwrap().as_path().display().to_string(); - dbg!(path); - print!("Hello, what's your name? "); -} \ No newline at end of file + let mut args: Vec = env::args().collect(); + let bin_name = args + .first() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str()) + .unwrap_or("fire") + .to_string(); + let config = load_config(); + + if args.len() >= 2 && args[1] == "__complete" { + args.drain(0..2); + let words = normalize_completion_words(args, &bin_name); + let suggestions = completion_suggestions(&config, &words); + for suggestion in render_with_descriptions(&suggestions) { + println!("{suggestion}"); + } + return; + } + + if let Some(words) = completion_words_from_env(&bin_name) { + let suggestions = completion_suggestions(&config, &words); + for suggestion in render_values_only(&suggestions) { + println!("{suggestion}"); + } + return; + } + + let command_args = &args[1..]; + + if command_args.is_empty() { + print_available_commands(&config); + return; + } + + if command_args[0] == "cli" { + eprintln!("[fire] `cli` command is reserved for Fire CLI management."); + return; + } + + if let Some(help_target) = extract_help_target(command_args) { + if help_target.is_empty() { + print_available_commands(&config); + return; + } + + let Some(resolved) = resolve_command(&config.commands, help_target) else { + eprintln!("[fire] Unknown command: {}", help_target[0]); + print_available_commands(&config); + process::exit(1); + }; + + let command_path = &help_target[..resolved.consumed]; + print_command_help(command_path, resolved.command); + return; + } + + let Some(resolved) = resolve_command(&config.commands, command_args) else { + eprintln!("[fire] Unknown command: {}", command_args[0]); + print_available_commands(&config); + process::exit(1); + }; + + execute_resolved_command(resolved); +} + +fn print_available_commands(config: &FireConfig) { + println!("Fire CLI - available commands:"); + for (name, entry) in &config.commands { + let description = entry.description().unwrap_or_default().trim(); + if description.is_empty() { + println!(" - {name}"); + } else { + println!(" - {name}\t{description}"); + } + } +} + +fn normalize_completion_words(mut words: Vec, bin_name: &str) -> Vec { + if words.first().map(String::as_str) == Some("--") { + words.remove(0); + } + + if words.first().map(String::as_str) == Some(bin_name) { + words.remove(0); + } else if words.first().map(String::as_str) == Some("fire") { + words.remove(0); + } + + words +} + +fn extract_help_target<'a>(command_args: &'a [String]) -> Option<&'a [String]> { + let last = command_args.last()?; + if last == ":h" { + return Some(&command_args[..command_args.len().saturating_sub(1)]); + } + None +} + +fn completion_words_from_env(bin_name: &str) -> Option> { + let line = env::var("COMP_LINE").ok()?; + let point = env::var("COMP_POINT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(line.len()); + + completion_words_from_line(&line, point, bin_name) +} + +fn completion_words_from_line(line: &str, point: usize, bin_name: &str) -> Option> { + let prefix = utf8_prefix_at_byte(line, point)?; + let mut words = split_shell_words(prefix); + + if prefix.chars().last().is_some_and(char::is_whitespace) { + words.push(String::new()); + } + + Some(normalize_completion_words(words, bin_name)) +} + +fn utf8_prefix_at_byte(value: &str, index: usize) -> Option<&str> { + let mut end = index.min(value.len()); + while !value.is_char_boundary(end) { + if end == 0 { + return None; + } + end -= 1; + } + Some(&value[..end]) +} + +fn split_shell_words(input: &str) -> Vec { + #[derive(Clone, Copy)] + enum Quote { + None, + Single, + Double, + } + + let mut words = Vec::new(); + let mut current = String::new(); + let mut quote = Quote::None; + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match quote { + Quote::None => match ch { + '\'' => quote = Quote::Single, + '"' => quote = Quote::Double, + '\\' => { + if let Some(next) = chars.next() { + current.push(next); + } else { + current.push(ch); + } + } + c if c.is_whitespace() => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + }, + Quote::Single => { + if ch == '\'' { + quote = Quote::None; + } else { + current.push(ch); + } + } + Quote::Double => match ch { + '"' => quote = Quote::None, + '\\' => { + if let Some(next) = chars.next() { + current.push(next); + } else { + current.push(ch); + } + } + _ => current.push(ch), + }, + } + } + + if !current.is_empty() { + words.push(current); + } + + words +} + +#[cfg(test)] +mod tests { + use super::{ + completion_words_from_line, extract_help_target, normalize_completion_words, + split_shell_words, utf8_prefix_at_byte, + }; + + #[test] + fn normalize_completion_words_removes_separator_and_binary_name() { + let words = vec![ + "--".to_string(), + "fire".to_string(), + "vars".to_string(), + "".to_string(), + ]; + + let normalized = normalize_completion_words(words, "fire"); + assert_eq!(normalized, vec!["vars".to_string(), "".to_string()]); + } + + #[test] + fn split_shell_words_handles_quotes() { + let words = split_shell_words("fire run \"start api\" --host 0.0.0.0"); + assert_eq!( + words, + vec![ + "fire".to_string(), + "run".to_string(), + "start api".to_string(), + "--host".to_string(), + "0.0.0.0".to_string() + ] + ); + } + + #[test] + fn utf8_prefix_handles_non_char_boundary() { + let value = "fire vars"; + let prefix = utf8_prefix_at_byte(value, 100).expect("prefix"); + assert_eq!(prefix, "fire vars"); + } + + #[test] + fn completion_words_from_line_parses_current_line() { + let words = completion_words_from_line("fire vars ", 10, "fire").expect("completion words"); + assert_eq!(words, vec!["vars".to_string(), "".to_string()]); + } + + #[test] + fn extract_help_target_detects_help_suffix() { + let args = vec!["run".to_string(), ":h".to_string()]; + let target = extract_help_target(&args).expect("help target"); + assert_eq!(target, &["run".to_string()]); + } +} diff --git a/src/main.rs b/src/main.rs index 47eb583..edcec0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,4 +2,4 @@ use fire_cli::setup_cli; fn main() { setup_cli() -} \ No newline at end of file +} diff --git a/src/resolve.rs b/src/resolve.rs new file mode 100644 index 0000000..ca76156 --- /dev/null +++ b/src/resolve.rs @@ -0,0 +1,94 @@ +use std::collections::BTreeMap; + +use crate::config::CommandEntry; + +pub(crate) struct ResolvedCommand<'a> { + pub(crate) command: &'a CommandEntry, + pub(crate) consumed: usize, + pub(crate) remaining_args: &'a [String], +} + +pub(crate) fn resolve_command<'a>( + commands: &'a BTreeMap, + args: &'a [String], +) -> Option> { + let mut consumed = 1; + let mut current = commands.get(args.first()?)?; + + while consumed < args.len() { + let Some(subcommands) = current.subcommands() else { + break; + }; + if let Some(next) = subcommands.get(&args[consumed]) { + current = next; + consumed += 1; + continue; + } + break; + } + + Some(ResolvedCommand { + command: current, + consumed, + remaining_args: &args[consumed..], + }) +} + +#[cfg(test)] +mod tests { + use crate::config::FireConfig; + + use super::*; + + fn sample_config() -> FireConfig { + let yaml = r#" +commands: + run: + description: run npm script + exec: npm run + commands: + build: npm run build + test: + description: run tests + run: npm run test +"#; + serde_yaml::from_str(yaml).expect("valid config") + } + + #[test] + fn resolves_deepest_subcommand_and_passes_remaining_args() { + let config = sample_config(); + let args = vec![ + "run".to_string(), + "build".to_string(), + "--host".to_string(), + "0.0.0.0".to_string(), + ]; + let resolved = resolve_command(&config.commands, &args).expect("resolved"); + + let commands = resolved.command.execution_commands().expect("exec"); + assert_eq!(commands, vec!["npm run build".to_string()]); + assert_eq!( + resolved.remaining_args, + &["--host".to_string(), "0.0.0.0".to_string()] + ); + } + + #[test] + fn falls_back_to_parent_exec_when_subcommand_does_not_match() { + let config = sample_config(); + let args = vec![ + "run".to_string(), + "start".to_string(), + "--watch".to_string(), + ]; + let resolved = resolve_command(&config.commands, &args).expect("resolved"); + + let commands = resolved.command.execution_commands().expect("exec"); + assert_eq!(commands, vec!["npm run".to_string()]); + assert_eq!( + resolved.remaining_args, + &["start".to_string(), "--watch".to_string()] + ); + } +} diff --git a/zsh_completations b/zsh_completations index 21821ac..0c13324 100644 --- a/zsh_completations +++ b/zsh_completations @@ -1,15 +1,24 @@ -#compdef fire-cli - -_fire_completations() { - echo "local subcmds=('c:description' 'test:$1' 'pwd:$(pwd)')" -} +#compdef fire _fire_cli() { - local current_command="${(ps: :)${words}}" - eval $(_fire_completations "$current_command") + local -a lines + local -a entries + local line value note - _describe 'command' subcmds -} + lines=("${(@f)$(fire __complete -- "${words[@]}")}") + (( ${#lines[@]} == 0 )) && return 1 -_fire_cli "$@" + for line in "${lines[@]}"; do + if [[ "$line" == *$'\t'* ]]; then + value="${line%%$'\t'*}" + note="${line#*$'\t'}" + entries+=("$value:$note") + else + entries+=("$line:") + fi + done + + _describe 'fire commands' entries +} +compdef _fire_cli fire