package parser import ( "fmt" "strconv" "strings" "time" ) // ParseDSL parses the whitespace-structured DSL and returns a slice of Workouts and a slice of Measurements. func ParseDSL(input string) ([]Workout, []Measurement, error) { lines := strings.Split(input, "\n") var workouts []Workout var measurements []Measurement var curWorkout *Workout var curSetGroup *SetGroup for idx, rawLine := range lines { line := strings.TrimRight(rawLine, "\r\n") if line == "" { continue } indent := countIndent(line) content := strings.TrimLeft(line, " \t") var setIndex int switch indent { case 0: // either a workout or a measurement parts := strings.SplitN(line, " ", 3) if (parts[1] == "measurement") { // Measurement line: date measurement Variable=Value [note] date, variable, value, note, err := parseMeasurementLine(content) if err != nil { return nil, nil, fmt.Errorf("line %d: %v", idx+1, err) } measurements = append(measurements, Measurement{ Date: date, Variable: variable, Value: value, Note: note, }) } else if (parts[1] == "workout") { // Workout line: date workout [type in (parentheses)] [note] date, typ, note, err := parseWorkoutLine(content) if err != nil { return nil, nil, fmt.Errorf("line %d: %v", idx+1, err) } workouts = append(workouts, Workout{ Date: date, Type: typ, Note: note, SetGroups: nil, }) curWorkout = &workouts[len(workouts)-1] curSetGroup = nil } else { return nil, nil, fmt.Errorf("Invalid top level item: %s.\n",parts[1]) } case 1: // SetGroup line (including planned sets) if curWorkout == nil { return nil, nil, fmt.Errorf("line %d: SetGroup without Workout", idx+1) } sg, err := parseSetGroupLine(content) if err != nil { return nil, nil, fmt.Errorf("line %d: %v", idx+1, err) } curWorkout.SetGroups = append(curWorkout.SetGroups, sg) curSetGroup = &curWorkout.SetGroups[len(curWorkout.SetGroups)-1] setIndex = 0 case 2: // Actual Set line (may be multiple sets separated by "-") if curSetGroup == nil { return nil, nil, fmt.Errorf("line %d: Actual Set without SetGroup", idx+1) } sets, weights, err := parseActualSetLine(content,curWorkout.Date) if err != nil { return nil, nil, fmt.Errorf("line %d: %v", idx+1, err) } for i := range(sets) { if !weights[i] { sets[i].Weight = curSetGroup.PlannedSets[setIndex].Weight } if setIndex < len(curSetGroup.PlannedSets) -1 { setIndex++ } } curSetGroup.ActualSets = append(curSetGroup.ActualSets, sets...) default: return nil, nil, fmt.Errorf("line %d: Unexpected indentation", idx+1) } } return workouts, measurements, nil } // countIndent returns the number of leading whitespace "levels" (spaces or tabs). // 0 = Workout, 1 = SetGroup, 2 = ActualSet. func countIndent(line string) int { indent := 0 for _, ch := range line { if ch == ' ' || ch == '\t' { indent++ } else { break } } return indent } // parseMeasurementLine splits the Measurement line into the date, variable, // value, and note fields. func parseMeasurementLine(line string) (time.Time, string, float64, string, error) { parts := strings.SplitN(line, " ", 3) dateStr := parts[0] date, err := time.Parse("2006-01-02", dateStr) if err != nil { return time.Time{}, "", 0, "", fmt.Errorf("invalid date: %v", err) } parts = strings.SplitN(parts[2],"=",2) variable := parts[0] value, _, err := parseWeightAndUnit(parts[1]) if err != nil { return time.Time{}, "", 0, "", fmt.Errorf("Cannot parse measurement value.\n") } note := "" if len(parts) > 2 { note = parts[3] } return date, variable, value, note, nil } // parseWorkoutLine splits the Workout line into date, type, note. // Date is the first non-whitespace field, type is in balanced parentheses, note is the rest. func parseWorkoutLine(line string) (time.Time, string, string, error) { parts := strings.SplitN(line, " ", 3) dateStr := parts[0] date, err := time.Parse("2006-01-02", dateStr) if err != nil { return time.Time{}, "", "", fmt.Errorf("invalid date: %v", err) } typ := "" note := "" if len(parts) == 3 { rest := strings.TrimSpace(parts[2]) // Look for type in balanced parentheses if strings.HasPrefix(rest, "(") { depth := 0 endIdx := -1 for i, ch := range rest { if ch == '(' { depth++ } else if ch == ')' { depth-- if depth == 0 { endIdx = i break } } } if endIdx != -1 { typ = strings.TrimSpace(rest[1:endIdx]) note = strings.TrimSpace(rest[endIdx+1:]) } else { // Unbalanced, treat as note note = rest } } else { note = rest } } return date, typ, note, nil } // parseSetGroupLine parses a SetGroup line. // The first field(s) are the exercise (may contain spaces, but never a number). // The next field is the weight, then setsxreps, then optional type (in parentheses), then optional note. func parseSetGroupLine(line string) (SetGroup, error) { fields := strings.Fields(line) if len(fields) < 3 { return SetGroup{}, fmt.Errorf("not enough fields for SetGroup") } // Find where the weight starts (first field with a digit) exerciseFields := []string{} weightIdx := -1 for i, f := range fields { if hasDigit(f) { weightIdx = i break } exerciseFields = append(exerciseFields, f) } if weightIdx == -1 || weightIdx+2 > len(fields) { return SetGroup{}, fmt.Errorf("no weight or setsxreps found in SetGroup line") } exercise := strings.Join(exerciseFields, " ") weightField := fields[weightIdx] setsxrepsField := fields[weightIdx+1] // Parse weight and optional unit weight, unit, err := parseWeightAndUnit(weightField) if err != nil { return SetGroup{}, fmt.Errorf("invalid weight: %v", err) } if unit == "kg" { weight = weight * 2.20462 // convert to pounds } // Parse setsxreps (e.g. 5x3) sets, reps, err := parseSetsXReps(setsxrepsField) if err != nil { return SetGroup{}, fmt.Errorf("invalid setsxreps: %v", err) } // Remaining fields: optional type (in parentheses), then optional note typ := "" note := "" rest := strings.Join(fields[weightIdx+2:], " ") rest = strings.TrimSpace(rest) if strings.HasPrefix(rest, "(") { // Find end of type in parentheses depth := 0 endIdx := -1 for i, ch := range rest { if ch == '(' { depth++ } else if ch == ')' { depth-- if depth == 0 { endIdx = i break } } } if endIdx != -1 { typ = strings.TrimSpace(rest[1:endIdx]) note = strings.TrimSpace(rest[endIdx+1:]) } else { // Unbalanced, treat as note note = rest } } else { note = rest } // Build planned sets plannedSets := make([]Set, sets) for i := 0; i < sets; i++ { plannedSets[i] = Set{ Weight: weight, Reps: reps, } } return SetGroup{ Exercise: exercise, Type: typ, Note: note, PlannedSets: plannedSets, ActualSets: nil, }, nil } func hasDigit(s string) bool { for _, ch := range s { if ch >= '0' && ch <= '9' { return true } } return false } // parseWeightAndUnit parses a weight field, e.g. "100kg", "225", "225lb" func parseWeightAndUnit(field string) (float64, string, error) { // Find where the first non-digit or non-dot occurs i := 0 for ; i < len(field); i++ { if (field[i] < '0' || field[i] > '9') && field[i] != '.' { break } } weightStr := field[:i] unit := strings.ToLower(field[i:]) weight, err := strconv.ParseFloat(weightStr, 64) if err != nil { return 0, "", err } return weight, unit, nil } // parseSetsXReps parses a setsxreps field, e.g. "5x3" func parseSetsXReps(field string) (int, int, error) { parts := strings.Split(field, "x") if len(parts) != 2 { return 0, 0, fmt.Errorf("expected format setsxreps, got %q", field) } sets, err := strconv.Atoi(parts[0]) if err != nil { return 0, 0, fmt.Errorf("invalid sets: %v", err) } reps, err := strconv.Atoi(parts[1]) if err != nil { return 0, 0, fmt.Errorf("invalid reps: %v", err) } return sets, reps, nil } // parseActualSetLine parses an Actual Set line (can be multiple sets separated by "-"). // The first field is set data (possibly with "-"), the rest is an optional note. func parseActualSetLine(line string, date time.Time) ([]Set, []bool, error) { parts := strings.SplitN(line, " ", 2) setsPart := parts[0] note := "" if len(parts) == 2 { note = strings.TrimSpace(parts[1]) } setStrs := strings.Split(setsPart, "-") sets := []Set{} weights := []bool{} for _, s := range setStrs { s = strings.TrimSpace(s) if s == "" { continue } // Parse as either "reps", "weightxreps", "weightunitxreps" weight := 0.0 reps := 0 if strings.Contains(s, "x") { parts := strings.SplitN(s, "x", 2) w, u, err := parseWeightAndUnit(parts[0]) if err != nil { return nil, nil, fmt.Errorf("invalid weight in actual set: %v", err) } if u == "kg" { w = w * 2.20462 } weights = append(weights,true) weight = w reps, err = strconv.Atoi(parts[1]) if err != nil { return nil, nil, fmt.Errorf("invalid reps in actual set: %v", err) } } else { // Just reps, no weight var err error reps, err = strconv.Atoi(s) if err != nil { return nil, nil, fmt.Errorf("invalid reps in actual set: %v", err) } weights = append(weights,false) } sets = append(sets, Set{ Weight: weight, Reps: reps, Note: "", Time: date, RIR: -1, }) } if note != "" && len(sets) > 0 { sets[len(sets)-1].Note = note if strings.HasPrefix(note, "max") { sets[len(sets)-1].RIR = 0 } } return sets, weights, nil }