Browse Source

Begain rewrite for more testing of features

  This shows we have testing for:
  * Node
  * Parser
  * Some Utils (that are located in utils.go)

  What needs further testing:
  * Expand testing for Utils

  What needs further development:
  * Given a test Endless sky save, parse it.
  * Given a test Endless sky plugin/mod, parse that (in __future__)
Apollo 2 years ago
parent
commit
6191cc6322
10 changed files with 923 additions and 24 deletions
  1. 5 24
      .gitignore
  2. 4 0
      cover
  3. 5 0
      go.mod
  4. 2 0
      go.sum
  5. 275 0
      node.go
  6. 249 0
      node_test.go
  7. 155 0
      parser.go
  8. 82 0
      parser_test.go
  9. 80 0
      utils.go
  10. 66 0
      utils_test.go

+ 5 - 24
.gitignore

@@ -1,26 +1,7 @@
-# ---> Go
-# Compiled Object files, Static and Dynamic libs (Shared Objects)
-*.o
-*.a
-*.so
+# Exclude symbolic links (which aren't actually needed)
+endless-sky
+saves
 
-# Folders
-_obj
-_test
-
-# Architecture specific extensions/prefixes
-*.[568vq]
-[568vq].out
-
-*.cgo1.go
-*.cgo2.c
-_cgo_defun.c
-_cgo_gotypes.go
-_cgo_export.*
-
-_testmain.go
-
-*.exe
-*.test
-*.prof
+# Exclude old files
+old_*
 

+ 4 - 0
cover

@@ -0,0 +1,4 @@
+go test -cover -coverprofile cover.out
+go tool cover -html=cover.out
+rm cover.out
+

+ 5 - 0
go.mod

@@ -0,0 +1,5 @@
+module red-green.com/endless-edit
+
+go 1.19
+
+require golang.org/x/exp v0.0.0-20221006183845-316c7553db56

+ 2 - 0
go.sum

@@ -0,0 +1,2 @@
+golang.org/x/exp v0.0.0-20221006183845-316c7553db56 h1:BrYbdKcCNjLyrN6aKqXy4hPw9qGI8IATkj4EWv9Q+kQ=
+golang.org/x/exp v0.0.0-20221006183845-316c7553db56/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=

+ 275 - 0
node.go

