Skip to content

Commit 1f7a3fe

Browse files
Copilotalexec
andauthored
Make ParseSlashCommand find commands anywhere in string and return found boolean (#117)
* Initial plan * Enhanced ParseSlashCommand to find commands anywhere in string Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Update README to reflect new ParseSlashCommand behavior Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Fix documentation comment in ParseSlashCommand Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Reorder return values: put found before err and after params Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
1 parent f21291e commit 1f7a3fe

4 files changed

Lines changed: 251 additions & 110 deletions

File tree

pkg/slashcommand/README.md

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,21 @@ Package `slashcommand` provides a parser for slash commands commonly used in AI
44

55
## Overview
66

7-
This package parses slash commands using bash-like argument parsing:
7+
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:
88
```
99
/task-name arg1 "arg 2" arg3
1010
```
1111

1212
The parser extracts:
1313
- **Task name**: The command identifier (without the leading `/`)
1414
- **Arguments**: Positional arguments accessed via `$ARGUMENTS`, `$1`, `$2`, `$3`, etc.
15+
- **Found status**: Boolean indicating whether a slash command was found
1516

1617
Arguments are parsed like bash:
1718
- Quoted arguments (single or double quotes) can contain spaces
1819
- Quotes are removed from parsed arguments
1920
- Escape sequences are supported in double quotes (`\"`)
21+
- Arguments are extracted until end of line
2022

2123
## Installation
2224

@@ -30,19 +32,34 @@ go get github.com/kitproj/coding-context-cli/pkg/slashcommand
3032
import "github.com/kitproj/coding-context-cli/pkg/slashcommand"
3133

3234
// Parse a simple command
33-
taskName, params, err := slashcommand.ParseSlashCommand("/fix-bug")
35+
taskName, params, found, err := slashcommand.ParseSlashCommand("/fix-bug")
3436
// taskName: "fix-bug"
3537
// params: map[]
38+
// found: true
3639

3740
// Parse a command with arguments
38-
taskName, params, err := slashcommand.ParseSlashCommand("/fix-bug 123")
41+
taskName, params, found, err := slashcommand.ParseSlashCommand("/fix-bug 123")
3942
// taskName: "fix-bug"
4043
// params: map["ARGUMENTS": "123", "1": "123"]
44+
// found: true
4145

4246
// Parse a command with quoted arguments
43-
taskName, params, err := slashcommand.ParseSlashCommand(`/code-review "Fix login bug" high`)
47+
taskName, params, found, err := slashcommand.ParseSlashCommand(`/code-review "Fix login bug" high`)
4448
// taskName: "code-review"
4549
// params: map["ARGUMENTS": "\"Fix login bug\" high", "1": "Fix login bug", "2": "high"]
50+
// found: true
51+
52+
// Command found in middle of text
53+
taskName, params, found, err := slashcommand.ParseSlashCommand("Please /deploy production now")
54+
// taskName: "deploy"
55+
// params: map["ARGUMENTS": "production now", "1": "production", "2": "now"]
56+
// found: true
57+
58+
// No command found
59+
taskName, params, found, err := slashcommand.ParseSlashCommand("No command here")
60+
// taskName: ""
61+
// params: nil
62+
// found: false
4663
```
4764

4865
## Command Format
@@ -53,13 +70,16 @@ taskName, params, err := slashcommand.ParseSlashCommand(`/code-review "Fix login
5370
```
5471

5572
### Argument Parsing Rules
56-
1. Commands **must** start with `/`
73+
1. Slash commands can appear **anywhere** in the input text
5774
2. Task name comes immediately after the `/` (no spaces)
58-
3. Arguments can be quoted with single (`'`) or double (`"`) quotes
59-
4. Quoted arguments can contain spaces
60-
5. Quotes are removed from parsed arguments
61-
6. Double quotes support escape sequences: `\"`
62-
7. Single quotes preserve everything literally (no escapes)
75+
3. Arguments are extracted until end of line (newline stops argument collection)
76+
4. Arguments can be quoted with single (`'`) or double (`"`) quotes
77+
5. Quoted arguments can contain spaces
78+
6. Quotes are removed from parsed arguments
79+
7. Double quotes support escape sequences: `\"`
80+
8. Single quotes preserve everything literally (no escapes)
81+
9. Text before the `/` is ignored (prefix lost)
82+
10. Text after a newline is ignored (suffix lost)
6383

6484
### Returned Parameters
6585
The `params` map contains:
@@ -74,47 +94,48 @@ The `params` map contains:
7494
/code-review "PR #42" # Quoted argument: $1 = "PR #42"
7595
/echo 'He said "hello"' # Single quotes preserve quotes: $1 = "He said \"hello\""
7696
/echo "He said \"hello\"" # Escaped quotes in double quotes: $1 = "He said \"hello\""
97+
Please /fix-bug 123 today # Command in middle: task = "fix-bug", $1 = "123", $2 = "today"
98+
Text /deploy prod\nNext line # Arguments stop at newline: task = "deploy", $1 = "prod"
7799
```
78100

79-
### Invalid Examples
101+
### Cases with No Command Found
80102
```
81-
fix-bug # Missing leading /
82-
/ # Empty command
83-
/fix-bug "unclosed # Unclosed quote
103+
fix-bug # Missing leading /: found = false
104+
No command here # No slash: found = false
84105
```
85106

86107
## Error Handling
87108

88-
The parser returns descriptive errors for invalid commands:
109+
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.
89110

90111
```go
91-
_, _, err := slashcommand.ParseSlashCommand("fix-bug")
92-
// Error: slash command must start with '/'
112+
// No command found - not an error
113+
_, _, found, err := slashcommand.ParseSlashCommand("No command here")
114+
// found: false, err: nil
93115

94-
_, _, err := slashcommand.ParseSlashCommand("/")
95-
// Error: slash command cannot be empty
96-
97-
_, _, err := slashcommand.ParseSlashCommand(`/fix-bug "unclosed`)
98-
// Error: unclosed quote in arguments
116+
// Unclosed quote - returns error
117+
_, _, found, err := slashcommand.ParseSlashCommand(`/fix-bug "unclosed`)
118+
// found: false, err: "unclosed quote in arguments"
99119
```
100120

101121
## API
102122

103123
### ParseSlashCommand
104124

105125
```go
106-
func ParseSlashCommand(command string) (taskName string, params map[string]string, err error)
126+
func ParseSlashCommand(command string) (taskName string, params map[string]string, found bool, err error)
107127
```
108128

109-
Parses a slash command string and extracts the task name and arguments.
129+
Parses a slash command string and extracts the task name and arguments. The function searches for a slash command anywhere in the input text.
110130

111131
**Parameters:**
112-
- `command` (string): The slash command to parse
132+
- `command` (string): The text that may contain a slash command
113133

114134
**Returns:**
115135
- `taskName` (string): The task name without the leading `/`
116136
- `params` (map[string]string): Contains `ARGUMENTS` (full arg string) and `1`, `2`, `3`, etc. (positional args)
117-
- `err` (error): Error if the command format is invalid
137+
- `found` (bool): True if a slash command was found, false otherwise
138+
- `err` (error): Error if the command format is invalid (e.g., unclosed quotes)
118139

119140
## Testing
120141

@@ -124,6 +145,8 @@ The package includes comprehensive tests covering:
124145
- Quoted arguments (both single and double quotes)
125146
- Escaped quotes
126147
- Empty quoted arguments
148+
- Commands embedded in text (prefix/suffix text)
149+
- Commands with newlines
127150
- Edge cases and error conditions
128151

129152
Run tests with:

pkg/slashcommand/example_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,34 @@ import (
88

99
func ExampleParseSlashCommand() {
1010
// Parse a simple command without parameters
11-
taskName, params, err := slashcommand.ParseSlashCommand("/fix-bug")
11+
taskName, params, found, err := slashcommand.ParseSlashCommand("/fix-bug")
1212
if err != nil {
1313
fmt.Printf("Error: %v\n", err)
1414
return
1515
}
16-
fmt.Printf("Task: %s, Params: %v\n", taskName, params)
16+
if found {
17+
fmt.Printf("Task: %s, Params: %v\n", taskName, params)
18+
}
1719

1820
// Parse a command with single argument
19-
taskName, params, err = slashcommand.ParseSlashCommand("/fix-bug 123")
21+
taskName, params, found, err = slashcommand.ParseSlashCommand("/fix-bug 123")
2022
if err != nil {
2123
fmt.Printf("Error: %v\n", err)
2224
return
2325
}
24-
fmt.Printf("Task: %s, $1: %s\n", taskName, params["1"])
26+
if found {
27+
fmt.Printf("Task: %s, $1: %s\n", taskName, params["1"])
28+
}
2529

2630
// Parse a command with multiple arguments
27-
taskName, params, err = slashcommand.ParseSlashCommand(`/implement-feature "User Login" high`)
31+
taskName, params, found, err = slashcommand.ParseSlashCommand(`/implement-feature "User Login" high`)
2832
if err != nil {
2933
fmt.Printf("Error: %v\n", err)
3034
return
3135
}
32-
fmt.Printf("Task: %s, $1: %s, $2: %s\n", taskName, params["1"], params["2"])
36+
if found {
37+
fmt.Printf("Task: %s, $1: %s, $2: %s\n", taskName, params["1"], params["2"])
38+
}
3339

3440
// Output:
3541
// Task: fix-bug, Params: map[]

pkg/slashcommand/parser.go

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,70 +6,91 @@ import (
66
)
77

88
// ParseSlashCommand parses a slash command string and extracts the task name and parameters.
9+
// It searches for a slash command anywhere in the input string, not just at the beginning.
910
// The expected format is: /task-name arg1 "arg 2" arg3
1011
//
12+
// The function will find the slash command even if it's embedded in other text. For example:
13+
// - "Please /fix-bug 123 today" -> taskName: "fix-bug", params: {"ARGUMENTS": "123 today", "1": "123", "2": "today"}, found: true
14+
// - "Some text /code-review" -> taskName: "code-review", params: {}, found: true
15+
//
1116
// Arguments are parsed like Bash:
1217
// - Quoted arguments can contain spaces
1318
// - Both single and double quotes are supported
1419
// - Quotes are removed from the parsed arguments
20+
// - Arguments are extracted until end of line
1521
//
1622
// Examples:
17-
// - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"}
18-
// - "/code-review \"PR #42\" high" -> taskName: "code-review", params: {"ARGUMENTS": "\"PR #42\" high", "1": "PR #42", "2": "high"}
23+
// - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"}, found: true
24+
// - "/code-review \"PR #42\" high" -> taskName: "code-review", params: {"ARGUMENTS": "\"PR #42\" high", "1": "PR #42", "2": "high"}, found: true
25+
// - "no command here" -> taskName: "", params: nil, found: false
1926
//
2027
// Returns:
2128
// - taskName: the task name (without the leading slash)
2229
// - params: a map containing:
2330
// - "ARGUMENTS": the full argument string (with quotes preserved)
2431
// - "1", "2", "3", etc.: positional arguments (with quotes removed)
25-
// - err: an error if the command format is invalid
26-
func ParseSlashCommand(command string) (taskName string, params map[string]string, err error) {
27-
command = strings.TrimSpace(command)
28-
29-
// Check if command starts with '/'
30-
if !strings.HasPrefix(command, "/") {
31-
return "", nil, fmt.Errorf("slash command must start with '/'")
32+
// - found: true if a slash command was found, false otherwise
33+
// - err: an error if the command format is invalid (e.g., unclosed quotes)
34+
func ParseSlashCommand(command string) (taskName string, params map[string]string, found bool, err error) {
35+
// Find the slash command anywhere in the string
36+
slashIdx := strings.Index(command, "/")
37+
if slashIdx == -1 {
38+
return "", nil, false, nil
3239
}
3340

34-
// Remove leading slash
35-
command = command[1:]
41+
// Extract from the slash onwards
42+
command = command[slashIdx+1:]
3643

3744
if command == "" {
38-
return "", nil, fmt.Errorf("slash command cannot be empty")
45+
return "", nil, false, nil
3946
}
4047

41-
// Find the task name (first word)
42-
spaceIdx := strings.IndexAny(command, " \t")
43-
if spaceIdx == -1 {
44-
// No arguments, just the task name
45-
return command, make(map[string]string), nil
48+
// Find the task name (first word after the slash)
49+
// Task name ends at first whitespace or newline
50+
endIdx := strings.IndexAny(command, " \t\n\r")
51+
if endIdx == -1 {
52+
// No arguments, just the task name (rest of the string)
53+
return command, make(map[string]string), true, nil
4654
}
4755

48-
taskName = command[:spaceIdx]
49-
argsString := strings.TrimSpace(command[spaceIdx:])
56+
taskName = command[:endIdx]
57+
58+
// Extract arguments until end of line
59+
restOfString := command[endIdx:]
60+
newlineIdx := strings.IndexAny(restOfString, "\n\r")
61+
var argsString string
62+
if newlineIdx == -1 {
63+
// No newline, use everything
64+
argsString = strings.TrimSpace(restOfString)
65+
} else {
66+
// Only use up to the newline
67+
argsString = strings.TrimSpace(restOfString[:newlineIdx])
68+
}
5069

5170
params = make(map[string]string)
5271

5372
// Store the full argument string (with quotes preserved)
54-
params["ARGUMENTS"] = argsString
73+
if argsString != "" {
74+
params["ARGUMENTS"] = argsString
75+
}
5576

5677
// If there are no arguments, return early
5778
if argsString == "" {
58-
return taskName, params, nil
79+
return taskName, params, true, nil
5980
}
6081

6182
// Parse positional arguments using bash-like parsing
6283
args, err := parseBashArgs(argsString)
6384
if err != nil {
64-
return "", nil, err
85+
return "", nil, false, err
6586
}
6687

6788
// Add positional arguments as $1, $2, $3, etc.
6889
for i, arg := range args {
6990
params[fmt.Sprintf("%d", i+1)] = arg
7091
}
7192

72-
return taskName, params, nil
93+
return taskName, params, true, nil
7394
}
7495

7596
// parseBashArgs parses a string into arguments like bash does, respecting quoted values

0 commit comments

Comments
 (0)