diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2bf64db..1622ccf 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -135,6 +135,26 @@ file:///path/to/local/rules Define a parameter for substitution in task prompts. Variables in task files using `${key}` syntax will be replaced with the specified value. +**Parameter Parsing Features:** + +The `-p` flag supports flexible parameter parsing with the following features: + +- **Basic key-value pairs**: `key=value` +- **Multiple values per key**: Duplicate keys are collected into a list (e.g., `-p tag=frontend -p tag=backend` results in `tag` having both values) +- **Quoted values**: Use single (`'`) or double (`"`) quotes for values containing spaces or special characters + - `-p description="Application crashes on startup"` + - `-p name='John Doe'` +- **Escape sequences**: Supported in both quoted and unquoted values + - Standard: `\n` (newline), `\t` (tab), `\r` (carriage return), `\\` (backslash) + - Quotes: `\"` (double quote), `\'` (single quote) + - Unicode: `\uXXXX` where XXXX are four hexadecimal digits + - Hex: `\xHH` where HH are two hexadecimal digits + - Octal: `\OOO` where OOO are up to three octal digits +- **Case-insensitive keys**: Keys are automatically converted to lowercase +- **UTF-8 support**: Full Unicode support in keys and values +- **Flexible separators**: Multiple `-p` flags can be used, or a single flag can contain comma or whitespace-separated pairs +- **Empty values**: Unquoted empty values (`key=`) result in empty parameter, quoted empty values (`key=""`) result in empty string + **Examples:** ```bash # Single parameter diff --git a/go.mod b/go.mod index 8ddfced..506f68f 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,26 @@ module github.com/kitproj/coding-context-cli -go 1.24.4 +go 1.24.5 require ( github.com/alecthomas/participle/v2 v2.1.4 github.com/goccy/go-yaml v1.18.0 github.com/hashicorp/go-getter/v2 v2.2.3 + github.com/stretchr/testify v1.10.0 ) require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.0 // indirect github.com/hashicorp/go-multierror v1.1.0 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/go-version v1.1.0 // indirect - github.com/klauspost/compress v1.11.2 // indirect + github.com/klauspost/compress v1.15.0 // indirect github.com/mitchellh/go-homedir v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/ulikunitz/xz v0.5.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ec179d7..40bfc34 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= @@ -24,11 +26,19 @@ github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PF github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/klauspost/compress v1.11.2 h1:MiK62aErc3gIiVEtyzKfeOHgW7atJb5g/KNX5m3c2nQ= -github.com/klauspost/compress v1.11.2/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= +github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 1144bbb..9004881 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,8 @@ import ( yaml "github.com/goccy/go-yaml" "github.com/kitproj/coding-context-cli/pkg/codingcontext" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) func main() { @@ -25,8 +27,8 @@ func main() { var resume bool var writeRules bool var agent codingcontext.Agent - params := make(codingcontext.Params) - includes := make(codingcontext.Selectors) + params := make(taskparser.Params) + includes := make(selectors.Selectors) var searchPaths []string var manifestURL string diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index d097626..185dc20 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -22,15 +22,16 @@ import ( "os" "github.com/kitproj/coding-context-cli/pkg/codingcontext" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" ) func main() { // Create a new context with options ctx := codingcontext.New( codingcontext.WithSearchPaths("file://.", "file://"+os.Getenv("HOME")), - codingcontext.WithParams(codingcontext.Params{ - "issue_number": "123", - "feature": "authentication", + codingcontext.WithParams(taskparams.Params{ + "issue_number": []string{"123"}, + "feature": []string{"authentication"}, }), codingcontext.WithLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))), ) @@ -63,13 +64,15 @@ import ( "os" "github.com/kitproj/coding-context-cli/pkg/codingcontext" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" ) func main() { // Create selectors for filtering rules - selectors := make(codingcontext.Selectors) - selectors.SetValue("language", "go") - selectors.SetValue("stage", "implementation") + sel := make(selectors.Selectors) + sel.SetValue("language", "go") + sel.SetValue("stage", "implementation") // Create context with all options ctx := codingcontext.New( @@ -77,10 +80,10 @@ func main() { "file://.", "git::https://github.com/org/repo//path/to/rules", ), - codingcontext.WithParams(codingcontext.Params{ - "issue_number": "123", + codingcontext.WithParams(taskparams.Params{ + "issue_number": []string{"123"}, }), - codingcontext.WithSelectors(selectors), + codingcontext.WithSelectors(sel), codingcontext.WithAgent(codingcontext.AgentCursor), codingcontext.WithResume(false), codingcontext.WithUserPrompt("Additional context or instructions"), @@ -232,11 +235,14 @@ Type representing MCP transport protocol (string type): #### `Params` -Map of parameter key-value pairs for template substitution: `map[string]string` +Map of parameter key-value pairs for template substitution: `map[string][]string` **Methods:** - `String() string` - Returns string representation - `Set(value string) error` - Parses and sets key=value pair (implements flag.Value) +- `Value(key string) string` - Returns the first value for the given key +- `Lookup(key string) (string, bool)` - Returns the first value and whether the key exists +- `Values(key string) []string` - Returns all values for the given key #### `Selectors` @@ -262,7 +268,7 @@ Types for parsing task content with slash commands: - `Argument` - Slash command argument (can be positional or named key=value) **Methods:** -- `(*SlashCommand) Params() map[string]string` - Returns parsed parameters as map +- `(*SlashCommand) Params() taskparams.Params` - Returns parsed parameters as map - `(*Text) Content() string` - Returns text content as string - Various `String()` methods for formatting each type @@ -284,8 +290,8 @@ Creates a new Context with the given options. **Options:** - `WithSearchPaths(paths ...string)` - Add search paths (supports go-getter URLs) -- `WithParams(params Params)` - Set parameters for substitution -- `WithSelectors(selectors Selectors)` - Set selectors for filtering rules +- `WithParams(params taskparams.Params)` - Set parameters for substitution (import `taskparams` package) +- `WithSelectors(selectors selectors.Selectors)` - Set selectors for filtering rules (import `selectors` package) - `WithAgent(agent Agent)` - Set target agent (excludes that agent's own rules) - `WithResume(resume bool)` - Enable resume mode (skips rules) - `WithUserPrompt(userPrompt string)` - Set user prompt to append to task @@ -304,23 +310,25 @@ Parses a markdown file into frontmatter and content. Generic function that works Parses task text content into blocks of text and slash commands. -#### `ParseParams(s string) (Params, error)` +#### `taskparams.Parse(s string) (taskparams.Params, error)` Parses a string containing key=value pairs with quoted values. **Examples:** ```go +import "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" + // Parse quoted key-value pairs -params, _ := ParseParams(`key1="value1" key2="value2"`) -// Result: map[string]string{"key1": "value1", "key2": "value2"} +params, _ := taskparams.Parse(`key1="value1" key2="value2"`) +// Result: taskparams.Params{"key1": []string{"value1"}, "key2": []string{"value2"}} // Parse with spaces in values -params, _ := ParseParams(`key1="value with spaces" key2="value2"`) -// Result: map[string]string{"key1": "value with spaces", "key2": "value2"} +params, _ := taskparams.Parse(`key1="value with spaces" key2="value2"`) +// Result: taskparams.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}} // Parse with escaped quotes -params, _ := ParseParams(`key1="value with \"escaped\" quotes"`) -// Result: map[string]string{"key1": "value with \"escaped\" quotes"} +params, _ := taskparams.Parse(`key1="value with \"escaped\" quotes"`) +// Result: taskparams.Params{"key1": []string{"value with \"escaped\" quotes"}} ``` #### `ParseAgent(s string) (Agent, error)` diff --git a/pkg/codingcontext/command_frontmatter.go b/pkg/codingcontext/command_frontmatter.go deleted file mode 100644 index 384a076..0000000 --- a/pkg/codingcontext/command_frontmatter.go +++ /dev/null @@ -1,38 +0,0 @@ -package codingcontext - -import ( - "encoding/json" -) - -// CommandFrontMatter represents the frontmatter fields for command files. -// Previously this was an empty placeholder struct, but now supports the expand field -// to control parameter expansion behavior in command content. -type CommandFrontMatter struct { - BaseFrontMatter `yaml:",inline"` - - // ExpandParams controls whether parameter expansion should occur - // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` -} - -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map -func (c *CommandFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion - type Alias CommandFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(c), - } - - if err := json.Unmarshal(data, aux); err != nil { - return err - } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &c.BaseFrontMatter.Content); err != nil { - return err - } - - return nil -} diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 4b2f05a..9ce9ac1 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -6,23 +6,28 @@ import ( "crypto/sha256" "fmt" "log/slog" + "maps" "os" "os/exec" "path/filepath" "strings" "github.com/hashicorp/go-getter/v2" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" ) // Context holds the configuration and state for assembling coding context type Context struct { - params Params - includes Selectors + params taskparser.Params + includes selectors.Selectors manifestURL string searchPaths []string downloadedPaths []string - task Markdown[TaskFrontMatter] // Parsed task - rules []Markdown[RuleFrontMatter] // Collected rule files + task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task + rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files totalTokens int logger *slog.Logger cmdRunner func(cmd *exec.Cmd) error @@ -31,71 +36,12 @@ type Context struct { userPrompt string // User-provided prompt to append to task } -// Option is a functional option for configuring a Context -type Option func(*Context) - -// WithParams sets the parameters -func WithParams(params Params) Option { - return func(c *Context) { - c.params = params - } -} - -// WithSelectors sets the selectors -func WithSelectors(selectors Selectors) Option { - return func(c *Context) { - c.includes = selectors - } -} - -// WithManifestURL sets the manifest URL -func WithManifestURL(manifestURL string) Option { - return func(c *Context) { - c.manifestURL = manifestURL - } -} - -// WithSearchPaths adds one or more search paths -func WithSearchPaths(paths ...string) Option { - return func(c *Context) { - c.searchPaths = append(c.searchPaths, paths...) - } -} - -// WithLogger sets the logger -func WithLogger(logger *slog.Logger) Option { - return func(c *Context) { - c.logger = logger - } -} - -// WithResume enables resume mode, which skips rule discovery and bootstrap scripts -func WithResume(resume bool) Option { - return func(c *Context) { - c.resume = resume - } -} - -// WithAgent sets the target agent, which excludes that agent's own rules -func WithAgent(agent Agent) Option { - return func(c *Context) { - c.agent = agent - } -} - -// WithUserPrompt sets the user prompt to append to the task -func WithUserPrompt(userPrompt string) Option { - return func(c *Context) { - c.userPrompt = userPrompt - } -} - // New creates a new Context with the given options func New(opts ...Option) *Context { c := &Context{ - params: make(Params), - includes: make(Selectors), - rules: make([]Markdown[RuleFrontMatter], 0), + params: make(taskparser.Params), + includes: make(selectors.Selectors), + rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0), logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), cmdRunner: func(cmd *exec.Cmd) error { return cmd.Run() @@ -112,7 +58,6 @@ type markdownVisitor func(path string) error // findMarkdownFile searches for a markdown file by name in the given directories. // Returns the path to the file if found, or an error if not found or multiple files match. func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, visitor markdownVisitor) error { - var searchDirs []string for _, path := range cc.downloadedPaths { searchDirs = append(searchDirs, searchDirFn(path)...) @@ -136,8 +81,8 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // If selectors are provided, check if the file matches // Parse frontmatter to check selectors - var fm BaseFrontMatter - if _, err := ParseMarkdownFile(path, &fm); err != nil { + var fm markdown.BaseFrontMatter + if _, err := markdown.ParseMarkdownFile(path, &fm); err != nil { // Skip files that can't be parsed return nil } @@ -148,7 +93,6 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi } return visitor(path) }) - if err != nil { return err } @@ -159,7 +103,6 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // findTask searches for a task markdown file and returns it with parameters substituted func (cc *Context) findTask(taskName string) error { - // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) @@ -172,8 +115,8 @@ func (cc *Context) findTask(taskName string) error { } taskFound = true - var frontMatter TaskFrontMatter - md, err := ParseMarkdownFile(path, &frontMatter) + var frontMatter markdown.TaskFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { return err } @@ -216,7 +159,7 @@ func (cc *Context) findTask(taskName string) error { } // Parse the task content (including user_prompt) to separate text blocks from slash commands - task, err := ParseTask(taskContent) + task, err := taskparser.ParseTask(taskContent) if err != nil { return err } @@ -231,7 +174,10 @@ func (cc *Context) findTask(taskName string) error { textContent := block.Text.Content() // Expand parameters in text blocks only if expand is not explicitly set to false if shouldExpandParams(frontMatter.ExpandParams) { - textContent = cc.expandParams(textContent, nil) + textContent, err = cc.expandParams(textContent, nil) + if err != nil { + return fmt.Errorf("failed to expand parameters: %w", err) + } } finalContent.WriteString(textContent) } else if block.SlashCommand != nil { @@ -243,10 +189,10 @@ func (cc *Context) findTask(taskName string) error { } } - cc.task = Markdown[TaskFrontMatter]{ + cc.task = markdown.Markdown[markdown.TaskFrontMatter]{ FrontMatter: frontMatter, Content: finalContent.String(), - Tokens: estimateTokens(finalContent.String()), + Tokens: tokencount.EstimateTokens(finalContent.String()), } cc.totalTokens += cc.task.Tokens @@ -267,7 +213,7 @@ func (cc *Context) findTask(taskName string) error { // Commands now support optional frontmatter with the expand field. // Parameters are substituted by default (when expand is nil or true). // Substitution is skipped only when expand is explicitly set to false. -func (cc *Context) findCommand(commandName string, params map[string]string) (string, error) { +func (cc *Context) findCommand(commandName string, params taskparser.Params) (string, error) { var content *string err := cc.visitMarkdownFiles(commandSearchPaths, func(path string) error { baseName := filepath.Base(path) @@ -276,8 +222,8 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st return nil } - var frontMatter CommandFrontMatter - md, err := ParseMarkdownFile(path, &frontMatter) + var frontMatter markdown.CommandFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { return err } @@ -285,7 +231,10 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st // Expand parameters only if expand is not explicitly set to false var processedContent string if shouldExpandParams(frontMatter.ExpandParams) { - processedContent = cc.expandParams(md.Content, params) + processedContent, err = cc.expandParams(md.Content, params) + if err != nil { + return fmt.Errorf("failed to expand parameters: %w", err) + } } else { processedContent = md.Content } @@ -307,18 +256,14 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st // - Command expansion: !`command` // - Path expansion: @path // If params is provided, it is merged with cc.params (with params taking precedence). -func (cc *Context) expandParams(content string, params map[string]string) string { +func (cc *Context) expandParams(content string, params taskparser.Params) (string, error) { // Merge params with cc.params - mergedParams := make(map[string]string) - for k, v := range cc.params { - mergedParams[k] = v - } - for k, v := range params { - mergedParams[k] = v - } + mergedParams := make(taskparser.Params) + maps.Copy(mergedParams, cc.params) + maps.Copy(mergedParams, params) // Use the expand function to handle all expansion types - return expand(content, mergedParams, cc.logger) + return mergedParams.Expand(content) } // shouldExpandParams returns true if parameter expansion should occur based on the expandParams field. @@ -458,8 +403,8 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err } err := cc.visitMarkdownFiles(func(path string) []string { return rulePaths(path, path == homeDir) }, func(path string) error { - var frontmatter RuleFrontMatter - md, err := ParseMarkdownFile(path, &frontmatter) + var frontmatter markdown.RuleFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontmatter) if err != nil { return fmt.Errorf("failed to parse markdown file: %w", err) } @@ -467,13 +412,16 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err // Expand parameters only if expand is not explicitly set to false var processedContent string if shouldExpandParams(frontmatter.ExpandParams) { - processedContent = cc.expandParams(md.Content, nil) + processedContent, err = cc.expandParams(md.Content, nil) + if err != nil { + return fmt.Errorf("failed to expand parameters: %w", err) + } } else { processedContent = md.Content } - tokens := estimateTokens(processedContent) + tokens := tokencount.EstimateTokens(processedContent) - cc.rules = append(cc.rules, Markdown[RuleFrontMatter]{ + cc.rules = append(cc.rules, markdown.Markdown[markdown.RuleFrontMatter]{ FrontMatter: frontmatter, Content: processedContent, Tokens: tokens, diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 09a22c4..a3e07a4 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -8,6 +8,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) // Test helper functions for creating fixtures @@ -16,7 +19,7 @@ import ( func createTask(t *testing.T, dir, name, frontmatter, content string) { t.Helper() taskDir := filepath.Join(dir, ".agents", "tasks") - if err := os.MkdirAll(taskDir, 0755); err != nil { + if err := os.MkdirAll(taskDir, 0o755); err != nil { t.Fatalf("failed to create task directory: %v", err) } @@ -28,7 +31,7 @@ func createTask(t *testing.T, dir, name, frontmatter, content string) { } taskPath := filepath.Join(taskDir, name+".md") - if err := os.WriteFile(taskPath, []byte(fileContent), 0644); err != nil { + if err := os.WriteFile(taskPath, []byte(fileContent), 0o644); err != nil { t.Fatalf("failed to create task file: %v", err) } } @@ -38,7 +41,7 @@ func createRule(t *testing.T, dir, relPath, frontmatter, content string) { t.Helper() rulePath := filepath.Join(dir, relPath) ruleDir := filepath.Dir(rulePath) - if err := os.MkdirAll(ruleDir, 0755); err != nil { + if err := os.MkdirAll(ruleDir, 0o755); err != nil { t.Fatalf("failed to create rule directory: %v", err) } @@ -49,7 +52,7 @@ func createRule(t *testing.T, dir, relPath, frontmatter, content string) { fileContent = content } - if err := os.WriteFile(rulePath, []byte(fileContent), 0644); err != nil { + if err := os.WriteFile(rulePath, []byte(fileContent), 0o644); err != nil { t.Fatalf("failed to create rule file: %v", err) } } @@ -58,7 +61,7 @@ func createRule(t *testing.T, dir, relPath, frontmatter, content string) { func createCommand(t *testing.T, dir, name, frontmatter, content string) { t.Helper() cmdDir := filepath.Join(dir, ".agents", "commands") - if err := os.MkdirAll(cmdDir, 0755); err != nil { + if err := os.MkdirAll(cmdDir, 0o755); err != nil { t.Fatalf("failed to create command directory: %v", err) } @@ -70,7 +73,7 @@ func createCommand(t *testing.T, dir, name, frontmatter, content string) { } cmdPath := filepath.Join(cmdDir, name+".md") - if err := os.WriteFile(cmdPath, []byte(fileContent), 0644); err != nil { + if err := os.WriteFile(cmdPath, []byte(fileContent), 0o644); err != nil { t.Fatalf("failed to create command file: %v", err) } } @@ -82,7 +85,7 @@ func createBootstrapScript(t *testing.T, dir, rulePath, scriptContent string) { baseNameWithoutExt := strings.TrimSuffix(fullRulePath, filepath.Ext(fullRulePath)) bootstrapPath := baseNameWithoutExt + "-bootstrap" - if err := os.WriteFile(bootstrapPath, []byte(scriptContent), 0755); err != nil { + if err := os.WriteFile(bootstrapPath, []byte(scriptContent), 0o755); err != nil { t.Fatalf("failed to create bootstrap script: %v", err) } } @@ -115,21 +118,21 @@ func TestNew(t *testing.T) { { name: "with params", opts: []Option{ - WithParams(Params{"key1": "value1", "key2": "value2"}), + WithParams(taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}}), }, check: func(t *testing.T, c *Context) { - if c.params["key1"] != "value1" { - t.Errorf("expected params[key1]=value1, got %v", c.params["key1"]) + if c.params.Value("key1") != "value1" { + t.Errorf("expected params[key1]=value1, got %v", c.params.Value("key1")) } - if c.params["key2"] != "value2" { - t.Errorf("expected params[key2]=value2, got %v", c.params["key2"]) + if c.params.Value("key2") != "value2" { + t.Errorf("expected params[key2]=value2, got %v", c.params.Value("key2")) } }, }, { name: "with selectors", opts: []Option{ - WithSelectors(Selectors{"env": {"dev": true, "test": true}}), + WithSelectors(selectors.Selectors{"env": {"dev": true, "test": true}}), }, check: func(t *testing.T, c *Context) { if !c.includes.GetValue("env", "dev") { @@ -204,14 +207,14 @@ func TestNew(t *testing.T) { { name: "multiple options combined", opts: []Option{ - WithParams(Params{"env": "production"}), - WithSelectors(Selectors{"lang": {"go": true}}), + WithParams(taskparser.Params{"env": []string{"production"}}), + WithSelectors(selectors.Selectors{"lang": {"go": true}}), WithSearchPaths("/custom/path"), WithResume(false), WithAgent(AgentCopilot), }, check: func(t *testing.T, c *Context) { - if c.params["env"] != "production" { + if c.params.Value("env") != "production" { t.Error("params not set correctly") } if !c.includes.GetValue("lang", "go") { @@ -289,7 +292,7 @@ func TestContext_Run_Basic(t *testing.T) { createTask(t, dir, "params-task", "", "Environment: ${env}\nFeature: ${feature}") }, opts: []Option{ - WithParams(Params{"env": "production", "feature": "auth"}), + WithParams(taskparser.Params{"env": []string{"production"}, "feature": []string{"auth"}}), }, taskName: "params-task", wantErr: false, @@ -341,7 +344,7 @@ func TestContext_Run_Basic(t *testing.T) { createTask(t, dir, "multi-params", "", "User: ${user}, Email: ${email}, Role: ${role}") }, opts: []Option{ - WithParams(Params{"user": "alice", "email": "alice@example.com", "role": "admin"}), + WithParams(taskparser.Params{"user": []string{"alice"}, "email": []string{"alice@example.com"}, "role": []string{"admin"}}), }, taskName: "multi-params", wantErr: false, @@ -449,7 +452,7 @@ func TestContext_Run_Rules(t *testing.T) { createRule(t, dir, ".agents/rules/param-rule.md", "", "Project: ${project}") }, opts: []Option{ - WithParams(Params{"project": "myapp"}), + WithParams(taskparser.Params{"project": []string{"myapp"}}), }, taskName: "param-task", wantErr: false, @@ -578,7 +581,7 @@ func TestContext_Run_Rules(t *testing.T) { createRule(t, dir, ".agents/rules/test-rule.md", "env: test", "Test rule") }, opts: []Option{ - WithSelectors(Selectors{"env": {"development": true}}), // CLI selector for development + WithSelectors(selectors.Selectors{"env": {"development": true}}), // CLI selector for development }, taskName: "or-task", wantErr: false, @@ -624,7 +627,7 @@ func TestContext_Run_Rules(t *testing.T) { createRule(t, dir, ".agents/rules/test-rule.md", "env: test", "Test rule") }, opts: []Option{ - WithSelectors(Selectors{"env": {"development": true}}), // CLI adds development + WithSelectors(selectors.Selectors{"env": {"development": true}}), // CLI adds development }, taskName: "array-or", wantErr: false, @@ -772,7 +775,7 @@ func TestContext_Run_Commands(t *testing.T) { createCommand(t, dir, "deploy", "", "Deploying to ${env}") }, opts: []Option{ - WithParams(Params{"env": "staging"}), + WithParams(taskparser.Params{"env": []string{"staging"}}), }, taskName: "ctx-params", wantErr: false, @@ -817,7 +820,7 @@ func TestContext_Run_Commands(t *testing.T) { createCommand(t, dir, "msg", "", "Value: ${value}") }, opts: []Option{ - WithParams(Params{"value": "general"}), + WithParams(taskparser.Params{"value": []string{"general"}}), }, taskName: "override-param", wantErr: false, @@ -915,7 +918,7 @@ func TestContext_Run_Integration(t *testing.T) { createRule(t, dir, ".agents/rules/dev.md", "env: development", "Dev only rule") }, opts: []Option{ - WithParams(Params{"app": "myservice", "env": "production"}), + WithParams(taskparser.Params{"app": []string{"myservice"}, "env": []string{"production"}}), }, taskName: "fullworkflow", wantErr: false, @@ -1006,7 +1009,7 @@ func TestContext_Run_Integration(t *testing.T) { // Create second directory with additional rule secondDir := filepath.Join(dir, "second") - if err := os.MkdirAll(secondDir, 0755); err != nil { + if err := os.MkdirAll(secondDir, 0o755); err != nil { t.Fatalf("failed to create second dir: %v", err) } createRule(t, secondDir, ".agents/rules/rule2.md", "", "Rule from second path") @@ -1121,7 +1124,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createTask(t, dir, "no-expand", "expand: false", "Issue: ${issue_number}\nTitle: ${issue_title}") }, opts: []Option{ - WithParams(Params{"issue_number": "123", "issue_title": "Bug fix"}), + WithParams(taskparser.Params{"issue_number": []string{"123"}, "issue_title": []string{"Bug fix"}}), }, taskName: "no-expand", wantErr: false, @@ -1140,7 +1143,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createTask(t, dir, "expand", "expand: true", "Issue: ${issue_number}\nTitle: ${issue_title}") }, opts: []Option{ - WithParams(Params{"issue_number": "123", "issue_title": "Bug fix"}), + WithParams(taskparser.Params{"issue_number": []string{"123"}, "issue_title": []string{"Bug fix"}}), }, taskName: "expand", wantErr: false, @@ -1159,7 +1162,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createTask(t, dir, "default", "", "Env: ${env}") }, opts: []Option{ - WithParams(Params{"env": "production"}), + WithParams(taskparser.Params{"env": []string{"production"}}), }, taskName: "default", wantErr: false, @@ -1176,7 +1179,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "deploy", "expand: false", "Deploying to ${env}") }, opts: []Option{ - WithParams(Params{"env": "staging"}), + WithParams(taskparser.Params{"env": []string{"staging"}}), }, taskName: "cmd-no-expand", wantErr: false, @@ -1193,7 +1196,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "deploy", "expand: true", "Deploying to ${env}") }, opts: []Option{ - WithParams(Params{"env": "staging"}), + WithParams(taskparser.Params{"env": []string{"staging"}}), }, taskName: "cmd-expand", wantErr: false, @@ -1210,7 +1213,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "info", "", "Project: ${project}") }, opts: []Option{ - WithParams(Params{"project": "myapp"}), + WithParams(taskparser.Params{"project": []string{"myapp"}}), }, taskName: "cmd-default", wantErr: false, @@ -1227,7 +1230,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule1.md", "expand: false", "Version: ${version}") }, opts: []Option{ - WithParams(Params{"version": "1.0.0"}), + WithParams(taskparser.Params{"version": []string{"1.0.0"}}), }, taskName: "rule-no-expand", wantErr: false, @@ -1247,7 +1250,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule1.md", "expand: true", "Version: ${version}") }, opts: []Option{ - WithParams(Params{"version": "1.0.0"}), + WithParams(taskparser.Params{"version": []string{"1.0.0"}}), }, taskName: "rule-expand", wantErr: false, @@ -1267,7 +1270,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule1.md", "", "App: ${app}") }, opts: []Option{ - WithParams(Params{"app": "service"}), + WithParams(taskparser.Params{"app": []string{"service"}}), }, taskName: "rule-default", wantErr: false, @@ -1287,7 +1290,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "cmd", "expand: true", "Command ${cmd_var}") }, opts: []Option{ - WithParams(Params{"task_var": "task_value", "cmd_var": "cmd_value"}), + WithParams(taskparser.Params{"task_var": []string{"task_value"}, "cmd_var": []string{"cmd_value"}}), }, taskName: "mixed1", wantErr: false, @@ -1308,7 +1311,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "cmd", "expand: false", "Command ${cmd_var}") }, opts: []Option{ - WithParams(Params{"task_var": "task_value", "cmd_var": "cmd_value"}), + WithParams(taskparser.Params{"task_var": []string{"task_value"}, "cmd_var": []string{"cmd_value"}}), }, taskName: "mixed2", wantErr: false, @@ -1329,7 +1332,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "greet", "expand: false", "Hello, ${name}! Your ID: ${id}") }, opts: []Option{ - WithParams(Params{"id": "123"}), + WithParams(taskparser.Params{"id": []string{"123"}}), }, taskName: "inline-no-expand", wantErr: false, @@ -1352,7 +1355,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule3.md", "", "Rule3: ${var3}") }, opts: []Option{ - WithParams(Params{"var1": "val1", "var2": "val2", "var3": "val3"}), + WithParams(taskparser.Params{"var1": []string{"val1"}, "var2": []string{"val2"}, "var3": []string{"val3"}}), }, taskName: "multi-rules", wantErr: false, @@ -1475,8 +1478,8 @@ func TestUserPrompt(t *testing.T) { }, opts: []Option{ WithUserPrompt("Issue: ${issue_number}"), - WithParams(Params{ - "issue_number": "123", + WithParams(taskparser.Params{ + "issue_number": []string{"123"}, }), }, taskName: "with-params", @@ -1495,9 +1498,9 @@ func TestUserPrompt(t *testing.T) { }, opts: []Option{ WithUserPrompt("Please fix:\n/issue-info\n"), - WithParams(Params{ - "issue_number": "456", - "issue_title": "Fix bug", + WithParams(taskparser.Params{ + "issue_number": []string{"456"}, + "issue_title": []string{"Fix bug"}, }), }, taskName: "complex", @@ -1569,8 +1572,8 @@ func TestUserPrompt(t *testing.T) { }, opts: []Option{ WithUserPrompt("Issue ${issue_number}"), - WithParams(Params{ - "issue_number": "789", + WithParams(taskparser.Params{ + "issue_number": []string{"789"}, }), }, taskName: "no-expand", diff --git a/pkg/codingcontext/free_text_task.go b/pkg/codingcontext/free_text_task.go deleted file mode 100644 index 7532d8a..0000000 --- a/pkg/codingcontext/free_text_task.go +++ /dev/null @@ -1,7 +0,0 @@ -package codingcontext - -// FreeTextTaskName is the task name used for free-text prompts -const FreeTextTaskName = "free-text" - -// FreeTextParamName is the parameter name used for the text content in free-text tasks -const FreeTextParamName = "text" diff --git a/pkg/codingcontext/frontmatter.go b/pkg/codingcontext/frontmatter.go deleted file mode 100644 index 50a30f7..0000000 --- a/pkg/codingcontext/frontmatter.go +++ /dev/null @@ -1,6 +0,0 @@ -package codingcontext - -// BaseFrontMatter represents parsed YAML frontmatter from markdown files -type BaseFrontMatter struct { - Content map[string]any `json:"-" yaml:",inline"` -} diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go new file mode 100644 index 0000000..3dc9f54 --- /dev/null +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -0,0 +1,155 @@ +package markdown + +import ( + "encoding/json" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" +) + +// BaseFrontMatter represents parsed YAML frontmatter from markdown files +type BaseFrontMatter struct { + Content map[string]any `json:"-" yaml:",inline"` +} + +// TaskFrontMatter represents the standard frontmatter fields for task files +type TaskFrontMatter struct { + BaseFrontMatter `yaml:",inline"` + + // Agent specifies the default agent if not specified via -a flag + // This is not used for selecting tasks or rules, only as a default + Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` + + // Languages specifies the programming language(s) for filtering rules + // Array of languages for OR logic (e.g., ["go", "python"]) + Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + + // Model specifies the AI model identifier + // Does not filter rules, metadata only + Model string `yaml:"model,omitempty" json:"model,omitempty"` + + // SingleShot indicates whether the task runs once or multiple times + // Does not filter rules, metadata only + SingleShot bool `yaml:"single_shot,omitempty" json:"single_shot,omitempty"` + + // Timeout specifies the task timeout in time.Duration format (e.g., "10m", "1h") + // Does not filter rules, metadata only + Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` + + // MCPServers maps server names to their configurations + // Does not filter rules, metadata only + MCPServers mcp.MCPServerConfigs `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` + + // Resume indicates if this task should be resumed + Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` + + // Selectors contains additional custom selectors for filtering rules + Selectors map[string]any `yaml:"selectors,omitempty" json:"selectors,omitempty"` + + // ExpandParams controls whether parameter expansion should occur + // Defaults to true if not specified + ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +func (t *TaskFrontMatter) UnmarshalJSON(data []byte) error { + // First unmarshal into a temporary type to avoid infinite recursion + type Alias TaskFrontMatter + aux := &struct { + *Alias + }{ + Alias: (*Alias)(t), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Also unmarshal into Content map + if err := json.Unmarshal(data, &t.BaseFrontMatter.Content); err != nil { + return err + } + + return nil +} + +// CommandFrontMatter represents the frontmatter fields for command files. +// Previously this was an empty placeholder struct, but now supports the expand field +// to control parameter expansion behavior in command content. +type CommandFrontMatter struct { + BaseFrontMatter `yaml:",inline"` + + // ExpandParams controls whether parameter expansion should occur + // Defaults to true if not specified + ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +func (c *CommandFrontMatter) UnmarshalJSON(data []byte) error { + // First unmarshal into a temporary type to avoid infinite recursion + type Alias CommandFrontMatter + aux := &struct { + *Alias + }{ + Alias: (*Alias)(c), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Also unmarshal into Content map + if err := json.Unmarshal(data, &c.BaseFrontMatter.Content); err != nil { + return err + } + + return nil +} + +// RuleFrontMatter represents the standard frontmatter fields for rule files +type RuleFrontMatter struct { + BaseFrontMatter `yaml:",inline"` + + // TaskNames specifies which task(s) this rule applies to + // Array of task names for OR logic + TaskNames []string `yaml:"task_names,omitempty" json:"task_names,omitempty"` + + // Languages specifies which programming language(s) this rule applies to + // Array of languages for OR logic (e.g., ["go", "python"]) + Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + + // Agent specifies which AI agent this rule is intended for + Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` + + // MCPServers maps server names to their configurations + // Metadata only, does not filter + MCPServers mcp.MCPServerConfigs `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` + + // RuleName is an optional identifier for the rule file + RuleName string `yaml:"rule_name,omitempty" json:"rule_name,omitempty"` + + // ExpandParams controls whether parameter expansion should occur + // Defaults to true if not specified + ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +func (r *RuleFrontMatter) UnmarshalJSON(data []byte) error { + // First unmarshal into a temporary type to avoid infinite recursion + type Alias RuleFrontMatter + aux := &struct { + *Alias + }{ + Alias: (*Alias)(r), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Also unmarshal into Content map + if err := json.Unmarshal(data, &r.BaseFrontMatter.Content); err != nil { + return err + } + + return nil +} diff --git a/pkg/codingcontext/rule_frontmatter_test.go b/pkg/codingcontext/markdown/frontmatter_rule_test.go similarity index 94% rename from pkg/codingcontext/rule_frontmatter_test.go rename to pkg/codingcontext/markdown/frontmatter_rule_test.go index 0dfdfdd..1db55fe 100644 --- a/pkg/codingcontext/rule_frontmatter_test.go +++ b/pkg/codingcontext/markdown/frontmatter_rule_test.go @@ -1,9 +1,10 @@ -package codingcontext +package markdown import ( "testing" "github.com/goccy/go-yaml" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" ) func TestRuleFrontMatter_Marshal(t *testing.T) { @@ -50,9 +51,9 @@ agent: cursor TaskNames: []string{"test-task"}, Languages: []string{"go", "python"}, Agent: "copilot", - MCPServers: MCPServerConfigs{ + MCPServers: mcp.MCPServerConfigs{ "database": { - Type: TransportTypeStdio, + Type: mcp.TransportTypeStdio, Command: "database-server", }, }, diff --git a/pkg/codingcontext/task_frontmatter_test.go b/pkg/codingcontext/markdown/frontmatter_task_test.go similarity index 90% rename from pkg/codingcontext/task_frontmatter_test.go rename to pkg/codingcontext/markdown/frontmatter_task_test.go index 6eb895d..8653075 100644 --- a/pkg/codingcontext/task_frontmatter_test.go +++ b/pkg/codingcontext/markdown/frontmatter_task_test.go @@ -1,9 +1,10 @@ -package codingcontext +package markdown import ( "testing" "github.com/goccy/go-yaml" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" ) func TestTaskFrontMatter_Marshal(t *testing.T) { @@ -32,9 +33,9 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem-server"}, - "git": {Type: TransportTypeStdio, Command: "git-server"}, + MCPServers: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "filesystem-server"}, + "git": {Type: mcp.TransportTypeStdio, Command: "git-server"}, }, Resume: false, Selectors: map[string]any{ @@ -160,9 +161,9 @@ selectors: Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem-server"}, - "git": {Type: TransportTypeStdio, Command: "git-server"}, + MCPServers: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "filesystem-server"}, + "git": {Type: mcp.TransportTypeStdio, Command: "git-server"}, }, Selectors: map[string]any{ "stage": "implementation", diff --git a/pkg/codingcontext/markdown.go b/pkg/codingcontext/markdown/markdown.go similarity index 75% rename from pkg/codingcontext/markdown.go rename to pkg/codingcontext/markdown/markdown.go index 2be3a34..9a36903 100644 --- a/pkg/codingcontext/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -1,4 +1,4 @@ -package codingcontext +package markdown import ( "bufio" @@ -7,8 +7,22 @@ import ( "os" yaml "github.com/goccy/go-yaml" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" ) +// Markdown represents a markdown file with frontmatter and content +type Markdown[T any] struct { + FrontMatter T // Parsed YAML frontmatter + Content string // Expanded content of the markdown + Tokens int // Estimated token count +} + +// TaskMarkdown is a Markdown with TaskFrontMatter +type TaskMarkdown = Markdown[TaskFrontMatter] + +// RuleMarkdown is a Markdown with RuleFrontMatter +type RuleMarkdown = Markdown[RuleFrontMatter] + // ParseMarkdownFile parses a markdown file into frontmatter and content func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) { fh, err := os.Open(path) @@ -67,6 +81,6 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) return Markdown[T]{ FrontMatter: *frontMatter, Content: content.String(), - Tokens: estimateTokens(content.String()), + Tokens: tokencount.EstimateTokens(content.String()), }, nil } diff --git a/pkg/codingcontext/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go similarity index 97% rename from pkg/codingcontext/markdown_test.go rename to pkg/codingcontext/markdown/markdown_test.go index 13e95e1..5b3c522 100644 --- a/pkg/codingcontext/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -1,4 +1,4 @@ -package codingcontext +package markdown import ( "os" @@ -63,7 +63,7 @@ This is the content. // Create a temporary file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("failed to create temp file: %v", err) } @@ -180,7 +180,7 @@ This task has no frontmatter. // Create a temporary file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("failed to create temp file: %v", err) } diff --git a/pkg/codingcontext/mcp_server_config.go b/pkg/codingcontext/mcp/mcp.go similarity index 77% rename from pkg/codingcontext/mcp_server_config.go rename to pkg/codingcontext/mcp/mcp.go index 433ee66..b6b0624 100644 --- a/pkg/codingcontext/mcp_server_config.go +++ b/pkg/codingcontext/mcp/mcp.go @@ -1,7 +1,21 @@ -package codingcontext +package mcp -import ( - "encoding/json" +import "encoding/json" + +// TransportType defines the communication protocol used by the server. +// Supported by both Claude and Cursor. +type TransportType string + +const ( + // TransportTypeStdio is for local processes (executables). + TransportTypeStdio TransportType = "stdio" + + // TransportTypeSSE is for Server-Sent Events (Remote). + // Note: Claude Code prefers HTTP over SSE, but supports it. + TransportTypeSSE TransportType = "sse" + + // TransportTypeHTTP is for standard HTTP/POST interactions. + TransportTypeHTTP TransportType = "http" ) // MCPServerConfig defines the common configuration fields supported by both platforms. diff --git a/pkg/codingcontext/mcp_server_config_test.go b/pkg/codingcontext/mcp/mcp_test.go similarity index 99% rename from pkg/codingcontext/mcp_server_config_test.go rename to pkg/codingcontext/mcp/mcp_test.go index 8754a2a..a3420e7 100644 --- a/pkg/codingcontext/mcp_server_config_test.go +++ b/pkg/codingcontext/mcp/mcp_test.go @@ -1,4 +1,4 @@ -package codingcontext +package mcp import ( "encoding/json" diff --git a/pkg/codingcontext/options.go b/pkg/codingcontext/options.go new file mode 100644 index 0000000..d7f2bf8 --- /dev/null +++ b/pkg/codingcontext/options.go @@ -0,0 +1,67 @@ +package codingcontext + +import ( + "log/slog" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" +) + +// Option is a functional option for configuring a Context +type Option func(*Context) + +// WithParams sets the parameters +func WithParams(params taskparser.Params) Option { + return func(c *Context) { + c.params = params + } +} + +// WithSelectors sets the selectors +func WithSelectors(selectors selectors.Selectors) Option { + return func(c *Context) { + c.includes = selectors + } +} + +// WithManifestURL sets the manifest URL +func WithManifestURL(manifestURL string) Option { + return func(c *Context) { + c.manifestURL = manifestURL + } +} + +// WithSearchPaths adds one or more search paths +func WithSearchPaths(paths ...string) Option { + return func(c *Context) { + c.searchPaths = append(c.searchPaths, paths...) + } +} + +// WithLogger sets the logger +func WithLogger(logger *slog.Logger) Option { + return func(c *Context) { + c.logger = logger + } +} + +// WithResume enables resume mode, which skips rule discovery and bootstrap scripts +func WithResume(resume bool) Option { + return func(c *Context) { + c.resume = resume + } +} + +// WithAgent sets the target agent, which excludes that agent's own rules +func WithAgent(agent Agent) Option { + return func(c *Context) { + c.agent = agent + } +} + +// WithUserPrompt sets the user prompt to append to the task +func WithUserPrompt(userPrompt string) Option { + return func(c *Context) { + c.userPrompt = userPrompt + } +} diff --git a/pkg/codingcontext/params.go b/pkg/codingcontext/params.go deleted file mode 100644 index 49022f5..0000000 --- a/pkg/codingcontext/params.go +++ /dev/null @@ -1,109 +0,0 @@ -package codingcontext - -import ( - "fmt" - "strings" -) - -// Params is a map of parameter key-value pairs for template substitution -type Params map[string]string - -// String implements the fmt.Stringer interface for Params -func (p *Params) String() string { - return fmt.Sprint(*p) -} - -// Set implements the flag.Value interface for Params -func (p *Params) Set(value string) error { - kv := strings.SplitN(value, "=", 2) - if len(kv) != 2 { - return fmt.Errorf("invalid parameter format: %s", value) - } - if *p == nil { - *p = make(map[string]string) - } - (*p)[kv[0]] = kv[1] - return nil -} - -// ParseParams parses a string containing key=value pairs separated by spaces. -// Values must be quoted with double quotes, and quotes can be escaped. -// Unquoted values are treated as an error. -// Examples: -// - `key1="value1" key2="value2"` -// - `key1="value with spaces" key2="value2"` -// - `key1="value with \"escaped\" quotes"` -func ParseParams(s string) (Params, error) { - params := make(Params) - if s == "" { - return params, nil - } - - s = strings.TrimSpace(s) - var i int - for i < len(s) { - // Skip whitespace - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - if i >= len(s) { - break - } - - // Find the key (until '=') - keyStart := i - for i < len(s) && s[i] != '=' { - i++ - } - if i >= len(s) { - break - } - key := strings.TrimSpace(s[keyStart:i]) - if key == "" { - i++ - continue - } - - // Skip '=' - i++ - - // Skip whitespace after '=' - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - if i >= len(s) { - return nil, fmt.Errorf("missing quoted value for key %q", key) - } - - // Values must be quoted - if s[i] != '"' { - return nil, fmt.Errorf("unquoted value for key %q: values must be double-quoted", key) - } - - // Parse the double-quoted value - var value strings.Builder - i++ // skip opening quote - quoted := false - for i < len(s) { - if s[i] == '\\' && i+1 < len(s) && s[i+1] == '"' { - value.WriteByte('"') - i += 2 - } else if s[i] == '"' { - i++ // skip closing quote - quoted = true - break - } else { - value.WriteByte(s[i]) - i++ - } - } - - if !quoted { - return nil, fmt.Errorf("unclosed quote for key %q", key) - } - - params[key] = value.String() - } - - return params, nil -} diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index c5f2585..313124e 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -1,46 +1,36 @@ package codingcontext -// Markdown represents a markdown file with frontmatter and content -type Markdown[T any] struct { - FrontMatter T // Parsed YAML frontmatter - Content string // Expanded content of the markdown - Tokens int // Estimated token count -} - -// TaskMarkdown is a Markdown with TaskFrontMatter -type TaskMarkdown = Markdown[TaskFrontMatter] +import ( + "maps" -// RuleMarkdown is a Markdown with RuleFrontMatter -type RuleMarkdown = Markdown[RuleFrontMatter] + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" +) // Result holds the assembled context from running a task type Result struct { - Rules []Markdown[RuleFrontMatter] // List of included rule files - Task Markdown[TaskFrontMatter] // Task file with frontmatter and content - Tokens int // Total token count - Agent Agent // The agent used (from task or -a flag) + Rules []markdown.Markdown[markdown.RuleFrontMatter] // List of included rule files + Task markdown.Markdown[markdown.TaskFrontMatter] // Task file with frontmatter and content + Tokens int // Total token count + Agent Agent // The agent used (from task or -a flag) } // MCPServers returns all MCP servers from both rules and the task. // Servers from the task take precedence over servers from rules. // If multiple rules define the same server name, the behavior is non-deterministic. -func (r *Result) MCPServers() MCPServerConfigs { - servers := make(MCPServerConfigs) +func (r *Result) MCPServers() mcp.MCPServerConfigs { + servers := make(mcp.MCPServerConfigs) // Add servers from rules first (so task can override) for _, rule := range r.Rules { if rule.FrontMatter.MCPServers != nil { - for name, config := range rule.FrontMatter.MCPServers { - servers[name] = config - } + maps.Copy(servers, rule.FrontMatter.MCPServers) } } // Add servers from task (overriding any from rules) if r.Task.FrontMatter.MCPServers != nil { - for name, config := range r.Task.FrontMatter.MCPServers { - servers[name] = config - } + maps.Copy(servers, r.Task.FrontMatter.MCPServers) } return servers diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index 8cca02d..742a054 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -2,153 +2,156 @@ package codingcontext import ( "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" ) func TestResult_MCPServers(t *testing.T) { tests := []struct { name string result Result - want MCPServerConfigs + want mcp.MCPServerConfigs }{ { name: "no MCP servers", result: Result{ - Rules: []Markdown[RuleFrontMatter]{}, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{}, }, }, - want: MCPServerConfigs{}, + want: mcp.MCPServerConfigs{}, }, { name: "MCP servers from task only", result: Result{ - Rules: []Markdown[RuleFrontMatter]{}, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - "git": {Type: TransportTypeStdio, Command: "git"}, + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "filesystem"}, + "git": {Type: mcp.TransportTypeStdio, Command: "git"}, }, }, }, }, - want: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - "git": {Type: TransportTypeStdio, Command: "git"}, + want: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "filesystem"}, + "git": {Type: mcp.TransportTypeStdio, Command: "git"}, }, }, { name: "MCP servers from rules only", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "jira": {Type: TransportTypeStdio, Command: "jira"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "jira": {Type: mcp.TransportTypeStdio, Command: "jira"}, }, }, }, { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "api": {Type: TransportTypeHTTP, URL: "https://api.example.com"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "api": {Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}, }, }, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{}, }, }, - want: MCPServerConfigs{ - "jira": {Type: TransportTypeStdio, Command: "jira"}, - "api": {Type: TransportTypeHTTP, URL: "https://api.example.com"}, + want: mcp.MCPServerConfigs{ + "jira": {Type: mcp.TransportTypeStdio, Command: "jira"}, + "api": {Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}, }, }, { name: "MCP servers from both task and rules", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "jira": {Type: TransportTypeStdio, Command: "jira"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "jira": {Type: mcp.TransportTypeStdio, Command: "jira"}, }, }, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "filesystem"}, }, }, }, }, - want: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - "jira": {Type: TransportTypeStdio, Command: "jira"}, + want: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "filesystem"}, + "jira": {Type: mcp.TransportTypeStdio, Command: "jira"}, }, }, { name: "multiple rules with MCP servers", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "server1": {Type: TransportTypeStdio, Command: "server1"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "server1": {Type: mcp.TransportTypeStdio, Command: "server1"}, }, }, }, { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "server2": {Type: TransportTypeStdio, Command: "server2"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "server2": {Type: mcp.TransportTypeStdio, Command: "server2"}, }, }, }, { - FrontMatter: RuleFrontMatter{}, + FrontMatter: markdown.RuleFrontMatter{}, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "task-server": {Type: TransportTypeStdio, Command: "task-server"}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "task-server": {Type: mcp.TransportTypeStdio, Command: "task-server"}, }, }, }, }, - want: MCPServerConfigs{ - "task-server": {Type: TransportTypeStdio, Command: "task-server"}, - "server1": {Type: TransportTypeStdio, Command: "server1"}, - "server2": {Type: TransportTypeStdio, Command: "server2"}, + want: mcp.MCPServerConfigs{ + "task-server": {Type: mcp.TransportTypeStdio, Command: "task-server"}, + "server1": {Type: mcp.TransportTypeStdio, Command: "server1"}, + "server2": {Type: mcp.TransportTypeStdio, Command: "server2"}, }, }, { name: "task overrides rule server with same name", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "rule-filesystem"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "rule-filesystem"}, }, }, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "task-filesystem"}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{ + MCPServers: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "task-filesystem"}, }, }, }, }, - want: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "task-filesystem"}, + want: mcp.MCPServerConfigs{ + "filesystem": {Type: mcp.TransportTypeStdio, Command: "task-filesystem"}, }, }, } diff --git a/pkg/codingcontext/rule_frontmatter.go b/pkg/codingcontext/rule_frontmatter.go deleted file mode 100644 index f0a8583..0000000 --- a/pkg/codingcontext/rule_frontmatter.go +++ /dev/null @@ -1,54 +0,0 @@ -package codingcontext - -import ( - "encoding/json" -) - -// RuleFrontMatter represents the standard frontmatter fields for rule files -type RuleFrontMatter struct { - BaseFrontMatter `yaml:",inline"` - - // TaskNames specifies which task(s) this rule applies to - // Array of task names for OR logic - TaskNames []string `yaml:"task_names,omitempty" json:"task_names,omitempty"` - - // Languages specifies which programming language(s) this rule applies to - // Array of languages for OR logic (e.g., ["go", "python"]) - Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` - - // Agent specifies which AI agent this rule is intended for - Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` - - // MCPServers maps server names to their configurations - // Metadata only, does not filter - MCPServers MCPServerConfigs `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` - - // RuleName is an optional identifier for the rule file - RuleName string `yaml:"rule_name,omitempty" json:"rule_name,omitempty"` - - // ExpandParams controls whether parameter expansion should occur - // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` -} - -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map -func (r *RuleFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion - type Alias RuleFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(r), - } - - if err := json.Unmarshal(data, aux); err != nil { - return err - } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &r.BaseFrontMatter.Content); err != nil { - return err - } - - return nil -} diff --git a/pkg/codingcontext/selectors.go b/pkg/codingcontext/selectors/selectors.go similarity index 94% rename from pkg/codingcontext/selectors.go rename to pkg/codingcontext/selectors/selectors.go index cf6dc4a..b4b91a8 100644 --- a/pkg/codingcontext/selectors.go +++ b/pkg/codingcontext/selectors/selectors.go @@ -1,8 +1,10 @@ -package codingcontext +package selectors import ( "fmt" "strings" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" ) // Selectors stores selector key-value pairs where values are stored in inner maps @@ -87,7 +89,7 @@ func (s *Selectors) GetValue(key, value string) bool { // Multiple values for the same key use OR logic (matches if frontmatter value is in the inner map). // This enables combining CLI selectors (-s flag) with task frontmatter selectors: // both are added to the same Selectors map, creating an OR condition for rules to match. -func (includes *Selectors) MatchesIncludes(frontmatter BaseFrontMatter) bool { +func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) bool { for key, values := range *includes { fmValue, exists := frontmatter.Content[key] if !exists { diff --git a/pkg/codingcontext/selector_map_test.go b/pkg/codingcontext/selectors/selectors_test.go similarity index 75% rename from pkg/codingcontext/selector_map_test.go rename to pkg/codingcontext/selectors/selectors_test.go index cb19f61..c99b6b9 100644 --- a/pkg/codingcontext/selector_map_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -1,7 +1,9 @@ -package codingcontext +package selectors import ( "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" ) func TestSelectorMap_Set(t *testing.T) { @@ -80,61 +82,61 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { name string selectors []string setupSelectors func(s Selectors) // Optional function to set up array selectors directly - frontmatter BaseFrontMatter + frontmatter markdown.BaseFrontMatter wantMatch bool }{ { name: "single selector - match", selectors: []string{"env=production"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: true, }, { name: "single selector - no match", selectors: []string{"env=production"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "development"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "development"}}, wantMatch: false, }, { name: "single selector - key missing (allowed)", selectors: []string{"env=production"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, wantMatch: true, }, { name: "multiple selectors - all match", selectors: []string{"env=production", "language=go"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, wantMatch: true, }, { name: "multiple selectors - one doesn't match", selectors: []string{"env=production", "language=go"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, wantMatch: false, }, { name: "multiple selectors - one key missing (allowed)", selectors: []string{"env=production", "language=go"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: true, }, { name: "empty selectors - always match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: true, }, { name: "boolean value conversion - match", selectors: []string{"is_active=true"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"is_active": true}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"is_active": true}}, wantMatch: true, }, { name: "array selector - match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -145,7 +147,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "array selector - no match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, wantMatch: false, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -156,7 +158,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "array selector - key missing (allowed)", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "prod"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -166,7 +168,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "mixed selectors - array and string both match", selectors: []string{"env=prod"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -176,7 +178,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "mixed selectors - string doesn't match", selectors: []string{"env=dev"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, wantMatch: false, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -186,7 +188,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "multiple array selectors - both match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "go"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -198,7 +200,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "multiple array selectors - one doesn't match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "java"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "java"}}, wantMatch: false, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -210,25 +212,25 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "OR logic - same key multiple values matches", selectors: []string{"env=prod", "env=dev"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "dev"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "dev"}}, wantMatch: true, }, { name: "OR logic - same key multiple values no match", selectors: []string{"env=prod", "env=dev"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "staging"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "staging"}}, wantMatch: false, }, { name: "empty value selector - key exists in frontmatter (no match)", selectors: []string{"env="}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: false, }, { name: "empty value selector - key missing in frontmatter (match)", selectors: []string{"env="}, - frontmatter: BaseFrontMatter{Content: map[string]any{"language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, wantMatch: true, }, } diff --git a/pkg/codingcontext/task_frontmatter.go b/pkg/codingcontext/task_frontmatter.go deleted file mode 100644 index 7d8d5d9..0000000 --- a/pkg/codingcontext/task_frontmatter.go +++ /dev/null @@ -1,66 +0,0 @@ -package codingcontext - -import ( - "encoding/json" -) - -// TaskFrontMatter represents the standard frontmatter fields for task files -type TaskFrontMatter struct { - BaseFrontMatter `yaml:",inline"` - - // Agent specifies the default agent if not specified via -a flag - // This is not used for selecting tasks or rules, only as a default - Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` - - // Languages specifies the programming language(s) for filtering rules - // Array of languages for OR logic (e.g., ["go", "python"]) - Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` - - // Model specifies the AI model identifier - // Does not filter rules, metadata only - Model string `yaml:"model,omitempty" json:"model,omitempty"` - - // SingleShot indicates whether the task runs once or multiple times - // Does not filter rules, metadata only - SingleShot bool `yaml:"single_shot,omitempty" json:"single_shot,omitempty"` - - // Timeout specifies the task timeout in time.Duration format (e.g., "10m", "1h") - // Does not filter rules, metadata only - Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` - - // MCPServers maps server names to their configurations - // Does not filter rules, metadata only - MCPServers MCPServerConfigs `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` - - // Resume indicates if this task should be resumed - Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` - - // Selectors contains additional custom selectors for filtering rules - Selectors map[string]any `yaml:"selectors,omitempty" json:"selectors,omitempty"` - - // ExpandParams controls whether parameter expansion should occur - // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` -} - -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map -func (t *TaskFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion - type Alias TaskFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(t), - } - - if err := json.Unmarshal(data, aux); err != nil { - return err - } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &t.BaseFrontMatter.Content); err != nil { - return err - } - - return nil -} diff --git a/pkg/codingcontext/task_parser.go b/pkg/codingcontext/task_parser.go deleted file mode 100644 index 232b8e5..0000000 --- a/pkg/codingcontext/task_parser.go +++ /dev/null @@ -1,188 +0,0 @@ -package codingcontext - -import ( - "strconv" - "strings" - - "github.com/alecthomas/participle/v2" - "github.com/alecthomas/participle/v2/lexer" -) - -// Input is the top-level wrapper for parsing -type Input struct { - Blocks []Block `parser:"@@*"` -} - -// Task represents a parsed task, which is a sequence of blocks -type Task []Block - -// Block represents either a slash command or text content -type Block struct { - SlashCommand *SlashCommand `parser:"@@"` - Text *Text `parser:"| @@"` -} - -// SlashCommand represents a command starting with "/" that ends with a newline or EOF -// The newline is optional to handle EOF, but when present, prevents matching inline slashes -type SlashCommand struct { - Name string `parser:"Slash @Term"` - Arguments []Argument `parser:"(Whitespace @@)* Whitespace? Newline?"` -} - -// Params converts the slash command's arguments into a parameter map -// Returns a map with: -// - "ARGUMENTS": space-separated string of all arguments -// - "1", "2", etc.: positional parameters (1-indexed) -// - named parameters: key-value pairs from key="value" arguments -func (s *SlashCommand) Params() map[string]string { - params := make(map[string]string) - - // Build the ARGUMENTS string from all arguments - if len(s.Arguments) > 0 { - var argStrings []string - for _, arg := range s.Arguments { - if arg.Key != "" { - // Named parameter: key="value" - argStrings = append(argStrings, arg.Key+"="+arg.Value) - } else { - // Positional parameter - argStrings = append(argStrings, arg.Value) - } - } - params["ARGUMENTS"] = strings.Join(argStrings, " ") - } - - // Add positional and named parameters - for i, arg := range s.Arguments { - // Positional parameter (1-indexed) - posKey := strconv.Itoa(i + 1) - if arg.Key != "" { - // This is a named parameter - store as key="value" for positional - params[posKey] = arg.Key + "=" + arg.Value - // Also store the value under the key name (strip quotes if present) - params[arg.Key] = stripQuotes(arg.Value) - } else { - // Pure positional parameter (strip quotes if present) - params[posKey] = stripQuotes(arg.Value) - } - } - - return params -} - -// stripQuotes removes surrounding double quotes from a string if present. -// Single quotes are not supported as the grammar only allows double-quoted strings. -func stripQuotes(s string) string { - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - // Remove quotes and handle escaped quotes inside - unquoted := s[1 : len(s)-1] - return strings.ReplaceAll(unquoted, `\"`, `"`) - } - return s -} - -// Argument represents either a named (key=value) or positional argument -type Argument struct { - Key string `parser:"(@Term Assign)?"` - Value string `parser:"(@String | @Term)"` -} - -// Text represents a block of text -// It can span multiple lines, consuming line content and newlines -// But it will stop before a newline that's followed by a slash (potential command) -type Text struct { - Lines []TextLine `parser:"@@+"` -} - -// TextLine is a single line of text content (not starting with a slash) -// It matches tokens until the end of the line -type TextLine struct { - NonSlashStart []string `parser:"(@Term | @String | @Assign | @Whitespace)"` // First token can't be Slash - RestOfLine []string `parser:"(@Term | @String | @Slash | @Assign | @Whitespace)*"` // Rest can include Slash - NewlineOpt string `parser:"@Newline?"` -} - -// Content returns the text content with all lines concatenated -func (t *Text) Content() string { - var sb strings.Builder - for _, line := range t.Lines { - for _, tok := range line.NonSlashStart { - sb.WriteString(tok) - } - for _, tok := range line.RestOfLine { - sb.WriteString(tok) - } - sb.WriteString(line.NewlineOpt) - } - return sb.String() -} - -// Define the lexer using participle's lexer.MustSimple -var taskLexer = lexer.MustSimple([]lexer.SimpleRule{ - {Name: "Slash", Pattern: `/`}, // Any "/" - {Name: "Assign", Pattern: `=`}, // "=" - {Name: "String", Pattern: `"(?:\\.|[^"])*"`}, // Quoted strings with escapes - {Name: "Whitespace", Pattern: `[ \t]+`}, // Spaces and tabs (horizontal only) - {Name: "Newline", Pattern: `[\n\r]+`}, // Newlines - {Name: "Term", Pattern: `[^ \t\n\r/"=]+`}, // Any char except space, newline, /, ", = -}) - -var parser = participle.MustBuild[Input]( - participle.Lexer(taskLexer), - participle.UseLookahead(4), // Use lookahead to help distinguish Text from SlashCommand patterns -) - -// ParseTask parses a task string into a Task structure -func ParseTask(text string) (Task, error) { - input, err := parser.ParseString("", text) - if err != nil { - return nil, err - } - return Task(input.Blocks), nil -} - -// String returns the original text representation of a task -func (t Task) String() string { - var sb strings.Builder - for _, block := range t { - sb.WriteString(block.String()) - } - return sb.String() -} - -// String returns the original text representation of a block -func (b Block) String() string { - if b.SlashCommand != nil { - return b.SlashCommand.String() - } - if b.Text != nil { - return b.Text.String() - } - return "" -} - -// String returns the original text representation of a slash command -func (s SlashCommand) String() string { - var sb strings.Builder - sb.WriteString("/") - sb.WriteString(s.Name) - for _, arg := range s.Arguments { - sb.WriteString(" ") - sb.WriteString(arg.String()) - } - sb.WriteString("\n") - return sb.String() -} - -// String returns the original text representation of an argument -func (a Argument) String() string { - if a.Key != "" { - return a.Key + "=" + a.Value - } - return a.Value -} - -// String returns the original text representation of text -func (t Text) String() string { - return t.Content() -} diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/taskparser/expander.go similarity index 84% rename from pkg/codingcontext/expander.go rename to pkg/codingcontext/taskparser/expander.go index e696e16..4f3baee 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/taskparser/expander.go @@ -1,8 +1,7 @@ -package codingcontext +package taskparser import ( "fmt" - "log/slog" "os" "os/exec" "strings" @@ -14,7 +13,7 @@ import ( // 3. Path expansion: @path // SECURITY: Processes rune-by-rune to prevent injection attacks where expanded // content contains further expansion sequences (e.g., command output with ${param}). -func expand(content string, params map[string]string, logger *slog.Logger) string { +func (p Params) Expand(content string) (string, error) { var result strings.Builder result.Grow(len(content)) runes := []rune(content) @@ -31,10 +30,9 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin if end < len(runes) { // Extract parameter name paramName := string(runes[i+2 : end]) - if val, ok := params[paramName]; ok { + if val, ok := p.Lookup(paramName); ok { result.WriteString(val) } else { - logger.Warn("parameter not found", "param", paramName) result.WriteString(string(runes[i : end+1])) } i = end + 1 @@ -53,10 +51,7 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin // Extract command command := string(runes[i+2 : end]) cmd := exec.Command("sh", "-c", command) - output, err := cmd.CombinedOutput() - if err != nil { - logger.Warn("command expansion failed", "command", command, "error", err) - } + output, _ := cmd.CombinedOutput() // Write command output (even if command failed, output may contain error info) result.WriteString(string(output)) i = end + 1 @@ -89,8 +84,7 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin path := unescapePath(string(runes[pathStart:pathEnd])) // Validate the path - if err := validatePath(path); err != nil { - logger.Warn("path validation failed", "path", path, "error", err) + if err := ValidatePath(path); err != nil { // Return the original @path if validation fails result.WriteString(string(runes[i:pathEnd])) i = pathEnd @@ -100,7 +94,6 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin // Read the file fileContent, err := os.ReadFile(path) if err != nil { - logger.Warn("path expansion failed", "path", path, "error", err) // Return the original @path if file doesn't exist result.WriteString(string(runes[i:pathEnd])) } else { @@ -118,7 +111,7 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin i++ } - return result.String() + return result.String(), nil } // isWhitespaceRune checks if a rune is whitespace (space, tab, newline, carriage return) @@ -131,11 +124,11 @@ func unescapePath(path string) string { return strings.ReplaceAll(path, "\\ ", " ") } -// validatePath validates a file path for basic safety checks. +// ValidatePath validates a file path for basic safety checks. // Note: This tool is designed to work with user-created markdown files in their // workspace and grants read access to files the user can read. The primary // defense is that users should only use trusted markdown files. -func validatePath(path string) error { +func ValidatePath(path string) error { // Check for null bytes which are never valid in file paths if strings.Contains(path, "\x00") { return fmt.Errorf("path contains null byte") diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/taskparser/expander_test.go similarity index 81% rename from pkg/codingcontext/expander_test.go rename to pkg/codingcontext/taskparser/expander_test.go index 663e00e..ad5d325 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/taskparser/expander_test.go @@ -1,77 +1,79 @@ -package codingcontext +package taskparser_test import ( - "log/slog" "os" "path/filepath" "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/stretchr/testify/require" ) func TestExpandParameters(t *testing.T) { tests := []struct { name string - params Params + params taskparser.Params content string expected string }{ { name: "single parameter expansion", - params: Params{"name": "Alice"}, + params: taskparser.Params{"name": []string{"Alice"}}, content: "Hello, ${name}!", expected: "Hello, Alice!", }, { name: "multiple parameter expansions", - params: Params{"first": "John", "last": "Doe"}, + params: taskparser.Params{"first": []string{"John"}, "last": []string{"Doe"}}, content: "${first} ${last}", expected: "John Doe", }, { name: "parameter not found - returns unchanged with warning", - params: Params{}, + params: taskparser.Params{}, content: "Value: ${missing}", expected: "Value: ${missing}", }, { name: "mixed found and not found parameters", - params: Params{"found": "yes"}, + params: taskparser.Params{"found": []string{"yes"}}, content: "${found} and ${notfound}", expected: "yes and ${notfound}", }, { name: "no parameters to expand", - params: Params{"key": "value"}, + params: taskparser.Params{"key": []string{"value"}}, content: "Plain text without parameters", expected: "Plain text without parameters", }, { name: "parameter with special characters", - params: Params{"path": "/tmp/file.txt"}, + params: taskparser.Params{"path": []string{"/tmp/file.txt"}}, content: "File: ${path}", expected: "File: /tmp/file.txt", }, { name: "unclosed parameter - treated as literal", - params: Params{"name": "value"}, + params: taskparser.Params{"name": []string{"value"}}, content: "Text ${name and more", expected: "Text ${name and more", }, { name: "empty parameter name - expands to empty", - params: Params{"": "value"}, + params: taskparser.Params{"": []string{"value"}}, content: "Text ${} more", expected: "Text value more", }, { name: "parameter at end of string", - params: Params{"end": "final"}, + params: taskparser.Params{"end": []string{"final"}}, content: "Start ${end}", expected: "Start final", }, { name: "nested braces - outer takes precedence", - params: Params{"outer": "value"}, + params: taskparser.Params{"outer": []string{"value"}}, content: "${outer{inner}}", expected: "${outer{inner}}", }, @@ -79,7 +81,10 @@ func TestExpandParameters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := tt.params.Expand(tt.content) + if err != nil { + t.Errorf("expand() = %v, want nil", err) + } if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -158,7 +163,8 @@ func TestExpandCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := (taskparser.Params{}).Expand(tt.content) + require.NoError(t, err) if tt.contains != "" { if !strings.Contains(result, tt.contains) { t.Errorf("expand() = %q, should contain %q", result, tt.contains) @@ -178,17 +184,17 @@ func TestExpandPaths(t *testing.T) { // Create test files testFile1 := filepath.Join(tmpDir, "test1.txt") - if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil { + if err := os.WriteFile(testFile1, []byte("content1"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } testFile2 := filepath.Join(tmpDir, "test2.txt") - if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil { + if err := os.WriteFile(testFile2, []byte("content2"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } testFileWithSpace := filepath.Join(tmpDir, "test file.txt") - if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0644); err != nil { + if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0o644); err != nil { t.Fatalf("failed to create test file with space: %v", err) } @@ -256,7 +262,8 @@ func TestExpandPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := (taskparser.Params{}).Expand(tt.content) + require.NoError(t, err) if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -269,43 +276,43 @@ func TestExpandCombined(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "data.txt") - if err := os.WriteFile(testFile, []byte("file-${param}"), 0644); err != nil { + if err := os.WriteFile(testFile, []byte("file-${param}"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } tests := []struct { name string - params Params + params taskparser.Params content string expected string }{ { name: "combined expansions - command, path, parameter", - params: Params{"name": "World"}, + params: taskparser.Params{"name": []string{"World"}}, content: "!`echo Hello` ${name} from @" + testFile, expected: "Hello\n World from file-${param}", }, { name: "file content NOT re-expanded (security fix)", - params: Params{"param": "replaced"}, + params: taskparser.Params{"param": []string{"replaced"}}, content: "@" + testFile, expected: "file-${param}", // Changed: file content is not re-expanded }, { name: "command output NOT re-expanded (security fix)", - params: Params{"dynamic": "value"}, + params: taskparser.Params{"dynamic": []string{"value"}}, content: "!`echo '${dynamic}'`", expected: "${dynamic}\n", // Changed: command output is not re-expanded }, { name: "all expansion types together", - params: Params{"x": "X", "y": "Y"}, + params: taskparser.Params{"x": []string{"X"}, "y": []string{"Y"}}, content: "${x} !`echo middle` ${y}", expected: "X middle\n Y", }, { name: "no expansions needed", - params: Params{}, + params: taskparser.Params{}, content: "plain text", expected: "plain text", }, @@ -313,7 +320,8 @@ func TestExpandCombined(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := tt.params.Expand(tt.content) + require.NoError(t, err) if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -324,10 +332,10 @@ func TestExpandCombined(t *testing.T) { func TestExpandBasic(t *testing.T) { // Test basic expansion functionality content := "Hello ${name}!" - params := Params{"name": "World"} - logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + params := taskparser.Params{"name": []string{"World"}} - result := expand(content, params, logger) + result, err := params.Expand(content) + require.NoError(t, err) expected := "Hello World!" if result != expected { t.Errorf("expand() = %q, want %q", result, expected) @@ -374,7 +382,7 @@ func TestValidatePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validatePath(tt.path) + err := taskparser.ValidatePath(tt.path) if (err != nil) != tt.wantErr { t.Errorf("validatePath() error = %v, wantErr %v", err, tt.wantErr) } @@ -385,35 +393,35 @@ func TestValidatePath(t *testing.T) { func TestExpandSecurityNoReExpansion(t *testing.T) { tests := []struct { name string - params Params + params taskparser.Params content string expected string desc string }{ { name: "parameter value with command syntax not expanded", - params: Params{"evil": "!`echo INJECTED`"}, + params: taskparser.Params{"evil": []string{"!`echo INJECTED`"}}, content: "Value: ${evil}", expected: "Value: !`echo INJECTED`", desc: "Parameter containing command syntax should not be executed", }, { name: "parameter value with path syntax not expanded", - params: Params{"path": "@/etc/passwd"}, + params: taskparser.Params{"path": []string{"@/etc/passwd"}}, content: "Path: ${path}", expected: "Path: @/etc/passwd", desc: "Parameter containing path syntax should not be read", }, { name: "command output with parameter syntax not expanded", - params: Params{"secret": "SECRET"}, + params: taskparser.Params{"secret": []string{"SECRET"}}, content: "!`echo '${secret}'`", expected: "${secret}\n", desc: "Command output containing parameter syntax should not be expanded", }, { name: "command output with path syntax not expanded", - params: Params{}, + params: taskparser.Params{}, content: "!`echo '@/etc/passwd'`", expected: "@/etc/passwd\n", desc: "Command output containing path syntax should not be read", @@ -422,7 +430,8 @@ func TestExpandSecurityNoReExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := tt.params.Expand(tt.content) + require.NoError(t, err) if result != tt.expected { t.Errorf("Security test failed: %s\nexpand() = %q, want %q", tt.desc, result, tt.expected) } diff --git a/pkg/codingcontext/taskparser/grammar.go b/pkg/codingcontext/taskparser/grammar.go new file mode 100644 index 0000000..c378e85 --- /dev/null +++ b/pkg/codingcontext/taskparser/grammar.go @@ -0,0 +1,128 @@ +package taskparser + +import ( + "github.com/alecthomas/participle/v2" + "github.com/alecthomas/participle/v2/lexer" +) + +// Task represents a parsed task, which is a sequence of blocks +type Task []Block + +// Input is the top-level wrapper for parsing +type Input struct { + Blocks []Block `parser:"@@*"` +} + +// Block represents either a slash command or text content +type Block struct { + SlashCommand *SlashCommand `parser:"@@"` + Text *Text `parser:"| @@"` +} + +// SlashCommand represents a command starting with "/" that ends with a newline or EOF +// The newline is optional to handle EOF, but when present, prevents matching inline slashes +// Leading whitespace is optional to allow indented commands +type SlashCommand struct { + LeadingWhitespace string `parser:"Whitespace?"` + Name string `parser:"Slash @Term"` + Arguments []Argument `parser:"(Whitespace @@)* Whitespace? Newline?"` +} + +// Argument represents either a named (key=value) or positional argument +type Argument struct { + Key string `parser:"(@Term Assign)?"` + Value string `parser:"(@String | @Term)"` +} + +// Text represents a block of text +// It can span multiple lines, consuming line content and newlines +// But it will stop before a newline that's followed by a slash (potential command) +type Text struct { + Lines []TextLine `parser:"@@+"` +} + +// TextLine is a single line of text content (not starting with a slash) +// It matches tokens until the end of the line +type TextLine struct { + NonSlashStart []string `parser:"(@Term | @String | @Assign | @Whitespace)"` // First token can't be Slash + RestOfLine []string `parser:"(@Term | @String | @Slash | @Assign | @Whitespace)*"` // Rest can include Slash + NewlineOpt string `parser:"@Newline?"` +} + +// Define the lexer using participle's lexer.MustSimple +var taskLexer = lexer.MustSimple([]lexer.SimpleRule{ + {Name: "Slash", Pattern: `/`}, // Any "/" + {Name: "Assign", Pattern: `=`}, // "=" + {Name: "String", Pattern: `"(?:\\.|[^"])*"`}, // Quoted strings with escapes + {Name: "Whitespace", Pattern: `[ \t]+`}, // Spaces and tabs (horizontal only) + {Name: "Newline", Pattern: `[\n\r]+`}, // Newlines + {Name: "Term", Pattern: `[^ \t\n\r/"=]+`}, // Any char except space, newline, /, ", = +}) + +func parser() *participle.Parser[Input] { + return participle.MustBuild[Input]( + participle.Lexer(taskLexer), + participle.UseLookahead(4), // Use lookahead to help distinguish Text from SlashCommand patterns + ) +} + +// ========== PARAMS GRAMMAR ========== + +// ParamsInput is the top-level structure for parsing parameters +// It now directly parses into named parameters and positional arguments +type ParamsInput struct { + Items []ParamsItem `parser:"@@*"` +} + +// ParamsItem represents either a named parameter or a positional argument +type ParamsItem struct { + Pos lexer.Position + Separator *Separator `parser:"@@"` // Whitespace or comma + Named *NamedParam `parser:"| @@"` // key=value + Positional *Value `parser:"| @@"` // standalone value +} + +// Separator represents whitespace or comma separators +type Separator struct { + Pos lexer.Position + Val string `parser:"(@Whitespace | @Comma)"` +} + +// NamedParam represents a key=value pair +type NamedParam struct { + Pos lexer.Position + Key string `parser:"@Token"` + PreEqualsSpace *string `parser:"Whitespace?"` + Equals string `parser:"@Assign"` + PostEqualsSpace *string `parser:"Whitespace?"` + Value *Value `parser:"@@?"` // Optional to handle empty values like key= +} + +// Value represents a parsed value (quoted or unquoted) +// The Raw field captures the entire token including quotes and escapes +type Value struct { + Pos lexer.Position + Raw string `parser:"(@QuotedDouble | @QuotedSingle | @Token)"` +} + +// paramsLexer defines the lexer for parsing parameters +// Using a simpler approach: capture entire quoted strings and unquoted tokens +var paramsLexer = lexer.MustSimple([]lexer.SimpleRule{ + {Name: "Whitespace", Pattern: `[\s\p{Z}]+`}, // Match ASCII and Unicode whitespace + {Name: "Comma", Pattern: `,`}, + {Name: "Assign", Pattern: `=`}, + // Quoted strings - capture entire string including quotes + // Handles escaped quotes inside + {Name: "QuotedDouble", Pattern: `"(?:\\.|[^"\\])*"`}, + {Name: "QuotedSingle", Pattern: `'(?:\\.|[^'\\])*'`}, + // Unquoted token - matches any sequence of non-delimiter characters + // Can contain escape sequences + {Name: "Token", Pattern: `(?:\\.|[^\s\p{Z},="'\\])+`}, +}) + +func paramsParser() *participle.Parser[ParamsInput] { + return participle.MustBuild[ParamsInput]( + participle.Lexer(paramsLexer), + participle.UseLookahead(3), // Lookahead to distinguish key=value from positional + ) +} diff --git a/pkg/codingcontext/param_map_test.go b/pkg/codingcontext/taskparser/param_map_test.go similarity index 50% rename from pkg/codingcontext/param_map_test.go rename to pkg/codingcontext/taskparser/param_map_test.go index 46a8297..178e629 100644 --- a/pkg/codingcontext/param_map_test.go +++ b/pkg/codingcontext/taskparser/param_map_test.go @@ -1,8 +1,12 @@ -package codingcontext +package taskparser_test import ( "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParams_Set(t *testing.T) { @@ -21,8 +25,8 @@ func TestParams_Set(t *testing.T) { wantErr: false, }, { - name: "key=value with equals in value", - value: "key=value=with=equals", + name: "key=value with equals in value (requires quotes)", + value: `key="value=with=equals"`, wantKey: "key", wantVal: "value=with=equals", wantErr: false, @@ -35,30 +39,36 @@ func TestParams_Set(t *testing.T) { wantErr: false, }, { - name: "invalid format - no equals", + name: "positional argument - no equals", value: "keyvalue", - wantErr: true, + wantVal: "keyvalue", + wantErr: false, }, { - name: "invalid format - only key", + name: "positional argument - only key", value: "key", - wantErr: true, + wantVal: "key", + wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := Params{} + p := taskparser.Params{} err := p.Set(tt.value) - if (err != nil) != tt.wantErr { - t.Errorf("Params.Set() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !tt.wantErr { - if p[tt.wantKey] != tt.wantVal { - t.Errorf("Params[%q] = %q, want %q", tt.wantKey, p[tt.wantKey], tt.wantVal) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + if tt.wantKey != "" { + // Named parameter + assert.Equal(t, tt.wantVal, p.Value(tt.wantKey)) + } else { + // Positional argument + args := p.Arguments() + require.NotEmpty(t, args, "expected positional arguments") + assert.Equal(t, tt.wantVal, args[0]) } } }) @@ -66,9 +76,9 @@ func TestParams_Set(t *testing.T) { } func TestParams_String(t *testing.T) { - p := Params{ - "key1": "value1", - "key2": "value2", + p := taskparser.Params{ + "key1": []string{"value1"}, + "key2": []string{"value2"}, } s := p.String() if s == "" { @@ -77,99 +87,86 @@ func TestParams_String(t *testing.T) { } func TestParams_SetMultiple(t *testing.T) { - p := Params{} - - if err := p.Set("key1=value1"); err != nil { - t.Fatalf("Params.Set() failed: %v", err) - } - if err := p.Set("key2=value2"); err != nil { - t.Fatalf("Params.Set() failed: %v", err) - } - - if len(p) != 2 { - t.Errorf("Params length = %d, want 2", len(p)) - } - if p["key1"] != "value1" { - t.Errorf("Params[key1] = %q, want %q", p["key1"], "value1") - } - if p["key2"] != "value2" { - t.Errorf("Params[key2] = %q, want %q", p["key2"], "value2") - } + p, err := taskparser.ParseParams("key1=value1, key2=value2") + require.NoError(t, err) + assert.Len(t, p, 2) + assert.Equal(t, "value1", p.Value("key1")) + assert.Equal(t, "value2", p.Value("key2")) } func TestParseParams(t *testing.T) { tests := []struct { name string input string - expected Params + expected taskparser.Params wantError bool errorMsg string }{ { name: "empty string", input: "", - expected: Params{}, + expected: taskparser.Params{}, wantError: false, }, { name: "single quoted key=value", input: `key="value"`, - expected: Params{"key": "value"}, + expected: taskparser.Params{"key": []string{"value"}}, wantError: false, }, { name: "multiple quoted key=value pairs", input: `key1="value1" key2="value2" key3="value3"`, - expected: Params{"key1": "value1", "key2": "value2", "key3": "value3"}, + expected: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}, "key3": []string{"value3"}}, wantError: false, }, { name: "double-quoted value with spaces", input: `key1="value with spaces" key2="value2"`, - expected: Params{"key1": "value with spaces", "key2": "value2"}, + expected: taskparser.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}}, wantError: false, }, { name: "escaped double quotes", input: `key1="value with \"escaped\" quotes"`, - expected: Params{"key1": `value with "escaped" quotes`}, + expected: taskparser.Params{"key1": []string{`value with "escaped" quotes`}}, wantError: false, }, { name: "value with equals sign in quotes", input: `key1="value=with=equals" key2="normal"`, - expected: Params{"key1": "value=with=equals", "key2": "normal"}, + expected: taskparser.Params{"key1": []string{"value=with=equals"}, "key2": []string{"normal"}}, wantError: false, }, { name: "empty quoted value", input: `key1="" key2="value2"`, - expected: Params{"key1": "", "key2": "value2"}, + expected: taskparser.Params{"key1": []string{""}, "key2": []string{"value2"}}, wantError: false, }, { name: "whitespace around equals", input: `key1 = "value1" key2="value2"`, - expected: Params{"key1": "value1", "key2": "value2"}, + expected: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}}, wantError: false, }, { name: "quoted value with spaces and equals", input: `key1="value with spaces and = signs"`, - expected: Params{"key1": "value with spaces and = signs"}, + expected: taskparser.Params{"key1": []string{"value with spaces and = signs"}}, wantError: false, }, { name: "unquoted value - error", input: `key1=value1`, - wantError: true, - errorMsg: "unquoted value", + expected: taskparser.Params{"key1": []string{"value1"}}, + wantError: false, }, { - name: "mixed quoted and unquoted - error", + name: "mixed quoted and unquoted", input: `key1="quoted value" key2=unquoted`, - wantError: true, - errorMsg: "unquoted value", + expected: taskparser.Params{"key1": []string{"quoted value"}, "key2": []string{"unquoted"}}, + wantError: false, }, { name: "unclosed quote - error", @@ -178,30 +175,26 @@ func TestParseParams(t *testing.T) { errorMsg: "unclosed quote", }, { - name: "missing value after equals - error", - input: `key1= key2="value2"`, - wantError: true, - errorMsg: "unquoted value", + name: "missing value after equals with comma separator", + input: `key1=, key2="value2"`, + expected: taskparser.Params{"key1": []string{}, "key2": []string{"value2"}}, + wantError: false, }, { - name: "single quote not supported - error", + name: "single quotes", input: `key1='value'`, - wantError: true, - errorMsg: "unquoted value", + expected: taskparser.Params{"key1": []string{"value"}}, + wantError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := ParseParams(tt.input) - - if (err != nil) != tt.wantError { - t.Errorf("ParseParams() error = %v, wantError %v", err, tt.wantError) - return - } + result, err := taskparser.ParseParams(tt.input) if tt.wantError { - if err != nil && tt.errorMsg != "" { + require.Error(t, err) + if tt.errorMsg != "" { if !strings.Contains(err.Error(), tt.errorMsg) { t.Errorf("ParseParams() error = %v, want error containing %q", err, tt.errorMsg) } @@ -209,15 +202,10 @@ func TestParseParams(t *testing.T) { return } - if len(result) != len(tt.expected) { - t.Errorf("ParseParams() length = %d, want %d", len(result), len(tt.expected)) - return - } - + require.NoError(t, err) + assert.Len(t, result, len(tt.expected)) for k, v := range tt.expected { - if result[k] != v { - t.Errorf("ParseParams()[%q] = %q, want %q", k, result[k], v) - } + assert.Equal(t, v, result.Values(k)) } }) } diff --git a/pkg/codingcontext/taskparser/params.go b/pkg/codingcontext/taskparser/params.go new file mode 100644 index 0000000..8e50712 --- /dev/null +++ b/pkg/codingcontext/taskparser/params.go @@ -0,0 +1,479 @@ +package taskparser + +import ( + "errors" + "fmt" + "maps" + "strconv" + "strings" +) + +const ( + // ArgumentsKey is the key used to store positional arguments in Params + ArgumentsKey = "ARGUMENTS" +) + +var ( + // ErrEmptyKey is returned when a parameter key is empty + ErrEmptyKey = errors.New("empty key in parameter") + // ErrInvalidEscapeSequence is returned when an escape sequence is invalid + ErrInvalidEscapeSequence = errors.New("invalid escape sequence") + // ErrInvalidFormat is returned when the input format is invalid + ErrInvalidFormat = errors.New("invalid parameter format: missing '='") + // ErrMismatchedQuotes is returned when quotes don't match + ErrMismatchedQuotes = errors.New("mismatched quote types") + // ErrUnclosedQuote is returned when a quoted string is not properly closed + ErrUnclosedQuote = errors.New("unclosed quote") +) + +// Params is a map of string keys to string slice values with +// convenience methods for accessing single and multiple values. +type Params map[string][]string + +// ParseParams parses a parameter string into a Params map that supports both +// named parameters (key-value pairs) and positional arguments. +// +// The function provides a flexible, permissive parser that handles various +// quoting styles, escape sequences, and separators. +// +// Named Parameters: +// +// Basic syntax: key=value +// Multiple pairs can be separated by commas, spaces, or both +// Whitespace around the = sign is optional +// Keys are case-insensitive (converted to lowercase) +// The same key can appear multiple times; all values are collected +// +// Examples: +// "key=value" +// "key=value,foo=bar" +// "key = value, foo = bar" +// "key=value1 key=value2 key=value3" // Multiple values for same key +// +// Positional Arguments: +// +// Values without a key are treated as positional arguments and stored +// under the ArgumentsKey constant ("ARGUMENTS"). +// Positional and named parameters can be interleaved. +// +// Examples: +// "value" +// "value1 value2 value3" +// "value1, value2, value3" +// "key=value positional1 positional2" +// "positional1 key=value positional2" +// +// Quoted Values: +// +// Both single and double quotes are supported for values containing +// special characters. Quotes can be escaped within matching quote types. +// Empty quoted values create a value with an empty string. +// +// Examples: +// `key="string value"` +// `key='string value'` +// `key="value=with=equals"` +// `key="value,with,commas"` +// `key="bar\"baz\""` // Escaped quotes +// +// Unquoted Values: +// +// Unquoted values cannot contain spaces (spaces separate arguments). +// Values containing =, ,, or spaces should be quoted. +// Trailing whitespace is trimmed from unquoted values. +// +// Escape Sequences: +// +// Escape sequences work in both quoted and unquoted contexts: +// Standard: \n (newline), \t (tab), \r (carriage return), \\ +// (backslash), \" (double quote), \' (single quote) +// Numeric: \xHH (hex), \uHHHH (Unicode), \OOO (octal, 1-3 digits) +// Other: Any other escape returns the character after backslash +// +// Examples: +// `key="line1\nline2\ttabbed"` +// `key="\x41\x42"` // "AB" +// `key="\u00a0"` // Non-breaking space +// +// Separators: +// +// Multiple separators are supported: commas, spaces, or both. +// Trailing separators are ignored. +// +// Examples: +// "key=value,foo=bar" +// "key=value foo=bar" +// "key=value, foo=bar, baz=qux" +// +// Empty Values: +// +// Unquoted empty: key= creates an empty slice []string{} +// Quoted empty: key="" or key='' creates []string{""} +// +// Unicode Support: +// +// Full Unicode and UTF-8 support for keys and values. +// Unicode whitespace is recognized as separators. +// All unicode whitespace is automatically trimmed from start/end of values. +// +// Examples: +// "ключ=значение" +// "key=こんにちは" +// "emoji=🚀" +// +// Error Conditions: +// +// Returns errors for: +// - Unclosed quotes: key="unclosed +// - Empty keys: =value +// - Invalid escape sequences: incomplete or invalid hex/unicode escapes +// - Mismatched quotes: key='value" or key="value' +// +// Return Value: +// +// The returned Params map has: +// - Named parameters: lowercase keys with string slice values +// - Positional arguments: stored under ArgumentsKey ("ARGUMENTS") +// +// Example: +// params, _ := ParseParams("key=value1 key=value2 arg1 arg2") +// // params["key"] = []string{"value1", "value2"} +// // params[ArgumentsKey] = []string{"arg1", "arg2"} +// +// See the Params type methods (Value, Values, Arguments, Lookup) for +// convenient access to parsed parameters. +func ParseParams(value string) (Params, error) { + params := make(Params) + + // Handle empty input + value = strings.TrimSpace(value) + if value == "" { + return params, nil + } + + // Check for unclosed quotes + if err := validateQuotes(value); err != nil { + return nil, err + } + + // Parse using Participle + input, err := paramsParser().ParseString("", value) + if err != nil { + return nil, err + } + + // Convert parsed structure to Params map + return convertToParams(input) +} + +// convertToParams converts the parsed AST to Params map +func convertToParams(input *ParamsInput) (Params, error) { + params := make(Params) + + for _, item := range input.Items { + // Skip separators + if item.Separator != nil { + continue + } + + // Handle named parameters + if item.Named != nil { + key := strings.ToLower(item.Named.Key) + if key == "" { + return nil, ErrEmptyKey + } + + // Handle empty value (key= vs key="") + if item.Named.Value == nil { + // Empty unquoted value: key= + if params[key] == nil { + params[key] = []string{} + } + } else { + value, wasQuoted, err := extractValue(item.Named.Value) + if err != nil { + return nil, err + } + + // Add value if quoted (even if empty) or non-empty + if wasQuoted || value != "" { + params[key] = append(params[key], value) + } else if params[key] == nil { + // Empty unquoted value + params[key] = []string{} + } + } + continue + } + + // Handle positional arguments + if item.Positional != nil { + value, _, err := extractValue(item.Positional) + if err != nil { + return nil, err + } + if params[ArgumentsKey] == nil { + params[ArgumentsKey] = []string{} + } + params[ArgumentsKey] = append(params[ArgumentsKey], value) + } + } + + return params, nil +} + +// extractValue extracts the string value from a Value node +// Returns the value, whether it was quoted, and any error +func extractValue(val *Value) (string, bool, error) { + raw := val.Raw + + // Check if it's a quoted string + if len(raw) >= 2 { + if (raw[0] == '"' && raw[len(raw)-1] == '"') || (raw[0] == '\'' && raw[len(raw)-1] == '\'') { + // Quoted value - extract content and process escapes + content := raw[1 : len(raw)-1] + processed, err := processEscapes(content) + if err != nil { + return "", true, err + } + return strings.TrimSpace(processed), true, nil + } + } + + // Unquoted value - process escapes + processed, err := processEscapes(raw) + if err != nil { + return "", false, err + } + return strings.TrimSpace(processed), false, nil +} + +// processEscapes processes all escape sequences in a string +func processEscapes(s string) (string, error) { + if !strings.Contains(s, "\\") { + // Fast path: no escapes + return s, nil + } + + var result strings.Builder + result.Grow(len(s)) // Pre-allocate + + for i := 0; i < len(s); i++ { + if s[i] != '\\' { + result.WriteByte(s[i]) + continue + } + + // Handle escape sequence + if i+1 >= len(s) { + // Incomplete escape at end - treat as literal backslash + result.WriteByte('\\') + continue + } + + next := s[i+1] + switch next { + case 'n': + result.WriteByte('\n') + i++ + case 't': + result.WriteByte('\t') + i++ + case 'r': + result.WriteByte('\r') + i++ + case '\\': + result.WriteByte('\\') + i++ + case '"': + result.WriteByte('"') + i++ + case '\'': + result.WriteByte('\'') + i++ + case 'u': + // Unicode escape: \uXXXX + if i+5 < len(s) { + hex := s[i+2 : i+6] + val, err := strconv.ParseUint(hex, 16, 16) + if err != nil { + return "", fmt.Errorf("%w: \\u%s", ErrInvalidEscapeSequence, hex) + } + result.WriteRune(rune(val)) + i += 5 + } else { + return "", fmt.Errorf("%w: incomplete \\u escape", ErrInvalidEscapeSequence) + } + case 'x': + // Hex escape: \xHH + if i+3 < len(s) { + hex := s[i+2 : i+4] + val, err := strconv.ParseUint(hex, 16, 8) + if err != nil { + return "", fmt.Errorf("%w: \\x%s", ErrInvalidEscapeSequence, hex) + } + result.WriteByte(byte(val)) + i += 3 + } else { + return "", fmt.Errorf("%w: incomplete \\x escape", ErrInvalidEscapeSequence) + } + case '0', '1', '2', '3', '4', '5', '6', '7': + // Octal escape: \OOO (1-3 digits) + end := i + 2 + for end < len(s) && end < i+4 && s[end] >= '0' && s[end] <= '7' { + end++ + } + octal := s[i+1 : end] + val, err := strconv.ParseUint(octal, 8, 8) + if err != nil { + return "", fmt.Errorf("%w: \\%s", ErrInvalidEscapeSequence, octal) + } + result.WriteByte(byte(val)) + i = end - 1 + default: + // Any other escape - return the character after backslash + result.WriteByte(next) + i++ + } + } + + return result.String(), nil +} + +// validateQuotes checks if all quoted strings in the input are properly closed +func validateQuotes(input string) error { + inDoubleQuote := false + inSingleQuote := false + escapeNext := false + + for _, r := range input { + if escapeNext { + escapeNext = false + continue + } + + if r == '\\' { + escapeNext = true + continue + } + + if r == '"' && !inSingleQuote { + inDoubleQuote = !inDoubleQuote + } else if r == '\'' && !inDoubleQuote { + inSingleQuote = !inSingleQuote + } + } + + if inDoubleQuote || inSingleQuote { + return ErrUnclosedQuote + } + + return nil +} + +func (p Params) Set(value string) error { + // Auto-quote values that need quoting for better CLI UX + quotedValue := autoQuoteParamValue(value) + + params, err := ParseParams(quotedValue) + if err != nil { + return err + } + + maps.Copy(p, params) + + return nil +} + +// autoQuoteParamValue automatically quotes the value part of a key=value parameter +// if it contains characters that require quoting (spaces, commas, equals, etc.) +// and is not already quoted. +func autoQuoteParamValue(input string) string { + equalsIndex := strings.IndexByte(input, '=') + if equalsIndex == -1 || equalsIndex == len(input)-1 { + return input // No = sign or empty value + } + + value := strings.TrimSpace(input[equalsIndex+1:]) + if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'')) { + return input // Already quoted + } + + if needsQuoting(value) { + return input[:equalsIndex+1] + strconv.Quote(value) + } + + return input +} + +// needsQuoting checks if a value contains characters that require quoting +func needsQuoting(value string) bool { + if value == "" { + return false + } + + unicodeWhitespace := "\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000" + for _, r := range value { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' || + r == ',' || r == '=' || r == '"' || r == '\'' || + strings.ContainsRune(unicodeWhitespace, r) { + return true + } + } + return false +} + +func (p Params) String() string { + pairs := make([]string, 0, len(p)) + for key, values := range p { + for _, value := range values { + pairs = append(pairs, key+"="+value) + } + } + + return strings.Join(pairs, ",") +} + +// Value returns the first value for the given key, or an empty string if +// the key is not found or has no values. +func (p Params) Value(key string) string { + if p == nil { + return "" + } + values := p[strings.ToLower(key)] + if len(values) == 0 { + return "" + } + return values[0] +} + +func (p Params) Lookup(key string) (string, bool) { + if p == nil { + return "", false + } + values := p[strings.ToLower(key)] + if len(values) == 0 { + return "", false + } + return values[0], true +} + +// Values returns all values for the given key, or an empty slice if +// the key is not found. +func (p Params) Values(key string) []string { + if p == nil { + return nil + } + return p[strings.ToLower(key)] +} + +// Arguments returns all positional arguments (values without keys), or an empty slice +// if there are no positional arguments. This is distinct from named parameters +// accessed via Value() or Values(). +func (p Params) Arguments() []string { + if p == nil { + return nil + } + return p[ArgumentsKey] +} diff --git a/pkg/codingcontext/taskparser/params_test.go b/pkg/codingcontext/taskparser/params_test.go new file mode 100644 index 0000000..bdc1458 --- /dev/null +++ b/pkg/codingcontext/taskparser/params_test.go @@ -0,0 +1,789 @@ +package taskparser_test + +import ( + "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTaskParameters(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected taskparser.Params + }{ + { + name: "empty string", + input: "", + expected: taskparser.Params{}, + }, + { + name: "whitespace only", + input: " ", + expected: taskparser.Params{}, + }, + { + name: "single pair", + input: "key=value", + expected: taskparser.Params{ + "key": {"value"}, + }, + }, + { + name: "comma separated pairs", + input: "key=value,foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "space separated pairs", + input: "key=value foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "wrapped single quotes", + input: "key=\"'value'\"", + expected: taskparser.Params{ + "key": {"'value'"}, + }, + }, + { + name: "wrapped single quotes", + input: "key='\"value\"'", + expected: taskparser.Params{ + "key": {`"value"`}, + }, + }, + { + name: "mixed separators", + input: "key1=value1, key2=value2 key3=value3", + expected: taskparser.Params{ + "key1": {"value1"}, + "key2": {"value2"}, + "key3": {"value3"}, + }, + }, + { + name: "trailing comma", + input: "key=value,", + expected: taskparser.Params{ + "key": {"value"}, + }, + }, + { + name: "multiple spaces", + input: "key=value foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "whitespace around equals", + input: "key = value, foo = bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "non-breaking spaces trimmed from unquoted values", + input: "key=\u00a0value\u00a0, foo=\u00a0bar\u00a0", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "non-breaking spaces trimmed from quoted values", + input: "key=\"\u00a0value\u00a0\", foo='\u00a0bar\u00a0'", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "unicode escape sequence in quoted value", + input: `key="foo\u00a0", foo='bar\u00a0'`, + expected: taskparser.Params{ + "key": {"foo"}, + "foo": {"bar"}, + }, + }, + { + name: "unicode escape sequence in unquoted value", + input: `key=foo\u00a0, foo=bar\u00a0`, + expected: taskparser.Params{ + "key": {"foo"}, + "foo": {"bar"}, + }, + }, + { + name: "unicode escape sequence with regular characters", + input: `key="test\u00a0value", foo=hello\u0020world`, + expected: taskparser.Params{ + "key": {"test\u00a0value"}, + "foo": {"hello world"}, + }, + }, + { + name: "quoted values with double quotes", + input: `key="string value", foo="bar"`, + expected: taskparser.Params{ + "key": {"string value"}, + "foo": {"bar"}, + }, + }, + { + name: "quoted values with single quotes", + input: `key='string value', foo='bar'`, + expected: taskparser.Params{ + "key": {"string value"}, + "foo": {"bar"}, + }, + }, + { + name: "quotes with spaces around", + input: `key = "value" , foo = "bar"`, + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "escaped quotes in double quoted string", + input: `key="bar\"baz\""`, + expected: taskparser.Params{ + "key": {`bar"baz"`}, + }, + }, + { + name: "escaped quotes in single quoted string", + input: `key='bar\'baz\''`, + expected: taskparser.Params{ + "key": {`bar'baz'`}, + }, + }, + { + name: "multiple escape sequences", + input: `text="line1\nline2\ttabbed\rreturned\\backslash"`, + expected: taskparser.Params{ + "text": {"line1\nline2\ttabbed\rreturned\\backslash"}, + }, + }, + { + name: "unquoted values with spaces become positional arguments", + input: "key=value with spaces", + expected: taskparser.Params{ + "key": {"value"}, + taskparser.ArgumentsKey: {"with", "spaces"}, + }, + }, + { + name: "quoted values with spaces", + input: `key="value with spaces"`, + expected: taskparser.Params{ + "key": {"value with spaces"}, + }, + }, + { + name: "value with comma in quotes", + input: `key="value,with,commas"`, + expected: taskparser.Params{ + "key": {"value,with,commas"}, + }, + }, + { + name: "complex example from user", + input: `key="string value", foo="bar\"baz\"", multiline="line1\nline2"`, + expected: taskparser.Params{ + "key": {"string value"}, + "foo": {`bar"baz"`}, + "multiline": {"line1\nline2"}, + }, + }, + { + name: "hex escape sequence", + input: `key="\x41\x42"`, + expected: taskparser.Params{ + "key": {"AB"}, + }, + }, + { + name: "octal escape sequence", + input: `key="\101\102"`, + expected: taskparser.Params{ + "key": {"AB"}, + }, + }, + { + name: "octal escape with different lengths", + input: `key="\7\77\177"`, + expected: taskparser.Params{ + "key": {"\x07?\x7f"}, + }, + }, + { + name: "non-octal digit escape", + input: `key="\8\9\89"`, + expected: taskparser.Params{ + "key": {"8989"}, + }, + }, + { + name: "non-octal escape characters", + input: `key="\a\z\A\!"`, + expected: taskparser.Params{ + "key": {"azA!"}, + }, + }, + { + name: "mixed octal and non-octal escapes", + input: `key="\101\8\102\9"`, + expected: taskparser.Params{ + "key": {"A8B9"}, + }, + }, + { + name: "octal escape boundary", + input: `key="\08\09"`, + expected: taskparser.Params{ + "key": {"\x008\x009"}, + }, + }, + { + name: "duplicate keys", + input: "key=value1 key=value2, key=value3", + expected: taskparser.Params{ + "key": {"value1", "value2", "value3"}, + }, + }, + { + name: "multiple duplicate keys", + input: "key1=value1, key2=value2, key1=value3, key2=value4", + expected: taskparser.Params{ + "key1": {"value1", "value3"}, + "key2": {"value2", "value4"}, + }, + }, + { + name: "case folding", + input: "Key=value, FOO=bar, KeyName=value1, keyName=value2, KEYNAME=value3", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + "keyname": {"value1", "value2", "value3"}, + }, + }, + { + name: "UTF-8 characters", + input: "ключ=значение, key=こんにちは, 键=值, emoji=🚀", + expected: taskparser.Params{ + "ключ": {"значение"}, + "key": {"こんにちは"}, + "键": {"值"}, + "emoji": {"🚀"}, + }, + }, + { + name: "UTF-8 with Unicode whitespace", + input: "ключ1=значение1\u2003ключ2=значение2", + expected: taskparser.Params{ + "ключ1": {"значение1"}, + "ключ2": {"значение2"}, + }, + }, + { + name: "quoted UTF-8 value with spaces", + input: `ключ="значение с пробелами"`, + expected: taskparser.Params{ + "ключ": {"значение с пробелами"}, + }, + }, + { + name: "mixed UTF-8 and ASCII", + input: "key=value ключ=значение foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "ключ": {"значение"}, + "foo": {"bar"}, + }, + }, + { + name: "UTF-8 value containing equals sign", + input: `key="значение=со=равно"`, + expected: taskparser.Params{ + "key": {"значение=со=равно"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + + // Check positional arguments using Arguments() accessor + expectedArgs, hasArgs := tt.expected[taskparser.ArgumentsKey] + actualArgs := result.Arguments() + if hasArgs { + assert.Equal(t, expectedArgs, actualArgs, "positional arguments mismatch") + } else { + assert.Empty(t, actualArgs, "expected no positional arguments") + } + + // Check named parameters + for key, expectedValues := range tt.expected { + if key != taskparser.ArgumentsKey { + assert.Equal(t, expectedValues, result.Values(key), "values mismatch for key %q", key) + } + } + + // Verify no unexpected keys + for key := range result { + if key != taskparser.ArgumentsKey && tt.expected[key] == nil { + t.Errorf("unexpected key in result: %q", key) + } + } + }) + } +} + +func TestParams_Value(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + key string + expected string + }{ + { + name: "single value", + input: "key=value", + key: "key", + expected: "value", + }, + { + name: "multiple values returns first", + input: "key=value1 key=value2 key=value3", + key: "key", + expected: "value1", + }, + { + name: "non-existent key returns empty", + input: "key=value", + key: "nonexistent", + expected: "", + }, + { + name: "empty params returns empty", + input: "", + key: "key", + expected: "", + }, + { + name: "case insensitive lookup", + input: "Key=value, KeyName=value2", + key: "KEY", + expected: "value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, params.Value(tt.key)) + }) + } +} + +func TestParams_Values(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + key string + expected []string + }{ + { + name: "single value", + input: "key=value", + key: "key", + expected: []string{"value"}, + }, + { + name: "multiple values returns all", + input: "key=value1 key=value2 key=value3", + key: "key", + expected: []string{"value1", "value2", "value3"}, + }, + { + name: "non-existent key returns nil", + input: "key=value", + key: "nonexistent", + expected: nil, + }, + { + name: "empty params returns nil", + input: "", + key: "key", + expected: nil, + }, + { + name: "multiple keys", + input: "key1=value1 key2=value2 key1=value3", + key: "key1", + expected: []string{"value1", "value3"}, + }, + { + name: "case insensitive lookup", + input: "Key=value1 Key=value2, KeyName=value3 keyName=value4", + key: "KEY", + expected: []string{"value1", "value2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, params.Values(tt.key)) + }) + } +} + +func TestParams_NilSafety(t *testing.T) { + t.Parallel() + + var params taskparser.Params + + assert.Equal(t, "", params.Value("key")) + assert.Nil(t, params.Values("key")) +} + +func TestParse_EmptyValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + key string + expected []string + }{ + { + name: "empty unquoted value returns empty slice", + input: "key=", + key: "key", + expected: []string{}, + }, + { + name: "empty unquoted value with trailing space returns empty slice", + input: "key= ", + key: "key", + expected: []string{}, + }, + { + name: "explicitly quoted empty value returns slice with empty string", + input: `key=""`, + key: "key", + expected: []string{""}, + }, + { + name: "empty value before comma returns empty slice", + input: "key=,foo=bar", + key: "key", + expected: []string{}, + }, + { + name: "empty value with trailing comma at end returns empty slice", + input: "key=,", + key: "key", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, params.Values(tt.key)) + }) + } +} + +func TestParse_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + { + name: "empty key", + input: "=value", + }, + { + name: "empty key, second value", + input: "key=value =value", + }, + { + name: "incorrect quotes", + input: "key='value\"", + }, + { + name: "incorrect quotes, double quote first", + input: "key=\"value'", + }, + { + name: "incorrect quotes, multiple", + input: "key=\"value', key2=\"value2'", + }, + { + name: "empty key, second value, comma", + input: "key=value, =value", + }, + { + name: "unclosed double quote", + input: `key="unclosed`, + }, + { + name: "unclosed single quote", + input: `key='unclosed`, + }, + { + name: "incomplete hex escape - missing one digit", + input: `key="\x4"`, + }, + { + name: "incomplete hex escape - missing both digits", + input: `key="\x"`, + }, + { + name: "invalid hex escape", + input: `key="\xGH"`, + }, + { + name: "incomplete hex escape - quote immediately after x with extra char", + input: `key="\x"X"`, + }, + { + name: "incomplete hex escape - invalid char followed by quote", + input: `key="\xG"`, + }, + { + name: "invalid hex escape - valid first digit, invalid second digit", + input: `key="\x4G"`, + }, + { + name: "incomplete unicode escape - missing one digit", + input: `key="\u00a"`, + }, + { + name: "incomplete unicode escape - missing all digits", + input: `key="\u"`, + }, + { + name: "invalid unicode escape", + input: `key="\u00GH"`, + }, + { + name: "incomplete unicode escape in unquoted value", + input: `key=foo\u00a`, + }, + { + name: "invalid unicode escape in unquoted value", + input: `key=foo\u00GH`, + }, + { + name: "wrapped quotes - malformed single quote wrapping", + input: `key='\"'value\"'`, + }, + { + name: "unclosed trailing quote", + input: `key=value=with=equals"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := taskparser.ParseParams(tt.input) + require.Error(t, err) + }) + } +} + +func TestParseParams_PositionalArguments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected taskparser.Params + }{ + { + name: "single positional argument", + input: "value", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value"}, + }, + }, + { + name: "multiple positional arguments", + input: "value1 value2 value3", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value1", "value2", "value3"}, + }, + }, + { + name: "positional arguments with commas", + input: "value1, value2, value3", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value1", "value2", "value3"}, + }, + }, + { + name: "positional argument with spaces becomes multiple arguments", + input: "value with spaces", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value", "with", "spaces"}, + }, + }, + { + name: "quoted positional argument", + input: `"quoted value"`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"quoted value"}, + }, + }, + { + name: "single quoted positional argument", + input: `'quoted value'`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"quoted value"}, + }, + }, + { + name: "mixed positional and named arguments", + input: "positional1 key=value positional2", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"positional1", "positional2"}, + "key": {"value"}, + }, + }, + { + name: "positional before named", + input: "arg1 arg2 key=value", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2"}, + "key": {"value"}, + }, + }, + { + name: "positional after named", + input: "key=value arg1 arg2", + expected: taskparser.Params{ + "key": {"value"}, + taskparser.ArgumentsKey: {"arg1", "arg2"}, + }, + }, + { + name: "positional between named", + input: "key1=value1 arg1 key2=value2", + expected: taskparser.Params{ + "key1": {"value1"}, + taskparser.ArgumentsKey: {"arg1"}, + "key2": {"value2"}, + }, + }, + { + name: "multiple positional with named", + input: "arg1 key1=value1 arg2 arg3 key2=value2 arg4", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2", "arg3", "arg4"}, + "key1": {"value1"}, + "key2": {"value2"}, + }, + }, + { + name: "positional with quoted value containing spaces", + input: `arg1 "quoted arg with spaces" arg3`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "quoted arg with spaces", "arg3"}, + }, + }, + { + name: "positional with empty quoted value", + input: `arg1 "" arg3`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "", "arg3"}, + }, + }, + { + name: "positional arguments with UTF-8", + input: "значение1 значение2", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"значение1", "значение2"}, + }, + }, + { + name: "positional with escape sequences", + input: `arg1 "line1\nline2" arg3`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "line1\nline2", "arg3"}, + }, + }, + { + name: "positional arguments separated by commas", + input: "arg1,arg2,arg3", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2", "arg3"}, + }, + }, + { + name: "positional with trailing comma", + input: "arg1,arg2,", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2"}, + }, + }, + { + name: "positional with leading comma", + input: ",arg1,arg2", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/codingcontext/taskparser/taskparser.go b/pkg/codingcontext/taskparser/taskparser.go new file mode 100644 index 0000000..abbbdf9 --- /dev/null +++ b/pkg/codingcontext/taskparser/taskparser.go @@ -0,0 +1,214 @@ +package taskparser + +import ( + "strings" +) + +// ParseTask parses a task string into a structured Task representation. +// A task consists of alternating blocks of text content and slash commands +// (commands starting with /). The parser distinguishes between inline +// slashes (part of text) and command slashes (at the start of a line). +// +// Task Structure: +// +// A Task is a sequence of Block elements, where each block is either: +// - A Text block: regular text content +// - A SlashCommand block: a command starting with / at line start +// (optional whitespace before / is allowed) +// +// Command Detection: +// +// - Command: A / at the start of a line (after newline or at start of input) +// starts a command. Optional whitespace (spaces or tabs) before the / +// is allowed and preserved. +// +// - Text: A / not at the start of a line, or preceded by non-whitespace +// characters, is treated as regular text +// +// - Line boundaries: Newlines separate commands from text +// +// Examples: +// "/fix-bug" // Command +// " /deploy" // Command (whitespace before slash allowed) +// "Some text /fix" // Text (slash not at line start) +// "text/deploy" // Text (non-whitespace before slash prevents command) +// +// Command Arguments: +// +// Commands can have: +// - Positional arguments: values without keys +// - Named arguments: key=value pairs +// - Quoted arguments: both single and double quotes supported +// - Mixed arguments: positional and named can be interleaved +// +// Examples: +// "/fix-bug 123 urgent" // Positional only +// "/deploy env=\"production\"" // Named only +// "/task arg1 key=\"value\" arg2" // Mixed +// "/deploy arg1 env=\"production\" arg2" // Positional before/after named +// +// Note: The argument parsing in ParseTask uses a simpler grammar than +// ParseParams. For more advanced parsing (commas, escape sequences, etc.), +// use the Params() method on SlashCommand. +// +// Text Content: +// +// Text blocks: +// - Preserve whitespace: all whitespace, including indentation +// - Can span multiple lines +// - No special processing: stored as-is +// +// Examples: +// "Line 1\n Indented line 2\nLine 3" +// +// Task Composition: +// +// Tasks can contain: +// - Only text: "This is a task with only text." +// - Only commands: "/command1\n/command2\n" +// - Mixed: "Introduction text\n/command1 arg1\nMiddle text\n/command2\n" +// +// SlashCommand.Params() Method: +// +// The SlashCommand type provides a Params() method that converts command +// arguments to a Params map using ParseParams. This provides: +// - Full ParseParams feature support (commas, escape sequences, etc.) +// - Consistent API with ParseParams +// - More permissive parsing than the initial grammar +// +// Example: +// task, _ := ParseTask("/deploy env=\"production\" region=\"us-east-1\"\n") +// cmd := task[0].SlashCommand +// params := cmd.Params() +// // params["env"] = []string{"production"} +// // params["region"] = []string{"us-east-1"} +// +// Error Conditions: +// +// The function rarely returns errors due to the permissive grammar. +// Potential errors include malformed input that cannot be tokenized. +// +// Examples: +// +// // Simple text task +// task, _ := ParseTask("This is a simple text block.") +// // task[0].Text.Content() == "This is a simple text block." +// +// // Simple command +// task, _ := ParseTask("/fix-bug\n") +// // task[0].SlashCommand.Name == "fix-bug" +// +// // Command with whitespace before slash (allowed) +// task, _ := ParseTask(" /deploy env=\"production\"\n") +// // task[0].SlashCommand.Name == "deploy" +// +// // Command with positional arguments +// task, _ := ParseTask("/fix-bug 123 urgent\n") +// // task[0].SlashCommand.Arguments[0].Value == "123" +// +// // Mixed content +// task, _ := ParseTask("Introduction text\n/fix-bug 123\nSome text after") +// // len(task) == 3 (text, command, text) +func ParseTask(text string) (Task, error) { + input, err := parser().ParseString("", text) + if err != nil { + return nil, err + } + return Task(input.Blocks), nil +} + +// Params converts the slash command's arguments into a parameter map using ParseParams. +// This provides a more permissive parser that supports commas, single quotes, and other features. +// Returns a map with: +// - "ARGUMENTS": positional arguments (values without keys) +// - named parameters: key-value pairs from key="value" or key='value' arguments +func (s *SlashCommand) Params() Params { + // Reconstruct the arguments string from the parsed Arguments + var argStrings []string + for _, arg := range s.Arguments { + if arg.Key != "" { + // Named parameter: key="value" or key='value' + argStrings = append(argStrings, arg.Key+"="+arg.Value) + } else { + // Positional parameter + argStrings = append(argStrings, arg.Value) + } + } + + // Join arguments with spaces (preserving the original format) + argsString := strings.Join(argStrings, " ") + + // Use ParseParams to parse the arguments string + // This is more permissive and handles commas, single quotes, etc. + params, err := ParseParams(argsString) + if err != nil { + // If parsing fails, return empty params + // This should rarely happen since ParseParams handles the same format + // that was parsed by the grammar, but we handle it gracefully + return make(Params) + } + + return params +} + +// Content returns the text content with all lines concatenated +func (t *Text) Content() string { + var sb strings.Builder + for _, line := range t.Lines { + for _, tok := range line.NonSlashStart { + sb.WriteString(tok) + } + for _, tok := range line.RestOfLine { + sb.WriteString(tok) + } + sb.WriteString(line.NewlineOpt) + } + return sb.String() +} + +// String returns the original text representation of a task +func (t Task) String() string { + var sb strings.Builder + for _, block := range t { + sb.WriteString(block.String()) + } + return sb.String() +} + +// String returns the original text representation of a block +func (b Block) String() string { + if b.SlashCommand != nil { + return b.SlashCommand.String() + } + if b.Text != nil { + return b.Text.String() + } + return "" +} + +// String returns the original text representation of a slash command +func (s SlashCommand) String() string { + var sb strings.Builder + sb.WriteString(s.LeadingWhitespace) + sb.WriteString("/") + sb.WriteString(s.Name) + for _, arg := range s.Arguments { + sb.WriteString(" ") + sb.WriteString(arg.String()) + } + sb.WriteString("\n") + return sb.String() +} + +// String returns the original text representation of an argument +func (a Argument) String() string { + if a.Key != "" { + return a.Key + "=" + a.Value + } + return a.Value +} + +// String returns the original text representation of text +func (t Text) String() string { + return t.Content() +} diff --git a/pkg/codingcontext/task_parser_test.go b/pkg/codingcontext/taskparser/taskparser_test.go similarity index 54% rename from pkg/codingcontext/task_parser_test.go rename to pkg/codingcontext/taskparser/taskparser_test.go index 96c0e5a..68fddba 100644 --- a/pkg/codingcontext/task_parser_test.go +++ b/pkg/codingcontext/taskparser/taskparser_test.go @@ -1,4 +1,4 @@ -package codingcontext +package taskparser import ( "strings" @@ -220,6 +220,26 @@ func TestParseTask(t *testing.T) { } }, }, + { + name: "non-whitespace before slash prevents command", + input: "text/deploy env=\"production\"\n", + wantErr: false, + check: func(t *testing.T, task Task) { + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + if task[0].Text == nil { + t.Fatal("expected text block, not command") + } + // The slash should be part of the text, not a command + if task[0].SlashCommand != nil { + t.Fatal("expected no slash command when non-whitespace precedes slash") + } + if !strings.Contains(task[0].Text.Content(), "/deploy") { + t.Errorf("expected text to contain '/deploy', got %q", task[0].Text.Content()) + } + }, + }, { name: "text with equals sign", input: "This is text with key=value pairs.", @@ -326,3 +346,246 @@ func TestTask_String(t *testing.T) { }) } } + +func TestSlashCommand_Params(t *testing.T) { + tests := []struct { + name string + input string + commandIndex int // Which block contains the slash command (0-based) + expectedName string + expectedParams Params + expectedArgs []string // Positional arguments + }{ + { + name: "simple named parameters", + input: "/deploy env=\"production\" region=\"us-east-1\" version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: nil, + }, + { + name: "whitespace before initial slash", + input: " /deploy env=\"production\" region=\"us-east-1\" version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: nil, + }, + { + name: "text before slash command", + input: "Some introduction text\n/deploy env=\"production\"\n", + commandIndex: 1, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + }, + expectedArgs: nil, + }, + { + name: "positional arguments only", + input: "/fix-bug 123 urgent\n", + commandIndex: 0, + expectedName: "fix-bug", + expectedParams: Params{ + ArgumentsKey: {"123", "urgent"}, + }, + expectedArgs: []string{"123", "urgent"}, + }, + { + name: "mixed positional and named arguments", + input: "/task arg1 key=\"value\" arg2 env=\"prod\"\n", + commandIndex: 0, + expectedName: "task", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2"}, + "key": {"value"}, + "env": {"prod"}, + }, + expectedArgs: []string{"arg1", "arg2"}, + }, + { + name: "positional before named", + input: "/deploy arg1 arg2 env=\"production\"\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2"}, + "env": {"production"}, + }, + expectedArgs: []string{"arg1", "arg2"}, + }, + { + name: "positional after named", + input: "/deploy env=\"production\" arg1 arg2\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2"}, + "env": {"production"}, + }, + expectedArgs: []string{"arg1", "arg2"}, + }, + { + name: "positional between named", + input: "/deploy env=\"production\" arg1 region=\"us-east-1\"\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1"}, + "env": {"production"}, + "region": {"us-east-1"}, + }, + expectedArgs: []string{"arg1"}, + }, + { + name: "quoted positional arguments", + input: "/deploy \"quoted arg\" 'single quoted' normal\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"quoted arg", "single quoted", "normal"}, + }, + expectedArgs: []string{"quoted arg", "single quoted", "normal"}, + }, + { + name: "multiple named parameters with spaces", + input: "/deploy env=\"production\" region=\"us-east-1\" version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: nil, + }, + { + name: "text before and after slash command", + input: "Before text\n/deploy env=\"production\" arg1\nAfter text", + commandIndex: 1, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1"}, + "env": {"production"}, + }, + expectedArgs: []string{"arg1"}, + }, + { + name: "no arguments", + input: "/deploy\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{}, + expectedArgs: nil, + }, + { + name: "single quoted named parameter", + input: "/deploy env='production'\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + }, + expectedArgs: nil, + }, + { + name: "complex mixed arguments", + input: "/deploy arg1 env=\"production\" arg2 region=\"us-east-1\" arg3 version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2", "arg3"}, + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: []string{"arg1", "arg2", "arg3"}, + }, + { + name: "multiple slash commands - test second command", + input: "/command1 arg1\n/deploy env=\"production\" arg1 region=\"us-east-1\"\n/command3 arg3\n", + commandIndex: 1, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1"}, + "env": {"production"}, + "region": {"us-east-1"}, + }, + expectedArgs: []string{"arg1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task, err := ParseTask(tt.input) + if err != nil { + t.Fatalf("ParseTask() error = %v", err) + } + + // Check for SlashCommand at the expected index + if len(task) <= tt.commandIndex { + t.Fatalf("expected at least %d blocks, got %d", tt.commandIndex+1, len(task)) + } + if task[tt.commandIndex].SlashCommand == nil { + t.Fatalf("expected slash command block at index %d", tt.commandIndex) + } + + cmd := task[tt.commandIndex].SlashCommand + if cmd.Name != tt.expectedName { + t.Errorf("expected command name %q, got %q", tt.expectedName, cmd.Name) + } + + // Use Params() to validate expectations + params := cmd.Params() + + // Validate positional arguments + actualArgs := params.Arguments() + if len(tt.expectedArgs) != len(actualArgs) { + t.Errorf("expected %d positional arguments, got %d: expected=%v, got=%v", + len(tt.expectedArgs), len(actualArgs), tt.expectedArgs, actualArgs) + } else { + for i, expected := range tt.expectedArgs { + if i < len(actualArgs) && actualArgs[i] != expected { + t.Errorf("positional arg[%d]: expected %q, got %q", i, expected, actualArgs[i]) + } + } + } + + // Validate named parameters + for key, expectedValues := range tt.expectedParams { + if key == ArgumentsKey { + continue // Already validated above + } + actualValues := params.Values(key) + if len(expectedValues) != len(actualValues) { + t.Errorf("key %q: expected %d values, got %d: expected=%v, got=%v", + key, len(expectedValues), len(actualValues), expectedValues, actualValues) + } else { + for i, expected := range expectedValues { + if i < len(actualValues) && actualValues[i] != expected { + t.Errorf("key %q[%d]: expected %q, got %q", key, i, expected, actualValues[i]) + } + } + } + } + + // Verify no unexpected keys (except ArgumentsKey which we handle separately) + for key := range params { + if key != ArgumentsKey { + if _, exists := tt.expectedParams[key]; !exists { + t.Errorf("unexpected key in params: %q", key) + } + } + } + }) + } +} diff --git a/pkg/codingcontext/token_counter.go b/pkg/codingcontext/tokencount/tokencount.go similarity index 70% rename from pkg/codingcontext/token_counter.go rename to pkg/codingcontext/tokencount/tokencount.go index fa94212..89d0146 100644 --- a/pkg/codingcontext/token_counter.go +++ b/pkg/codingcontext/tokencount/tokencount.go @@ -1,13 +1,13 @@ -package codingcontext +package tokencount import ( "unicode/utf8" ) -// estimateTokens estimates the number of LLM tokens in the given text. +// EstimateTokens estimates the number of LLM tokens in the given text. // Uses a simple heuristic of approximately 4 characters per token, // which is a common approximation for English text with GPT-style tokenizers. -func estimateTokens(text string) int { +func EstimateTokens(text string) int { charCount := utf8.RuneCountInString(text) // Approximate: 1 token ≈ 4 characters return charCount / 4 diff --git a/pkg/codingcontext/token_counter_test.go b/pkg/codingcontext/tokencount/tokencount_test.go similarity index 87% rename from pkg/codingcontext/token_counter_test.go rename to pkg/codingcontext/tokencount/tokencount_test.go index 786367a..1e9afb5 100644 --- a/pkg/codingcontext/token_counter_test.go +++ b/pkg/codingcontext/tokencount/tokencount_test.go @@ -1,6 +1,10 @@ -package codingcontext +package tokencount_test -import "testing" +import ( + "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" +) func TestEstimateTokens(t *testing.T) { tests := []struct { @@ -56,7 +60,7 @@ This is content.`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := estimateTokens(tt.text) + got := tokencount.EstimateTokens(tt.text) if got != tt.want { t.Errorf("estimateTokens() = %d, want %d", got, tt.want) } diff --git a/pkg/codingcontext/transport_type.go b/pkg/codingcontext/transport_type.go deleted file mode 100644 index f58bc07..0000000 --- a/pkg/codingcontext/transport_type.go +++ /dev/null @@ -1,17 +0,0 @@ -package codingcontext - -// TransportType defines the communication protocol used by the server. -// Supported by both Claude and Cursor. -type TransportType string - -const ( - // TransportTypeStdio is for local processes (executables). - TransportTypeStdio TransportType = "stdio" - - // TransportTypeSSE is for Server-Sent Events (Remote). - // Note: Claude Code prefers HTTP over SSE, but supports it. - TransportTypeSSE TransportType = "sse" - - // TransportTypeHTTP is for standard HTTP/POST interactions. - TransportTypeHTTP TransportType = "http" -)