@@ -0,0 +1,275 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+)
+
+// A interconnected structure which connects to multiple entities
+//
+// Handles depth (in tabs) for the Endless sky save format (which is ugly)
+type Node struct {
+	Tag      string  // A tag, useful for identifying this node from other nodes in Children
+	Line     string  // The line of text for the node (the Parser removes the depth number of tabs)
+	Depth    int     // Number of tabs deep this node is (root and such are depth 0)
+	Parent   *Node   `json:"-"` // The parent of this node (used for traversing up the node tree), must be json ignored or recursive looping
+	Children []*Node // Any other nodes that can be tied to this node
+}
+
+// Returns the Endless sky representation (node depth is number of tabs)
+//
+// Useful for representing the node's line at the correct depth
+func (n *Node) String() string {
+	return fmt.Sprintf("%s%s", strings.Repeat("\t", n.Depth), n.Line)
+}
+
+// Strings everything (the current line plus any children)
+func (n *Node) StringAll() string {
+	out := strings.Builder{}
+	out.WriteString(n.String() + "\n")
+	if n.Len() != 0 {
+		// Recurse over children calling this function for the line and other children
+		for _, kid := range n.Children {
+			out.WriteString(kid.StringAll())
+		}
+	}
+	return out.String()
+}
+
+// Adds the given node as a child
+//
+// Used to build complex structures
+func (n *Node) AddChild(child *Node) {
+	child.Parent = n
+	n.Children = append(n.Children, child)
+}
+
+// Creates a new child node and returns it
+//
+// Used to build complex structures
+func (n *Node) NewChild() *Node {
+	kid := Node{
+		Parent: n,
+	}
+	n.AddChild(&kid)
+	return &kid
+}
+
+// Removes the child given index
+func (n *Node) RmChild(index int) error {
+	if index > n.Len() || index < 0 {
+		return fmt.Errorf("invalid index %d (min=0, max=%d)", index, n.Len())
+	}
+	var kids []*Node
+	for idx, k := range n.Children {
+		if idx != index {
+			kids = append(kids, k)
+		} else {
+			k.Parent = nil
+		}
+	}
+	n.Children = kids
+	return nil
+}
+
+// Removes all children
+//
+// Useful for when you will be reassigning a collection of node children underneath
+func (n *Node) RmAllChildren() {
+	for _, k := range n.Children {
+		k.Parent = nil
+	}
+	n.Children = []*Node{}
+}
+
+// Removes the current node from the parent
+//
+// Similar to RmChild or RmAllChildren except called from the child's perspective
+func (n *Node) DetachFromParent() (*Node, error) {
+	if n.Parent != nil {
+		p := n.Parent
+		index := -1
+		for idx, k := range p.Children {
+			if k.Line == n.Line && k.Depth == n.Depth && k.Tag == n.Tag && k.Len() == n.Len() {
+				index = idx
+			}
+		}
+		if index != -1 {
+			return p.SplitChild(index)
+		} else {
+			return nil, fmt.Errorf("failed detaching, didn't find ourselves in parent")
+		}
+	}
+	return nil, fmt.Errorf("failed detaching, invalid parent")
+}
+
+// Obtain a pointer to the child
+func (n *Node) Child(index int) (*Node, error) {
+	if index > n.Len() || index < 0 {
+		return nil, fmt.Errorf("invalid index %d (min=0, max=%d)", index, n.Len())
+	}
+	return n.Children[index], nil
+}
+
+// Replaces the child by index
+func (n *Node) ReplaceChild(index int, node *Node) error {
+	if index > n.Len() || index < 0 {
+		return fmt.Errorf("invalid index %d (min=0, max=%d)", index, n.Len())
+	}
+	n.Children[index].Parent = nil
+	node.Parent = n
+	n.Children[index] = node
+	return nil
+}
+
+// Removes the child but returns it (useful for if you plan on replacing it)
+func (n *Node) SplitChild(index int) (*Node, error) {
+	if index > n.Len() || index < 0 {
+		return nil, fmt.Errorf("invalid index %d (min=0, max=%d)", index, n.Len())
+	}
+	k := n.Children[index]
+	k.Parent = nil
+	err := n.RmChild(index)
+	if err != nil {
+		k.Parent = n // Some error recovery
+		return nil, err
+	}
+	return k, nil
+}
+
+// Returns the number of children underneath
+func (n *Node) Len() int {
+	return len(n.Children)
+}
+
+// Endless sky stores most data as 'key value'
+//
+// With special cases:
+//
+// As String '"some key" value' (Or '"some key" "some value"')
+//
+// As Int: '"some key" 13'
+//
+// As Float: '"some key" 9.81'
+//
+// Return's key part
+func (n *Node) Key() string {
+	parts := strings.Split(n.Line, " ")
+	if strings.Contains(parts[0], "\"") {
+		pref := ""
+		for _, part := range parts {
+			if strings.HasSuffix(part, "\"") {
+				if pref != "" {
+					pref += " "
+				}
+				pref += part
+				break
+			}
+			if pref != "" {
+				pref += " "
+			}
+			pref += part
+		}
+		pref = strings.ReplaceAll(pref, "\"", "")
+		return pref
+	}
+	return parts[0]
+}
+
+// Endless sky stores mose data as 'key value'
+//
+// With special cases:
+//
+// As String '"some key" value' (Or '"some key" "some value"')
+//
+// As Int: '"some key" 13'
+//
+// As Float: '"some key" 9.81'
+//
+// Return's value part (as string)
+func (n *Node) Value() string {
+	key := n.Key()
+	val := strings.Replace(n.Line, n.Format(key)+" ", "", 1)
+	val = strings.ReplaceAll(val, "\"", "")
+	return val
+}
+
+// Returns the value as integer (errors return 0)
+func (n *Node) ValueInt() int {
+	v, err := strconv.Atoi(n.Value())
+	if err != nil {
+		log.Printf("ValueInt('%s') => %v", n.Value(), err)
+		return 0
+	}
+	return v
+}
+
+// Returns the value as float64 (errors return 0.0, but are logged)
+func (n *Node) ValueFloat64() float64 {
+	v, err := strconv.ParseFloat(n.Value(), 64)
+	if err != nil {
+		log.Printf("ValueFloat64('%s') => %v", n.Value(), err)
+		return 0.0
+	}
+	return v
+}
+
+// Returns the value as float32 (errors return 0.0, but are logged)
+func (n *Node) ValueFloat32() float32 {
+	v, err := strconv.ParseFloat(n.Value(), 32)
+	if err != nil {
+		log.Printf("ValueFloat32('%s') => %v", n.Value(), err)
+		return 0.0
+	}
+	return float32(v)
+}
+
+// Format function
+//
+// Endless sky demands any multi word key or value must be wrapped in "s
+//
+// Both Node.Key() and Node.Value() remove the "s so this must be called to form the new Node.Line (for setters)
+func (n *Node) Format(value string) string {
+	if strings.Contains(value, " ") {
+		return "\"" + value + "\""
+	}
+	return value
+}
+
+// Assigns a new value for the Node
+//
+// This updates/changes the Node.Line (as that's the only way to do it)
+func (n *Node) SetValue(val interface{}) {
+	if val == nil { // Make it a flag/boolean
+		n.Line = n.Format(n.Key())
+		return
+	}
+	str_val := fmt.Sprintf("%v", val)
+	n.Line = fmt.Sprintf("%s %s", n.Format(n.Key()), n.Format(str_val))
+}
+
+// Assigns a new key for the Node
+//
+// This must be a string (key's can't be anything else)
+//
+// This updates/changes the Node.Line (as that's the only way to do it)
+func (n *Node) SetKey(key string) {
+	val := n.Value()
+	if val != "" {
+		n.Line = fmt.Sprintf("%s %s", n.Format(key), n.Format(val))
+	} else {
+		n.Line = n.Format(key)
+	}
+}
+
+// Assigns the whole line from the key to the value
+func (n *Node) Set(key string, val interface{}) {
+	if val == nil {
+		n.Line = n.Format(key)
+		return
+	}
+	str_val := fmt.Sprintf("%v", val)
+	n.Line = fmt.Sprintf("%s %s", n.Format(key), n.Format(str_val))
+}

