first commit
This commit is contained in:
commit
beaf0a5f3a
2
README.md
Normal file
2
README.md
Normal file
@ -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)
|
236
channelmsg.go
Normal file
236
channelmsg.go
Normal file
@ -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</%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
|
||||||
|
}
|
70
channelmsg_test.go
Normal file
70
channelmsg_test.go
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
"<cmsg ch='0'>hello channel messages</cmsg>": ChannelMessageTestStruct{
|
||||||
|
Message: Message{
|
||||||
|
Dialect: "cmsg",
|
||||||
|
Channel: "0",
|
||||||
|
Message: "hello channel messages",
|
||||||
|
ClosesChannel: false,
|
||||||
|
},
|
||||||
|
Chatter: false,
|
||||||
|
Anonymous: false,
|
||||||
|
},
|
||||||
|
"<cmsg ch='0' />": ChannelMessageTestStruct{
|
||||||
|
Message: Message{
|
||||||
|
Dialect: "cmsg",
|
||||||
|
Channel: "0",
|
||||||
|
ClosesChannel: true,
|
||||||
|
},
|
||||||
|
Chatter: false,
|
||||||
|
Anonymous: false,
|
||||||
|
},
|
||||||
|
"<cmsg>status</cmsg>": 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user