package channelmsg import ( "errors" "fmt" "log" "strings" "unicode" ) var ( ChannelMarkerPrefix = "ch" LogDebugOutput = false XmlAttributeEnclosingQuote = "\"" ) func TryLogDebugOutput(format string, v ...any) { if LogDebugOutput { log.Printf(format, v...) } } func StringHasAngleBrackets(s string) bool { return strings.HasPrefix(s, "<") && strings.HasSuffix(s, ">") } func StringHasMatchingEnclosingQuotes(s string) bool { return ((strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'")) || (strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\""))) } type token struct { Dialect string Channel string Attributes map[string]string Message string ClosesMessage bool ClosesChannel bool } func newToken() token { return token{Attributes: make(map[string]string)} } var ( ErrEmptyString = errors.New("empty string is not a valid token") ErrNoAngleBrackets = errors.New("token does not have angle brackets") ErrMalformedToken = errors.New("token is malformed") ) func ContainsInvalidXmlElementNameChars(s string) bool { for _, r := range s { if !(unicode.IsLetter(r) || unicode.IsNumber(r) || r == '.' || r == '_' || r == '-') { return true } } return false } func IsValidXmlElementName(s string) bool { if len(s) == 0 || len(s) > 1024 || !(s[0] == '_' || unicode.IsLetter(rune(s[0]))) || strings.HasPrefix(strings.ToLower(s), "xml") { return false } return !ContainsInvalidXmlElementNameChars(s) } func parseToken(s string) (token, error) { results := newToken() if s = strings.TrimSpace(s); len(s) == 0 { return token{}, ErrEmptyString } else if !StringHasAngleBrackets(s) { return token{}, ErrNoAngleBrackets } s = strings.TrimSpace(s[1 : len(s)-1]) if strings.HasPrefix(s, "/") { results.ClosesMessage = true s = strings.TrimSpace(s[1:]) } if strings.HasSuffix(s, "/") { results.ClosesMessage = true results.ClosesChannel = true s = strings.TrimSpace(s[:len(s)-1]) } // dialect fields := strings.Fields(s) if len(fields) == 0 { return token{}, fmt.Errorf("%w: empty after removing angle brackets", ErrMalformedToken) } field := fields[0] if !IsValidXmlElementName(field) { return token{}, fmt.Errorf("%w: invalid XmlElementName '%s'", ErrMalformedToken, field) } results.Dialect = fields[0] // parse attributes for position := 1; position < len(fields); position++ { field := fields[position] i := strings.Index(field, "=") if i == -1 { return token{}, fmt.Errorf("%w: malformed attribute %s found - missing delimiter '='", ErrMalformedToken, field) } key, value := strings.TrimSpace(field[:i]), strings.TrimSpace(field[i+1:]) if !StringHasMatchingEnclosingQuotes(value) { for position = position + 1; position < len(fields) && !StringHasMatchingEnclosingQuotes(value); position++ { value = fmt.Sprintf("%s %s", value, fields[position]) } } if !StringHasMatchingEnclosingQuotes(value) { return token{}, fmt.Errorf("%w: malformed attribute found - missing quotes", ErrMalformedToken) } value = strings.TrimSpace(value[1 : len(value)-1]) if key == ChannelMarkerPrefix { results.Channel = value } else { results.Attributes[key] = value } } return results, nil } type Message struct { Dialect string Channel string Attributes map[string]string Message string ClosesChannel bool } func (m *Message) String() string { if m.Chatter() { return m.Message } var builder strings.Builder builder.WriteString(fmt.Sprintf("<%s", m.Dialect)) if !m.Anonymous() { builder.WriteString(fmt.Sprintf(" %s=%s%s%s", ChannelMarkerPrefix, XmlAttributeEnclosingQuote, m.Channel, XmlAttributeEnclosingQuote)) } for k, v := range m.Attributes { builder.WriteString(fmt.Sprintf(" %s=%s%s%s", k, XmlAttributeEnclosingQuote, v, XmlAttributeEnclosingQuote)) } if m.ClosesChannel { if len(m.Message) > 0 { panic("channelmsg.go Message::String error: cannot close the channel and send a message at the same time") } builder.WriteString(" />") return builder.String() } builder.WriteString(fmt.Sprintf(">%s", m.Message, m.Dialect)) return builder.String() } func (m *Message) IsEqual(rhs Message) bool { attributesMatch := func() bool { if want, got := len(m.Attributes), len(rhs.Attributes); want != got { return false } for k, v := range m.Attributes { if want, got := v, rhs.Attributes[k]; want != got { return false } } return true } return (m.Dialect == rhs.Dialect && m.Channel == rhs.Channel && m.Message == rhs.Message && m.ClosesChannel == rhs.ClosesChannel && attributesMatch()) } func (m *Message) Anonymous() bool { return len(m.Channel) == 0 } func (m *Message) Chatter() bool { return m.Anonymous() && len(m.Dialect) == 0 } func New() Message { return Message{Attributes: make(map[string]string)} } func Parse(s string) Message { results := New() if s = strings.TrimSpace(s); len(s) == 0 { TryLogDebugOutput("channelmsg.go Parse error: empty string\n") return results } else if !StringHasAngleBrackets(s) { TryLogDebugOutput("channelmsg.go Parse error: missing enclosing angle brackets\n") results.Message = s return results } // could be a valid channelmsg headerEnd := strings.Index(s, ">") headerToken, err := parseToken(s[:headerEnd+1]) if err != nil { TryLogDebugOutput("channelmsg.go Parse error parsing headerToken: %v\n", err) results.Message = s return results } if headerEnd == len(s)-1 { if !headerToken.ClosesMessage { TryLogDebugOutput("channelmsg.go Parse error: single-token message did not close the message/channel\n") results.Message = s return results } results.Dialect = headerToken.Dialect results.Channel = headerToken.Channel results.ClosesChannel = headerToken.ClosesChannel results.Message = headerToken.Message results.Attributes = headerToken.Attributes return results } // parse the footer token footerStart := strings.LastIndex(s, "<") if footerStart <= 0 { TryLogDebugOutput("channelmsg.go Parse error: cannot find footerToken start\n") results.Message = s return results } footerToken, err := parseToken(s[footerStart:]) if err != nil || footerToken.Dialect != headerToken.Dialect || !footerToken.ClosesMessage || len(footerToken.Attributes) > 0 { TryLogDebugOutput("channelmsg.go Parse error: footerToken %+v malformed\n", footerToken) results.Message = s return results } // cool. results.Dialect = headerToken.Dialect results.Channel = headerToken.Channel results.ClosesChannel = footerToken.ClosesChannel results.Message = footerToken.Message results.Attributes = headerToken.Attributes results.Message = strings.TrimSpace(s[headerEnd+1 : footerStart]) return results }