+ 249 - 0
node_test.go

@@ -0,0 +1,249 @@
+package main
+
+import "testing"
+
+func TestNodeString(t *testing.T) {
+	n := Node{
+		Tag:   "1",
+		Line:  "Test",
+		Depth: 1,
+	}
+	if n.String() != "\tTest" {
+		t.Errorf("Expected 1 tab for depth=1, got '%s'", n.String())
+	}
+	n1 := n.NewChild()
+	n1.Line = "Hello World"
+	n1.Depth = 2
+	if n.StringAll() != "\tTest\n\t\tHello World\n" {
+		t.Errorf("Expected '\tTest\n\t\tHello World\n' got '%s'", n.StringAll())
+	}
+}
+
+func TestNodeAddChild(t *testing.T) {
+	n := Node{
+		Tag:   "1",
+		Line:  "Test",
+		Depth: 1,
+	}
+	n1 := Node{
+		Tag:   "2",
+		Line:  "Test 2",
+		Depth: 2,
+	}
+	n.AddChild(&n1)
+	if len(n.Children) != 1 {
+		t.Errorf("Expected to add the child, but didn't (Children Count: %d)", len(n.Children))
+	}
+	if n1.Parent.Tag != n.Tag {
+		t.Errorf("Expected child's parent to have the same tag, expected '1', got '%s'", n1.Parent.Tag)
+	}
+	if n.Children[0].Tag != n1.Tag {
+		t.Errorf("Expected child's tag to be the same as other node's tag, expected '2', got '%s'", n.Children[0].Tag)
+	}
+}
+
+func TestNodeNewChild(t *testing.T) {
+	n := Node{
+		Tag:   "1",
+		Line:  "Test",
+		Depth: 1,
+	}
+	n1 := n.NewChild()
+	n1.Tag = "2"
+	n1.Line = "Test 2"
+	n1.Depth = 2
+	if len(n.Children) != 1 {
+		t.Errorf("Expected to add the child, but didn't (Children Count: %d)", len(n.Children))
+	}
+	if n.Children[0].Line != n1.Line {
+		t.Errorf("Expected 'Test 2', got '%s' ('%s')", n.Children[0].Line, n1.Line)
+	}
+	if n1.Depth != n.Children[0].Depth {
+		t.Errorf("Expected depth=2, got %d (%d)", n1.Depth, n.Children[0].Depth)
+	}
+}
+
+func TestNodeKey(t *testing.T) {
+	n := Node{
+		Line: "Test Value",
+	}
+	if n.Key() != "Test" {
+		t.Errorf("Expected 'Test', got '%s' (%s)", n.Key(), n.Line)
+	}
+	n.Line = "\"Some Test\" Value"
+	if n.Key() != "Some Test" {
+		t.Errorf("Expected 'Some Test', got '%s' (%s)", n.Key(), n.Line)
+	}
+	n.Line = "Test \"Some Value\""
+	if n.Key() != "Test" {
+		t.Errorf("Expected 'Test', got '%s' (%s)", n.Key(), n.Line)
+	}
+	n.Line = "\"Some Test\" \"Some Value\""
+	if n.Key() != "Some Test" {
+		t.Errorf("Expected 'Some Test', got '%s', (%s)", n.Key(), n.Line)
+	}
+}
+
+func TestNodeValue(t *testing.T) {
+	n := Node{
+		Line: "Test Value",
+	}
+	if n.Value() != "Value" {
+		t.Errorf("Expected 'Value', got '%s', (%s)", n.Value(), n.Line)
+	}
+	n.Line = "\"Some Test\" Value"
+	if n.Value() != "Value" {
+		t.Errorf("Expected 'Value', got '%s', (%s)", n.Value(), n.Line)
+	}
+	n.Line = "Test \"Some Value\""
+	if n.Value() != "Some Value" {
+		t.Errorf("Expected 'Some Value', got '%s', (%s)", n.Value(), n.Line)
+	}
+	n.Line = "\"Some Test\" \"Some Value\""
+	if n.Value() != "Some Value" {
+		t.Errorf("Expected 'Some Value', got '%s', (%s)", n.Value(), n.Line)
+	}
+}
+
+func TestNodeValueTypes(t *testing.T) {
+	n := Node{
+		Line: "\"Some Super Test\" 13",
+	}
+	if n.Key() != "Some Super Test" {
+		t.Errorf("Expected 'Some Super Test', got '%s' (%s)", n.Key(), n.Line)
+	}
+	if n.Value() != "13" {
+		t.Errorf("Expected '13', got '%s' (%s)", n.Value(), n.Line)
+	}
+	if n.ValueInt() != 13 {
+		t.Errorf("Expected 13, got %d (%s)", n.ValueInt(), n.Line)
+	}
+	n.Line = "\"Some Super Duper Test\" 9.81"
+	if n.Key() != "Some Super Duper Test" {
+		t.Errorf("Expected 'Some Super Duper Test', got '%s' (%s)", n.Key(), n.Line)
+	}
+	if n.Value() != "9.81" {
+		t.Errorf("Expected '9.81', got '%s', (%s)", n.Value(), n.Line)
+	}
+	if n.ValueFloat32() != 9.81 {
+		t.Errorf("Expected 9.81, got %f, (%s)", n.ValueFloat32(), n.Line)
+	}
+	if n.ValueFloat64() != 9.81 {
+		t.Errorf("Expected 'Value', got %f, (%s)", n.ValueFloat64(), n.Line)
+	}
+}
+
+func TestNodeSetters(t *testing.T) {
+	n := Node{}
+	n.SetKey("Hello World")
+	if n.Line != "\"Hello World\"" {
+		t.Errorf("Expected '\"Hello World\"', got '%s'", n.Line)
+	}
+	n.SetValue("Meow")
+	if n.Line != "\"Hello World\" Meow" {
+		t.Errorf("Expected '\"Hello World\" Meow', got '%s'", n.Line)
+	}
+	n.SetValue(9.81)
+	if n.Line != "\"Hello World\" 9.81" {
+		t.Errorf("Expected '\"Hello World\" 9.81', got '%s'", n.Line)
+	}
+	n.Set("Taco", "Cat")
+	if n.Line != "Taco Cat" {
+		t.Errorf("Expected 'Taco Cat', got '%s'", n.Line)
+	}
+	n.Set("Meow", nil)
+	if n.Line != "Meow" {
+		t.Errorf("Expected 'Meow', got '%s'", n.Line)
+	}
+	n.Set("I am super man", 42)
+	if n.Line != "\"I am super man\" 42" {
+		t.Errorf("Expected '\"I am super man\" 42', got '%s'", n.Line)
+	}
+}
+
+func TestNodeChildActions(t *testing.T) {
+	n := Node{
+		Tag: "Root",
+	}
+	n.Set("I am root", nil)
+	n1 := n.NewChild()
+	n1.Set("I am a child", 1)
+	n1.Depth = 1
+	n2 := n.NewChild()
+	n2.Set("I am a child", 2)
+	n2.Depth = 1
+	n2_1 := n2.NewChild()
+	n2_1.Depth = 2
+	n2_1.Set("I am a child", 3)
+	if n.Len() != 2 {
+		t.Errorf("Expected 2 children, got %d", len(n.Children))
+	}
+	t1, err := n.Child(0)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if t1.Line != "\"I am a child\" 1" {
+		t.Errorf("Expected '\"I am a child\" 1', got '%s'", t1.Line)
+	}
+	t1, err = n.Child(1)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if t1.Len() != 1 {
+		t.Errorf("Expected 1 child, got %d", t1.Len())
+	}
+	t2, err := t1.Child(0)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if t2.Line != "\"I am a child\" 3" {
+		t.Errorf("Expected '\"I am a child\" 3', got '%s'", t2.Line)
+	}
+	// Split the child off
+	n3, err := n.SplitChild(1)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if n3.Line != "\"I am a child\" 2" {
+		t.Errorf("Expected '\"I am a child\" 2', got '%s'", n3.Line)
+	}
+	if n3.Len() != 1 {
+		t.Errorf("Expected 1 child, got %d", n3.Len())
+	}
+	// Add it back in
+	n.AddChild(n3)
+	// Now replace the first child with a new one
+	n3 = &Node{
+		Tag:   "Kid 1",
+		Depth: 1,
+	}
+	n3.Set("I am a child", 1.1)
+	err = n.ReplaceChild(0, n3)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	n3_1 := n3.NewChild()
+	n3_1.Depth = 2
+	n3_1.Set("I am a child", 1.2)
+	if n.Len() != 2 {
+		t.Errorf("Expected 2 children, got %d", n.Len())
+	}
+	t1, err = n.Child(0)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if t1.Len() != 1 {
+		t.Errorf("Expected 1 child, got %d", t1.Len())
+	}
+	t1, err = t1.DetachFromParent()
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if t1.Parent != nil {
+		t.Errorf("Expected to detach from parent, should be root then")
+	}
+	n.RmAllChildren()
+	if n.Len() != 0 {
+		t.Errorf("Expected no children, got %d", n.Len())
+	}
+}

