diff --git a/examples/edge_case_test.rb b/examples/edge_case_test.rb new file mode 100755 index 0000000..db5614a --- /dev/null +++ b/examples/edge_case_test.rb @@ -0,0 +1,107 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "thor/interactive" + +class EdgeCaseTest < Thor + include Thor::Interactive::Command + + configure_interactive( + prompt: "test> ", + default_handler: ->(input, instance) { + puts "DEFAULT HANDLER: '#{input}'" + } + ) + + desc "topics [FILTER]", "List topics" + option :summarize, type: :boolean, desc: "Summarize topics" + option :format, type: :string, desc: "Output format" + def topics(filter = nil) + puts "TOPICS COMMAND:" + puts " Filter: #{filter.inspect}" + puts " Options: #{options.to_h.inspect}" + end + + desc "echo TEXT", "Echo text" + def echo(text) + puts "ECHO: '#{text}'" + end + + desc "test", "Run edge case tests" + def test + puts "\n=== Edge Case Tests ===" + + puts "\n1. Unknown option:" + puts " Input: /topics --unknown-option" + process_input("/topics --unknown-option") + + puts "\n2. Unknown option with value:" + puts " Input: /topics --unknown-option value" + process_input("/topics --unknown-option value") + + puts "\n3. Mixed text and option-like strings:" + puts " Input: /topics The start of a string --option the rest" + process_input("/topics The start of a string --option the rest") + + puts "\n4. Valid option mixed with text:" + puts " Input: /topics Some text --summarize more text" + process_input("/topics Some text --summarize more text") + + puts "\n5. Option-like text in echo command (no options defined):" + puts " Input: /echo This has --what-looks-like an option" + process_input("/echo This has --what-looks-like an option") + + puts "\n6. Real option after text:" + puts " Input: /topics AI and ML --format json" + process_input("/topics AI and ML --format json") + + puts "\n=== End Tests ===" + end + + private + + def process_input(input) + # Simulate what the shell does + if input.start_with?('/') + send(:handle_slash_command, input[1..-1]) + else + @default_handler.call(input, self) + end + rescue => e + puts " ERROR: #{e.message}" + end + + def handle_slash_command(command_input) + parts = command_input.split(/\s+/, 2) + command = parts[0] + args = parts[1] || "" + + if command == "topics" + # Parse with shellwords + require 'shellwords' + parsed = Shellwords.split(args) rescue args.split(/\s+/) + invoke("topics", parsed) + elsif command == "echo" + echo(args) + end + rescue => e + puts " ERROR: #{e.message}" + end +end + +if __FILE__ == $0 + puts "Edge Case Testing" + puts "=================" + + # Create instance and run tests + app = EdgeCaseTest.new + app.test + + puts "\nInteractive mode - try these:" + puts " /topics --unknown-option" + puts " /topics The start --option the rest" + puts + + app.interactive +end \ No newline at end of file diff --git a/examples/options_demo.rb b/examples/options_demo.rb new file mode 100755 index 0000000..b5695ec --- /dev/null +++ b/examples/options_demo.rb @@ -0,0 +1,235 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "thor/interactive" + +class OptionsDemo < Thor + include Thor::Interactive::Command + + configure_interactive( + prompt: "opts> " + ) + + desc "process FILE", "Process a file with various options" + option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose output" + option :format, type: :string, default: "json", enum: ["json", "xml", "yaml", "csv"], desc: "Output format" + option :output, type: :string, aliases: "-o", desc: "Output file" + option :limit, type: :numeric, aliases: "-l", desc: "Limit number of results" + option :skip, type: :numeric, default: 0, desc: "Skip N results" + option :tags, type: :array, desc: "Tags to filter by" + option :config, type: :hash, desc: "Additional configuration" + option :dry_run, type: :boolean, desc: "Don't actually process, just show what would happen" + def process(file) + if options[:dry_run] + puts "DRY RUN MODE - No actual processing" + end + + puts "Processing file: #{file}" + puts "=" * 50 + + if options[:verbose] + puts "Verbose mode enabled" + puts "All options:" + options.each do |key, value| + puts " #{key}: #{value.inspect}" + end + puts + end + + puts "Format: #{options[:format]}" + puts "Output: #{options[:output] || 'stdout'}" + + if options[:limit] + puts "Limiting to #{options[:limit]} results" + puts "Skipping first #{options[:skip]} results" if options[:skip] > 0 + end + + if options[:tags] && !options[:tags].empty? + puts "Filtering by tags: #{options[:tags].join(', ')}" + end + + if options[:config] && !options[:config].empty? + puts "Configuration:" + options[:config].each do |key, value| + puts " #{key}: #{value}" + end + end + + unless options[:dry_run] + puts "\n[Simulating processing...]" + sleep(1) + puts "✓ Processing complete!" + end + end + + desc "search QUERY", "Search with options" + option :case_sensitive, type: :boolean, aliases: "-c", desc: "Case sensitive search" + option :regex, type: :boolean, aliases: "-r", desc: "Use regex" + option :files, type: :array, aliases: "-f", desc: "Files to search in" + option :max_results, type: :numeric, default: 10, desc: "Maximum results" + def search(query) + puts "Searching for: #{query}" + puts "Options:" + puts " Case sensitive: #{options[:case_sensitive] ? 'Yes' : 'No'}" + puts " Regex mode: #{options[:regex] ? 'Yes' : 'No'}" + puts " Max results: #{options[:max_results]}" + + if options[:files] + puts " Searching in files: #{options[:files].join(', ')}" + else + puts " Searching in all files" + end + + # Simulate search + results = [ + "result_1.txt:10: #{query} found here", + "result_2.txt:25: another #{query} match", + "result_3.txt:40: #{query} appears again" + ] + + puts "\nResults:" + results.take(options[:max_results]).each do |result| + puts " #{result}" + end + end + + desc "convert INPUT OUTPUT", "Convert file from one format to another" + option :from, type: :string, required: true, desc: "Input format" + option :to, type: :string, required: true, desc: "Output format" + option :preserve_metadata, type: :boolean, desc: "Preserve file metadata" + option :compression, type: :string, enum: ["none", "gzip", "bzip2", "xz"], default: "none" + def convert(input, output) + puts "Converting: #{input} → #{output}" + puts "Format: #{options[:from]} → #{options[:to]}" + puts "Compression: #{options[:compression]}" + puts "Preserve metadata: #{options[:preserve_metadata] ? 'Yes' : 'No'}" + + # Validation + unless File.exist?(input) + puts "Error: Input file '#{input}' not found" + return + end + + puts "\n[Simulating conversion...]" + puts "✓ Conversion complete!" + end + + desc "test", "Test various option formats" + def test + puts "\n=== Option Parsing Test Cases ===" + puts "\nTry these commands to test option parsing:\n" + + examples = [ + "/process file.txt --verbose", + "/process file.txt -v", + "/process data.json --format xml --output result.xml", + "/process data.json --format=yaml --limit=100", + "/process file.txt --tags important urgent todo", + "/process file.txt --config env:production db:postgres", + "/process file.txt -v --format csv -o output.csv --limit 50", + "/process file.txt --dry-run --verbose", + "", + "/search 'hello world' --case-sensitive", + "/search pattern -r -f file1.txt file2.txt file3.txt", + "/search query --max-results 5", + "", + "/convert input.json output.yaml --from json --to yaml", + "/convert data.csv data.json --from=csv --to=json --compression=gzip" + ] + + examples.each do |example| + puts example.empty? ? "" : " #{example}" + end + + puts "\n=== Features Demonstrated ===" + puts "✓ Boolean options (--verbose, -v)" + puts "✓ String options (--format xml, --format=xml)" + puts "✓ Numeric options (--limit 100)" + puts "✓ Array options (--tags tag1 tag2 tag3)" + puts "✓ Hash options (--config key1:val1 key2:val2)" + puts "✓ Required options (--from, --to in convert)" + puts "✓ Default values (format: json, skip: 0)" + puts "✓ Enum validation (format must be json/xml/yaml/csv)" + puts "✓ Short aliases (-v for --verbose, -o for --output)" + puts "✓ Multiple options in one command" + end + + desc "help_options", "Explain how options work in interactive mode" + def help_options + puts <<~HELP + + === Option Parsing in thor-interactive === + + Thor-interactive now fully supports Thor's option parsing! + + BASIC USAGE: + /command arg1 arg2 --option value --flag + + OPTION TYPES: + Boolean: --verbose or -v (no value needed) + String: --format json or --format=json + Numeric: --limit 100 or --limit=100 + Array: --tags tag1 tag2 tag3 + Hash: --config key1:val1 key2:val2 + + FEATURES: + • Long form: --option-name value + • Short form: -o value + • Equals syntax: --option=value + • Multiple options: --opt1 val1 --opt2 val2 + • Default values: Defined in Thor command + • Required options: Must be provided + • Enum validation: Limited to specific values + + BACKWARD COMPATIBILITY: + • Commands without options work as before + • Natural language still works for text commands + • Single-text commands preserve their behavior + • Default handler unaffected + + EXAMPLES: + # Boolean flag + /process file.txt --verbose + + # String option with equals + /process file.txt --format=xml + + # Multiple options + /process file.txt -v --format csv --limit 10 + + # Array option + /search term --files file1.txt file2.txt + + # Hash option + /deploy --config env:prod region:us-west + + HOW IT WORKS: + 1. Thor-interactive detects if command has options defined + 2. Uses Thor's option parser to parse the arguments + 3. Separates options from regular arguments + 4. Sets options hash on Thor instance + 5. Calls command with remaining arguments + 6. Falls back to original behavior if parsing fails + + NATURAL LANGUAGE: + Natural language input still works! Options are only + parsed for Thor commands that define them. Text sent + to default handlers is unchanged. + + HELP + end + + default_task :test +end + +if __FILE__ == $0 + puts "Thor Options Demo" + puts "==================" + puts + puts "This demo shows Thor option parsing in interactive mode." + puts "Type '/test' to see examples or '/help_options' for details." + puts + + OptionsDemo.new.interactive +end \ No newline at end of file diff --git a/lib/thor/interactive/shell.rb b/lib/thor/interactive/shell.rb index f963ded..0b8f66c 100644 --- a/lib/thor/interactive/shell.rb +++ b/lib/thor/interactive/shell.rb @@ -205,8 +205,8 @@ def handle_command(command_input) if thor_command?(command_word) task = @thor_class.tasks[command_word] - if task && single_text_command?(task) - # Single text command - pass everything after command as one argument + if task && single_text_command?(task) && !task.options.any? + # Single text command without options - pass everything after command as one argument text_part = command_input.sub(/^#{Regexp.escape(command_word)}\s*/, '') if text_part.empty? invoke_thor_command(command_word, []) @@ -295,13 +295,29 @@ def invoke_thor_command(command, args) if command == "help" show_help(args.first) else - # Always use direct method calls to avoid Thor's invoke deduplication - # Thor's invoke method silently fails on subsequent calls to the same method - if @thor_instance.respond_to?(command) - @thor_instance.send(command, *args) + # Get the Thor task/command definition + task = @thor_class.tasks[command] + + if task && task.options && !task.options.empty? + # Parse options if the command has them defined + parsed_args, parsed_options = parse_thor_options(args, task) + + # Set options on the Thor instance + @thor_instance.options = Thor::CoreExt::HashWithIndifferentAccess.new(parsed_options) + + # Call with parsed arguments only (options are in the options hash) + if @thor_instance.respond_to?(command) + @thor_instance.send(command, *parsed_args) + else + @thor_instance.send(command, *parsed_args) + end else - # If method doesn't exist, this will raise a proper error - @thor_instance.send(command, *args) + # No options defined, use original behavior + if @thor_instance.respond_to?(command) + @thor_instance.send(command, *args) + else + @thor_instance.send(command, *args) + end end end rescue SystemExit => e @@ -320,6 +336,36 @@ def invoke_thor_command(command, args) puts "Error: #{e.message}" puts "Command: #{command}, Args: #{args.inspect}" if ENV["DEBUG"] end + + def parse_thor_options(args, task) + # Convert args array to a format Thor's option parser expects + remaining_args = [] + parsed_options = {} + + # Create a temporary parser using Thor's options + parser = Thor::Options.new(task.options) + + # Parse the arguments + begin + if args.is_a?(Array) + # Parse the options from the array + parsed_options = parser.parse(args) + remaining_args = parser.remaining + else + # Single string argument, split it first + split_args = safe_parse_input(args) || args.split(/\s+/) + parsed_options = parser.parse(split_args) + remaining_args = parser.remaining + end + rescue Thor::Error => e + # If parsing fails, treat everything as arguments (backward compatibility) + puts "Option parsing error: #{e.message}" if ENV["DEBUG"] + remaining_args = args.is_a?(Array) ? args : [args] + parsed_options = {} + end + + [remaining_args, parsed_options] + end def show_help(command = nil) if command && @thor_class.tasks.key?(command) diff --git a/lib/thor/interactive/version_constant.rb b/lib/thor/interactive/version_constant.rb index bbbb975..1e17c2b 100644 --- a/lib/thor/interactive/version_constant.rb +++ b/lib/thor/interactive/version_constant.rb @@ -4,5 +4,5 @@ # This file is separate to avoid circular dependencies during gem installation module ThorInteractive - VERSION = "0.1.0.pre.3" + VERSION = "0.1.0.pre.4" end diff --git a/spec/integration/option_parsing_spec.rb b/spec/integration/option_parsing_spec.rb new file mode 100644 index 0000000..84601ae --- /dev/null +++ b/spec/integration/option_parsing_spec.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Option parsing" do + let(:app_with_options) do + Class.new(Thor) do + include Thor::Interactive::Command + + desc "process FILE", "Process a file" + option :verbose, type: :boolean, aliases: "-v", desc: "Verbose output" + option :format, type: :string, default: "json", desc: "Output format" + option :limit, type: :numeric, desc: "Limit results" + option :tags, type: :array, desc: "Tags to apply" + option :config, type: :hash, desc: "Configuration options" + def process(file) + puts "Processing #{file}" + puts "Verbose: #{options[:verbose]}" if options[:verbose] + puts "Format: #{options[:format]}" + puts "Limit: #{options[:limit]}" if options[:limit] + puts "Tags: #{options[:tags].join(', ')}" if options[:tags] + puts "Config: #{options[:config]}" if options[:config] + end + + desc "simple", "Command without options" + def simple(arg1, arg2 = nil) + puts "Args: #{arg1}, #{arg2}" + end + + desc "flag", "Command with boolean flag" + option :enabled, type: :boolean, desc: "Enable feature" + def flag + puts "Enabled: #{options[:enabled]}" + end + end + end + + describe "basic option parsing" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + + it "parses boolean options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--verbose"]) + end + + expect(output).to include("Processing file.txt") + expect(output).to include("Verbose: true") + end + + it "parses short option aliases" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "-v"]) + end + + expect(output).to include("Verbose: true") + end + + it "parses string options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--format", "xml"]) + end + + expect(output).to include("Format: xml") + end + + it "parses string options with equals syntax" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--format=yaml"]) + end + + expect(output).to include("Format: yaml") + end + + it "parses numeric options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--limit", "10"]) + end + + expect(output).to include("Limit: 10") + end + + it "parses array options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--tags", "important", "urgent"]) + end + + expect(output).to include("Tags: important, urgent") + end + + it "parses hash options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--config", "key1:value1", "key2:value2"]) + end + + expect(output).to include("Config: {\"key1\"=>\"value1\", \"key2\"=>\"value2\"}") + end + + it "handles multiple options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--verbose", "--format", "csv", "--limit", "5"]) + end + + expect(output).to include("Processing file.txt") + expect(output).to include("Verbose: true") + expect(output).to include("Format: csv") + expect(output).to include("Limit: 5") + end + + it "uses default values when options not provided" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt"]) + end + + expect(output).to include("Format: json") # Default value + expect(output).not_to include("Verbose:") # Not set + expect(output).not_to include("Limit:") # Not set + end + end + + describe "commands without options" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + + it "works normally for commands without options" do + output = capture_stdout do + shell.send(:invoke_thor_command, "simple", ["arg1", "arg2"]) + end + + expect(output).to include("Args: arg1, arg2") + end + + it "doesn't parse options for commands that don't define them" do + # Even if we pass option-like arguments, they should be treated as regular args + output = capture_stdout do + shell.send(:invoke_thor_command, "simple", ["--verbose", "--format=xml"]) + end + + expect(output).to include("Args: --verbose, --format=xml") + end + end + + describe "option parsing with natural language commands" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + + it "still supports single-text commands with options" do + # When we detect a single-text command, we should still parse options + task = app_with_options.tasks["process"] + allow(shell).to receive(:single_text_command?).with(task).and_return(false) + + output = capture_stdout do + shell.send(:handle_command, "process file.txt --verbose --format xml") + end + + expect(output).to include("Processing file.txt") + expect(output).to include("Verbose: true") + expect(output).to include("Format: xml") + end + end + + describe "error handling" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + + it "handles invalid option values gracefully" do + # Numeric option with non-numeric value + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--limit", "not-a-number"]) + end + + # Should handle the error gracefully + expect(output).to include("file.txt") + end + + it "handles unknown options gracefully" do + output = capture_stdout do + shell.send(:invoke_thor_command, "process", ["file.txt", "--unknown-option"]) + end + + # Should still process the file, ignoring unknown option + expect(output).to include("Processing file.txt") + end + end + + describe "slash command format" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + + it "parses options in slash commands" do + output = capture_stdout do + shell.send(:process_input, "/process file.txt --verbose --limit 10") + end + + expect(output).to include("Processing file.txt") + expect(output).to include("Verbose: true") + expect(output).to include("Limit: 10") + end + end + + describe "option parsing internals" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + let(:task) { app_with_options.tasks["process"] } + + it "correctly separates options from arguments" do + args, options = shell.send(:parse_thor_options, + ["file.txt", "--verbose", "--format", "xml", "extra_arg"], + task + ) + + expect(args).to eq(["file.txt", "extra_arg"]) + expect(options[:verbose]).to eq(true) + expect(options[:format]).to eq("xml") + end + + it "handles single string argument with options" do + args, options = shell.send(:parse_thor_options, + "file.txt --verbose", + task + ) + + # When given a single string, it should be parsed + expect(options[:verbose]).to eq(true) + end + + it "returns empty options when parsing fails" do + task_mock = double("task", options: { verbose: { type: :boolean } }) + + # Force a parsing error + allow(Thor::Options).to receive(:new).and_raise(Thor::Error, "Parse error") + + args, options = shell.send(:parse_thor_options, + ["file.txt", "--bad-option"], + task_mock + ) + + expect(args).to eq(["file.txt", "--bad-option"]) + expect(options).to eq({}) + end + end + + describe "backward compatibility" do + let(:shell) { Thor::Interactive::Shell.new(app_with_options) } + + it "maintains compatibility with existing single-text commands" do + # Create a method that expects a single text argument + app_with_options.class_eval do + desc "echo TEXT", "Echo text" + def echo(text) + puts "Echo: #{text}" + end + end + + output = capture_stdout do + shell.send(:process_input, "/echo hello world this is text") + end + + expect(output).to include("Echo: hello world this is text") + end + + it "doesn't break natural language processing" do + shell = Thor::Interactive::Shell.new(app_with_options, + default_handler: ->(input, instance) { puts "Natural: #{input}" } + ) + + output = capture_stdout do + shell.send(:process_input, "this is natural language") + end + + expect(output).to include("Natural: this is natural language") + end + end +end \ No newline at end of file