From f20fcb466d3501d62130587cc35cb0e28c06e394 Mon Sep 17 00:00:00 2001 From: William Dillon Date: Sat, 21 Feb 2026 15:58:10 -0500 Subject: [PATCH] first commit - still working on tests for edge cases --- README.md | 2 + go.mod | 3 + message.go | 207 ++++++++++++++++++++++++++++++++++++++++++++++++ message_test.go | 60 ++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 message.go create mode 100644 message_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6b4064 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# msmsg +msmsg (Mad Scienstist Messaging Protocol) is a simple protocol for multiplexing, as specified by Pete McNeil. See https://www.lifeatwarp9.com/2016/02/channels-a-friendly-protocol-for-multiplexing-ipc/ for the full specs. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c9f4bbb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.wmdillon.com/wmdillon/msmsg + +go 1.25.5 diff --git a/message.go b/message.go new file mode 100644 index 0000000..87ec26c --- /dev/null +++ b/message.go @@ -0,0 +1,207 @@ +package msmsg + +import ( + "errors" + "fmt" + "strings" +) + +type Message struct { + Dialect string + ID string + Parameters map[string]string + Body string + ClosesChannel bool +} + +func (m Message) String() string { + switch { + case m.Chatter(): + return m.Body + } + var builder strings.Builder + + return builder.String() +} + +func ByteIsSpace(b byte) bool { + return b == ' ' || + b == '\t' || + b == '\r' || + b == '\n' +} + +// Lines that are not encapsulated in XML tags or +// are not recognized as part of a known dialect +// are called chatter. +func (m *Message) Chatter() bool { + return len(m.Dialect) == 0 && len(m.ID) == 0 && len(m.Parameters) == 0 +} + +// Lines that have no associated ChannelID are +// called anonymous messages. +func (m *Message) Anonymous() bool { + return !m.Chatter() && len(m.ID) == 0 +} + +type token struct { + dialect string + id string + parameters map[string]string + body string + closesMessage bool + closesChannel bool +} + +var ( + errTokenMalformed = errors.New("token is malformed") +) + +func parseToken(s string) (token, error) { + var results token + + if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") { + return token{body: s}, fmt.Errorf("%w: missing opening and/or closing angle bracket", errTokenMalformed) + } + + buffer := strings.TrimSpace(s[1 : len(s)-1]) + if strings.HasPrefix(buffer, "/") { + results.closesMessage = true + buffer = strings.TrimSpace(buffer[1:]) + } + if strings.HasSuffix(buffer, "/") { + results.closesMessage = true + results.closesChannel = true + buffer = strings.TrimSpace(buffer[:len(buffer)-1]) + } + + // try to grab the first word off the front, this should be the dialect + i := strings.IndexAny(buffer, " \t\r\n") + if i == -1 { + results.dialect = buffer + return results, nil + } else if i < len(buffer) { + // is the next non-whitespace char an '='? + for j := i + 1; j < len(buffer); j++ { + if ByteIsSpace(buffer[j]) { + i = j + break + } else if buffer[j] == '=' { + i = 0 + break + } else { + break + } + } + if i > 0 { + results.dialect = buffer[:i] + buffer = strings.TrimSpace(buffer[i:]) + } + } + + // everything else is presumed to be key=value pairs + type STATE int8 + const ( + KEY STATE = iota + VALUE + ) + var key, value strings.Builder + var quote byte + var state STATE + for i = 0; i < len(buffer); i++ { + theChar := buffer[i] + switch state { + case KEY: + // parse the key and prepare to parse the value by finding the '=' and the quotation mark + if ByteIsSpace(theChar) { + continue + } else if theChar == '=' { + // find the next quotation mark + for i = i + 1; i < len(buffer); i++ { + theChar = buffer[i] + if ByteIsSpace(theChar) { + continue + } else if theChar == '\'' || theChar == '"' { + quote = theChar + state = VALUE + break + } else { + return token{body: s}, fmt.Errorf("%w: parameter value missing quotation mark", errTokenMalformed) + } + } + } else { + key.WriteByte(theChar) + } + case VALUE: + // if quote == \0 something bad has happened - we don't know what we're looking for + if quote == '\x00' { + return token{body: s}, fmt.Errorf("%w: missing quotation mark in VALUE state...", errTokenMalformed) + } else if theChar == quote { + // we have a key and a value - add them to the parameters + if results.parameters == nil { + results.parameters = make(map[string]string, 1) + } + results.parameters[key.String()] = value.String() + key.Reset() + value.Reset() + quote = '\x00' + state = KEY + continue + } else { + value.WriteByte(theChar) + } + } + } + return results, nil +} + +// This message is opening this channel. + +func ParseMessage(s string) Message { + var message Message + if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") { + message.Body = s + } + headerEnd := strings.IndexByte(s, '>') + headerString := s[:headerEnd+1] + header, err := parseToken(headerString) + if err != nil { + return Message{Body: s} + } + message.Dialect = strings.Clone(header.dialect) + for k, v := range header.parameters { + switch strings.ToLower(k) { + case "ch": + message.ID = strings.Clone(v) + default: + if message.Parameters == nil { + message.Parameters = make(map[string]string, len(header.parameters)) + } + message.Parameters[strings.Clone(k)] = strings.Clone(v) + } + } + // is there a footer? + if headerEnd == len(s)-1 { + if header.closesChannel { + message.ClosesChannel = true + } + return message + } + footerStart := strings.LastIndexByte(s, '<') + if footerStart <= 0 { + // malformed + return Message{Body: s} + } + footer, err := parseToken(s[footerStart:]) + if err != nil { + return Message{Body: s} + } + if want, got := header.dialect, footer.dialect; want != got { + return Message{Body: s} + } else if !footer.closesMessage { + return Message{Body: s} + } + // the stuff between is the body + message.Body = strings.TrimSpace(s[headerEnd+1 : footerStart]) + return message +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..f8fc7b0 --- /dev/null +++ b/message_test.go @@ -0,0 +1,60 @@ +package msmsg + +import ( + "reflect" + "testing" +) + +var TheTestObjects = map[string]Message{ + "This message is opening this channel.": { + Dialect: "dialect", + ID: "0", + Body: "This message is opening this channel.", + }, + "": { + Dialect: "dialect", + ID: "0", + }, + "": { + Dialect: "dialect", + ID: "0", + ClosesChannel: true, + }, + "": { + Dialect: "dialect", + ID: "0", + ClosesChannel: true, + Parameters: map[string]string{ + "ctrl": "shutdown", + }, + }, + "Hello": {Dialect: "dialect", Body: "Hello"}, + + "this is chatter": { + Body: "this is chatter", + }, + // these are malformed + "": {Body: ""}, + "Hello": {Body: "Hello"}, + "Hello": {Body: "Hello"}, + "Hello": {Body: "Hello"}, + "Hello": {Body: "Hello"}, +} + +func TestParseMessage(t *testing.T) { + for input, wantOutput := range TheTestObjects { + gotOutput := ParseMessage(input) + if want, got := wantOutput.Dialect, gotOutput.Dialect; want != got { + t.Fatalf("error parsing %s: wanted dialect %q; got %q\n", input, want, got) + } else if want, got := wantOutput.ID, gotOutput.ID; want != got { + t.Fatalf("error parsing %s: wanted ID %q; got %q\n", input, want, got) + } else if want, got := wantOutput.Body, gotOutput.Body; want != got { + t.Fatalf("error parsing %s: wanted body %q; got %q\n", input, want, got) + } else if !reflect.DeepEqual(wantOutput.Parameters, gotOutput.Parameters) { + t.Fatalf("error parsing %s: wanted parameters %+v; got %+v\n", input, wantOutput.Parameters, gotOutput.Parameters) + } else if want, got := wantOutput.ClosesChannel, gotOutput.ClosesChannel; want != got { + t.Fatalf("error parsing %s: wanted ClosesChannel=%v; got %v\n", input, want, got) + } + } +}