+ 155 - 0
parser.go

@@ -0,0 +1,155 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+)
+
+// A Parser for handling Endless Sky save format
+type Parser struct {
+	Debug bool // Do we print debug info (to logs)
+}
+
+// Parses a given file returning a tree of Nodes
+func (p *Parser) Load(filename string) (Root *Node, err error) {
+	Root = &Node{
+		Tag:   "ROOT",
+		Line:  filename,
+		Depth: -1,
+	}
+
+	file, err := os.Open(filename)
+	if err != nil {
+		return
+	}
+	defer file.Close()
+	radar := bufio.NewScanner(file)
+	content := []string{}
+	for radar.Scan() {
+		line := radar.Text()
+		line = strings.ReplaceAll(line, "\n", "")
+		content = append(content, line)
+	}
+	p.parse(&content, 0, Root)
+	return
+}
+
+// Assistant func
+//
+// Processes the current line and next line for making the node tree/structure
+func (p *Parser) parse(file *[]string, linenum int, node *Node) {
+	if linenum+1 > len(*file) {
+		if p.Debug {
+			fmt.Println("EOF")
+		}
+		return
+	}
+	line := (*file)[linenum]
+	var next_line string
+	if linenum+2 <= len(*file) {
+		next_line = (*file)[linenum+1]
+	} else {
+		next_line = ""
+	}
+	next_depth := FindDepth(next_line)
+	if p.Debug {
+		fmt.Printf("%4d | '%s'\nNXTL | '%s'\n", linenum, line, next_line)
+	}
+	kid := node.NewChild()
+	kid.Depth = FindDepth(line)
+	kid.Line = CleanLine(line)
+	if next_depth == kid.Depth {
+		if p.Debug {
+			fmt.Printf("Same Depth (%d)\n", node.Depth)
+		}
+		p.parse(file, linenum+1, node)
+	} else if next_depth > kid.Depth {
+		if p.Debug {
+			fmt.Printf("Deeper (%d->%d)\n", kid.Depth, next_depth)
+		}
+		p.parse(file, linenum+1, kid)
+	} else if next_depth < kid.Depth {
+		diff := kid.Depth - next_depth
+		at := node
+		if p.Debug {
+			fmt.Printf("Surface (%d<-%d) diff=%d\n", kid.Depth, next_depth, diff)
+		}
+		for up := 0; up < diff; up++ {
+			if at.Parent != nil {
+				at = at.Parent
+			}
+		}
+		p.parse(file, linenum+1, at)
+	}
+}
+
+// Assistant func
+//
+// # Supports writing the current node's line, and the node's children too
+//
+// This uses recursive calls to write out the children
+func (p *Parser) saveassist(file *os.File, node *Node) error {
+	_, err := file.WriteString(node.String() + "\n")
+	if err != nil {
+		return err
+	}
+	if node.Len() != 0 {
+		for _, kid := range node.Children {
+			err := p.saveassist(file, kid)
+			if err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// Saves the node and it's children to the given filename
+func (p *Parser) Save(filename string, Root *Node) error {
+	file, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0660)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+	for _, kid := range Root.Children {
+		err = p.saveassist(file, kid)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// TODO: Move these Util funcs to utils.go (update tests)
+
+// Util func
+//
+// Finds how many tabs deep the line is
+func FindDepth(line string) int {
+	if strings.HasPrefix(line, "\t") {
+		depth := 0
+		for _, c := range line {
+			if c != '\t' {
+				break
+			}
+			depth += 1
+		}
+		return depth
+	}
+	return 0
+}
+
+// Util func
+//
+// # Cleans the starting tabs from the line
+//
+// Utilizes FindDepth to chop off that many characters in the beginning of the line
+func CleanLine(line string) string {
+	if strings.HasPrefix(line, "\t") {
+		depth := FindDepth(line)
+		return line[depth:]
+	}
+	return line
+}

+ 82 - 0
parser_test.go

@@ -0,0 +1,82 @@
+package main
+
+import (
+	"os"
+	"testing"
+)
+
+func TestParseLoad(t *testing.T) {
+	err := os.WriteFile(
+		"_tmp1.txt",
+		[]byte("pilot Test Pilot\nship \"Little Fish\"\n\tname \"Hello World\"\n\tkind \"Space Fish\""),
+		0660,
+	)
+	if err != nil {
+		t.Errorf("Failed creating temp file")
+	}
+	defer func() {
+		err = os.Remove("_tmp1.txt")
+		if err != nil {
+			t.Errorf("Failed cleaning up temp file")
+		}
+	}()
+
+	p := Parser{}
+	root, err := p.Load("_tmp1.txt")
+	if err != nil {
+		t.Fail()
+		t.Logf("Unexpected error, %v", err)
+		p.Debug = true
+		p.Load("_tmp1.txt")
+		p.Debug = false
+	}
+	if root.Len() != 2 {
+		t.Fail()
+		t.Logf("Incorrect child count for Root node, expected 2")
+		t.Logf("%#v", root)
+		for _, k := range root.Children {
+			t.Logf("%#v", k)
+		}
+	}
+	k, err := root.Child(1)
+	if err != nil {
+		t.Errorf("Unexpected error, %v", err)
+	}
+	if k.Line != "ship \"Little Fish\"" || k.Depth != 0 {
+		t.Errorf("Expected 'ship \"Little Fish\"' depth of 0, got '%s' (depth=%d)", k.Line, k.Depth)
+	}
+	if k.Len() != 2 {
+		t.Fail()
+		t.Logf("Incorrect child count for Root.1 node, expected 2")
+		t.Logf("%#v", k)
+		for _, k0 := range k.Children {
+			t.Logf("%#v", k0)
+		}
+	}
+	k1, err := k.Child(0)
+	if err != nil {
+		t.Errorf("Unexpected error, %v", err)
+	}
+	if k1.Line != "name \"Hello World\"" || k1.Depth != 1 {
+		t.Errorf("Expected 'name \"Hello World\"' depth 1, got '%s' (depth=%d)", k1.Line, k1.Depth)
+	}
+}
+
+func TestParseSave(t *testing.T) {
+	n := Node{}
+	n1 := n.NewChild()
+	n1.Line = "Hello World"
+	n1 = n.NewChild()
+	n1.Line = "Tacocat"
+	n2 := n1.NewChild()
+	n2.Line = "Meow?"
+	n2.Depth = 1
+	p := Parser{}
+	err := p.Save("_tmp2.txt", &n)
+	if err != nil {
+		t.Errorf("Unexpected error, %v", err)
+	}
+	defer func() {
+		os.Remove("_tmp2.txt")
+	}()
+}

+ 80 - 0
utils.go

@@ -0,0 +1,80 @@
+package main
+
+import (
+	"strconv"
+
+	"golang.org/x/exp/slices"
+)
+
+// Attempts to identify if we can convert to a int
+func CanInt(text string) bool {
+	negative := false
+	numbers := []rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
+	for _, c := range text {
+		if !negative && c == '-' {
+			negative = true
+			continue
+		} else if negative && c == '-' {
+			return false
+		}
+		if !slices.Contains(numbers, c) {
+			return false
+		}
+	}
+	return true
+}
+
+// Attempts to identify if we can convert to a float
+//
+// Requires encountering only 1 '.', if multiple found (it's not a float), if none found (it's not a float)
+func CanFloat(text string) bool {
+	found_dot := false
+	negative := false
+	numbers := []rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
+	for _, c := range text {
+		if !found_dot && c == '.' {
+			found_dot = true
+			continue
+		} else if found_dot && c == '.' {
+			return false
+		}
+		if !negative && c == '-' {
+			negative = true
+			continue
+		} else if negative && c == '-' {
+			return false
+		}
+		if !slices.Contains(numbers, c) {
+			return false
+		}
+	}
+	return found_dot
+}
+
+// https://stackoverflow.com/questions/13020308/how-to-fmt-printf-an-integer-with-thousands-comma/31046325#31046325
+//
+// This should take 1234567 and put it as 1,234,567
+func FormatCredit(n int64) string {
+	in := strconv.FormatInt(n, 10)
+	numOfDigits := len(in)
+	if n < 0 {
+		numOfDigits-- // First character is the - sign (not a digit)
+	}
+	numOfCommas := (numOfDigits - 1) / 3
+
+	out := make([]byte, len(in)+numOfCommas)
+	if n < 0 {
+		in, out[0] = in[1:], '-'
+	}
+
+	for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {
+		out[j] = in[i]
+		if i == 0 {
+			return string(out)
+		}
+		if k++; k == 3 {
+			j, k = j-1, 0
+			out[j] = ','
+		}
+	}
+}

+ 66 - 0
utils_test.go

@@ -0,0 +1,66 @@
+package main
+
+import "testing"
+
+func TestCanInt(t *testing.T) {
+	if !CanInt("12345") {
+		t.Errorf("Expected valid int conversion of '12345'")
+	}
+	if CanInt("9.81") {
+		t.Errorf("Unexpected int conversion of '9.81', this should return true on CanFloat (not CanInt)")
+	}
+	if !CanInt("-42") {
+		t.Errorf("Expected valid int conversion of '-42'")
+	}
+	if CanInt("-24-88") {
+		t.Errorf("Unexpected int conversion of '-24-88', this should not be allowed as we have 2 '-'")
+	}
+	if !CanInt("-1234567890") {
+		t.Errorf("Expected valid int conversion of '-1234567890'")
+	}
+	if CanInt("123abc") {
+		t.Errorf("Unexpected int conversion of '123abc', contains letters 'abc' which are not numerics")
+	}
+}
+
+func TestCanFloat(t *testing.T) {
+	if !CanFloat("9.81") {
+		t.Errorf("Expected valid float conversion of '9.81'")
+	}
+	if CanFloat("198.164.255.0") {
+		t.Errorf("Unexpected float conversion of '198.164.255.0', this is a IP, not a floating value")
+	}
+	if !CanFloat("-42.0") {
+		t.Errorf("Expected valid float conversion of '-42.0'")
+	}
+	if !CanFloat("1234567890.1234567890") {
+		t.Errorf("Expected valid float conversion of '1234567890.1234567890'")
+	}
+	if CanFloat("-123.-1234") {
+		t.Errorf("Unexpected float conversion of '-123.-1234', contains 2 '-'")
+	}
+	if !CanFloat("0.15") {
+		t.Errorf("Expected valid float conversion of '0.15'")
+	}
+	if !CanFloat(".15") {
+		t.Errorf("Expected valid float conversion of '.15'")
+	}
+	if CanFloat("-123.abc") {
+		t.Errorf("Unexpected float conversion of '-123.abc', contains 'abc' which are not numerics")
+	}
+}
+
+func TestFormatCredits(t *testing.T) {
+	if FormatCredit(1000) != "1,000" {
+		t.Errorf("Expected '1,000' from 1000, got '%s'", FormatCredit(1000))
+	}
+	if FormatCredit(10000) != "10,000" {
+		t.Errorf("Expected '10,000' from 10000, got '%s'", FormatCredit(10000))
+	}
+	if FormatCredit(1000000) != "1,000,000" {
+		t.Errorf("Expected '1,000,000' from 1000000, got '%s'", FormatCredit(1000000))
+	}
+	if FormatCredit(-4000) != "-4,000" {
+		t.Errorf("Expected '-4,000' from -4000, got '%s'", FormatCredit(-4000))
+	}
+}