diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33c3c9f..7f078eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: '^1.17.6' + go-version: '^1.20.3' - name: Go Format run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0839582..518c564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 2.0.0 (2023-04-14) + +* Support Go 1.20. +* Rewrite `Pack` algorithm + ## 1.1.0 (2022-01-24) * Support Go 1.17. diff --git a/README.md b/README.md index 0185979..5d73b4b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A golang library for packing and unpacking hosts list ## Install ```bash -go get github.com/Wing924/hostutils +go get github.com/Wing924/hostutils/v2 ``` ## Examples @@ -22,7 +22,7 @@ package main import ( "fmt" - "github.com/Wing924/hostutils" + "github.com/Wing924/hostutils/v2" ) func main() { diff --git a/go.mod b/go.mod index bb45a74..fbf04a7 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,11 @@ module github.com/Wing924/hostutils +go 1.20 + +require github.com/stretchr/testify v1.2.2 + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.2.2 + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect ) diff --git a/go.sum b/go.sum index e03ee77..73b746c 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,5 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= diff --git a/pack.go b/pack.go index 249cdcd..49e8ec8 100644 --- a/pack.go +++ b/pack.go @@ -6,12 +6,12 @@ import ( "strings" ) -// PackString Pack space septated full hosts list into short abbreviated hosts. +// PackString Pack space separated full hosts list into short abbreviated hosts. func PackString(hosts string) (packedHosts []string) { return Pack([]string{hosts}) } -// Pack Pack full hosts list into short abbreviated hosts. +// Pack full hosts list into short abbreviated hosts. func Pack(hosts []string) (packedHosts []string) { regHosts := regularizeHosts(hosts[:]) if regHosts == nil { diff --git a/v2/common.go b/v2/common.go new file mode 100644 index 0000000..f763e38 --- /dev/null +++ b/v2/common.go @@ -0,0 +1,43 @@ +package hostutils + +import ( + "golang.org/x/exp/constraints" + "regexp" +) + +var ( + reComment = regexp.MustCompile(`#.*`) + reSpaces = regexp.MustCompile(`\s+`) + rePackedHost = regexp.MustCompile(`^([^\[]*)\[([-,:0-9\s]+)](.*)$`) + reCondSpace = regexp.MustCompile(`,\s*`) + reCondBlk = regexp.MustCompile(`^(\d+)([-:](\d+))?$`) +) + +func max[T constraints.Ordered](a T, b T) T { + if a > b { + return a + } + return b +} + +func regularizeHosts(hosts []string) []string { + if hosts == nil { + return nil + } + uniqHosts := make(map[string]bool) + for _, host := range hosts { + noCmtHosts := reComment.ReplaceAllString(host, "") + for _, h := range reSpaces.Split(noCmtHosts, -1) { + if h != "" { + uniqHosts[h] = true + } + } + } + result := make([]string, len(uniqHosts)) + var i = 0 + for host := range uniqHosts { + result[i] = host + i++ + } + return result +} diff --git a/v2/normalize.go b/v2/normalize.go new file mode 100644 index 0000000..6c12d7f --- /dev/null +++ b/v2/normalize.go @@ -0,0 +1,11 @@ +package hostutils + +// Normalize Unpack and pack hosts +func Normalize(hosts []string) (packedHosts []string) { + return Pack(Unpack(hosts)) +} + +// NormalizeString Unpack and pack hosts +func NormalizeString(hosts string) (packedHosts []string) { + return Pack(Unpack([]string{hosts})) +} diff --git a/v2/normalize_test.go b/v2/normalize_test.go new file mode 100644 index 0000000..9869dac --- /dev/null +++ b/v2/normalize_test.go @@ -0,0 +1,32 @@ +package hostutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalize(t *testing.T) { + testNormalize(t, nil, nil) + testNormalize(t, []string{}, nil) + testNormalize(t, []string{"ww[101]"}, []string{"ww101"}) + testNormalize(t, []string{"ww[101-103]", "ww[104]"}, []string{"ww[101-104]"}) + testNormalize(t, []string{"a[101,103,105]", "a[102,104,107]"}, []string{"a[101-105,107]"}) + testNormalize(t, []string{"[101-103]", "104"}, []string{"[101-104]"}) +} + +func TestNormalizeString(t *testing.T) { + testNormalizeString(t, "", nil) + testNormalizeString(t, "ww[101]", []string{"ww101"}) + testNormalizeString(t, "ww[101-103] ww[104]", []string{"ww[101-104]"}) + testNormalizeString(t, "a[101,103,105] a[102,104,107]", []string{"a[101-105,107]"}) + testNormalizeString(t, "[101-103] 104", []string{"[101-104]"}) +} + +func testNormalize(t *testing.T, input []string, expected []string) { + assert.Equal(t, expected, Normalize(input)) +} + +func testNormalizeString(t *testing.T, input string, expected []string) { + assert.Equal(t, expected, NormalizeString(input)) +} diff --git a/v2/pack.go b/v2/pack.go new file mode 100644 index 0000000..1849039 --- /dev/null +++ b/v2/pack.go @@ -0,0 +1,252 @@ +package hostutils + +import ( + "bytes" + "fmt" + "golang.org/x/exp/slices" + "strconv" +) + +type host struct { + NonDigits []string + Digits []digit +} + +type digit struct { + Value int + Digit int +} + +type hostGroup struct { + host + RangeIndex int + Conds []cond +} + +type cond struct { + Low int + High int + Digit int +} + +// PackString Pack space separated full hosts list into short abbreviated hosts. +func PackString(hosts string) []string { + return Pack([]string{hosts}) +} + +// Pack full hosts list into short abbreviated hosts. +func Pack(hosts []string) []string { + regHosts := regularizeHosts(hosts) + if len(regHosts) == 0 { + return nil + } + if len(regHosts) == 1 { + return regHosts + } + uniqHosts := make([]*host, 0, len(regHosts)) + for _, host := range regHosts { + uniqHosts = append(uniqHosts, parseHost(host)) + } + slices.SortFunc(uniqHosts, func(a, b *host) bool { + return a.Less(b) + }) + + result := make([]string, 0, len(uniqHosts)) + + var group *hostGroup + for i := 1; i < len(uniqHosts); i++ { + if group == nil { + group = mergeHost(uniqHosts[i-1], uniqHosts[i]) + if group == nil { + result = append(result, uniqHosts[i-1].String()) + } + } else { + if !group.AppendHost(uniqHosts[i]) { + result = append(result, group.String()) + group = nil + } + } + } + if group == nil { + result = append(result, uniqHosts[len(uniqHosts)-1].String()) + } else { + result = append(result, group.String()) + } + + return result +} + +func mergeHost(h1, h2 *host) *hostGroup { + if len(h1.NonDigits) != len(h2.NonDigits) || len(h1.Digits) != len(h2.Digits) { + return nil + } + idx := -1 + for i := range h1.NonDigits { + if h1.NonDigits[i] != h2.NonDigits[i] { + return nil + } + if i < len(h1.Digits) { + if idx == -1 { + if h1.Digits[i] != h2.Digits[i] { + idx = i + } + } else if h1.Digits[i] != h2.Digits[i] { + return nil + } + } + } + if idx == -1 { + panic("idx must not be -1 unless h1 and h2 are same") + } + g := &hostGroup{ + host: *h1, + RangeIndex: idx, + } + if h1.Digits[idx].Digit == h2.Digits[idx].Digit && h1.Digits[idx].Value+1 == h2.Digits[idx].Value { + g.Conds = append(g.Conds, cond{ + Low: h1.Digits[idx].Value, + High: h2.Digits[idx].Value, + Digit: h1.Digits[idx].Digit, + }) + } else { + g.Conds = append(g.Conds, cond{ + Low: h1.Digits[idx].Value, + High: h1.Digits[idx].Value, + Digit: h1.Digits[idx].Digit, + }, cond{ + Low: h2.Digits[idx].Value, + High: h2.Digits[idx].Value, + Digit: h2.Digits[idx].Digit, + }) + } + return g +} + +func (g *hostGroup) AppendHost(h *host) bool { + if len(g.NonDigits) != len(h.NonDigits) || len(g.Digits) != len(h.Digits) { + return false + } + idx := g.RangeIndex + for i := range g.NonDigits { + if g.NonDigits[i] != h.NonDigits[i] { + return false + } + if i != idx && i < len(g.Digits) && g.Digits[i] != h.Digits[i] { + return false + } + } + lastRange := g.Conds[len(g.Conds)-1] + if lastRange.Digit == h.Digits[idx].Digit && lastRange.High+1 == h.Digits[idx].Value { + g.Conds[len(g.Conds)-1].High = h.Digits[idx].Value + } else { + g.Conds = append(g.Conds, cond{ + Low: h.Digits[idx].Value, + High: h.Digits[idx].Value, + Digit: h.Digits[idx].Digit, + }) + } + return true +} + +func (g *hostGroup) String() string { + var buf bytes.Buffer + for i := range g.NonDigits { + buf.WriteString(g.NonDigits[i]) + if i != g.RangeIndex { + if i < len(g.Digits) { + _, _ = fmt.Fprintf(&buf, "%0*d", g.Digits[i].Digit, g.Digits[i].Value) + } + } else { + buf.WriteByte('[') + for j, r := range g.Conds { + if j != 0 { + buf.WriteByte(',') + } + if r.Low == r.High { + _, _ = fmt.Fprintf(&buf, "%0*d", r.Digit, r.Low) + } else { + _, _ = fmt.Fprintf(&buf, "%0*d-%0*d", r.Digit, r.Low, r.Digit, r.High) + } + } + buf.WriteByte(']') + } + } + return buf.String() +} + +func parseHost(s string) *host { + if s == "" { + return &host{ + NonDigits: []string{""}, + } + } + host := &host{} + buf := make([]rune, 0, len(s)) + digitMode := false + for _, c := range s { + digit := isDigit(c) + if digitMode == digit { + buf = append(buf, c) + } else { + host.appendToken(string(buf), digitMode) + buf = buf[:0] + buf = append(buf, c) + digitMode = !digitMode + } + } + if len(buf) > 0 { + host.appendToken(string(buf), digitMode) + } + return host +} + +func (h *host) String() string { + var buf bytes.Buffer + for i, s := range h.NonDigits { + buf.WriteString(s) + if i < len(h.Digits) { + _, _ = fmt.Fprintf(&buf, "%0*d", h.Digits[i].Digit, h.Digits[i].Value) + } + } + return buf.String() +} + +func (h *host) appendToken(token string, digitMode bool) { + if digitMode { + n, _ := strconv.Atoi(token) + h.Digits = append(h.Digits, digit{ + Value: n, + Digit: len(token), + }) + } else { + h.NonDigits = append(h.NonDigits, token) + } +} + +func (h *host) Less(rhs *host) bool { + for i := range h.NonDigits { + if h.NonDigits[i] < rhs.NonDigits[i] { + return true + } + if h.NonDigits[i] > rhs.NonDigits[i] { + return false + } + if h.Digits[i].Digit < rhs.Digits[i].Digit { + return true + } + if h.Digits[i].Digit > rhs.Digits[i].Digit { + return false + } + if h.Digits[i].Value < rhs.Digits[i].Value { + return true + } + if h.Digits[i].Value > rhs.Digits[i].Value { + return false + } + } + return false +} + +func isDigit(c rune) bool { + return c >= '0' && c <= '9' +} diff --git a/v2/pack_test.go b/v2/pack_test.go new file mode 100644 index 0000000..dbdc4f9 --- /dev/null +++ b/v2/pack_test.go @@ -0,0 +1,186 @@ +package hostutils + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func TestPack(t *testing.T) { + cases := []struct { + input, output []string + }{ + {nil, nil}, + {[]string{""}, nil}, + {[]string{"abc101z"}, []string{"abc101z"}}, + {[]string{"abc101z", "abc102z"}, []string{"abc[101-102]z"}}, + {[]string{"abc103z", "abc102z", "abc101z"}, []string{"abc[101-103]z"}}, + {[]string{"abc101z", "abc102z", "abc104z"}, []string{"abc[101-102,104]z"}}, + {[]string{"abc101z", "abc102z", "abc104"}, []string{"abc[101-102]z", "abc104"}}, + {[]string{"abc101z-02", "abc102z-02", "abc102z-03"}, []string{"abc[101-102]z-02", "abc102z-03"}}, + {[]string{"abc101z-01", "abc102z-02", "abc102z-03"}, []string{"abc101z-01", "abc102z-[02-03]"}}, + } + for _, test := range cases { + test := test + t.Run(strings.Join(test.output, ";"), func(t *testing.T) { + actual := Pack(test.input) + assert.EqualValues(t, test.output, actual) + }) + } +} + +func TestParseHost(t *testing.T) { + cases := []struct { + input string + expected host + }{ + {"", host{ + NonDigits: []string{""}, + Digits: nil, + }}, + {"a", host{ + NonDigits: []string{"a"}, + Digits: nil, + }}, + {"abc", host{ + NonDigits: []string{"abc"}, + Digits: nil, + }}, + {"1", host{ + NonDigits: []string{""}, + Digits: []digit{{1, 1}}, + }}, + {"01", host{ + NonDigits: []string{""}, + Digits: []digit{{1, 2}}, + }}, + {"a0", host{ + NonDigits: []string{"a"}, + Digits: []digit{{0, 1}}, + }}, + {"abc101", host{ + NonDigits: []string{"abc"}, + Digits: []digit{{101, 3}}, + }}, + {"abc011", host{ + NonDigits: []string{"abc"}, + Digits: []digit{{11, 3}}, + }}, + {"abc001", host{ + NonDigits: []string{"abc"}, + Digits: []digit{{1, 3}}, + }}, + {"abc001def", host{ + NonDigits: []string{"abc", "def"}, + Digits: []digit{{1, 3}}, + }}, + {"abc001def2", host{ + NonDigits: []string{"abc", "def"}, + Digits: []digit{{1, 3}, {2, 1}}, + }}, + } + for _, test := range cases { + test := test + t.Run(test.input, func(t *testing.T) { + actual := parseHost(test.input) + assert.EqualValues(t, test.expected, *actual) + assert.Equal(t, test.input, actual.String()) + }) + } +} + +func TestMergeHost(t *testing.T) { + cases := []struct { + a, b, result string + }{ + {"a1", "a2", "a[1-2]"}, + {"b1", "a2", ""}, + {"a1a", "a2", ""}, + {"a01", "a02", "a[01-02]"}, + {"a01", "a03", "a[01,03]"}, + {"a01x", "a03x", "a[01,03]x"}, + {"a01-02", "a02-02", "a[01-02]-02"}, + {"a01-02", "a01-03", "a01-[02-03]"}, + {"a01-02", "a03-04", ""}, + {"a01", "a001", "a[01,001]"}, + } + + for _, test := range cases { + test := test + t.Run(test.result, func(t *testing.T) { + h1 := parseHost(test.a) + h2 := parseHost(test.b) + g := mergeHost(h1, h2) + if test.result != "" { + require.NotNil(t, g) + assert.Equal(t, test.result, g.String()) + } else { + require.Nil(t, g) + } + }) + } +} + +func TestHostGroup_AppendHost(t *testing.T) { + { + g := &hostGroup{ + host: *parseHost("abc101z"), + RangeIndex: 0, + Conds: []cond{ + {101, 102, 3}, + }, + } + ok := g.AppendHost(parseHost("ab103z")) + assert.False(t, ok) + } + { + g := &hostGroup{ + host: *parseHost("abc101z"), + RangeIndex: 0, + Conds: []cond{ + {101, 102, 3}, + }, + } + ok := g.AppendHost(parseHost("abc103z1")) + assert.False(t, ok) + } + { + g := &hostGroup{ + host: *parseHost("abc101z1"), + RangeIndex: 0, + Conds: []cond{ + {101, 102, 3}, + }, + } + ok := g.AppendHost(parseHost("abc103z5")) + assert.False(t, ok) + } + { + g := &hostGroup{ + host: *parseHost("abc101z"), + RangeIndex: 0, + Conds: []cond{ + {101, 102, 3}, + }, + } + ok := g.AppendHost(parseHost("abc103z")) + assert.True(t, ok) + assert.Len(t, g.Conds, 1) + assert.Equal(t, cond{101, 103, 3}, g.Conds[0]) + } + { + g := &hostGroup{ + host: *parseHost("abc101z"), + RangeIndex: 0, + Conds: []cond{ + {101, 102, 3}, + }, + } + ok := g.AppendHost(parseHost("abc104z")) + assert.True(t, ok) + assert.Len(t, g.Conds, 2) + assert.Equal(t, cond{101, 102, 3}, g.Conds[0]) + assert.Equal(t, cond{104, 104, 3}, g.Conds[1]) + } +} diff --git a/v2/unpack.go b/v2/unpack.go new file mode 100644 index 0000000..9aede9e --- /dev/null +++ b/v2/unpack.go @@ -0,0 +1,73 @@ +package hostutils + +import ( + "fmt" + "sort" + "strconv" +) + +// UnpackString Unpack space separated short abbreviated hosts into full hosts list. +func UnpackString(packedHosts string) (hosts []string) { + return Unpack([]string{packedHosts}) +} + +// Unpack short abbreviated hosts into full hosts list. +func Unpack(packedHosts []string) (hosts []string) { + regHosts := regularizeHosts(packedHosts) + if regHosts == nil { + return nil + } + resultSet := make(map[string]bool) + + for _, packedHost := range regHosts { + unpackHosts(packedHost, resultSet) + } + + result := make([]string, len(resultSet)) + i := 0 + for key := range resultSet { + result[i] = key + i++ + } + sort.Strings(result) + return result +} + +func unpackHosts(packedHost string, resultSet map[string]bool) { + m := rePackedHost.FindStringSubmatch(packedHost) + if m != nil { + prefix := m[1] + cond := m[2] + suffix := m[3] + for _, num := range unpackCond(cond) { + newHost := fmt.Sprintf("%s%s%s", prefix, num, suffix) + unpackHosts(newHost, resultSet) + } + } else { + resultSet[packedHost] = true + } +} + +func unpackCond(cond string) []string { + var result []string + + for _, blk := range reCondSpace.Split(cond, -1) { + m := reCondBlk.FindStringSubmatch(blk) + if m != nil { + if m[2] == "" { + result = append(result, m[1]) + } else { + digit := max(len(m[1]), len(m[3])) + low, _ := strconv.Atoi(m[1]) + high, _ := strconv.Atoi(m[3]) + if low > high { + low, high = high, low + } + for i := low; i <= high; i++ { + result = append(result, fmt.Sprintf("%0*d", digit, i)) + } + } + } + } + return result +} diff --git a/v2/unpack_test.go b/v2/unpack_test.go new file mode 100644 index 0000000..7aac2ec --- /dev/null +++ b/v2/unpack_test.go @@ -0,0 +1,163 @@ +package hostutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnpack(t *testing.T) { + testUnpack(t, nil, nil) + testUnpack(t, []string{}, []string{}) + testUnpack(t, []string{"aa[123*2]b"}, []string{"aa[123*2]b"}) + testUnpack(t, []string{""}, []string{}) + testUnpack(t, + []string{"example[999-1001]c.com"}, + []string{ + "example0999c.com", + "example1000c.com", + "example1001c.com", + }) + testUnpack(t, + []string{"example[1001-999]c.com"}, + []string{ + "example0999c.com", + "example1000c.com", + "example1001c.com", + }) + testUnpack(t, + []string{"example[101-103]c.com"}, + []string{ + "example101c.com", + "example102c.com", + "example103c.com", + }) + testUnpack(t, + []string{"example[1001,101-102]c.com"}, + []string{ + "example1001c.com", + "example101c.com", + "example102c.com", + }) + testUnpack(t, + []string{"example[103-101]c.com"}, + []string{ + "example101c.com", + "example102c.com", + "example103c.com", + }) + testUnpack(t, + []string{"example[1-2][01-02]c.com"}, + []string{ + "example101c.com", + "example102c.com", + "example201c.com", + "example202c.com", + }) + testUnpack(t, []string{"www.example.com"}, []string{"www.example.com"}) + testUnpack(t, + []string{"example[101-105]c.com"}, + []string{ + "example101c.com", + "example102c.com", + "example103c.com", + "example104c.com", + "example105c.com", + }) + testUnpack(t, + []string{"example-100-[101-105]c.com"}, + []string{ + "example-100-101c.com", + "example-100-102c.com", + "example-100-103c.com", + "example-100-104c.com", + "example-100-105c.com", + }) + testUnpack(t, + []string{"example[01-03]c.com"}, + []string{ + "example01c.com", + "example02c.com", + "example03c.com", + }) + testUnpack(t, + []string{"example[101-103,201]c.com"}, + []string{ + "example101c.com", + "example102c.com", + "example103c.com", + "example201c.com", + }) + testUnpack(t, + []string{"example[101,103-105,201]c.com"}, + []string{ + "example101c.com", + "example103c.com", + "example104c.com", + "example105c.com", + "example201c.com", + }) + testUnpack(t, + []string{"example[101,103-105,201]c.com", "test[101-102]z.com"}, + []string{ + "example101c.com", + "example103c.com", + "example104c.com", + "example105c.com", + "example201c.com", + "test101z.com", + "test102z.com", + }) + testUnpack(t, + []string{"example[101,103,105,201]c.com", "test[101-102]z.com"}, + []string{ + "example101c.com", + "example103c.com", + "example105c.com", + "example201c.com", + "test101z.com", + "test102z.com", + }) + testUnpack(t, + []string{"example[01-02,102,0003]c.com"}, + []string{ + "example0003c.com", + "example01c.com", + "example02c.com", + "example102c.com", + }) +} + +func TestUnpackString(t *testing.T) { + testUnpackString(t, "", []string{}) + testUnpackString(t, "aa[123*2]b", []string{"aa[123*2]b"}) + testUnpackString(t, "example[101,103,105,201]c.com test[101-102]z.com", + []string{ + "example101c.com", + "example103c.com", + "example105c.com", + "example201c.com", + "test101z.com", + "test102z.com", + }) + testUnpackString(t, ` + example[101,103,105,201]c.com + test[101-102]z.com`, + []string{ + "example101c.com", + "example103c.com", + "example105c.com", + "example201c.com", + "test101z.com", + "test102z.com", + }) + +} + +func testUnpack(t *testing.T, input []string, expected []string) { + assert.Equal(t, expected, Unpack(input)) +} + +func testUnpackString(t *testing.T, input string, expected []string) { + assert.Equal(t, expected, UnpackString(input)) +}