diff --git a/pkg/slashcommand/README.md b/pkg/slashcommand/README.md index b4a3cd7..4a6151f 100644 --- a/pkg/slashcommand/README.md +++ b/pkg/slashcommand/README.md @@ -4,7 +4,7 @@ Package `slashcommand` provides a parser for slash commands commonly used in AI ## Overview -This package parses slash commands using bash-like argument parsing: +This package parses slash commands using bash-like argument parsing. The parser can find slash commands anywhere in the input text, not just at the beginning: ``` /task-name arg1 "arg 2" arg3 ``` @@ -12,11 +12,13 @@ This package parses slash commands using bash-like argument parsing: The parser extracts: - **Task name**: The command identifier (without the leading `/`) - **Arguments**: Positional arguments accessed via `$ARGUMENTS`, `$1`, `$2`, `$3`, etc. +- **Found status**: Boolean indicating whether a slash command was found Arguments are parsed like bash: - Quoted arguments (single or double quotes) can contain spaces - Quotes are removed from parsed arguments - Escape sequences are supported in double quotes (`\"`) +- Arguments are extracted until end of line ## Installation @@ -30,19 +32,34 @@ go get github.com/kitproj/coding-context-cli/pkg/slashcommand import "github.com/kitproj/coding-context-cli/pkg/slashcommand" // Parse a simple command -taskName, params, err := slashcommand.ParseSlashCommand("/fix-bug") +taskName, params, found, err := slashcommand.ParseSlashCommand("/fix-bug") // taskName: "fix-bug" // params: map[] +// found: true // Parse a command with arguments -taskName, params, err := slashcommand.ParseSlashCommand("/fix-bug 123") +taskName, params, found, err := slashcommand.ParseSlashCommand("/fix-bug 123") // taskName: "fix-bug" // params: map["ARGUMENTS": "123", "1": "123"] +// found: true // Parse a command with quoted arguments -taskName, params, err := slashcommand.ParseSlashCommand(`/code-review "Fix login bug" high`) +taskName, params, found, err := slashcommand.ParseSlashCommand(`/code-review "Fix login bug" high`) // taskName: "code-review" // params: map["ARGUMENTS": "\"Fix login bug\" high", "1": "Fix login bug", "2": "high"] +// found: true + +// Command found in middle of text +taskName, params, found, err := slashcommand.ParseSlashCommand("Please /deploy production now") +// taskName: "deploy" +// params: map["ARGUMENTS": "production now", "1": "production", "2": "now"] +// found: true + +// No command found +taskName, params, found, err := slashcommand.ParseSlashCommand("No command here") +// taskName: "" +// params: nil +// found: false ``` ## Command Format @@ -53,13 +70,16 @@ taskName, params, err := slashcommand.ParseSlashCommand(`/code-review "Fix login ``` ### Argument Parsing Rules -1. Commands **must** start with `/` +1. Slash commands can appear **anywhere** in the input text 2. Task name comes immediately after the `/` (no spaces) -3. Arguments can be quoted with single (`'`) or double (`"`) quotes -4. Quoted arguments can contain spaces -5. Quotes are removed from parsed arguments -6. Double quotes support escape sequences: `\"` -7. Single quotes preserve everything literally (no escapes) +3. Arguments are extracted until end of line (newline stops argument collection) +4. Arguments can be quoted with single (`'`) or double (`"`) quotes +5. Quoted arguments can contain spaces +6. Quotes are removed from parsed arguments +7. Double quotes support escape sequences: `\"` +8. Single quotes preserve everything literally (no escapes) +9. Text before the `/` is ignored (prefix lost) +10. Text after a newline is ignored (suffix lost) ### Returned Parameters The `params` map contains: @@ -74,28 +94,28 @@ The `params` map contains: /code-review "PR #42" # Quoted argument: $1 = "PR #42" /echo 'He said "hello"' # Single quotes preserve quotes: $1 = "He said \"hello\"" /echo "He said \"hello\"" # Escaped quotes in double quotes: $1 = "He said \"hello\"" +Please /fix-bug 123 today # Command in middle: task = "fix-bug", $1 = "123", $2 = "today" +Text /deploy prod\nNext line # Arguments stop at newline: task = "deploy", $1 = "prod" ``` -### Invalid Examples +### Cases with No Command Found ``` -fix-bug # Missing leading / -/ # Empty command -/fix-bug "unclosed # Unclosed quote +fix-bug # Missing leading /: found = false +No command here # No slash: found = false ``` ## Error Handling -The parser returns descriptive errors for invalid commands: +The parser returns errors only for malformed commands (e.g., unclosed quotes). If no slash command is found, the function returns `found=false` without an error. ```go -_, _, err := slashcommand.ParseSlashCommand("fix-bug") -// Error: slash command must start with '/' +// No command found - not an error +_, _, found, err := slashcommand.ParseSlashCommand("No command here") +// found: false, err: nil -_, _, err := slashcommand.ParseSlashCommand("/") -// Error: slash command cannot be empty - -_, _, err := slashcommand.ParseSlashCommand(`/fix-bug "unclosed`) -// Error: unclosed quote in arguments +// Unclosed quote - returns error +_, _, found, err := slashcommand.ParseSlashCommand(`/fix-bug "unclosed`) +// found: false, err: "unclosed quote in arguments" ``` ## API @@ -103,18 +123,19 @@ _, _, err := slashcommand.ParseSlashCommand(`/fix-bug "unclosed`) ### ParseSlashCommand ```go -func ParseSlashCommand(command string) (taskName string, params map[string]string, err error) +func ParseSlashCommand(command string) (taskName string, params map[string]string, found bool, err error) ``` -Parses a slash command string and extracts the task name and arguments. +Parses a slash command string and extracts the task name and arguments. The function searches for a slash command anywhere in the input text. **Parameters:** -- `command` (string): The slash command to parse +- `command` (string): The text that may contain a slash command **Returns:** - `taskName` (string): The task name without the leading `/` - `params` (map[string]string): Contains `ARGUMENTS` (full arg string) and `1`, `2`, `3`, etc. (positional args) -- `err` (error): Error if the command format is invalid +- `found` (bool): True if a slash command was found, false otherwise +- `err` (error): Error if the command format is invalid (e.g., unclosed quotes) ## Testing @@ -124,6 +145,8 @@ The package includes comprehensive tests covering: - Quoted arguments (both single and double quotes) - Escaped quotes - Empty quoted arguments +- Commands embedded in text (prefix/suffix text) +- Commands with newlines - Edge cases and error conditions Run tests with: diff --git a/pkg/slashcommand/example_test.go b/pkg/slashcommand/example_test.go index 6c5a4ac..e2b4664 100644 --- a/pkg/slashcommand/example_test.go +++ b/pkg/slashcommand/example_test.go @@ -8,28 +8,34 @@ import ( func ExampleParseSlashCommand() { // Parse a simple command without parameters - taskName, params, err := slashcommand.ParseSlashCommand("/fix-bug") + taskName, params, found, err := slashcommand.ParseSlashCommand("/fix-bug") if err != nil { fmt.Printf("Error: %v\n", err) return } - fmt.Printf("Task: %s, Params: %v\n", taskName, params) + if found { + fmt.Printf("Task: %s, Params: %v\n", taskName, params) + } // Parse a command with single argument - taskName, params, err = slashcommand.ParseSlashCommand("/fix-bug 123") + taskName, params, found, err = slashcommand.ParseSlashCommand("/fix-bug 123") if err != nil { fmt.Printf("Error: %v\n", err) return } - fmt.Printf("Task: %s, $1: %s\n", taskName, params["1"]) + if found { + fmt.Printf("Task: %s, $1: %s\n", taskName, params["1"]) + } // Parse a command with multiple arguments - taskName, params, err = slashcommand.ParseSlashCommand(`/implement-feature "User Login" high`) + taskName, params, found, err = slashcommand.ParseSlashCommand(`/implement-feature "User Login" high`) if err != nil { fmt.Printf("Error: %v\n", err) return } - fmt.Printf("Task: %s, $1: %s, $2: %s\n", taskName, params["1"], params["2"]) + if found { + fmt.Printf("Task: %s, $1: %s, $2: %s\n", taskName, params["1"], params["2"]) + } // Output: // Task: fix-bug, Params: map[] diff --git a/pkg/slashcommand/parser.go b/pkg/slashcommand/parser.go index b9cff23..6e667f3 100644 --- a/pkg/slashcommand/parser.go +++ b/pkg/slashcommand/parser.go @@ -6,62 +6,83 @@ import ( ) // ParseSlashCommand parses a slash command string and extracts the task name and parameters. +// It searches for a slash command anywhere in the input string, not just at the beginning. // The expected format is: /task-name arg1 "arg 2" arg3 // +// The function will find the slash command even if it's embedded in other text. For example: +// - "Please /fix-bug 123 today" -> taskName: "fix-bug", params: {"ARGUMENTS": "123 today", "1": "123", "2": "today"}, found: true +// - "Some text /code-review" -> taskName: "code-review", params: {}, found: true +// // Arguments are parsed like Bash: // - Quoted arguments can contain spaces // - Both single and double quotes are supported // - Quotes are removed from the parsed arguments +// - Arguments are extracted until end of line // // Examples: -// - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"} -// - "/code-review \"PR #42\" high" -> taskName: "code-review", params: {"ARGUMENTS": "\"PR #42\" high", "1": "PR #42", "2": "high"} +// - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"}, found: true +// - "/code-review \"PR #42\" high" -> taskName: "code-review", params: {"ARGUMENTS": "\"PR #42\" high", "1": "PR #42", "2": "high"}, found: true +// - "no command here" -> taskName: "", params: nil, found: false // // Returns: // - taskName: the task name (without the leading slash) // - params: a map containing: // - "ARGUMENTS": the full argument string (with quotes preserved) // - "1", "2", "3", etc.: positional arguments (with quotes removed) -// - err: an error if the command format is invalid -func ParseSlashCommand(command string) (taskName string, params map[string]string, err error) { - command = strings.TrimSpace(command) - - // Check if command starts with '/' - if !strings.HasPrefix(command, "/") { - return "", nil, fmt.Errorf("slash command must start with '/'") +// - found: true if a slash command was found, false otherwise +// - err: an error if the command format is invalid (e.g., unclosed quotes) +func ParseSlashCommand(command string) (taskName string, params map[string]string, found bool, err error) { + // Find the slash command anywhere in the string + slashIdx := strings.Index(command, "/") + if slashIdx == -1 { + return "", nil, false, nil } - // Remove leading slash - command = command[1:] + // Extract from the slash onwards + command = command[slashIdx+1:] if command == "" { - return "", nil, fmt.Errorf("slash command cannot be empty") + return "", nil, false, nil } - // Find the task name (first word) - spaceIdx := strings.IndexAny(command, " \t") - if spaceIdx == -1 { - // No arguments, just the task name - return command, make(map[string]string), nil + // Find the task name (first word after the slash) + // Task name ends at first whitespace or newline + endIdx := strings.IndexAny(command, " \t\n\r") + if endIdx == -1 { + // No arguments, just the task name (rest of the string) + return command, make(map[string]string), true, nil } - taskName = command[:spaceIdx] - argsString := strings.TrimSpace(command[spaceIdx:]) + taskName = command[:endIdx] + + // Extract arguments until end of line + restOfString := command[endIdx:] + newlineIdx := strings.IndexAny(restOfString, "\n\r") + var argsString string + if newlineIdx == -1 { + // No newline, use everything + argsString = strings.TrimSpace(restOfString) + } else { + // Only use up to the newline + argsString = strings.TrimSpace(restOfString[:newlineIdx]) + } params = make(map[string]string) // Store the full argument string (with quotes preserved) - params["ARGUMENTS"] = argsString + if argsString != "" { + params["ARGUMENTS"] = argsString + } // If there are no arguments, return early if argsString == "" { - return taskName, params, nil + return taskName, params, true, nil } // Parse positional arguments using bash-like parsing args, err := parseBashArgs(argsString) if err != nil { - return "", nil, err + return "", nil, false, err } // Add positional arguments as $1, $2, $3, etc. @@ -69,7 +90,7 @@ func ParseSlashCommand(command string) (taskName string, params map[string]strin params[fmt.Sprintf("%d", i+1)] = arg } - return taskName, params, nil + return taskName, params, true, nil } // parseBashArgs parses a string into arguments like bash does, respecting quoted values diff --git a/pkg/slashcommand/parser_test.go b/pkg/slashcommand/parser_test.go index 5e20730..a877df7 100644 --- a/pkg/slashcommand/parser_test.go +++ b/pkg/slashcommand/parser_test.go @@ -9,6 +9,7 @@ func TestParseSlashCommand(t *testing.T) { tests := []struct { name string command string + wantFound bool wantTask string wantParams map[string]string wantErr bool @@ -17,14 +18,16 @@ func TestParseSlashCommand(t *testing.T) { { name: "simple command without parameters", command: "/fix-bug", + wantFound: true, wantTask: "fix-bug", wantParams: map[string]string{}, wantErr: false, }, { - name: "command with single unquoted argument", - command: "/fix-bug 123", - wantTask: "fix-bug", + name: "command with single unquoted argument", + command: "/fix-bug 123", + wantFound: true, + wantTask: "fix-bug", wantParams: map[string]string{ "ARGUMENTS": "123", "1": "123", @@ -32,9 +35,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "command with multiple unquoted arguments", - command: "/implement-feature login high urgent", - wantTask: "implement-feature", + name: "command with multiple unquoted arguments", + command: "/implement-feature login high urgent", + wantFound: true, + wantTask: "implement-feature", wantParams: map[string]string{ "ARGUMENTS": "login high urgent", "1": "login", @@ -44,9 +48,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "command with double-quoted argument containing spaces", - command: `/code-review "Fix authentication bug in login flow"`, - wantTask: "code-review", + name: "command with double-quoted argument containing spaces", + command: `/code-review "Fix authentication bug in login flow"`, + wantFound: true, + wantTask: "code-review", wantParams: map[string]string{ "ARGUMENTS": `"Fix authentication bug in login flow"`, "1": "Fix authentication bug in login flow", @@ -54,9 +59,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "command with single-quoted argument containing spaces", - command: `/code-review 'Fix authentication bug'`, - wantTask: "code-review", + name: "command with single-quoted argument containing spaces", + command: `/code-review 'Fix authentication bug'`, + wantFound: true, + wantTask: "code-review", wantParams: map[string]string{ "ARGUMENTS": `'Fix authentication bug'`, "1": "Fix authentication bug", @@ -64,9 +70,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "command with mixed quoted and unquoted arguments", - command: `/deploy "staging server" v1.2.3 --force`, - wantTask: "deploy", + name: "command with mixed quoted and unquoted arguments", + command: `/deploy "staging server" v1.2.3 --force`, + wantFound: true, + wantTask: "deploy", wantParams: map[string]string{ "ARGUMENTS": `"staging server" v1.2.3 --force`, "1": "staging server", @@ -76,9 +83,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "command with extra whitespace", - command: ` /fix-bug 123 "high priority" `, - wantTask: "fix-bug", + name: "command with extra whitespace", + command: ` /fix-bug 123 "high priority" `, + wantFound: true, + wantTask: "fix-bug", wantParams: map[string]string{ "ARGUMENTS": `123 "high priority"`, "1": "123", @@ -87,32 +95,33 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "missing leading slash", - command: "fix-bug", - wantTask: "", - wantParams: nil, - wantErr: true, - errContains: "must start with '/'", + name: "missing leading slash", + command: "fix-bug", + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: false, }, { - name: "empty command", - command: "/", - wantTask: "", - wantParams: nil, - wantErr: true, - errContains: "cannot be empty", + name: "empty command", + command: "/", + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: false, }, { - name: "empty string", - command: "", - wantTask: "", - wantParams: nil, - wantErr: true, - errContains: "must start with '/'", + name: "empty string", + command: "", + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: false, }, { name: "unclosed double quote", command: `/fix-bug "unclosed`, + wantFound: false, wantTask: "", wantParams: nil, wantErr: true, @@ -121,6 +130,7 @@ func TestParseSlashCommand(t *testing.T) { { name: "unclosed single quote", command: `/fix-bug 'unclosed`, + wantFound: false, wantTask: "", wantParams: nil, wantErr: true, @@ -129,6 +139,7 @@ func TestParseSlashCommand(t *testing.T) { { name: "task name with hyphens", command: "/implement-new-feature", + wantFound: true, wantTask: "implement-new-feature", wantParams: map[string]string{}, wantErr: false, @@ -136,14 +147,16 @@ func TestParseSlashCommand(t *testing.T) { { name: "task name with underscores", command: "/fix_critical_bug", + wantFound: true, wantTask: "fix_critical_bug", wantParams: map[string]string{}, wantErr: false, }, { - name: "empty quoted argument", - command: `/fix-bug ""`, - wantTask: "fix-bug", + name: "empty quoted argument", + command: `/fix-bug ""`, + wantFound: true, + wantTask: "fix-bug", wantParams: map[string]string{ "ARGUMENTS": `""`, "1": "", @@ -151,9 +164,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "argument with special characters", - command: `/deploy https://example.com/api/v1`, - wantTask: "deploy", + name: "argument with special characters", + command: `/deploy https://example.com/api/v1`, + wantFound: true, + wantTask: "deploy", wantParams: map[string]string{ "ARGUMENTS": "https://example.com/api/v1", "1": "https://example.com/api/v1", @@ -161,9 +175,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "argument with numbers", - command: `/review 12345`, - wantTask: "review", + name: "argument with numbers", + command: `/review 12345`, + wantFound: true, + wantTask: "review", wantParams: map[string]string{ "ARGUMENTS": "12345", "1": "12345", @@ -171,9 +186,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "multiple arguments with various spacing", - command: `/task a b c`, - wantTask: "task", + name: "multiple arguments with various spacing", + command: `/task a b c`, + wantFound: true, + wantTask: "task", wantParams: map[string]string{ "ARGUMENTS": "a b c", "1": "a", @@ -183,9 +199,10 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "escaped quote in double quotes", - command: `/echo "He said \"hello\""`, - wantTask: "echo", + name: "escaped quote in double quotes", + command: `/echo "He said \"hello\""`, + wantFound: true, + wantTask: "echo", wantParams: map[string]string{ "ARGUMENTS": `"He said \"hello\""`, "1": `He said "hello"`, @@ -193,20 +210,90 @@ func TestParseSlashCommand(t *testing.T) { wantErr: false, }, { - name: "single quotes preserve everything", - command: `/echo 'He said "hello"'`, - wantTask: "echo", + name: "single quotes preserve everything", + command: `/echo 'He said "hello"'`, + wantFound: true, + wantTask: "echo", wantParams: map[string]string{ "ARGUMENTS": `'He said "hello"'`, "1": `He said "hello"`, }, wantErr: false, }, + // New test cases for finding slash commands anywhere in the string + { + name: "command in middle of string", + command: "Please /fix-bug 123 today", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": "123 today", + "1": "123", + "2": "today", + }, + wantErr: false, + }, + { + name: "command with prefix text", + command: "Hey can you /deploy production", + wantFound: true, + wantTask: "deploy", + wantParams: map[string]string{ + "ARGUMENTS": "production", + "1": "production", + }, + wantErr: false, + }, + { + name: "command with text before and on same line", + command: "Some text /code-review \"PR #42\" high more text", + wantFound: true, + wantTask: "code-review", + wantParams: map[string]string{ + "ARGUMENTS": `"PR #42" high more text`, + "1": "PR #42", + "2": "high", + "3": "more", + "4": "text", + }, + wantErr: false, + }, + { + name: "command followed by newline", + command: "Text before /fix-bug 123\nText after on next line", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": "123", + "1": "123", + }, + wantErr: false, + }, + { + name: "command in middle without arguments", + command: "Some prefix /task-name and suffix", + wantFound: true, + wantTask: "task-name", + wantParams: map[string]string{ + "ARGUMENTS": "and suffix", + "1": "and", + "2": "suffix", + }, + wantErr: false, + }, + { + name: "command at end without args", + command: "Please run /deploy", + wantFound: true, + wantTask: "deploy", + wantParams: map[string]string{}, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotTask, gotParams, err := ParseSlashCommand(tt.command) + gotTask, gotParams, gotFound, err := ParseSlashCommand(tt.command) if (err != nil) != tt.wantErr { t.Errorf("ParseSlashCommand() error = %v, wantErr %v", err, tt.wantErr) @@ -220,6 +307,10 @@ func TestParseSlashCommand(t *testing.T) { return } + if gotFound != tt.wantFound { + t.Errorf("ParseSlashCommand() gotFound = %v, want %v", gotFound, tt.wantFound) + } + if gotTask != tt.wantTask { t.Errorf("ParseSlashCommand() gotTask = %v, want %v", gotTask, tt.wantTask) }