fix(parser): align Go and JS parsers with capture grammar spec

Kind prefixes now follow the canonical grammar: `-` for todo,
`@time` for event, `!time` for reminder. Removed `*`/`◇`/`▸`
as capture aliases (display-layer only). Added `\` escape prefix,
`?` query mode, `!pin` flag extraction, `##word` hash escape,
and tag lowercasing. Both parsers produce identical results.
This commit is contained in:
2026-05-16 11:12:36 -04:00
parent 957265f7b4
commit 97ad71d66b
8 changed files with 358 additions and 128 deletions
+148 -56
View File
@@ -13,7 +13,10 @@ type Result struct {
Description *string
TimeAnchor *string
Tags []string
FilterTags []string
CardSuffix *string
Pin bool
Query bool
}
var validCardTypes = map[string]string{
@@ -39,26 +42,70 @@ func Parse(input string) (*Result, error) {
remaining := input
if sp := strings.IndexByte(remaining, ' '); sp >= 0 {
switch remaining[:sp] {
case "-", "▸":
r.Glyph = "todo"
remaining = strings.TrimSpace(remaining[sp+1:])
case "*", "◇":
r.Glyph = "event"
remaining = strings.TrimSpace(remaining[sp+1:])
// Step 1: Escape check — `\` prefix → thought, no prefix detection
if strings.HasPrefix(remaining, `\`) {
remaining = remaining[1:]
r.Glyph = "note"
clean, err := extractModifiers(r, remaining, false)
if err != nil {
return nil, err
}
} else {
switch remaining {
case "-", "▸":
r.Glyph = "todo"
remaining = ""
case "*", "◇":
r.Body = clean
if r.Body == "" && r.Title == nil {
return nil, fmt.Errorf("empty body after extracting modifiers")
}
return r, nil
}
// Step 2: Query check — `?` prefix → search mode
if strings.HasPrefix(remaining, "?") {
remaining = strings.TrimSpace(remaining[1:])
r.Query = true
r.Glyph = ""
tokens := strings.Fields(remaining)
var bodyParts []string
for _, tok := range tokens {
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
tag := strings.ToLower(tok[1:])
r.FilterTags = append(r.FilterTags, tag)
} else {
bodyParts = append(bodyParts, tok)
}
}
r.Body = strings.Join(bodyParts, " ")
return r, nil
}
// Step 3: Kind prefix — `-`, `@time`, `!time`
// `@` and `!` are kind prefixes ONLY if followed by a valid time token.
// Otherwise the input is treated as a plain note.
if strings.HasPrefix(remaining, "- ") {
r.Glyph = "todo"
remaining = strings.TrimSpace(remaining[2:])
} else if remaining == "-" {
r.Glyph = "todo"
remaining = ""
} else if strings.HasPrefix(remaining, "@") {
if rest, ok := tryPrefixTime(r, remaining[1:]); ok {
r.Glyph = "event"
remaining = ""
remaining = rest
}
} else if strings.HasPrefix(remaining, "!") {
afterBang := remaining[1:]
// `!pin` is a flag, not a reminder prefix
firstWord := ""
if fields := strings.Fields(afterBang); len(fields) > 0 {
firstWord = fields[0]
}
if !strings.EqualFold(firstWord, "pin") {
if rest, ok := tryPrefixTime(r, afterBang); ok {
r.Glyph = "reminder"
remaining = rest
}
}
}
// Steps 4-5: Title and description extraction
var titleRaw, descRaw string
hasTitle := false
@@ -106,46 +153,9 @@ func Parse(input string) (*Result, error) {
}
}
seen := map[string]bool{}
extract := func(text string) (string, error) {
tokens := strings.Fields(text)
var parts []string
for _, tok := range tokens {
switch {
case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:]
if err := validateTime(timeStr); err != nil {
return "", fmt.Errorf("invalid time %q: %w", timeStr, err)
}
if r.TimeAnchor != nil {
return "", 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 "", fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return "", fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
parts = append(parts, tok)
}
}
return strings.Join(parts, " "), nil
}
// Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body
if hasTitle {
clean, err := extract(titleRaw)
clean, err := extractModifiers(r, titleRaw, false)
if err != nil {
return nil, err
}
@@ -154,7 +164,7 @@ func Parse(input string) (*Result, error) {
}
}
if descRaw != "" {
clean, err := extract(descRaw)
clean, err := extractModifiers(r, descRaw, false)
if err != nil {
return nil, err
}
@@ -163,7 +173,7 @@ func Parse(input string) (*Result, error) {
}
}
clean, err := extract(remaining)
clean, err := extractModifiers(r, remaining, true)
if err != nil {
return nil, err
}
@@ -176,6 +186,88 @@ func Parse(input string) (*Result, error) {
return r, nil
}
// tryPrefixTime attempts to extract a time token from the start of text.
// Returns (remaining text, true) on success, or ("", false) if no valid time found.
func tryPrefixTime(r *Result, text string) (string, bool) {
text = strings.TrimSpace(text)
if text == "" {
return "", false
}
sp := strings.IndexByte(text, ' ')
var timeStr, rest string
if sp >= 0 {
timeStr = text[:sp]
rest = strings.TrimSpace(text[sp+1:])
} else {
timeStr = text
rest = ""
}
if validateTime(timeStr) != nil {
return "", false
}
r.TimeAnchor = &timeStr
return rest, true
}
// extractModifiers extracts tags, flags, time anchors, and card suffixes from text.
// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts).
func extractModifiers(r *Result, text string, handleFlags bool) (string, error) {
tokens := strings.Fields(text)
var parts []string
seen := map[string]bool{}
for _, t := range r.Tags {
seen[strings.ToLower(t)] = true
}
for _, tok := range tokens {
switch {
// !pin flag
case handleFlags && strings.EqualFold(tok, "!pin"):
r.Pin = true
// ##word escape → literal #word in body
case strings.HasPrefix(tok, "##") && len(tok) > 2:
parts = append(parts, "#"+tok[2:])
// @time inline time anchor (for todos: sets due_at)
// If not valid time format, keep as body text
case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:]
if validateTime(timeStr) != nil {
parts = append(parts, tok)
} else if r.TimeAnchor != nil {
return "", fmt.Errorf("multiple time anchors")
} else {
r.TimeAnchor = &timeStr
}
// #tag extraction (lowercased, deduplicated)
case strings.HasPrefix(tok, "#") && len(tok) > 1:
tag := strings.ToLower(tok[1:])
if !seen[tag] {
r.Tags = append(r.Tags, tag)
seen[tag] = true
}
// ^card suffix (kept for now, spec says remove later)
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
cardType, ok := validCardTypes[suffix]
if !ok {
return "", fmt.Errorf("invalid card type %q", suffix)
}
if r.CardSuffix != nil {
return "", fmt.Errorf("multiple card suffixes")
}
r.CardSuffix = &cardType
default:
parts = append(parts, tok)
}
}
return strings.Join(parts, " "), nil
}
func validateTime(s string) error {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {