Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
72af113204
|
|||
|
c9f3e7a639
|
|||
|
42c0e48081
|
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/deckarep/golang-set/v2"
|
||||||
|
"r00t2.io/sysutils/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewEmbedFsMember returns an embedFsMember from embedFs.
|
||||||
|
func NewEmbedFsMember(embedFs *embed.FS) (m *embedFsMember) {
|
||||||
|
|
||||||
|
m = &embedFsMember{
|
||||||
|
FS: embedFs,
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
NewMergedFS returns a MergedFS.
|
||||||
|
|
||||||
|
The last filesystem in a MergedFS is always embedTplFS.
|
||||||
|
If fsPaths is empty, mfs will only contain embedTplFS.
|
||||||
|
|
||||||
|
Nonexisting paths in fsPaths will be skipped (and added to the noExist slice).
|
||||||
|
|
||||||
|
Order will be retained but duplicates will be skipped and added to the dupes slice.
|
||||||
|
|
||||||
|
noExist and dupes will be nil if no non-existing or duplicate paths are encountereed.
|
||||||
|
*/
|
||||||
|
func NewMergedFS(fsPaths ...string) (mfs *MergedFS, noExist, dupes []string, err error) {
|
||||||
|
|
||||||
|
var m MergedFS
|
||||||
|
var exists bool
|
||||||
|
var fsPath string
|
||||||
|
var fsMem mergedFsMember
|
||||||
|
|
||||||
|
m = MergedFS{
|
||||||
|
fses: make([]mergedFsMember, 0, len(fsPaths)+1),
|
||||||
|
overridePaths: make([]string, 0, len(fsPaths)+1),
|
||||||
|
uniq: mapset.NewSet[string](),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fsPath = range fsPaths {
|
||||||
|
if exists, err = paths.RealPathExists(&fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
noExist = append(noExist, fsPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !m.uniq.Add(fsPath) {
|
||||||
|
dupes = append(dupes, fsPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fsMem, err = NewRealFsMember(fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.fses = append(m.fses, fsMem)
|
||||||
|
m.overridePaths = append(m.overridePaths, fsPath)
|
||||||
|
}
|
||||||
|
// embedded fs last
|
||||||
|
m.fses = append(m.fses, NewEmbedFsMember(&embedTplFS))
|
||||||
|
m.overridePaths = append(m.overridePaths, "")
|
||||||
|
|
||||||
|
m.fses = slices.Clip(m.fses)
|
||||||
|
m.overridePaths = slices.Clip(m.overridePaths)
|
||||||
|
|
||||||
|
mfs = &m
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRealFsMember returns a realFsMember (which properly conforms to mergedFsMember) from a real filesystem path.
|
||||||
|
func NewRealFsMember(fsPath string) (fsm *realFsMember, err error) {
|
||||||
|
|
||||||
|
var fsp string
|
||||||
|
var m realFsMember
|
||||||
|
|
||||||
|
if err = paths.RealPath(&fsp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Root, err = os.OpenRoot(fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fsm = &m
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close is a no-op, but used to conform to mergedFsMember.
|
||||||
|
func (e *embedFsMember) Close() (err error) { return }
|
||||||
|
|
||||||
|
// Stat wraps fs.Stat on the embeded filesystem.
|
||||||
|
func (e *embedFsMember) Stat(fnm string) (stat fs.FileInfo, err error) {
|
||||||
|
|
||||||
|
if stat, err = fs.Stat(e.FS, fnm); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"r00t2.io/sysutils/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Append adds fsPaths as fs.FS to the MergedFS (before the embed.FS).
|
||||||
|
|
||||||
|
If fsPaths is empty, Append is a NO-OP.
|
||||||
|
|
||||||
|
noExist and dupes have the same meaning as in NewMergedFS.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) Append(fsPaths ...string) (noExist, dupes []string, err error) {
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
var fsPath string
|
||||||
|
var newPaths []string
|
||||||
|
var fsMem *realFsMember
|
||||||
|
var newFs []mergedFsMember
|
||||||
|
|
||||||
|
m.mut.Lock()
|
||||||
|
defer m.mut.Unlock()
|
||||||
|
|
||||||
|
newPaths = make([]string, 0, len(fsPaths))
|
||||||
|
newFs = make([]mergedFsMember, 0, len(fsPaths))
|
||||||
|
|
||||||
|
for _, fsPath = range fsPaths {
|
||||||
|
if exists, err = paths.RealPathExists(&fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
noExist = append(noExist, fsPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fsMem, err = NewRealFsMember(fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.uniq.Add(fsPath) {
|
||||||
|
dupes = append(dupes, fsPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newFs = append(newFs, fsMem)
|
||||||
|
newPaths = append(newPaths, fsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newFs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.overridePaths = append(m.overridePaths, newPaths...)
|
||||||
|
m.fses = append(m.fses[:len(m.fses)-1], append(newFs, m.fses[len(m.fses)-1])...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all realFsMember members. This MergedFS will not be able to be used after this is called.
|
||||||
|
func (m *MergedFS) Close() (err error) {
|
||||||
|
|
||||||
|
var fsMem mergedFsMember
|
||||||
|
|
||||||
|
m.mut.Lock()
|
||||||
|
defer m.mut.Unlock()
|
||||||
|
|
||||||
|
for _, fsMem = range m.fses {
|
||||||
|
if err = fsMem.(io.Closer).Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Insert adds fsPaths as fs.FS to a MergedFS starting at index idx.
|
||||||
|
|
||||||
|
If fsPaths is empty, Insert is a NO-OP.
|
||||||
|
|
||||||
|
If idx is 0 or negative, they will be prepended.
|
||||||
|
If idx is larger than the last index of real directories (excluding the last embed.FS),
|
||||||
|
it will be reduced to the highest valid index.
|
||||||
|
|
||||||
|
noExist and dupes have the same meaning as in NewMergedFS.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) Insert(idx int, fsPaths ...string) (noExist, dupes []string, err error) {
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
var fsPath string
|
||||||
|
var newPaths []string
|
||||||
|
var fsMem *realFsMember
|
||||||
|
var newFs []mergedFsMember
|
||||||
|
|
||||||
|
if len(fsPaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mut.Lock()
|
||||||
|
defer m.mut.Unlock()
|
||||||
|
|
||||||
|
newPaths = make([]string, 0, len(fsPaths))
|
||||||
|
newFs = make([]mergedFsMember, 0, len(fsPaths))
|
||||||
|
|
||||||
|
for _, fsPath = range fsPaths {
|
||||||
|
if exists, err = paths.RealPathExists(&fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
noExist = append(noExist, fsPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fsMem, err = NewRealFsMember(fsPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.uniq.Add(fsPath) {
|
||||||
|
dupes = append(dupes, fsPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newPaths = append(newPaths, fsPath)
|
||||||
|
newFs = append(newFs, fsMem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newFs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx < 0 {
|
||||||
|
m.fses = append(newFs, m.fses...)
|
||||||
|
m.overridePaths = append(newPaths, m.overridePaths...)
|
||||||
|
return
|
||||||
|
} else if idx >= len(m.fses)-1 {
|
||||||
|
idx = len(m.fses) - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
m.fses = append(m.fses[:idx], append(newFs, m.fses[idx:]...)...)
|
||||||
|
m.overridePaths = append(m.overridePaths[:idx], append(newPaths, m.overridePaths[idx:]...)...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens filename fnm and returns it as file handler fh.
|
||||||
|
func (m *MergedFS) Open(fnm string) (fh fs.File, err error) {
|
||||||
|
|
||||||
|
var fsref fs.FS
|
||||||
|
|
||||||
|
m.mut.RLock()
|
||||||
|
defer m.mut.RUnlock()
|
||||||
|
|
||||||
|
// Iterate until a match is found.
|
||||||
|
for _, fsref = range m.fses {
|
||||||
|
if fh, err = fsref.Open(fnm); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found.
|
||||||
|
err = &fs.PathError{
|
||||||
|
Op: "Open",
|
||||||
|
Path: fnm,
|
||||||
|
Err: fs.ErrNotExist,
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Paths returns a slice of real filesystem paths that are used by this MergedFS.
|
||||||
|
|
||||||
|
If sorted is true, they will be returned as a sorted slice -
|
||||||
|
otherwise they will be returned in the order they are checked.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) Paths(sorted bool) (fsPaths []string) {
|
||||||
|
|
||||||
|
m.mut.RLock()
|
||||||
|
defer m.mut.RUnlock()
|
||||||
|
|
||||||
|
if sorted {
|
||||||
|
fsPaths = m.uniq.ToSlice()
|
||||||
|
slices.Sort(fsPaths)
|
||||||
|
} else {
|
||||||
|
fsPaths = make([]string, len(m.overridePaths)-1)
|
||||||
|
copy(fsPaths, m.overridePaths[:len(m.overridePaths)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ReadDir returns a listing of path matcher fpath.
|
||||||
|
|
||||||
|
The result is a combination of all filesystems; the first encountered matching
|
||||||
|
name is added.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) ReadDir(fpath string) (entries []fs.DirEntry, err error) {
|
||||||
|
|
||||||
|
var idx int
|
||||||
|
var e fs.DirEntry
|
||||||
|
var tmpEntries []fs.DirEntry
|
||||||
|
var seen map[string]bool = make(map[string]bool)
|
||||||
|
|
||||||
|
m.mut.RLock()
|
||||||
|
defer m.mut.RUnlock()
|
||||||
|
|
||||||
|
// merge real and embedded, preferring first-seen real paths
|
||||||
|
for idx, _ = range m.fses {
|
||||||
|
if m.overridePaths[idx] == "" {
|
||||||
|
if tmpEntries, err = m.fses[idx].ReadDir(filepath.FromSlash(fpath)); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if tmpEntries, err = os.ReadDir(filepath.Join(m.overridePaths[idx], filepath.FromSlash(fpath))); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e = range tmpEntries {
|
||||||
|
if seen[e.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e.Name()] = true
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ReadFile returns the bytes for the first found fpath.
|
||||||
|
|
||||||
|
It wraps MergedFS.Open.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) ReadFile(fpath string) (b []byte, err error) {
|
||||||
|
|
||||||
|
var fh fs.File
|
||||||
|
|
||||||
|
if fh, err = m.Open(fpath); nil != err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if fh != nil {
|
||||||
|
if err = fh.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if b, err = io.ReadAll(fh); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Stat returns the fs.FileInfo for the first found fpath.
|
||||||
|
|
||||||
|
It wraps MergedFS.Open.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) Stat(fpath string) (stat fs.FileInfo, err error) {
|
||||||
|
|
||||||
|
var fh fs.File
|
||||||
|
|
||||||
|
if fh, err = m.Open(fpath); nil != err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if fh != nil {
|
||||||
|
if err = fh.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if stat, err = fh.Stat(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
RemoveFsByIdx removes the filesystem found at index idx.
|
||||||
|
|
||||||
|
An attempt to remove the final embed.FS filesystem will result in an ErrFsIdxOOB.
|
||||||
|
|
||||||
|
If idx is larger than the last index of "real" filesystems, an ErrFsIdxOOB will be returned.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) RemoveFsByIdx(idx uint) (err error) {
|
||||||
|
|
||||||
|
var fsPath string
|
||||||
|
|
||||||
|
m.mut.Lock()
|
||||||
|
defer m.mut.Unlock()
|
||||||
|
|
||||||
|
if idx+1 == uint(len(m.fses)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx > uint(len(m.overridePaths)-1) {
|
||||||
|
err = ErrFsIdxOOB
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fsPath = m.overridePaths[idx]
|
||||||
|
if m.uniq.Contains(fsPath) {
|
||||||
|
m.uniq.Remove(fsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.fses = append(m.fses[:idx], m.fses[idx+1:]...)
|
||||||
|
m.overridePaths = append(m.overridePaths[:idx], m.overridePaths[idx+1:]...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
RemoveFsByPath removes the fileststem with path dirPath.
|
||||||
|
|
||||||
|
If dirpath is empty, this function NOOPs.
|
||||||
|
|
||||||
|
If dirpath does not exist in this MergedFS, an ErrFsPathNoExists will be returned.
|
||||||
|
*/
|
||||||
|
func (m *MergedFS) RemoveFsByPath(dirPath string) (err error) {
|
||||||
|
|
||||||
|
var idx int
|
||||||
|
var fsp string
|
||||||
|
var p string = dirPath
|
||||||
|
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = paths.RealPath(&p); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mut.Lock()
|
||||||
|
defer m.mut.Unlock()
|
||||||
|
|
||||||
|
if !m.uniq.Contains(p) {
|
||||||
|
err = ErrFsPathNoExists
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.uniq.Remove(p)
|
||||||
|
|
||||||
|
for idx, fsp = range m.overridePaths {
|
||||||
|
if fsp == p {
|
||||||
|
m.fses = append(m.fses[:idx], m.fses[idx+1:]...)
|
||||||
|
m.overridePaths = append(m.overridePaths[:idx], m.overridePaths[idx+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ErrFsIdxOOB
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"r00t2.io/sysutils/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open opens file fnm.
|
||||||
|
func (r *realFsMember) Open(fnm string) (fh fs.File, err error) {
|
||||||
|
|
||||||
|
if fh, err = r.Root.Open(fnm); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDir returns directory entries at fpath joined with the realFsMember's root.
|
||||||
|
func (r *realFsMember) ReadDir(fpath string) (entries []fs.DirEntry, err error) {
|
||||||
|
|
||||||
|
var rel string
|
||||||
|
var fsp string = fpath
|
||||||
|
|
||||||
|
if !filepath.IsAbs(fsp) {
|
||||||
|
fsp = filepath.Join(
|
||||||
|
r.Root.Name(),
|
||||||
|
filepath.FromSlash(fsp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err = paths.RealPath(&fsp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rel, err = filepath.Rel(r.Root.Name(), fsp); err != nil {
|
||||||
|
// Not relative to this root, so skip.
|
||||||
|
err = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rel, "..") {
|
||||||
|
// Absolute but not relative to this root, so skip.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries, err = os.ReadDir(fsp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type (
|
||||||
|
mergedFsMember interface {
|
||||||
|
fs.ReadDirFS
|
||||||
|
fs.ReadFileFS
|
||||||
|
fs.StatFS
|
||||||
|
io.Closer
|
||||||
|
}
|
||||||
|
realFsMember struct {
|
||||||
|
*os.Root
|
||||||
|
}
|
||||||
|
embedFsMember struct {
|
||||||
|
*embed.FS
|
||||||
|
}
|
||||||
|
MergedFS struct {
|
||||||
|
// always appended with embedTplFs
|
||||||
|
fses []mergedFsMember
|
||||||
|
overridePaths []string
|
||||||
|
uniq mapset.Set[string]
|
||||||
|
mut sync.RWMutex
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/*
|
||||||
|
Package slicesx aims to extend functionality of the stdlib [slices] module.
|
||||||
|
*/
|
||||||
|
package slicesx
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package slicesx
|
||||||
|
|
||||||
|
/*
|
||||||
|
Last returns the last item/element in slice s.
|
||||||
|
|
||||||
|
e will be the nil/zero value of the slice element type
|
||||||
|
if the slice length is 0.
|
||||||
|
*/
|
||||||
|
func Last[S ~[]E, E any](s S) (e E) {
|
||||||
|
|
||||||
|
var idx int = LastIndex(s)
|
||||||
|
|
||||||
|
if idx < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e = s[idx]
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
LastIndex returns the index of the last element in slice s.
|
||||||
|
|
||||||
|
If s is empty, idx will be -1.
|
||||||
|
If s is nil, idx will be -2.
|
||||||
|
*/
|
||||||
|
func LastIndex[S ~[]E, E any](s S) (idx int) {
|
||||||
|
|
||||||
|
var l int = len(s)
|
||||||
|
|
||||||
|
idx = -2
|
||||||
|
if s != nil {
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if l == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = l - 1
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package slicesx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLast(t *testing.T) {
|
||||||
|
|
||||||
|
var v any
|
||||||
|
var e any
|
||||||
|
var intSl []int
|
||||||
|
var strSl []string
|
||||||
|
var boolSl []bool
|
||||||
|
var anySl []any
|
||||||
|
|
||||||
|
/*
|
||||||
|
int
|
||||||
|
*/
|
||||||
|
// nil
|
||||||
|
v = Last(intSl)
|
||||||
|
e = 0
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
intSl = make([]int, 0)
|
||||||
|
v = Last(intSl)
|
||||||
|
if v != e { // still expect 0
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
intSl = []int{0, 1, 2, 3}
|
||||||
|
v = Last(intSl)
|
||||||
|
e = 3
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
string
|
||||||
|
*/
|
||||||
|
// nil
|
||||||
|
v = Last(strSl)
|
||||||
|
e = ""
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
strSl = make([]string, 0)
|
||||||
|
v = Last(strSl)
|
||||||
|
if v != e { // still expect ""
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
strSl = []string{"a", "b", "c"}
|
||||||
|
v = Last(strSl)
|
||||||
|
e = "c"
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
bool
|
||||||
|
*/
|
||||||
|
// nil
|
||||||
|
v = Last(boolSl)
|
||||||
|
e = false
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
boolSl = make([]bool, 0)
|
||||||
|
v = Last(boolSl)
|
||||||
|
if v != e { // still expect false
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
boolSl = []bool{true, false, true, false, true}
|
||||||
|
v = Last(boolSl)
|
||||||
|
e = true
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
any/interface{}
|
||||||
|
*/
|
||||||
|
// nil
|
||||||
|
v = Last(anySl)
|
||||||
|
e = nil
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
anySl = make([]any, 0)
|
||||||
|
v = Last(anySl)
|
||||||
|
if v != e { // still expect nil
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
anySl = []any{3, true, "foo", "bar"}
|
||||||
|
v = Last(anySl)
|
||||||
|
e = "bar"
|
||||||
|
if v != e {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, e, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
this test only runs in Go 1.26+;
|
||||||
|
which lets you new() on values instead of just types.
|
||||||
|
|
||||||
|
commented out until req bumps to 1.26+ in go.mod.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
func TestLastPtr(t *testing.T) {
|
||||||
|
|
||||||
|
var v any
|
||||||
|
var i *int
|
||||||
|
var s *string
|
||||||
|
var b *bool
|
||||||
|
var ptrIntSl []*int
|
||||||
|
var ptrStrSl []*string
|
||||||
|
var ptrBoolSl []*bool
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// int
|
||||||
|
//
|
||||||
|
// nil
|
||||||
|
v = Last(ptrIntSl)
|
||||||
|
i = nil
|
||||||
|
if v != i {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, i, i)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
ptrIntSl = make([]*int, 0)
|
||||||
|
v = Last(ptrIntSl)
|
||||||
|
if v != i { // still expect nil
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, i, i)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
ptrIntSl = []*int{new(0), new(1), new(2), new(3)}
|
||||||
|
v = Last(ptrIntSl)
|
||||||
|
i = ptrIntSl[len(ptrIntSl)-1]
|
||||||
|
if v != i {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, i, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// string
|
||||||
|
//
|
||||||
|
// nil
|
||||||
|
v = Last(ptrStrSl)
|
||||||
|
if v != s {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, s, s)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
ptrStrSl = make([]*string, 0)
|
||||||
|
v = Last(ptrStrSl)
|
||||||
|
if v != s { // still expect nil
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, s, s)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
ptrStrSl = []*string{new("a"), new("b"), new("c")}
|
||||||
|
v = Last(ptrStrSl)
|
||||||
|
s = ptrStrSl[len(ptrStrSl)-1]
|
||||||
|
if v != s {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, s, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// bool
|
||||||
|
//
|
||||||
|
// nil
|
||||||
|
v = Last(ptrBoolSl)
|
||||||
|
if v != b {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, b, b)
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
ptrBoolSl = make([]*bool, 0)
|
||||||
|
v = Last(ptrBoolSl)
|
||||||
|
if v != b { // still expect nil
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, b, b)
|
||||||
|
}
|
||||||
|
// populated
|
||||||
|
ptrBoolSl = []*bool{new(true), new(false), new(true), new(false), new(true)}
|
||||||
|
v = Last(ptrBoolSl)
|
||||||
|
b = ptrBoolSl[len(ptrBoolSl)-1]
|
||||||
|
if v != b {
|
||||||
|
t.Errorf("%T %#v, expected %T %#v", v, v, b, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestLastIndex(t *testing.T) {
|
||||||
|
|
||||||
|
var e int
|
||||||
|
var v int
|
||||||
|
var s []string
|
||||||
|
|
||||||
|
e = -2
|
||||||
|
v = LastIndex(s)
|
||||||
|
if e != v {
|
||||||
|
t.Errorf("Got %d, expected %d", v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = -1
|
||||||
|
s = make([]string, 0)
|
||||||
|
v = LastIndex(s)
|
||||||
|
if e != v {
|
||||||
|
t.Errorf("Got %d, expected %d", v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = 0
|
||||||
|
s = []string{"a"}
|
||||||
|
v = LastIndex(s)
|
||||||
|
if e != v {
|
||||||
|
t.Errorf("Got %d, expected %d", v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = 1
|
||||||
|
s = []string{"a", "b"}
|
||||||
|
v = LastIndex(s)
|
||||||
|
if e != v {
|
||||||
|
t.Errorf("Got %d, expected %d", v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = 2
|
||||||
|
s = []string{"a", "b", "c"}
|
||||||
|
v = LastIndex(s)
|
||||||
|
if e != v {
|
||||||
|
t.Errorf("Got %d, expected %d", v, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
+528
-8
@@ -1,13 +1,14 @@
|
|||||||
package stringsx
|
package stringsx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
`bytes`
|
"bytes"
|
||||||
`errors`
|
"errors"
|
||||||
`fmt`
|
"fmt"
|
||||||
`io`
|
"io"
|
||||||
`slices`
|
"slices"
|
||||||
`strings`
|
"strings"
|
||||||
`unicode`
|
"unicode"
|
||||||
|
`unicode/utf8`
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -17,6 +18,7 @@ It is more strict than [HasBoundary] (which only requires
|
|||||||
that s has sym at the beginning OR the end.)
|
that s has sym at the beginning OR the end.)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
HasBookend("|foo|", "|") → true
|
HasBookend("|foo|", "|") → true
|
||||||
HasBookend("|foo", "|") → false
|
HasBookend("|foo", "|") → false
|
||||||
HasBookend("foo|", "|") → false
|
HasBookend("foo|", "|") → false
|
||||||
@@ -38,6 +40,7 @@ func HasBookend(s, sym string) (bounded bool) {
|
|||||||
HasBoundary returns true if string s starts OR ends with symbol sym.
|
HasBoundary returns true if string s starts OR ends with symbol sym.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
HasBoundary("|foo|", "|") → true
|
HasBoundary("|foo|", "|") → true
|
||||||
HasBoundary("|foo", "|") → true
|
HasBoundary("|foo", "|") → true
|
||||||
HasBoundary("foo|", "|") → true
|
HasBoundary("foo|", "|") → true
|
||||||
@@ -453,6 +456,37 @@ func Redact(s, maskStr string, leading, trailing uint, newlines bool) (redacted
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
RemoveWhitespace removes all leading, trailing, and INNER whitespace
|
||||||
|
from unicode string `s`.
|
||||||
|
|
||||||
|
This is done by allocating a strings.Builder with len(s).
|
||||||
|
This may consume more memory than needed if s is mostly whitespace;
|
||||||
|
in that case, it is better to use [StripWhitespace].
|
||||||
|
*/
|
||||||
|
func RemoveWhitespace(s string) (removed string) {
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/32081808/strip-all-whitespace-from-a-string/32081891#32081891
|
||||||
|
|
||||||
|
var c rune
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
if s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Grow(len(s))
|
||||||
|
for _, c = range s {
|
||||||
|
if !unicode.IsSpace(c) {
|
||||||
|
sb.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed = sb.String()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Reverse reverses string s. (It's absolutely insane that this isn't in stdlib.)
|
// Reverse reverses string s. (It's absolutely insane that this isn't in stdlib.)
|
||||||
func Reverse(s string) (revS string) {
|
func Reverse(s string) (revS string) {
|
||||||
|
|
||||||
@@ -465,6 +499,491 @@ func Reverse(s string) (revS string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
RunesInString returns an ordered slice of all unique runes in
|
||||||
|
UTF-8 string `s`.
|
||||||
|
|
||||||
|
If `s` is an empty string, runes will be a non-nil empty slice.
|
||||||
|
*/
|
||||||
|
func RunesInString(s string) (runes []rune) {
|
||||||
|
|
||||||
|
var r rune
|
||||||
|
var idx int
|
||||||
|
var runeMap map[rune]uint64 = RuneMapFromString(s)
|
||||||
|
|
||||||
|
runes = make([]rune, len(runeMap))
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
for r, _ = range runeMap {
|
||||||
|
runes[idx] = r
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(runes)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
RuneMapFromString returns a map of unique runes found in
|
||||||
|
UTF-8 string `s` along with a count of their occurrence.
|
||||||
|
|
||||||
|
If `s` is an empty string, runes will be a non-nil empty map.
|
||||||
|
|
||||||
|
Non-UTF-8 runes are skipped.
|
||||||
|
*/
|
||||||
|
func RuneMapFromString(s string) (runes map[rune]uint64) {
|
||||||
|
|
||||||
|
var r rune
|
||||||
|
var runeSz int
|
||||||
|
|
||||||
|
runes = make(map[rune]uint64)
|
||||||
|
|
||||||
|
for idx := 0; idx < len(s); {
|
||||||
|
r, runeSz = utf8.DecodeRuneInString(s[idx:])
|
||||||
|
if r == utf8.RuneError && runeSz == 1 {
|
||||||
|
idx += runeSz
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runes[r]++
|
||||||
|
idx += runeSz
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
SquashConsec squashes/collapses consecutive instances of sequences
|
||||||
|
seq to a single instance.
|
||||||
|
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII).
|
||||||
|
|
||||||
|
If seq is nil/empty, any n+1 consecutive instance of *any rune*
|
||||||
|
will be squashed to a single instance. This is *much* more performant
|
||||||
|
(as it simply wraps [SquashConsecRunesAll]) at the cost of lack of scoping.
|
||||||
|
|
||||||
|
If you are trying to squash/collapse whitespace instead, [SquashWhitespace]
|
||||||
|
may be more apropos.
|
||||||
|
|
||||||
|
If you want more fine-grained control over sequence replacement, see [SquashMap].
|
||||||
|
|
||||||
|
If s is an empty string, SquashConsec will return `s` as-is.
|
||||||
|
*/
|
||||||
|
func SquashConsec(s string, seq ...string) (squashed string) {
|
||||||
|
|
||||||
|
var idx int
|
||||||
|
var sIdx int
|
||||||
|
var kLen int
|
||||||
|
var start int
|
||||||
|
var seqIdx int
|
||||||
|
var nextNum int
|
||||||
|
var nextPos int
|
||||||
|
var sb *strings.Builder
|
||||||
|
|
||||||
|
squashed = s
|
||||||
|
|
||||||
|
if len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seq) == 0 {
|
||||||
|
squashed = SquashConsecRunesAll(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for seqIdx = range seq {
|
||||||
|
if seq[seqIdx] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb = new(strings.Builder)
|
||||||
|
sb.Grow(len(squashed))
|
||||||
|
kLen = len(seq[seqIdx])
|
||||||
|
for idx = 0; idx < len(squashed); {
|
||||||
|
sIdx = strings.Index(squashed[idx:], seq[seqIdx])
|
||||||
|
if sIdx < 0 {
|
||||||
|
sb.WriteString(squashed[idx:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start = idx + sIdx
|
||||||
|
sb.WriteString(squashed[idx:start])
|
||||||
|
nextNum = 0
|
||||||
|
nextPos = start
|
||||||
|
for strings.HasPrefix(squashed[nextPos:], seq[seqIdx]) {
|
||||||
|
nextNum++
|
||||||
|
nextPos += kLen
|
||||||
|
}
|
||||||
|
if nextNum >= 1 {
|
||||||
|
sb.WriteString(seq[seqIdx])
|
||||||
|
}
|
||||||
|
idx = nextPos
|
||||||
|
}
|
||||||
|
|
||||||
|
squashed = sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
SquashConsecRunes squashes/collapses consecutive instances of sequences
|
||||||
|
seq with a single instance.
|
||||||
|
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII).
|
||||||
|
|
||||||
|
If seq is nil/empty, any n+1 consecutive instance of *any rune*
|
||||||
|
will be squashed to a single instance. This is *much* more performant
|
||||||
|
(as it simply wraps [SquashConsecRunesAll]) at the cost of lack of scoping.
|
||||||
|
|
||||||
|
If s is an empty string, SquashConsecRunes will return `s` as-is.
|
||||||
|
*/
|
||||||
|
func SquashConsecRunes(s string, seq ...rune) (squashed string) {
|
||||||
|
|
||||||
|
var r rune
|
||||||
|
var idx int
|
||||||
|
var prev rune
|
||||||
|
var seqIdx int
|
||||||
|
var runeSz int
|
||||||
|
var sb *strings.Builder
|
||||||
|
|
||||||
|
squashed = s
|
||||||
|
|
||||||
|
if len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seq) == 0 {
|
||||||
|
squashed = SquashConsecRunesAll(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for seqIdx = range seq {
|
||||||
|
if seq[seqIdx] < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb = new(strings.Builder)
|
||||||
|
sb.Grow(len(squashed))
|
||||||
|
prev = -1
|
||||||
|
for idx = 0; idx < len(squashed); {
|
||||||
|
r, runeSz = utf8.DecodeRuneInString(squashed[idx:])
|
||||||
|
if r == utf8.RuneError && runeSz == 1 {
|
||||||
|
prev = -1
|
||||||
|
sb.WriteString(squashed[idx : idx+runeSz])
|
||||||
|
} else {
|
||||||
|
if r != seq[seqIdx] || prev != r {
|
||||||
|
prev = r
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx += runeSz
|
||||||
|
}
|
||||||
|
|
||||||
|
squashed = sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
SquashConsecRunesAll squashes/condenses/collapses all consecutive occurrences
|
||||||
|
of every rune in `s` to a single instance.
|
||||||
|
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII);
|
||||||
|
non-UTF-8 runes will be written as-is to squashed (even if duplicate).
|
||||||
|
|
||||||
|
That is to say, for an `s` of:
|
||||||
|
|
||||||
|
* `fo`
|
||||||
|
* `foo`
|
||||||
|
* `fooo`
|
||||||
|
* `foooo`
|
||||||
|
|
||||||
|
SquashConsecRunesAll would return `fo` for all of them.
|
||||||
|
|
||||||
|
SquashConsecRunesAll will be a no-op and return an empty string
|
||||||
|
if `s` is an empty string.
|
||||||
|
*/
|
||||||
|
func SquashConsecRunesAll(s string) (squashed string) {
|
||||||
|
|
||||||
|
var r rune
|
||||||
|
var runeSz int
|
||||||
|
var prev rune = -1
|
||||||
|
var sb *strings.Builder
|
||||||
|
|
||||||
|
squashed = s
|
||||||
|
|
||||||
|
if len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb = new(strings.Builder)
|
||||||
|
sb.Grow(len(s))
|
||||||
|
for idx := 0; idx < len(squashed); {
|
||||||
|
r, runeSz = utf8.DecodeRuneInString(squashed[idx:])
|
||||||
|
if r == utf8.RuneError && runeSz == 1 {
|
||||||
|
prev = -1
|
||||||
|
sb.WriteString(squashed[idx : idx+runeSz])
|
||||||
|
} else {
|
||||||
|
if prev != r {
|
||||||
|
prev = r
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx += runeSz
|
||||||
|
}
|
||||||
|
|
||||||
|
squashed = sb.String()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
SquashMap offers replacement of consecutive string series.
|
||||||
|
It is expected that s is UTF-8 or a compatible subset (e.g. ASCII).
|
||||||
|
|
||||||
|
If single is set to true, *single/standalone* instances of a sequence
|
||||||
|
in replaceMap keys will also be replaced with its corresponding value.
|
||||||
|
|
||||||
|
Be aware that this function is not ideal for very large replaceMap maps
|
||||||
|
or for very large strings, as s will be passed over for each key in replaceMap.
|
||||||
|
If dealing with very large strings or a very large explicit set of replacements,
|
||||||
|
you may want to directly use [strings.Map] or [strings.NewReplacer] (if multi-rune
|
||||||
|
replacement is needed) with your own logic.
|
||||||
|
|
||||||
|
Matching string replacements are done longest-to-shortest sequentially.
|
||||||
|
This means that given the string `fooo` (and assuming single==true),
|
||||||
|
and a replaceMap of:
|
||||||
|
|
||||||
|
map[string]string{
|
||||||
|
"o": "a",
|
||||||
|
"oo": "b",
|
||||||
|
}
|
||||||
|
|
||||||
|
the result will be "fba", not:
|
||||||
|
|
||||||
|
* "faaa"
|
||||||
|
* "fab"
|
||||||
|
|
||||||
|
Thus you may want to execute several calls if this is undesired.
|
||||||
|
Keys that are the same length are ordered alphabetically/alphanumerically
|
||||||
|
so this function is deterministic.
|
||||||
|
|
||||||
|
Empty keys in replaceMap (but NOT empty values) are skipped.
|
||||||
|
Keys in replaceMap that are not UTF-8 are skipped.
|
||||||
|
|
||||||
|
Note that this function offers high-granularity control over replacements
|
||||||
|
at the cost of complex setup.
|
||||||
|
|
||||||
|
If you want more simple squashing/collapsing of whitespace, [SquashWhitespace]
|
||||||
|
may be more apropos.
|
||||||
|
|
||||||
|
If you want more simple squashing/collapsing of non-whitespace, [SquashConsec]
|
||||||
|
may be more apropos.
|
||||||
|
|
||||||
|
If replaceMap is nil or empty, s will be returned as-is.
|
||||||
|
If s is an empty string, SquashMap will no-op.
|
||||||
|
*/
|
||||||
|
func SquashMap(s string, replaceMap map[string]string, single bool) (squashed string) {
|
||||||
|
|
||||||
|
var idx int
|
||||||
|
var sIdx int
|
||||||
|
var k string
|
||||||
|
var v string
|
||||||
|
var start int
|
||||||
|
var nextNum int
|
||||||
|
var nextPos int
|
||||||
|
var sb *strings.Builder
|
||||||
|
type kLen struct {
|
||||||
|
// k is the Key name.
|
||||||
|
k string
|
||||||
|
// r is the replacement string.
|
||||||
|
r string
|
||||||
|
// l is the length of k (in UTF-8 runes).
|
||||||
|
l int
|
||||||
|
// bl is the length of k (in bytes).
|
||||||
|
bl int
|
||||||
|
}
|
||||||
|
var kl kLen
|
||||||
|
var keysByLen []kLen
|
||||||
|
|
||||||
|
squashed = s
|
||||||
|
|
||||||
|
if len(replaceMap) == 0 || len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keysByLen = make([]kLen, 0, len(replaceMap))
|
||||||
|
for k, v = range replaceMap {
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keysByLen = append(
|
||||||
|
keysByLen,
|
||||||
|
kLen{
|
||||||
|
k: k,
|
||||||
|
r: v,
|
||||||
|
l: utf8.RuneCountInString(k),
|
||||||
|
bl: len(k),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slices.SortFunc(
|
||||||
|
keysByLen,
|
||||||
|
func(a, b kLen) (cmp int) {
|
||||||
|
cmp = 0
|
||||||
|
// Sort so that the longer comes first.
|
||||||
|
if a.l > b.l {
|
||||||
|
cmp = -1
|
||||||
|
} else if a.l < b.l {
|
||||||
|
cmp = 1
|
||||||
|
} else if a.l == b.l {
|
||||||
|
if a.k < b.k {
|
||||||
|
cmp = -1
|
||||||
|
} else if a.k > b.k {
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, kl = range keysByLen {
|
||||||
|
switch strings.Count(squashed, kl.k) {
|
||||||
|
case 0:
|
||||||
|
continue
|
||||||
|
case 1:
|
||||||
|
if !single {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
squashed = strings.ReplaceAll(squashed, kl.k, kl.r)
|
||||||
|
default:
|
||||||
|
sb = new(strings.Builder)
|
||||||
|
sb.Grow(len(squashed))
|
||||||
|
for idx = 0; idx < len(squashed); {
|
||||||
|
if sIdx = strings.Index(squashed[idx:], kl.k); sIdx < 0 {
|
||||||
|
sb.WriteString(squashed[idx:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start = idx + sIdx
|
||||||
|
sb.WriteString(squashed[idx:start])
|
||||||
|
nextNum = 0
|
||||||
|
nextPos = start
|
||||||
|
for strings.HasPrefix(squashed[nextPos:], kl.k) {
|
||||||
|
nextNum++
|
||||||
|
nextPos += kl.bl
|
||||||
|
}
|
||||||
|
if nextNum > 1 || (nextNum == 1 && single) {
|
||||||
|
sb.WriteString(kl.r)
|
||||||
|
} else {
|
||||||
|
sb.WriteString(kl.k)
|
||||||
|
}
|
||||||
|
idx = nextPos
|
||||||
|
}
|
||||||
|
|
||||||
|
squashed = sb.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
SquashWhitespace is used to collapse/squash consecutive whitespace.
|
||||||
|
It is expected that s is unicode or a compatible subset (e.g. ASCII).
|
||||||
|
|
||||||
|
If single is set to true, *single/standalone* instances of whitespace
|
||||||
|
will also be replaced with ws.
|
||||||
|
|
||||||
|
ws is a string that should replace *consecutive* whitespace.
|
||||||
|
ws does not necessarily have to be a whitespace string, and may be empty.
|
||||||
|
|
||||||
|
If you are trying to squash/collapse non-whitespace, [SquashConsec]
|
||||||
|
is more apropos.
|
||||||
|
|
||||||
|
If you want more fine-grained control over sequence replacement, see [SquashMap].
|
||||||
|
*/
|
||||||
|
func SquashWhitespace(s, ws string, single bool) (squashed string) {
|
||||||
|
|
||||||
|
var r rune
|
||||||
|
var runeSz int
|
||||||
|
var wsStart int
|
||||||
|
var invalid bool
|
||||||
|
var prevWs uint64
|
||||||
|
var sb *strings.Builder
|
||||||
|
|
||||||
|
squashed = s
|
||||||
|
|
||||||
|
if s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb = new(strings.Builder)
|
||||||
|
sb.Grow(len(s))
|
||||||
|
|
||||||
|
for rIdx := 0; rIdx < len(s); {
|
||||||
|
r, runeSz = utf8.DecodeRuneInString(s[rIdx:])
|
||||||
|
invalid = r == utf8.RuneError && runeSz == 1
|
||||||
|
if invalid || !unicode.IsSpace(r) {
|
||||||
|
if prevWs > 1 || (prevWs == 1 && single) {
|
||||||
|
sb.WriteString(ws)
|
||||||
|
} else if prevWs == 1 {
|
||||||
|
sb.WriteString(s[wsStart:rIdx])
|
||||||
|
}
|
||||||
|
prevWs = 0
|
||||||
|
if invalid {
|
||||||
|
// Invalid UTF-8 rune; write byte as-is and advance index.
|
||||||
|
sb.WriteString(s[rIdx : rIdx+runeSz])
|
||||||
|
} else {
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
rIdx += runeSz
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prevWs == 0 {
|
||||||
|
wsStart = rIdx
|
||||||
|
}
|
||||||
|
prevWs++
|
||||||
|
rIdx += runeSz
|
||||||
|
}
|
||||||
|
if prevWs > 1 || (prevWs == 1 && single) {
|
||||||
|
sb.WriteString(ws)
|
||||||
|
} else if prevWs == 1 {
|
||||||
|
sb.WriteString(s[wsStart:])
|
||||||
|
}
|
||||||
|
|
||||||
|
squashed = sb.String()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
StripWhitespace removes all leading, trailing, and INNER whitespace
|
||||||
|
from unicode string `s`.
|
||||||
|
|
||||||
|
This is done by mapping each rune in `s`.
|
||||||
|
This may use far more allocations than necessary if `s` is mostly NON-whitespace;
|
||||||
|
in that case, it is better to use [RemoveWhitespace].
|
||||||
|
*/
|
||||||
|
func StripWhitespace(s string) (stripped string) {
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/32081808/strip-all-whitespace-from-a-string/32081891#32081891
|
||||||
|
|
||||||
|
if s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stripped = strings.Map(
|
||||||
|
func(c rune) rune {
|
||||||
|
if unicode.IsSpace(c) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
},
|
||||||
|
s,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
TrimLines is like [strings.TrimSpace] but operates on *each line* of s.
|
TrimLines is like [strings.TrimSpace] but operates on *each line* of s.
|
||||||
It is *NIX-newline (`\n`) vs. Windows-newline (`\r\n`) agnostic.
|
It is *NIX-newline (`\n`) vs. Windows-newline (`\r\n`) agnostic.
|
||||||
@@ -500,7 +1019,8 @@ func TrimLines(s string, left, right bool) (trimmed string) {
|
|||||||
} else if right {
|
} else if right {
|
||||||
sl = TrimSpaceRight(sl)
|
sl = TrimSpaceRight(sl)
|
||||||
}
|
}
|
||||||
sb.WriteString(sl + nl)
|
sb.WriteString(sl)
|
||||||
|
sb.WriteString(nl)
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmed = sb.String()
|
trimmed = sb.String()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
// "fmt"
|
||||||
|
"log"
|
||||||
|
// "os"
|
||||||
|
// "path/filepath"
|
||||||
|
"text/template"
|
||||||
|
// "github.com/davecgh/go-spew/spew"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed "tplfiles"
|
||||||
|
tplfiles embed.FS
|
||||||
|
|
||||||
|
tpl *template.Template = template.New("")
|
||||||
|
|
||||||
|
tw *TplWalker = new(TplWalker)
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
TplWalker struct {
|
||||||
|
tpl *template.Template
|
||||||
|
fs fs.FS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *TplWalker) walkFunc(fpath string, d fs.DirEntry, inErr error) (err error) {
|
||||||
|
|
||||||
|
var b []byte
|
||||||
|
var f fs.File
|
||||||
|
|
||||||
|
if inErr != nil {
|
||||||
|
err = inErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if f, err = t.fs.Open(fpath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
var ok bool
|
||||||
|
var c io.Closer
|
||||||
|
if f != nil {
|
||||||
|
if c, ok = f.(io.Closer); ok {
|
||||||
|
if err = c.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if b, err = io.ReadAll(f); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = t.tpl.New(fpath).Parse(string(b)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TplWalker) Walk() (err error) {
|
||||||
|
|
||||||
|
if err = fs.WalkDir(t.fs, ".", t.walkFunc); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
tw.tpl = tpl
|
||||||
|
tw.fs = tplfiles
|
||||||
|
|
||||||
|
if err = tw.Walk(); err != nil {
|
||||||
|
log.Panicln(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var idx int
|
||||||
|
var sub *template.Template
|
||||||
|
|
||||||
|
log.Printf("Root template name is %q", tpl.Name())
|
||||||
|
for idx, sub = range tpl.Templates() {
|
||||||
|
log.Printf("Sub template %d: %q", idx, sub.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -636,7 +636,7 @@ pre.rouge .gs {
|
|||||||
<div class="details">
|
<div class="details">
|
||||||
<span id="author" class="author">Brent Saner</span><br>
|
<span id="author" class="author">Brent Saner</span><br>
|
||||||
<span id="email" class="email"><a href="mailto:bts@square-r00t.net">bts@square-r00t.net</a></span><br>
|
<span id="email" class="email"><a href="mailto:bts@square-r00t.net">bts@square-r00t.net</a></span><br>
|
||||||
<span id="revdate">Last rendered 2026-06-22 18:51:24 -0400</span>
|
<span id="revdate">Last rendered 2026-07-14 03:02:23 -0400</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="toc" class="toc2">
|
<div id="toc" class="toc2">
|
||||||
<div id="toctitle">Table of Contents</div>
|
<div id="toctitle">Table of Contents</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<span id="author" class="author">Brent Saner</span>
|
<span id="author" class="author">Brent Saner</span>
|
||||||
<span id="email" class="email"><bts@square-r00t.net></span>
|
<span id="email" class="email"><bts@square-r00t.net></span>
|
||||||
<span id="revdate">Last rendered 2026-06-22 18:51:29 -0400</span>
|
<span id="revdate">Last rendered 2026-07-14 03:02:27 -0400</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user