channelmsg/channelmsg.go

237 lines
6.6 KiB
Go
Raw Permalink Normal View History

2025-05-07 22:58:29 -04:00
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</%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
}