write output method, make tags order-preserving

This commit is contained in:
David 2021-10-09 15:28:12 -04:00
parent 092bc50199
commit dca7ed3387
2 changed files with 220 additions and 19 deletions

View File

@ -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()
}

View File

@ -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()
}
}
}