From e12526dca62fc55ea5a86cb08dd7a1de19728c15 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Mar 2026 12:29:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20string.go=20=E2=80=94=20core=20string?= =?UTF-8?q?=20primitives,=20same=20pattern=20as=20array.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HasPrefix, HasSuffix, TrimPrefix, TrimSuffix, Contains, Split, SplitN, StringJoin, Replace, Lower, Upper, Trim, RuneCount. utils.go and command.go now use string.go helpers — zero direct strings import in either file. 234 tests, 79.8% coverage. Co-Authored-By: Virgil --- pkg/core/command.go | 9 ++-- pkg/core/string.go | 104 +++++++++++++++++++++++++++++++++++++++++++ pkg/core/utils.go | 28 +++++------- tests/string_test.go | 70 +++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 pkg/core/string.go create mode 100644 tests/string_test.go diff --git a/pkg/core/command.go b/pkg/core/command.go index 177ecf0..2f654e7 100644 --- a/pkg/core/command.go +++ b/pkg/core/command.go @@ -21,7 +21,6 @@ package core import ( - "strings" "sync" ) @@ -65,7 +64,7 @@ func (cmd *Command) I18nKey() string { if path == "" { path = cmd.name } - return "cmd." + strings.ReplaceAll(path, "/", ".") + ".description" + return "cmd." + Replace(path, "/", ".") + ".description" } // Run executes the command's action with the given options. @@ -183,9 +182,9 @@ func (c *Core) Command(args ...any) any { c.commands.commands[path] = cmd // Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing - parts := strings.Split(path, "/") + parts := Split(path, "/") for i := len(parts) - 1; i > 0; i-- { - parentPath := strings.Join(parts[:i], "/") + parentPath := StringJoin(parts[:i], "/") if _, exists := c.commands.commands[parentPath]; !exists { c.commands.commands[parentPath] = &Command{ name: parts[i-1], @@ -220,6 +219,6 @@ func (c *Core) Commands() []string { // pathName extracts the last segment of a path. // "deploy/to/homelab" → "homelab" func pathName(path string) string { - parts := strings.Split(path, "/") + parts := Split(path, "/") return parts[len(parts)-1] } diff --git a/pkg/core/string.go b/pkg/core/string.go new file mode 100644 index 0000000..98c0f1f --- /dev/null +++ b/pkg/core/string.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// String operations for the Core framework. +// Provides safe, predictable string helpers that downstream packages +// use directly — same pattern as Array[T] for slices. + +package core + +import ( + "strings" + "unicode/utf8" +) + +// HasPrefix returns true if s starts with prefix. +// +// core.HasPrefix("--verbose", "--") // true +func HasPrefix(s, prefix string) bool { + return strings.HasPrefix(s, prefix) +} + +// HasSuffix returns true if s ends with suffix. +// +// core.HasSuffix("test.go", ".go") // true +func HasSuffix(s, suffix string) bool { + return strings.HasSuffix(s, suffix) +} + +// TrimPrefix removes prefix from s. +// +// core.TrimPrefix("--verbose", "--") // "verbose" +func TrimPrefix(s, prefix string) string { + return strings.TrimPrefix(s, prefix) +} + +// TrimSuffix removes suffix from s. +// +// core.TrimSuffix("test.go", ".go") // "test" +func TrimSuffix(s, suffix string) string { + return strings.TrimSuffix(s, suffix) +} + +// Contains returns true if s contains substr. +// +// core.Contains("hello world", "world") // true +func Contains(s, substr string) bool { + return strings.Contains(s, substr) +} + +// Split splits s by separator. +// +// core.Split("a/b/c", "/") // ["a", "b", "c"] +func Split(s, sep string) []string { + return strings.Split(s, sep) +} + +// SplitN splits s by separator into at most n parts. +// +// core.SplitN("key=value=extra", "=", 2) // ["key", "value=extra"] +func SplitN(s, sep string, n int) []string { + return strings.SplitN(s, sep, n) +} + +// StringJoin joins segments with separator. +// +// core.StringJoin([]string{"a", "b", "c"}, "/") // "a/b/c" +func StringJoin(elems []string, sep string) string { + return strings.Join(elems, sep) +} + +// Replace replaces all occurrences of old with new in s. +// +// core.Replace("deploy/to/homelab", "/", ".") // "deploy.to.homelab" +func Replace(s, old, new string) string { + return strings.ReplaceAll(s, old, new) +} + +// Lower returns s in lowercase. +// +// core.Lower("HELLO") // "hello" +func Lower(s string) string { + return strings.ToLower(s) +} + +// Upper returns s in uppercase. +// +// core.Upper("hello") // "HELLO" +func Upper(s string) string { + return strings.ToUpper(s) +} + +// Trim removes leading and trailing whitespace. +// +// core.Trim(" hello ") // "hello" +func Trim(s string) string { + return strings.TrimSpace(s) +} + +// RuneCount returns the number of runes (unicode characters) in s. +// +// core.RuneCount("hello") // 5 +// core.RuneCount("🔥") // 1 +func RuneCount(s string) int { + return utf8.RuneCountInString(s) +} diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 99c9a8b..c89bc62 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -1,6 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 // Utility functions for the Core framework. +// Built on core string.go primitives. package core @@ -8,8 +9,6 @@ import ( "fmt" "io" "os" - "strings" - "unicode/utf8" ) // Print writes a formatted line to a writer, defaulting to os.Stdout. @@ -26,9 +25,8 @@ func Print(w io.Writer, format string, args ...any) { // JoinPath joins string segments into a path with "/" separator. // // core.JoinPath("deploy", "to", "homelab") // → "deploy/to/homelab" -// core.JoinPath(args[:3]...) // → first 3 args as path func JoinPath(segments ...string) string { - return strings.Join(segments, "/") + return StringJoin(segments, "/") } // IsFlag returns true if the argument starts with a dash. @@ -37,7 +35,7 @@ func JoinPath(segments ...string) string { // core.IsFlag("-v") // true // core.IsFlag("deploy") // false func IsFlag(arg string) bool { - return strings.HasPrefix(arg, "-") + return HasPrefix(arg, "-") } // FilterArgs removes empty strings and Go test runner flags from an argument list. @@ -46,7 +44,7 @@ func IsFlag(arg string) bool { func FilterArgs(args []string) []string { var clean []string for _, a := range args { - if a == "" || strings.HasPrefix(a, "-test.") { + if a == "" || HasPrefix(a, "-test.") { continue } clean = append(clean, a) @@ -66,12 +64,11 @@ func FilterArgs(args []string) []string { // "--v" → "", "", false (double dash, 1 char) // "hello" → "", "", false (not a flag) func ParseFlag(arg string) (key, value string, valid bool) { - if strings.HasPrefix(arg, "--") { - // Long flag: must be 2+ chars - rest := strings.TrimPrefix(arg, "--") - parts := strings.SplitN(rest, "=", 2) + if HasPrefix(arg, "--") { + rest := TrimPrefix(arg, "--") + parts := SplitN(rest, "=", 2) name := parts[0] - if utf8.RuneCountInString(name) < 2 { + if RuneCount(name) < 2 { return "", "", false } if len(parts) == 2 { @@ -80,12 +77,11 @@ func ParseFlag(arg string) (key, value string, valid bool) { return name, "", true } - if strings.HasPrefix(arg, "-") { - // Short flag: must be exactly 1 char (rune) - rest := strings.TrimPrefix(arg, "-") - parts := strings.SplitN(rest, "=", 2) + if HasPrefix(arg, "-") { + rest := TrimPrefix(arg, "-") + parts := SplitN(rest, "=", 2) name := parts[0] - if utf8.RuneCountInString(name) != 1 { + if RuneCount(name) != 1 { return "", "", false } if len(parts) == 2 { diff --git a/tests/string_test.go b/tests/string_test.go new file mode 100644 index 0000000..99b2ee0 --- /dev/null +++ b/tests/string_test.go @@ -0,0 +1,70 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- String Operations --- + +func TestHasPrefix_Good(t *testing.T) { + assert.True(t, HasPrefix("--verbose", "--")) + assert.True(t, HasPrefix("-v", "-")) + assert.False(t, HasPrefix("hello", "-")) +} + +func TestHasSuffix_Good(t *testing.T) { + assert.True(t, HasSuffix("test.go", ".go")) + assert.False(t, HasSuffix("test.go", ".py")) +} + +func TestTrimPrefix_Good(t *testing.T) { + assert.Equal(t, "verbose", TrimPrefix("--verbose", "--")) + assert.Equal(t, "hello", TrimPrefix("hello", "--")) +} + +func TestTrimSuffix_Good(t *testing.T) { + assert.Equal(t, "test", TrimSuffix("test.go", ".go")) + assert.Equal(t, "test.go", TrimSuffix("test.go", ".py")) +} + +func TestContains_Good(t *testing.T) { + assert.True(t, Contains("hello world", "world")) + assert.False(t, Contains("hello world", "mars")) +} + +func TestSplit_Good(t *testing.T) { + assert.Equal(t, []string{"a", "b", "c"}, Split("a/b/c", "/")) +} + +func TestSplitN_Good(t *testing.T) { + assert.Equal(t, []string{"key", "value=extra"}, SplitN("key=value=extra", "=", 2)) +} + +func TestStringJoin_Good(t *testing.T) { + assert.Equal(t, "a/b/c", StringJoin([]string{"a", "b", "c"}, "/")) +} + +func TestReplace_Good(t *testing.T) { + assert.Equal(t, "deploy.to.homelab", Replace("deploy/to/homelab", "/", ".")) +} + +func TestLower_Good(t *testing.T) { + assert.Equal(t, "hello", Lower("HELLO")) +} + +func TestUpper_Good(t *testing.T) { + assert.Equal(t, "HELLO", Upper("hello")) +} + +func TestTrim_Good(t *testing.T) { + assert.Equal(t, "hello", Trim(" hello ")) +} + +func TestRuneCount_Good(t *testing.T) { + assert.Equal(t, 5, RuneCount("hello")) + assert.Equal(t, 1, RuneCount("🔥")) + assert.Equal(t, 0, RuneCount("")) +}