commit beaf0a5f3adc13ff61b96bb8a9586bf440d7b782 Author: William Dillon Date: Wed May 7 22:58:29 2025 -0400 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..7598af7 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# package channelmsg +package channelmsg is a simple message protocol based on specs originally written by Pete McNeil: [https://www.lifeatwarp9.com/2016/02/channels-a-friendly-protocol-for-multiplexing-ipc/](Pete McNeil's Channels Protocol) diff --git a/channelmsg.go b/channelmsg.go new file mode 100644 index 0000000..d60a951 --- /dev/null +++ b/channelmsg.go @@ -0,0 +1,236 @@ +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 +} diff --git a/channelmsg_test.go b/channelmsg_test.go new file mode 100644 index 0000000..30061a2 --- /dev/null +++ b/channelmsg_test.go @@ -0,0 +1,70 @@ +package channelmsg + +import ( + "os" + "testing" +) + +type ChannelMessageTestStruct struct { + Message Message + Chatter bool + Anonymous bool +} + +var ( + TheTestMessages = map[string]ChannelMessageTestStruct{ + "": ChannelMessageTestStruct{ + Chatter: true, + Anonymous: true, + }, + "hello channel messages": ChannelMessageTestStruct{ + Message: Message{ + Dialect: "cmsg", + Channel: "0", + Message: "hello channel messages", + ClosesChannel: false, + }, + Chatter: false, + Anonymous: false, + }, + "": ChannelMessageTestStruct{ + Message: Message{ + Dialect: "cmsg", + Channel: "0", + ClosesChannel: true, + }, + Chatter: false, + Anonymous: false, + }, + "status": ChannelMessageTestStruct{ + Message: Message{ + Dialect: "cmsg", + Message: "status", + ClosesChannel: false, + }, + Chatter: false, + Anonymous: true, + }, + } +) + +func TestMain(m *testing.M) { + LogDebugOutput = true + XmlAttributeEnclosingQuote = "'" + os.Exit(m.Run()) +} + +func TestParse(t *testing.T) { + for s, want := range TheTestMessages { + got := Parse(s) + if !want.Message.IsEqual(got) { + t.Fatalf("error parsing %s: wanted %+v; got %+v\n", s, want.Message, got) + } else if want.Anonymous != got.Anonymous() { + t.Fatalf("error parsing %s: wanted anonymous == %v; got %v\n", s, want.Anonymous, got.Anonymous()) + } else if want.Chatter != got.Chatter() { + t.Fatalf("error parsing %s: wanted chatter == %v; got %v\n", s, want.Chatter, got.Chatter()) + } else if want, got := s, got.String(); want != got { + t.Fatalf("error: wanted '%s'; got '%s'\n", want, got) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e5f053 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.wmdillon.com/GoApi/channelmsg + +go 1.24.3