diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..49468ed --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fd3d3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Binaries +/bin/ +__debug* + +# Temporary +tmp/ + +# EWW +node_modules/ + +reference/ diff --git a/cmd/hypo/main.go b/cmd/hypo/main.go new file mode 100644 index 0000000..3297671 --- /dev/null +++ b/cmd/hypo/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + "github.com/angelofallars/hypo/internal/cmd" +) + +func main() { + statusCode := cmd.Exec() + os.Exit(statusCode) +} diff --git a/example/helloworld.html b/example/helloworld.html new file mode 100644 index 0000000..f81bda8 --- /dev/null +++ b/example/helloworld.html @@ -0,0 +1,2 @@ +Hello world! + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6cb22b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/angelofallars/hypo + +go 1.21.5 + +require golang.org/x/net v0.19.0 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e9bf364 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..ef9d40b --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/angelofallars/hypo/internal/evaluator" + "github.com/angelofallars/hypo/internal/object" + "github.com/angelofallars/hypo/internal/parser" + "github.com/angelofallars/hypo/internal/repl" + "github.com/spf13/cobra" +) + +func Exec() int { + rootCmd := &cobra.Command{ + Use: "hypo [ file ]", + Short: "Hypo is a fast runtime for HTML, the programming language running outside the browser.", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + repl.Start() + } + + bytes, err := os.ReadFile(args[0]) + if err != nil { + cmd.PrintErrln(err) + return + } + + contents := string(bytes) + + node, err := parser.Parse(contents) + if err != nil { + cmd.PrintErrln(err) + return + } + + env := object.NewEnv() + + err = evaluator.Exec(node, env) + if err != nil { + cmd.PrintErrln(err) + return + } + }, + } + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + return 1 + } + + return 0 +} diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go new file mode 100644 index 0000000..4fadb13 --- /dev/null +++ b/internal/evaluator/evaluator.go @@ -0,0 +1,168 @@ +package evaluator + +import ( + "errors" + "fmt" + "strconv" + + "github.com/angelofallars/hypo/internal/object" + "golang.org/x/net/html" +) + +type command func(node *html.Node, env *object.Env) error + +type number = float64 + +type binOp uint + +const ( + add binOp = iota + subtract + multiply + divide +) + +// Exec executes an HTML, the programming language, program +// from an [html.Node], traversing through it and its siblings. +func Exec(node *html.Node, env *object.Env) error { + for n := node; n != nil; n = n.NextSibling { + cmd, ok := commands[n.Data] + if !ok { + continue + } + + err := cmd(n, env) + if err != nil { + fmt.Printf("error on <%v>: %v\n", n.Data, err) + } + } + + return nil +} + +// commands maps an HTML tag name to a command function. +var commands = map[string]command{ + // Literals + "s": evalPushString, + "data": evalPushNumber, + // Math Commands + "dd": evalBinOp(add), + "sub": evalBinOp(subtract), + "ul": evalBinOp(multiply), + "div": evalBinOp(divide), + // Stack Manipulation commands + "dt": evalDuplicate, + "del": evalDelete, + // I/O + "output": evalPrintOutput, +} + +// evalPushString pushes a string into the stack. +func evalPushString(node *html.Node, env *object.Env) error { + env.Stack.Push(node.FirstChild.Data) + return nil +} + +// evalPushNumber pushes a number into the stack. +func evalPushNumber(node *html.Node, env *object.Env) error { + attrs := attrMap(node) + + value, err := getAttr(attrs, "value") + if err != nil { + return err + } + + number, err := strconv.ParseFloat(value, 64) + if err != nil { + return errors.New("value is not a valid number") + } + + env.Stack.Push(number) + + return nil +} + +// evalBinOp performs a binary operation on the top two values of the stack. +func evalBinOp(op binOp) command { + return func(node *html.Node, env *object.Env) error { + top, err := env.Stack.Pop() + if err != nil { + return err + } + next, err := env.Stack.Pop() + if err != nil { + return err + } + + topNum, ok := top.(number) + if !ok { + return errors.New("first value is not a number") + } + + nextNum, ok := next.(number) + if !ok { + return errors.New("second value is not a number") + } + + var result number + switch op { + case add: + result = topNum + nextNum + case subtract: + result = topNum - nextNum + case multiply: + result = topNum * nextNum + case divide: + result = topNum / nextNum + } + + env.Stack.Push(result) + return nil + } +} + +// evalPrintOutput prints the top value without consuming it to stdout. +func evalPrintOutput(node *html.Node, env *object.Env) error { + last, err := env.Stack.Top() + if err != nil { + return err + } + + fmt.Println(last) + return nil +} + +// evalDelete deletes the top value on the stack. +func evalDelete(node *html.Node, env *object.Env) error { + _, err := env.Stack.Pop() + return err +} + +// evalDuplicate duplicates the top value on the stack. +func evalDuplicate(node *html.Node, env *object.Env) error { + v, err := env.Stack.Top() + if err != nil { + return err + } + + env.Stack.Push(v) + return nil +} + +// attrMap creates a map from the Attr slice of an [html.Node]. +func attrMap(node *html.Node) map[string]string { + m := make(map[string]string) + for _, attr := range node.Attr { + m[attr.Key] = attr.Val + } + return m +} + +// getAttr gets a value from the attribute map, if it exists. +func getAttr(attrs map[string]string, key string) (string, error) { + value, ok := attrs[key] + if !ok { + return "", fmt.Errorf("attribute '%v' not found", key) + } + return value, nil +} diff --git a/internal/object/env.go b/internal/object/env.go new file mode 100644 index 0000000..99796db --- /dev/null +++ b/internal/object/env.go @@ -0,0 +1,64 @@ +package object + +import ( + "errors" + "slices" +) + +// TODO: replace any with Object + +// Env is an environment of the runtime which contains runtime values. +type Env struct { + // Stack is the primary storage of values. + Stack stack + Vars map[string]any +} + +type stack struct { + slice []any +} + +func NewEnv() *Env { + return &Env{ + Stack: stack{make([]any, 0, 256)}, + Vars: map[string]any{ + // Start with standard variables for common values + "true": true, + "false": false, + "null": nil, + }, + } +} + +var ErrStackEmpty = errors.New("Stack empty") + +// Push a value to the top of the stack. +func (s *stack) Push(v any) { + s.slice = append(s.slice, v) +} + +// Pop a value from the top of the stack and return it. +func (s *stack) Pop() (any, error) { + v, err := s.Top() + if err != nil { + return nil, err + } + + topIndex := s.topIndex() + s.slice = slices.Delete(s.slice, topIndex, topIndex+1) + return v, nil +} + +// Receive a value from the top of the stack without consuming it. +func (s *stack) Top() (any, error) { + if len(s.slice) == 0 { + return nil, ErrStackEmpty + } + + return s.slice[s.topIndex()], nil +} + +// topIndex returns the last index in the stack. +func (s *stack) topIndex() int { + return len(s.slice) - 1 +} diff --git a/internal/object/object.go b/internal/object/object.go new file mode 100644 index 0000000..9edf710 --- /dev/null +++ b/internal/object/object.go @@ -0,0 +1,49 @@ +package object + +type ObjectType string + +const ( + NumberObject ObjectType = "Number" + StringObject ObjectType = "String" + BoolObject ObjectType = "Bool" + ArrayObject ObjectType = "Array" + ObjObject ObjectType = "Obj" + NullObject ObjectType = "Null" +) + +// Dummy method to make the type enum-like. +func (ot ObjectType) objectType() {} + +// Object represents an object in the runtime. +type Object interface { + // Dummy method + object() + Type() ObjectType +} + +type Number struct { + Value float64 +} + +func (n *Number) object() {} +func (n *Number) Type() ObjectType { + return NumberObject +} + +type String struct { + Value string +} + +func (n *String) object() {} +func (n *String) Type() ObjectType { + return StringObject +} + +type Bool struct { + Value bool +} + +func (n *Bool) object() {} +func (n *Bool) Type() ObjectType { + return BoolObject +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..c28e1ae --- /dev/null +++ b/internal/parser/parser.go @@ -0,0 +1,28 @@ +package parser + +import ( + "strings" + + "golang.org/x/net/html" +) + +// Parse parses a string into an HTML node. +func Parse(s string) (*html.Node, error) { + node, err := html.Parse(strings.NewReader(s)) + if err != nil { + return nil, err + } + + node = retrieveElement(node) + return node, nil +} + +// retrieveElement extracts the first element we want from the parsed HTML, +// as the initial output node is a root node. +func retrieveElement(node *html.Node) *html.Node { + if node.Type == html.DocumentNode && node.Data == "" { + // => => => =><[elem]> + return node.FirstChild.FirstChild.NextSibling.FirstChild + } + return node +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go new file mode 100644 index 0000000..c058773 --- /dev/null +++ b/internal/repl/repl.go @@ -0,0 +1,44 @@ +package repl + +import ( + "bufio" + "fmt" + "os" + + "github.com/angelofallars/hypo/internal/evaluator" + "github.com/angelofallars/hypo/internal/object" + "github.com/angelofallars/hypo/internal/parser" +) + +const ( + splash = "Hypo interpreter (C) 2023" + prompt = ">>> " +) + +func Start() { + scanner := bufio.NewScanner(os.Stdin) + env := object.NewEnv() + + fmt.Println(splash) + for { + fmt.Print(prompt) + scanned := scanner.Scan() + if !scanned { + return + } + + line := scanner.Text() + + node, err := parser.Parse(line) + if err != nil { + fmt.Errorf("%w\n", err) + continue + } + + err = evaluator.Exec(node, env) + if err != nil { + fmt.Errorf("%w\n", err) + continue + } + } +}