From dca7ed3387c6f9b80fad2faf45922a8a43526cd1 Mon Sep 17 00:00:00 2001 From: David Ashby Date: Sat, 9 Oct 2021 15:28:12 -0400 Subject: [PATCH] write output method, make tags order-preserving --- message.go | 99 +++++++++++++++++++++++++++++++--- message_test.go | 140 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 220 insertions(+), 19 deletions(-) diff --git a/message.go b/message.go index 14c6bce..19400ab 100644 --- a/message.go +++ b/message.go @@ -1,12 +1,37 @@ package main import ( + "bytes" "fmt" "strings" ) +type Tag struct { + Key string + Value string +} + +// Tags is an key-order-preserving, last-insert-wins slice +type Tags struct { + tags []Tag +} + +func (t *Tags) Insert(new Tag) { + for i := range t.tags { + if t.tags[i].Key == new.Key { + t.tags[i] = new + return + } + } + t.tags = append(t.tags, new) +} + +func (t *Tags) GetSlice() []Tag { + return t.tags +} + type Message struct { - Tags map[string]string + Tags []Tag Source string Command command Parameters []string @@ -32,8 +57,8 @@ func (m *Message) ParseMessage() error { return nil } -func (m *Message) parseTags() (map[string]string, error) { - tags := make(map[string]string) +func (m *Message) parseTags() ([]Tag, error) { + tags := &Tags{} for { next := m.findNext("=", ";") if next == -1 { @@ -42,17 +67,17 @@ func (m *Message) parseTags() (map[string]string, error) { key := m.Raw[m.parseIndex : m.parseIndex+next] m.parseIndex += next + 1 eot := m.findNext(";", " ") - tags[key] = unescapeTag(m.Raw[m.parseIndex : m.parseIndex+eot]) + tags.Insert(Tag{Key: key, Value: unescapeTag(m.Raw[m.parseIndex : m.parseIndex+eot])}) m.parseIndex += len(m.Raw[m.parseIndex:m.parseIndex+eot]) + 1 } else if m.Raw[m.parseIndex+next] == ';' { key := m.Raw[m.parseIndex : m.parseIndex+next] m.parseIndex += next + 1 - tags[key] = "" + tags.Insert(Tag{Key: key, Value: ""}) } else { break } } - return tags, nil + return tags.GetSlice(), nil } func (m *Message) parseSource() (string, error) { @@ -171,6 +196,64 @@ func unescapeTag(s string) string { return sb.String() } -func (m *Message) ToBytes() []byte { - return nil +func escapeTag(s string) string { + sb := strings.Builder{} + for i := range s { + switch s[i] { + case ';': + sb.Write([]byte{'\\', ':'}) + case ' ': + sb.Write([]byte{'\\', 's'}) + case '\\': + sb.Write([]byte{'\\', '\\'}) + case '\n': + sb.Write([]byte{'\\', 'n'}) + case '\r': + sb.Write([]byte{'\\', 'r'}) + default: + sb.WriteByte(s[i]) + } + } + return sb.String() +} + +func (m *Message) Canonicalize() []byte { + sb := bytes.NewBuffer([]byte{}) + if m.Tags != nil && len(m.Tags) > 0 { + sb.WriteByte('@') + for i := range m.Tags { + sb.WriteString(m.Tags[i].Key) + if m.Tags[i].Value != "" { + sb.WriteByte('=') + sb.WriteString(escapeTag(m.Tags[i].Value)) + } + if i+1 != len(m.Tags) { + sb.WriteByte(';') + } + } + sb.WriteByte(' ') + } + + if m.Source != "" { + sb.WriteByte(':') + sb.WriteString(m.Source) + sb.WriteByte(' ') + } + + sb.WriteString(string(m.Command)) + + if m.Parameters != nil && len(m.Parameters) > 0 { + sb.WriteByte(' ') + for i := range m.Parameters { + if i+1 == len(m.Parameters) && (strings.Contains(m.Parameters[i], " ") || m.Parameters[i] == "" || m.Parameters[i][0] == ':') { + sb.WriteByte(':') + } + sb.WriteString(m.Parameters[i]) + if i+1 != len(m.Parameters) { + sb.WriteByte(' ') + } + } + } + + return sb.Bytes() } diff --git a/message_test.go b/message_test.go index 15829a6..e99435c 100644 --- a/message_test.go +++ b/message_test.go @@ -20,7 +20,7 @@ func TestParsing(t *testing.T) { { input: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`, output: &Message{ - Tags: map[string]string{"id": "234AB"}, + Tags: []Tag{{"id", "234AB"}}, Source: "dan!d@localhost", Command: PRIVMSG, Parameters: []string{"#chan", "Hey what's up!"}, @@ -28,7 +28,7 @@ func TestParsing(t *testing.T) { { input: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`, output: &Message{ - Tags: map[string]string{"a": "b", "c": "32", "k": "", "rt": "ql7"}, + Tags: []Tag{{"a", "b"}, {"c", "32"}, {"k", ""}, {"rt", "ql7"}}, Source: "dan!d@localhost", Command: PRIVMSG, Parameters: []string{"#chan", "Hey what's up!"}, @@ -36,13 +36,13 @@ func TestParsing(t *testing.T) { { input: `@a=b\\and\nk;c=72\s45;d=gh\:764 CAP`, output: &Message{ - Tags: map[string]string{"a": "b\\and\nk", "c": "72 45", "d": `gh;764`}, - Command: command("CAP"), + Tags: []Tag{{"a", "b\\and\nk"}, {"c", "72 45"}, {"d", `gh;764`}}, + Command: CAP, }}, { input: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :dan!d@localhost PRIVMSG #chan :Hey what's up!`, output: &Message{ - Tags: map[string]string{"tag1": "value1", "tag2": "", "vendor1/tag3": "value2", "vendor2/tag4": ""}, + Tags: []Tag{{"tag1", "value1"}, {"tag2", ""}, {"vendor1/tag3", "value2"}, {"vendor2/tag4", ""}}, Source: "dan!d@localhost", Command: PRIVMSG, Parameters: []string{"#chan", "Hey what's up!"}, @@ -121,31 +121,31 @@ func TestParsing(t *testing.T) { { input: `@tag1=value\\ntest COMMAND`, output: &Message{ - Tags: map[string]string{"tag1": `value\ntest`}, + Tags: []Tag{{"tag1", `value\ntest`}}, Command: command("COMMAND"), }}, { input: `@tag1=value\1 COMMAND`, output: &Message{ - Tags: map[string]string{"tag1": `value1`}, + Tags: []Tag{{"tag1", `value1`}}, Command: command("COMMAND"), }}, { input: `@tag1=value\ COMMAND`, output: &Message{ - Tags: map[string]string{"tag1": `value`}, + Tags: []Tag{{"tag1", `value`}}, Command: command("COMMAND"), }}, { input: `@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND`, output: &Message{ - Tags: map[string]string{"tag1": "5", "tag2": "3", "tag3": "4"}, + Tags: []Tag{{"tag1", "5"}, {"tag2", "3"}, {"tag3", "4"}}, Command: command("COMMAND"), }}, { input: `@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND`, output: &Message{ - Tags: map[string]string{"tag1": "5", "tag2": "3", "tag3": "4", "vendor/tag2": "8"}, + Tags: []Tag{{"tag1", "5"}, {"tag2", "3"}, {"tag3", "4"}, {"vendor/tag2", "8"}}, Command: command("COMMAND"), }}, { @@ -172,7 +172,7 @@ func TestParsing(t *testing.T) { } for i := range tc.output.Tags { if tc.output.Tags[i] != m.Tags[i] { - t.Logf("tags: actual: %v, expected: %v", m.Tags[i], tc.output.Tags[i]) + t.Logf("tags: actual: %+v, expected: %+v", m.Tags[i], tc.output.Tags[i]) t.Fail() } } @@ -196,3 +196,121 @@ func TestParsing(t *testing.T) { } } } + +func TestCanonicalization(t *testing.T) { + testcases := []struct { + input *Message + output string + }{ + // these outputs are based on + // https://github.com/ircdocs/parser-tests/blob/master/tests/msg-split.yaml + // and https://modern.ircdocs.horse/#messages + { + input: &Message{ + Source: "irc.example.com", + Command: CAP, + Parameters: []string{"LS", "*", "multi-prefix extended-join sasl"}, + }, + output: `:irc.example.com CAP LS * :multi-prefix extended-join sasl`, + }, + { + input: &Message{ + Tags: []Tag{{"id", "234AB"}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }, + output: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + }, + { + input: &Message{ + Tags: []Tag{{"a", "b"}, {"c", "32"}, {"k", ""}, {"rt", "ql7"}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }, + output: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + }, + { + input: &Message{ + Tags: []Tag{{"a", "b\\and\nk"}, {"c", "72 45"}, {"d", `gh;764`}}, + Command: CAP, + }, + output: `@a=b\\and\nk;c=72\s45;d=gh\:764 CAP`, + }, + { + input: &Message{ + Tags: []Tag{{"tag1", "value1"}, {"tag2", ""}, {"vendor1/tag3", "value2"}, {"vendor2/tag4", ""}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }, + output: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + }, + { + input: &Message{ + Source: "src", + Command: AWAY, + }, + output: ":src AWAY", + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", "sasl"}, + }, + output: `CAP REQ sasl`, + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", ""}, + }, + output: `CAP REQ :`, + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", ":asdf"}, + }, + output: `CAP REQ ::asdf`, + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", " asdf qwer"}, + }, + output: `CAP REQ : asdf qwer`, + }, + { + input: &Message{ + Source: "coolguy", + Command: PRIVMSG, + Parameters: []string{"bar", "lol :) "}, + }, + output: `:coolguy PRIVMSG bar :lol :) `, + }, + { + input: &Message{ + Source: "coolguy!ag@net\x035w\x03ork.admin", + Command: PRIVMSG, + Parameters: []string{"foo", "bar baz"}, + }, + output: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz", + }, + { + input: &Message{ + Tags: []Tag{{"tag1", `value\ntest`}}, + Command: command("COMMAND"), + }, + output: `@tag1=value\\ntest COMMAND`, + }, + } + for _, tc := range testcases { + output := string(tc.input.Canonicalize()) + if output != tc.output { + t.Logf("received '%s', expected '%s'", output, tc.output) + t.Fail() + } + } +}