237 lines
6.6 KiB
Go
237 lines
6.6 KiB
Go
|
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
|
||
|
}
|