From 2592062434eeb5fe814dbca953d5fd23458bc135 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 2 Aug 2026 11:04:17 +0900 Subject: feat(symfony-console): implement the shell completion command plumbing The _complete and completion commands were registered but always panicked: get_class_of_command / instantiate_completion_output / tail_debug_log were todo!() and the completion.bash resource was not shipped. - make Command::complete return anyhow::Result so completion errors propagate to CompleteCommand's catch-all (exit code 2) like PHP - add Command::get_class as the port hook for PHP's get_class() debug log; every command supplies its PHP FQCN via the delegation macro - embed Resources/completion.bash at compile time (single-binary port); get_supported_shells becomes a static list - implement tail_debug_log by moving the shared output handle into the 'static process callback - add OutputInterface::as_console_output so unsupported-shell errors go to stderr as in PHP - fix CompletionInput::bind to keep the argument name PHP assigns in the foreach head even when the loop breaks on the first unset argument; application-level completion always hit this and returned no suggestions Co-Authored-By: Claude Fable 5 --- .../src/symfony/console/Resources/completion.bash | 84 +++++++++++++++ .../src/symfony/console/command/command.rs | 29 ++++- .../symfony/console/command/complete_command.rs | 23 ++-- .../console/command/dump_completion_command.rs | 119 +++++++++++++-------- .../src/symfony/console/command/help_command.rs | 12 ++- .../src/symfony/console/command/list_command.rs | 12 ++- .../symfony/console/completion/completion_input.rs | 4 +- .../src/symfony/console/output/console_output.rs | 3 + .../src/symfony/console/output/output_interface.rs | 10 ++ 9 files changed, 241 insertions(+), 55 deletions(-) create mode 100644 crates/shirabe-external-packages/src/symfony/console/Resources/completion.bash (limited to 'crates/shirabe-external-packages/src/symfony/console') diff --git a/crates/shirabe-external-packages/src/symfony/console/Resources/completion.bash b/crates/shirabe-external-packages/src/symfony/console/Resources/completion.bash new file mode 100644 index 00000000..bb44037b --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/Resources/completion.bash @@ -0,0 +1,84 @@ +# This file is part of the Symfony package. +# +# (c) Fabien Potencier +# +# For the full copyright and license information, please view +# https://symfony.com/doc/current/contributing/code/license.html + +_sf_{{ COMMAND_NAME }}() { + # Use newline as only separator to allow space in completion values + local IFS=$'\n' + local sf_cmd="${COMP_WORDS[0]}" + + # for an alias, get the real script behind it + sf_cmd_type=$(type -t $sf_cmd) + if [[ $sf_cmd_type == "alias" ]]; then + sf_cmd=$(alias $sf_cmd | sed -E "s/alias $sf_cmd='(.*)'/\1/") + elif [[ $sf_cmd_type == "file" ]]; then + sf_cmd=$(type -p $sf_cmd) + fi + + if [[ $sf_cmd_type != "function" && ! -x $sf_cmd ]]; then + return 1 + fi + + local cur prev words cword + _get_comp_words_by_ref -n := cur prev words cword + + local completecmd=("$sf_cmd" "_complete" "--no-interaction" "-sbash" "-c$cword" "-S{{ VERSION }}") + for w in ${words[@]}; do + w=$(printf -- '%b' "$w") + # remove quotes from typed values + quote="${w:0:1}" + if [ "$quote" == \' ]; then + w="${w%\'}" + w="${w#\'}" + elif [ "$quote" == \" ]; then + w="${w%\"}" + w="${w#\"}" + fi + # empty values are ignored + if [ ! -z "$w" ]; then + completecmd+=("-i$w") + fi + done + + local sfcomplete + if sfcomplete=$(${completecmd[@]} 2>&1); then + local quote suggestions + quote=${cur:0:1} + + # Use single quotes by default if suggestions contains backslash (FQCN) + if [ "$quote" == '' ] && [[ "$sfcomplete" =~ \\ ]]; then + quote=\' + fi + + if [ "$quote" == \' ]; then + # single quotes: no additional escaping (does not accept ' in values) + suggestions=$(for s in $sfcomplete; do printf $'%q%q%q\n' "$quote" "$s" "$quote"; done) + elif [ "$quote" == \" ]; then + # double quotes: double escaping for \ $ ` " + suggestions=$(for s in $sfcomplete; do + s=${s//\\/\\\\} + s=${s//\$/\\\$} + s=${s//\`/\\\`} + s=${s//\"/\\\"} + printf $'%q%q%q\n' "$quote" "$s" "$quote"; + done) + else + # no quotes: double escaping + suggestions=$(for s in $sfcomplete; do printf $'%q\n' $(printf '%q' "$s"); done) + fi + COMPREPLY=($(IFS=$'\n' compgen -W "$suggestions" -- $(printf -- "%q" "$cur"))) + __ltrim_colon_completions "$cur" + else + if [[ "$sfcomplete" != *"Command \"_complete\" is not defined."* ]]; then + >&2 echo + >&2 echo $sfcomplete + fi + + return 1 + fi +} + +complete -F _sf_{{ COMMAND_NAME }} {{ COMMAND_NAME }} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/command.rs b/crates/shirabe-external-packages/src/symfony/console/command/command.rs index 6e6c2212..1f1455f8 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/command.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/command.rs @@ -325,6 +325,13 @@ macro_rules! delegate_command_trait_impls_to_inner { $crate::delegate_to_inner!($field, fn ignore_validation_errors(&self)); $crate::delegate_to_inner!($field, fn get_ignore_validation_errors(&self) -> bool); }; + // Variant taking the command's PHP fully-qualified class name (see `Command::get_class`). + ($field:ident, $fqcn:literal) => { + $crate::delegate_command_trait_impls_to_inner!($field); + fn get_class(&self) -> String { + $fqcn.to_string() + } + }; } /// Polymorphic interface for all commands (PHP's `Command` base class as seen by @@ -369,8 +376,24 @@ pub trait Command: std::fmt::Debug + shirabe_php_shim::AsAny { Ok(()) } + /// The PHP fully-qualified class name of the concrete command. Port hook for PHP's + /// `\get_class($command)` (used by `CompleteCommand`'s debug log), which Rust cannot + /// reflect from a trait object; every concrete command supplies its FQCN, usually via + /// `delegate_command_trait_impls_to_inner!($field, "Fqcn")`. + fn get_class(&self) -> String; + /// Adds suggestions to `suggestions` for the current completion input. - fn complete(&self, _input: &CompletionInput, _suggestions: &mut CompletionSuggestions) {} + /// + /// PHP's `complete` is `void` but can throw; errors are surfaced through `anyhow::Result` + /// so they propagate to `CompleteCommand::execute`'s catch-all (which turns them into + /// exit code 2), matching the PHP exception flow. + fn complete( + &self, + _input: &CompletionInput, + _suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + Ok(()) + } /// Whether this command proxies to another application/command (Composer's /// `BaseCommand::isProxyCommand`). Exposed here so the `dyn Command` registry can detect proxy @@ -553,6 +576,10 @@ impl Command for CommandData { true } + fn get_class(&self) -> String { + panic!("get_class called on the base command state; concrete commands supply their FQCN"); + } + fn set_application( &self, application: Option>>, diff --git a/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs index 5d8b1a98..dcffb5f8 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs @@ -5,6 +5,7 @@ use crate::symfony::console::completion::completion_input::CompletionInput; use crate::symfony::console::completion::completion_suggestions::{ CompletionSuggestions, StringOrSuggestion, }; +use crate::symfony::console::completion::output::bash_completion_output::BashCompletionOutput; use crate::symfony::console::completion::output::completion_output_interface::CompletionOutputInterface; use crate::symfony::console::input::input_interface::InputInterface; use crate::symfony::console::input::input_option::InputOption; @@ -144,9 +145,7 @@ impl CompleteCommand { fn get_class_of_command(command: &std::rc::Rc>) -> String { // LazyCommand is intentionally not ported. - // TODO: get_class() takes a PhpMixed but the command is a `dyn Command`; reflecting the - // concrete class name of a trait object requires a class-name hook on Command (Phase C). - todo!() + command.borrow().get_class() } fn get_definition_options( @@ -162,8 +161,15 @@ fn get_definition_options( } /// new $completionOutput(); -fn instantiate_completion_output(_class: &PhpMixed) -> Box { - todo!() +fn instantiate_completion_output(class: &PhpMixed) -> Box { + match class.to_string().as_str() { + "Symfony\\Component\\Console\\Completion\\Output\\BashCompletionOutput" => { + Box::new(BashCompletionOutput) + } + // completion_outputs only ever registers the bash output (Composer registers no extra + // ones), so any other FQCN is a programming error. + other => panic!("unknown completion output class: {}", other), + } } impl Command for CompleteCommand { @@ -355,7 +361,7 @@ impl Command for CompleteCommand { command .borrow() - .complete(&completion_input, &mut suggestions); + .complete(&completion_input, &mut suggestions)?; } } } @@ -407,5 +413,8 @@ impl Command for CompleteCommand { } } - crate::delegate_command_trait_impls_to_inner!(inner); + crate::delegate_command_trait_impls_to_inner!( + inner, + "Symfony\\Component\\Console\\Command\\CompleteCommand" + ); } diff --git a/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs index 23ce06f7..680ff962 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs @@ -9,9 +9,14 @@ use crate::symfony::console::input::input_argument::InputArgument; use crate::symfony::console::input::input_interface::InputInterface; use crate::symfony::console::input::input_option::InputOption; use crate::symfony::console::output::output_interface::{self, OutputInterface}; +use crate::symfony::process::process::Process; use shirabe_php_shim::PhpMixed; use std::ops::{Deref, DerefMut}; +/// __DIR__.'/../Resources/completion.bash', embedded at compile time (this port ships as a +/// single binary and does not install the Resources directory alongside it). +const COMPLETION_BASH: &str = include_str!("../Resources/completion.bash"); + /// Dumps the completion script for the current shell. #[derive(Debug)] pub struct DumpCompletionCommand { @@ -59,15 +64,20 @@ impl DumpCompletionCommand { command } - pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + pub fn complete_impl( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { if input.must_suggest_argument_values_for("shell") { - // TODO(phase-d): PHP's complete() lets a DirectoryIterator failure propagate, but the - // Command::complete trait is infallible; on error the suggestions are skipped instead. - if let Ok(shells) = self.get_supported_shells() { - suggestions - .suggest_values(shells.into_iter().map(StringOrSuggestion::String).collect()); - } + suggestions.suggest_values( + self.get_supported_shells()? + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); } + Ok(()) } fn guess_shell() -> String { @@ -81,7 +91,13 @@ impl DumpCompletionCommand { ) } - fn tail_debug_log(&self, command_name: &str, _output: &dyn OutputInterface) { + /// The PHP closure captures `$output` by reference; `Process::run` needs a `'static` + /// callback, so the shared handle is moved into it instead. + fn tail_debug_log( + &self, + command_name: &str, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { let debug_file = format!( "{}/sf_{}.log", shirabe_php_shim::sys_get_temp_dir(), @@ -90,28 +106,34 @@ impl DumpCompletionCommand { if !shirabe_php_shim::file_exists(&debug_file) { shirabe_php_shim::touch(&debug_file); } - // TODO: Process::run() expects a `'static` callback, but the PHP closure captures - // `$output` by reference and writes each line to it. Bridging the borrowed `output` - // into a `'static` callback requires shared ownership of the output (Phase C). - todo!() + // new Process(['tail', '-f', $debugFile], null, null, null, 0) — timeout 0 disables it; + // like PHP, this tails forever until the user interrupts. + let mut process = Process::new( + vec!["tail".to_string(), "-f".to_string(), debug_file], + None, + None, + PhpMixed::Null, + Some(0.0), + )?; + process.run( + Some(Box::new(move |_type: &str, line: &str| { + output.borrow_mut().write( + &[line.to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + false + })), + indexmap::IndexMap::new(), + )?; + Ok(()) } fn get_supported_shells(&self) -> anyhow::Result> { - let mut shells = vec![]; - - // foreach (new \DirectoryIterator(__DIR__.'/../Resources/') as $file) - for file in shirabe_php_shim::directory_iterator(&format!( - "{}/../Resources/", - shirabe_php_shim::dir() - ))? { - if shirabe_php_shim::str_starts_with(&file.get_basename(), "completion.") - && file.is_file() - { - shells.push(file.get_extension()); - } - } - - Ok(shells) + // Deviation from PHP: the PHP implementation scans __DIR__.'/../Resources/' with a + // DirectoryIterator at runtime; the resources are embedded at compile time in this + // port, so the supported shells are a static list. + Ok(vec!["bash".to_string()]) } } @@ -192,7 +214,7 @@ impl Command for DumpCompletionCommand { ); if input.borrow().get_option("debug")?.to_bool() { - self.tail_debug_log(&command_name, &*output.borrow()); + self.tail_debug_log(&command_name, output.clone())?; return Ok(0); } @@ -201,17 +223,23 @@ impl Command for DumpCompletionCommand { Some(s) => s.to_string(), None => Self::guess_shell(), }; - let completion_file = format!( - "{}/../Resources/completion.{}", - shirabe_php_shim::dir(), - shell - ); - if !shirabe_php_shim::file_exists(&completion_file) { + // __DIR__.'/../Resources/completion.'.$shell — resolved against the embedded + // resources; a shell without an embedded script is PHP's !file_exists() branch. + let completion_file = match shell.as_str() { + "bash" => Some(COMPLETION_BASH), + _ => None, + }; + let Some(completion_file) = completion_file else { let supported_shells = self.get_supported_shells()?; - // TODO: PHP does `$output instanceof ConsoleOutputInterface ? $output->getErrorOutput() - // : $output`. There is no way to test trait membership through `&dyn OutputInterface` - // here; OutputInterface would need a downcast hook (Phase C). Writing to `output`. + // if ($output instanceof ConsoleOutputInterface) { $output = $output->getErrorOutput(); } + let output = { + let error_output = output + .borrow() + .as_console_output() + .map(|console_output| console_output.get_error_output()); + error_output.unwrap_or_else(|| output.clone()) + }; if !shell.is_empty() { output.borrow_mut().writeln( &[format!( @@ -232,7 +260,7 @@ impl Command for DumpCompletionCommand { } return Ok(2); - } + }; let application = self.get_application().unwrap(); let version = application.borrow().get_version(); @@ -243,7 +271,7 @@ impl Command for DumpCompletionCommand { "{{ VERSION }}".to_string(), ], &[command_name, version], - &shirabe_php_shim::file_get_contents(&completion_file).unwrap_or_default(), + completion_file, )], false, output_interface::OUTPUT_NORMAL, @@ -252,9 +280,16 @@ impl Command for DumpCompletionCommand { Ok(0) } - fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { - self.complete_impl(input, suggestions); + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions) } - crate::delegate_command_trait_impls_to_inner!(inner); + crate::delegate_command_trait_impls_to_inner!( + inner, + "Symfony\\Component\\Console\\Command\\DumpCompletionCommand" + ); } diff --git a/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs index 7c32a733..83ed4f5b 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs @@ -155,9 +155,17 @@ impl Command for HelpCommand { Ok(0) } - fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { self.complete_impl(input, suggestions); + Ok(()) } - crate::delegate_command_trait_impls_to_inner!(inner); + crate::delegate_command_trait_impls_to_inner!( + inner, + "Symfony\\Component\\Console\\Command\\HelpCommand" + ); } diff --git a/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs index 4153a20b..79673000 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs @@ -156,9 +156,17 @@ impl Command for ListCommand { Ok(0) } - fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { self.complete_impl(input, suggestions); + Ok(()) } - crate::delegate_command_trait_impls_to_inner!(inner); + crate::delegate_command_trait_impls_to_inner!( + inner, + "Symfony\\Component\\Console\\Command\\ListCommand" + ); } diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs b/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs index a16575de..8226b469 100644 --- a/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs @@ -124,6 +124,9 @@ impl CompletionInput { .cloned() .collect(); for current_argument_name in argument_names { + // PHP's foreach assigns the key variable before the body runs, so on break + // $argumentName still names the first argument that has no bound value. + argument_name = Some(current_argument_name.clone()); if !self .inner .inner @@ -132,7 +135,6 @@ impl CompletionInput { { break; } - argument_name = Some(current_argument_name.clone()); let argument_value = self.inner.inner.arguments[¤t_argument_name].clone(); self.completion_name = Some(current_argument_name.clone()); diff --git a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs index 254ec2ca..306f1e61 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs @@ -204,4 +204,7 @@ impl OutputInterface for ConsoleOutput { fn get_formatter(&self) -> std::rc::Rc> { self.inner.get_formatter() } + fn as_console_output(&self) -> Option<&dyn ConsoleOutputInterface> { + Some(self) + } } diff --git a/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs b/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs index 43810f13..28ebd85c 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs @@ -53,4 +53,14 @@ pub trait OutputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { /// Returns current output formatter instance. fn get_formatter(&self) -> std::rc::Rc>; + + /// Downcast hook standing in for PHP's `$output instanceof ConsoleOutputInterface` + /// (cf. `InputInterface::as_streamable`). Only `ConsoleOutput` returns `Some`. + fn as_console_output( + &self, + ) -> Option< + &dyn crate::symfony::console::output::console_output_interface::ConsoleOutputInterface, + > { + None + } } -- cgit v1.3.1