package main import ( "fmt" "strconv" "strings" ) func stripComment(line string) string { inSingle := false inDouble := false for i := 0; i < len(line); i++ { ch := line[i] switch { case ch == '\\' && !inSingle: i++ // skip escaped character (backslash escapes in double-quoted and unquoted contexts) case ch == '\'' && !inDouble: inSingle = !inSingle case ch == '"' && !inSingle: inDouble = !inDouble case ch == '#' && !inSingle && !inDouble: return strings.TrimRight(line[:i], " \t") } } return line } func parseExitCode(lines []string) ([]string, ExitCodeType, int) { if len(lines) == 0 { return lines, ExitCodeNone, 0 } last := lines[len(lines)-1] if len(last) < 3 || last[0] != '[' || last[len(last)-1] != ']' { return lines, ExitCodeNone, 0 } inner := last[1 : len(last)-1] if inner == "*" { return lines[:len(lines)-1], ExitCodeWildcard, 0 } code, err := strconv.Atoi(inner) if err != nil { return lines, ExitCodeNone, 0 } return lines[:len(lines)-1], ExitCodeExact, code } func trimTrailingEmpty(lines []string) []string { end := len(lines) for end > 0 && lines[end-1] == "" { end-- } return lines[:end] } func parse(path, content string) (ShoutFile, error) { rawLines := strings.Split(content, "\n") // Remove trailing newline if len(rawLines) > 0 && rawLines[len(rawLines)-1] == "" { rawLines = rawLines[:len(rawLines)-1] } var commands []Command var directives []Directive var current *Command seenCommand := false for i, line := range rawLines { lineNum := i + 1 if !seenCommand && strings.HasPrefix(line, "@") { if strings.HasPrefix(line, "@setup ") { setupPath := strings.TrimSpace(line[7:]) if setupPath == "" { return ShoutFile{}, fmt.Errorf("%s:%d: @setup requires a file path", path, lineNum) } directives = append(directives, Directive{ Type: "setup", Path: setupPath, Line: lineNum, }) } else if strings.HasPrefix(line, "@env ") { rest := strings.TrimSpace(line[5:]) eq := strings.Index(rest, "=") if eq <= 0 { return ShoutFile{}, fmt.Errorf("%s:%d: malformed @env directive (expected KEY=VALUE): %s", path, lineNum, line) } directives = append(directives, Directive{ Type: "env", Key: rest[:eq], Value: rest[eq+1:], Line: lineNum, }) } else { return ShoutFile{}, fmt.Errorf("%s:%d: unknown directive: %s", path, lineNum, line) } continue } if strings.HasPrefix(line, "$ ") { seenCommand = true if current != nil { trimmed := trimTrailingEmpty(current.Expected) remaining, ecType, ecVal := parseExitCode(trimmed) current.Expected = trimTrailingEmpty(remaining) current.ExitCodeType = ecType current.ExitCodeValue = ecVal commands = append(commands, *current) } current = &Command{ Line: lineNum, Raw: line, Cmd: stripComment(line[2:]), Expected: nil, } } else if current != nil { current.Expected = append(current.Expected, line) } } if current != nil { trimmed := trimTrailingEmpty(current.Expected) remaining, ecType, ecVal := parseExitCode(trimmed) current.Expected = trimTrailingEmpty(remaining) current.ExitCodeType = ecType current.ExitCodeValue = ecVal commands = append(commands, *current) } return ShoutFile{Path: path, Commands: commands, Directives: directives}, nil }