From 51cbf86d7730f7ad0524c987639ebd8dd5d388f0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 11:09:27 -0400 Subject: [PATCH] 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. --- internal/parse/grammar.go | 113 +++++++++++++++++++++++++++++ internal/parse/grammar_test.go | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 internal/parse/grammar.go create mode 100644 internal/parse/grammar_test.go diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go new file mode 100644 index 0000000..879aec5 --- /dev/null +++ b/internal/parse/grammar.go @@ -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 +} diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go new file mode 100644 index 0000000..2262e9e --- /dev/null +++ b/internal/parse/grammar_test.go @@ -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 "" + } + 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 +}