diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9f59a84..186bb42 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -395,7 +395,31 @@ This enables dynamic task execution with inline arguments. /task-name arg1 "arg with spaces" arg3 ``` -### Example +### Positional Parameters + +Positional arguments are automatically numbered starting from 1: +- `/fix-bug 123` → `$1` = `123` +- `/task arg1 arg2 arg3` → `$1` = `arg1`, `$2` = `arg2`, `$3` = `arg3` + +Quoted arguments preserve spaces: +- `/code-review "PR #42"` → `$1` = `PR #42` + +### Named Parameters + +Named parameters use the format `key="value"` with **mandatory double quotes**: +- `/fix-bug issue="PROJ-123"` → `$1` = `issue="PROJ-123"`, `$issue` = `PROJ-123` +- `/deploy env="production" version="1.2.3"` → `$1` = `env="production"`, `$2` = `version="1.2.3"`, `$env` = `production`, `$version` = `1.2.3` + +Named parameters are counted as positional arguments (retaining their original form) while also being available by their key name: +- `/task arg1 key="value" arg2` → `$1` = `arg1`, `$2` = `key="value"`, `$3` = `arg2`, `$key` = `value` + +Named parameter values can contain spaces and special characters: +- `/run message="Hello, World!"` → `$1` = `message="Hello, World!"`, `$message` = `Hello, World!` +- `/config query="x=y+z"` → `$1` = `query="x=y+z"`, `$query` = `x=y+z` + +**Note:** Unquoted values (e.g., `key=value`) or single-quoted values (e.g., `key='value'`) are treated as regular positional arguments, not named parameters. + +### Example with Positional Parameters Create a task file (`implement-feature.md`): ```yaml @@ -429,6 +453,43 @@ This is equivalent to manually running: coding-context -p 1=login -p 2="Add OAuth support" /implement-feature ``` +### Example with Named Parameters + +Create a wrapper task (`fix-issue-wrapper.md`): +```yaml +--- +task_name: fix-issue-wrapper +--- +/fix-bug issue="PROJ-456" priority="high" +``` + +The target task (`fix-bug.md`): +```yaml +--- +task_name: fix-bug +--- +# Fix Bug: ${issue} + +Priority: ${priority} +``` + +When you run: +```bash +coding-context fix-issue-wrapper +``` + +The output will be: +``` +# Fix Bug: PROJ-456 + +Priority: high +``` + +This is equivalent to manually running: +```bash +coding-context -p issue=PROJ-456 -p priority=high fix-bug +``` + ## See Also - [File Formats Reference](./file-formats) - Task and rule file specifications diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 4f221d2..68dbb09 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -2,12 +2,13 @@ package codingcontext import ( "fmt" + "strconv" "strings" ) // 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 expected format is: /task-name arg1 "arg 2" arg3 key="value" // // 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 @@ -15,20 +16,27 @@ import ( // // Arguments are parsed like Bash: // - Quoted arguments can contain spaces -// - Both single and double quotes are supported +// - Both single and double quotes are supported for positional arguments // - Quotes are removed from the parsed arguments // - Arguments are extracted until end of line // +// Named parameters: +// - Named parameters use key="value" format with mandatory double quotes +// - Named parameters are also counted as positional arguments (retaining their original form) +// // Examples: // - "/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 +// - "/fix-bug issue=\"PROJ-123\"" -> taskName: "fix-bug", params: {"ARGUMENTS": "issue=\"PROJ-123\"", "1": "issue=\"PROJ-123\"", "issue": "PROJ-123"}, found: true +// - "/task arg1 key=\"val\" arg2" -> taskName: "task", params: {"ARGUMENTS": "arg1 key=\"val\" arg2", "1": "arg1", "2": "key=\"val\"", "3": "arg2", "key": "val"}, 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) +// - "1", "2", "3", etc.: all arguments in order (with quotes removed), including named parameters in their original form +// - "key": named parameter value (with quotes removed) // - 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) { @@ -79,24 +87,30 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin return taskName, params, true, nil } - // Parse positional arguments using bash-like parsing - args, err := parseBashArgs(argsString) + // Parse arguments using bash-like parsing, handling both positional and named parameters + parsedParams, err := parseBashArgsWithNamed(argsString) if err != nil { return "", nil, false, err } - // Add positional arguments as $1, $2, $3, etc. - for i, arg := range args { - params[fmt.Sprintf("%d", i+1)] = arg + // Merge parsed params into params + for key, value := range parsedParams { + params[key] = value } return taskName, params, true, nil } -// parseBashArgs parses a string into arguments like bash does, respecting quoted values -func parseBashArgs(s string) ([]string, error) { - var args []string +// parseBashArgsWithNamed parses a string into a map of parameters. +// The map contains positional keys ("1", "2", "3", etc.) and named parameter keys. +// Named parameters must use key="value" format with mandatory double quotes. +// Returns the parameters map and any error. +func parseBashArgsWithNamed(s string) (map[string]string, error) { + params := make(map[string]string) + argNum := 1 + var current strings.Builder + var rawArg strings.Builder // Tracks the raw argument including quotes inQuotes := false quoteChar := byte(0) escaped := false @@ -107,6 +121,7 @@ func parseBashArgs(s string) ([]string, error) { if escaped { current.WriteByte(ch) + rawArg.WriteByte(ch) escaped = false continue } @@ -114,6 +129,7 @@ func parseBashArgs(s string) ([]string, error) { if ch == '\\' && inQuotes && quoteChar == '"' { // Only recognize escape in double quotes escaped = true + rawArg.WriteByte(ch) continue } @@ -122,28 +138,58 @@ func parseBashArgs(s string) ([]string, error) { inQuotes = true quoteChar = ch justClosedQuotes = false + rawArg.WriteByte(ch) } else if ch == quoteChar && inQuotes { // End of quoted string - mark that we just closed quotes inQuotes = false quoteChar = 0 justClosedQuotes = true + rawArg.WriteByte(ch) } else if (ch == ' ' || ch == '\t') && !inQuotes { // Whitespace outside quotes - end of argument if current.Len() > 0 || justClosedQuotes { - args = append(args, current.String()) + arg := current.String() + rawArgStr := rawArg.String() + + // Add as positional argument + params[strconv.Itoa(argNum)] = rawArgStr + argNum++ + + // Check if this is also a named parameter with mandatory double quotes + if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed { + params[key] = value + } else { + // For non-named params, use stripped value as positional + params[strconv.Itoa(argNum-1)] = arg + } + current.Reset() + rawArg.Reset() justClosedQuotes = false } } else { // Regular character current.WriteByte(ch) + rawArg.WriteByte(ch) justClosedQuotes = false } } // Add the last argument if current.Len() > 0 || justClosedQuotes { - args = append(args, current.String()) + arg := current.String() + rawArgStr := rawArg.String() + + // Add as positional argument + params[strconv.Itoa(argNum)] = rawArgStr + + // Check if this is also a named parameter with mandatory double quotes + if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed { + params[key] = value + } else { + // For non-named params, use stripped value as positional + params[strconv.Itoa(argNum)] = arg + } } // Check for unclosed quotes @@ -151,5 +197,50 @@ func parseBashArgs(s string) ([]string, error) { return nil, fmt.Errorf("unclosed quote in arguments") } - return args, nil + return params, nil +} + +// parseNamedParamWithQuotes checks if an argument is a named parameter in key="value" format. +// Double quotes are mandatory for the value portion. +// Returns the key, value (with quotes stripped), and whether it was a valid named parameter. +// Key must be non-empty and cannot contain spaces or tabs. +func parseNamedParamWithQuotes(rawArg string) (key string, value string, isNamed bool) { + // Find the equals sign + eqIdx := strings.Index(rawArg, "=") + if eqIdx == -1 { + return "", "", false + } + + key = rawArg[:eqIdx] + // Key must be a valid identifier (non-empty, no spaces or tabs) + if key == "" || strings.ContainsAny(key, " \t") { + return "", "", false + } + + // The value portion (after '=') + valuePart := rawArg[eqIdx+1:] + + // Value must start with double quote (mandatory) + if len(valuePart) < 2 || valuePart[0] != '"' { + return "", "", false + } + + // Value must end with double quote + if valuePart[len(valuePart)-1] != '"' { + return "", "", false + } + + // Extract the value between quotes and handle escaped quotes + quotedValue := valuePart[1 : len(valuePart)-1] + var unescaped strings.Builder + for i := 0; i < len(quotedValue); i++ { + if quotedValue[i] == '\\' && i+1 < len(quotedValue) && quotedValue[i+1] == '"' { + unescaped.WriteByte('"') + i++ // Skip the escaped quote + } else { + unescaped.WriteByte(quotedValue[i]) + } + } + + return key, unescaped.String(), true } diff --git a/pkg/codingcontext/slashcommand_test.go b/pkg/codingcontext/slashcommand_test.go index c9fcb95..3b8ddc8 100644 --- a/pkg/codingcontext/slashcommand_test.go +++ b/pkg/codingcontext/slashcommand_test.go @@ -150,6 +150,185 @@ func TestParseSlashCommand(t *testing.T) { wantParams: map[string]string{}, wantErr: false, }, + // Named parameter tests + { + name: "command with single named parameter", + command: `/fix-bug issue="PROJ-123"`, + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": `issue="PROJ-123"`, + "1": `issue="PROJ-123"`, + "issue": "PROJ-123", + }, + wantErr: false, + }, + { + name: "command with multiple named parameters", + command: `/deploy env="production" version="1.2.3"`, + wantFound: true, + wantTask: "deploy", + wantParams: map[string]string{ + "ARGUMENTS": `env="production" version="1.2.3"`, + "1": `env="production"`, + "2": `version="1.2.3"`, + "env": "production", + "version": "1.2.3", + }, + wantErr: false, + }, + { + name: "command with mixed positional and named parameters", + command: `/task arg1 key="value" arg2`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `arg1 key="value" arg2`, + "1": "arg1", + "2": `key="value"`, + "3": "arg2", + "key": "value", + }, + wantErr: false, + }, + { + name: "named parameter with spaces in value", + command: `/implement feature="Add user authentication"`, + wantFound: true, + wantTask: "implement", + wantParams: map[string]string{ + "ARGUMENTS": `feature="Add user authentication"`, + "1": `feature="Add user authentication"`, + "feature": "Add user authentication", + }, + wantErr: false, + }, + { + name: "named parameter with escaped quotes in value", + command: `/log message="User said \"hello\""`, + wantFound: true, + wantTask: "log", + wantParams: map[string]string{ + "ARGUMENTS": `message="User said \"hello\""`, + "1": `message="User said \"hello\""`, + "message": `User said "hello"`, + }, + wantErr: false, + }, + { + name: "positional before and after named parameter", + command: `/task before key="middle" after`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `before key="middle" after`, + "1": "before", + "2": `key="middle"`, + "3": "after", + "key": "middle", + }, + wantErr: false, + }, + { + name: "multiple named parameters with different types of values", + command: `/config host="localhost" port="8080" debug="true"`, + wantFound: true, + wantTask: "config", + wantParams: map[string]string{ + "ARGUMENTS": `host="localhost" port="8080" debug="true"`, + "1": `host="localhost"`, + "2": `port="8080"`, + "3": `debug="true"`, + "host": "localhost", + "port": "8080", + "debug": "true", + }, + wantErr: false, + }, + { + name: "named parameter with empty value", + command: `/task key=""`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key=""`, + "1": `key=""`, + "key": "", + }, + wantErr: false, + }, + { + name: "named parameter with equals sign in value", + command: `/run equation="x=y+z"`, + wantFound: true, + wantTask: "run", + wantParams: map[string]string{ + "ARGUMENTS": `equation="x=y+z"`, + "1": `equation="x=y+z"`, + "equation": "x=y+z", + }, + wantErr: false, + }, + // Edge case tests for named parameters + { + name: "numeric key in named parameter overwrites positional", + command: `/task arg1 1="override"`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `arg1 1="override"`, + "1": "override", + "2": `1="override"`, + }, + wantErr: false, + }, + { + name: "ARGUMENTS key in named parameter overwrites", + command: `/task arg1 ARGUMENTS="custom"`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": "custom", + "1": "arg1", + "2": `ARGUMENTS="custom"`, + }, + wantErr: false, + }, + { + name: "duplicate named parameter keys - last value wins", + command: `/task key="first" key="second"`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key="first" key="second"`, + "1": `key="first"`, + "2": `key="second"`, + "key": "second", + }, + wantErr: false, + }, + { + name: "unquoted key=value is treated as positional argument not named parameter", + command: `/task key=value`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key=value`, + "1": "key=value", + }, + wantErr: false, + }, + { + name: "single-quoted key=value is treated as positional argument not named parameter", + command: `/task key='value'`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key='value'`, + "1": "key=value", + }, + wantErr: false, + }, } for _, tt := range tests {