feat: string.go — core string primitives, same pattern as array.go

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-20 12:29:15 +00:00
parent c8ebf40e78
commit e12526dca6
4 changed files with 190 additions and 21 deletions

View file

@ -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]
}

104
pkg/core/string.go Normal file
View file

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

View file

@ -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 {

70
tests/string_test.go Normal file
View file

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