feat(parse): add capture grammar parser
Extracts glyph prefix, @time anchors, #tags, and ^card suffixes from raw input. Table-driven tests cover all grammar forms including edge cases. 24 tests passing.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package parse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Body string
|
||||
Glyph string
|
||||
TimeAnchor *string
|
||||
Tags []string
|
||||
CardSuffix *string
|
||||
}
|
||||
|
||||
var validCardTypes = map[string]string{
|
||||
"card": "snippet",
|
||||
"c": "snippet",
|
||||
"snippet": "snippet",
|
||||
"template": "template",
|
||||
"checklist": "checklist",
|
||||
"decision": "decision",
|
||||
"link": "link",
|
||||
}
|
||||
|
||||
func Parse(input string) (*Result, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return nil, fmt.Errorf("empty input")
|
||||
}
|
||||
|
||||
r := &Result{
|
||||
Glyph: "note",
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
tokens := strings.Fields(input)
|
||||
if len(tokens) == 0 {
|
||||
return nil, fmt.Errorf("empty input")
|
||||
}
|
||||
|
||||
first := tokens[0]
|
||||
switch first {
|
||||
case "-", "▸":
|
||||
r.Glyph = "todo"
|
||||
tokens = tokens[1:]
|
||||
case "*", "◇":
|
||||
r.Glyph = "event"
|
||||
tokens = tokens[1:]
|
||||
}
|
||||
|
||||
var bodyParts []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, tok := range tokens {
|
||||
switch {
|
||||
case strings.HasPrefix(tok, "@") && len(tok) > 1:
|
||||
timeStr := tok[1:]
|
||||
if err := validateTime(timeStr); err != nil {
|
||||
return nil, fmt.Errorf("invalid time %q: %w", timeStr, err)
|
||||
}
|
||||
if r.TimeAnchor != nil {
|
||||
return nil, fmt.Errorf("multiple time anchors")
|
||||
}
|
||||
r.TimeAnchor = &timeStr
|
||||
|
||||
case strings.HasPrefix(tok, "#") && len(tok) > 1:
|
||||
tag := tok[1:]
|
||||
if !seen[tag] {
|
||||
r.Tags = append(r.Tags, tag)
|
||||
seen[tag] = true
|
||||
}
|
||||
|
||||
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||
suffix := tok[1:]
|
||||
cardType, ok := validCardTypes[suffix]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid card type %q", suffix)
|
||||
}
|
||||
if r.CardSuffix != nil {
|
||||
return nil, fmt.Errorf("multiple card suffixes")
|
||||
}
|
||||
r.CardSuffix = &cardType
|
||||
|
||||
default:
|
||||
bodyParts = append(bodyParts, tok)
|
||||
}
|
||||
}
|
||||
|
||||
r.Body = strings.Join(bodyParts, " ")
|
||||
if r.Body == "" {
|
||||
return nil, fmt.Errorf("empty body after extracting modifiers")
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func validateTime(s string) error {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("expected HH:MM format")
|
||||
}
|
||||
h, err := strconv.Atoi(parts[0])
|
||||
if err != nil || h < 0 || h > 23 {
|
||||
return fmt.Errorf("hours must be 0-23")
|
||||
}
|
||||
m, err := strconv.Atoi(parts[1])
|
||||
if err != nil || m < 0 || m > 59 {
|
||||
return fmt.Errorf("minutes must be 0-59")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package parse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sp(s string) *string { return &s }
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantBody string
|
||||
wantGlyph string
|
||||
wantTime *string
|
||||
wantTags []string
|
||||
wantCard *string
|
||||
wantErrSub string
|
||||
}{
|
||||
// Glyph detection
|
||||
{"plain note", "hello world", "hello world", "note", nil, nil, nil, ""},
|
||||
{"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""},
|
||||
{"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""},
|
||||
{"star event", "* dentist", "dentist", "event", nil, nil, nil, ""},
|
||||
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, ""},
|
||||
|
||||
// Time anchor
|
||||
{"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""},
|
||||
{"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""},
|
||||
{"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"},
|
||||
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"},
|
||||
|
||||
// Tags
|
||||
{"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""},
|
||||
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""},
|
||||
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""},
|
||||
{"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""},
|
||||
|
||||
// Card suffix
|
||||
{"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""},
|
||||
{"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""},
|
||||
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""},
|
||||
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""},
|
||||
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"},
|
||||
|
||||
// Combined
|
||||
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", sp("15:00"), []string{"ops"}, nil, ""},
|
||||
{"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, []string{"nginx"}, sp("snippet"), ""},
|
||||
|
||||
// Edge cases
|
||||
{"empty input", "", "", "", nil, nil, nil, "empty"},
|
||||
{"only glyph", "-", "", "", nil, nil, nil, "empty body"},
|
||||
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"},
|
||||
{"whitespace only", " ", "", "", nil, nil, nil, "empty"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Parse(tt.input)
|
||||
|
||||
if tt.wantErrSub != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErrSub)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrSub) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.wantErrSub, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got.Body != tt.wantBody {
|
||||
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
|
||||
}
|
||||
if got.Glyph != tt.wantGlyph {
|
||||
t.Errorf("glyph: got %q, want %q", got.Glyph, tt.wantGlyph)
|
||||
}
|
||||
if !ptrEq(got.TimeAnchor, tt.wantTime) {
|
||||
t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime))
|
||||
}
|
||||
if !tagsEq(got.Tags, tt.wantTags) {
|
||||
t.Errorf("tags: got %v, want %v", got.Tags, tt.wantTags)
|
||||
}
|
||||
if !ptrEq(got.CardSuffix, tt.wantCard) {
|
||||
t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(tt.wantCard))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptrEq(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func strPtr(p *string) string {
|
||||
if p == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func tagsEq(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user