Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added base64, image.png, wasm data: to image, (limited) support for namespaces (virtual map access). moved the image functions under image.* #217

Merged
merged 9 commits into from
Sep 7, 2024
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ TINYGO_STACKS:=-stack-size=40mb

wasm: Makefile *.go */*.go $(GEN) wasm/wasm_exec.js wasm/wasm_exec.html wasm/grol_wasm.html
# GOOS=wasip1 GOARCH=wasm go build -o grol.wasm -trimpath -ldflags="-w -s" -tags "$(GO_BUILD_TAGS)" .
GOOS=js GOARCH=wasm go build -o wasm/grol.wasm -trimpath -ldflags="-w -s" -tags "$(GO_BUILD_TAGS)" ./wasm
GOOS=js GOARCH=wasm $(WASM_GO) build -o wasm/grol.wasm -trimpath -ldflags="-w -s" -tags "$(GO_BUILD_TAGS)" ./wasm
# GOOS=wasip1 GOARCH=wasm tinygo build -target=wasi -no-debug -o grol_tiny.wasm -tags "$(GO_BUILD_TAGS)" .
# Tiny go generates errors https://github.com/tinygo-org/tinygo/issues/1140
# GOOS=js GOARCH=wasm tinygo build $(TINYGO_STACKS) -no-debug -o wasm/grol.wasm -tags "$(GO_BUILD_TAGS)" ./wasm
Expand All @@ -50,11 +50,15 @@ wasm: Makefile *.go */*.go $(GEN) wasm/wasm_exec.js wasm/wasm_exec.html wasm/gro
sleep 3
open http://localhost:8080/


#WASM_GO:=/opt/homebrew/Cellar/go/1.23.1/bin/go
WASM_GO:=go

GIT_TAG=$(shell git describe --tags --always --dirty)
# used to copy to site a release version
wasm-release: Makefile *.go */*.go $(GEN) wasm/wasm_exec.js wasm/wasm_exec.html
@echo "Building wasm release GIT_TAG=$(GIT_TAG)"
GOOS=js GOARCH=wasm go install -trimpath -ldflags="-w -s" -tags "$(GO_BUILD_TAGS)" grol.io/grol/wasm@$(GIT_TAG)
GOOS=js GOARCH=wasm $(WASM_GO) install -trimpath -ldflags="-w -s" -tags "$(GO_BUILD_TAGS)" grol.io/grol/wasm@$(GIT_TAG)
# No buildinfo and no tinygo install so we set version old style:
# GOOS=js GOARCH=wasm tinygo build $(TINYGO_STACKS) -o wasm/grol.wasm -no-debug -ldflags="-X main.TinyGoVersion=$(GIT_TAG)" -tags "$(GO_BUILD_TAGS)" ./wasm
mv "$(shell go env GOPATH)/bin/js_wasm/wasm" wasm/grol.wasm
Expand All @@ -67,10 +71,10 @@ install:

wasm/wasm_exec.js: Makefile
# cp "$(shell tinygo env TINYGOROOT)/targets/wasm_exec.js" ./wasm/
cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" ./wasm/
cp "$(shell $(WASM_GO) env GOROOT)/misc/wasm/wasm_exec.js" ./wasm/

wasm/wasm_exec.html:
cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.html" ./wasm/
cp "$(shell $(WASM_GO) env GOROOT)/misc/wasm/wasm_exec.html" ./wasm/

test: grol
CGO_ENABLED=0 go test -tags $(GO_BUILD_TAGS) ./...
Expand Down
9 changes: 9 additions & 0 deletions eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,15 @@ func (s *State) evalInternal(node any) object.Object { //nolint:funlen,gocognit,
case *ast.MapLiteral:
return s.evalMapLiteral(node)
case *ast.IndexExpression:
if node.Value().Type() == token.DOT {
// See commits in PR#217 for a version using double map lookup, trading off the string concat (alloc)
// for a map lookup. code is a lot simpler without actual ns map though so we stick to this version
// for now.
extName := node.Left.Value().Literal() + "." + node.Index.Value().Literal()
if ext, ok := s.Extensions[extName]; ok {
return ext
}
}
return s.evalIndexExpression(s.Eval(node.Left), node)
case *ast.Comment:
return object.NULL
Expand Down
2 changes: 1 addition & 1 deletion eval/eval_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type State struct {
env *object.Environment
rootEnv *object.Environment // same as ancestor of env but used for reset in panic recovery.
cache Cache
Extensions map[string]object.Extension
Extensions object.ExtensionMap
NoLog bool // turn log() into println() (for EvalString)
// Max depth / recursion level - default DefaultMaxDepth,
// note that a simple function consumes at least 2 levels and typically at least 3 or 4.
Expand Down
8 changes: 4 additions & 4 deletions examples/image.gr
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func ycbcr(angle) {

size = 1024
imgName = "canvas"
canvas = image(imgName, size, size)
canvas = image.new(imgName, size, size)
div = 6

t = 0
Expand All @@ -29,15 +29,15 @@ for t < 12*PI {
y = cos(t) * (pow(E, cos(t)) - 2*cos(4*t) - pow(sin(t/12), 5))
angle := int(t*180./PI) % 360 // so ycbr() get memoized with 360 values
color = ycbcr(angle)
image_set_ycbcr(canvas, int(size/2+(size/div)*x+0.5), int(size/2.5+(size/div)*y+0.5), color)
image.set_ycbcr(canvas, int(size/2+(size/div)*x+0.5), int(size/2.5+(size/div)*y+0.5), color)
// Or in HSL:
// color[0] = t/(12*PI) // hue
// image_set_hsl(canvas, int(size/2+(size/div)*x+0.5), int(size/2.5+(size/div)*y+0.5), color)
t = t + 0.0005
t = t + 0.0005 // 0.0001 for profiling.
}
elapsed = time() - now
log("Time elapsed: ", elapsed, " seconds")

image_save(imgName)
image.save(imgName)

println("Saved image to grol.png")
23 changes: 23 additions & 0 deletions extensions/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package extensions

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -373,6 +374,28 @@ func createMisc() {
},
}
MustCreate(intFn)
intFn.Name = "base64"
intFn.Callback = func(st any, _ string, args []object.Object) object.Object {
s := st.(*eval.State)
o := args[0]
var data []byte
switch o.Type() {
case object.REFERENCE:
ref := o.(object.Reference)
if ref.Value().Type() != object.STRING {
return s.Errorf("cannot convert ref to %s to base64", ref.Value().Type())
}
data = []byte(ref.Value().(object.String).Value)
case object.STRING:
data = []byte(o.(object.String).Value)
default:
return s.Errorf("cannot convert %s to base64", o.Type())
}
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
base64.StdEncoding.Encode(encoded, data)
return object.String{Value: string(encoded)}
}
MustCreate(intFn)
}

func createTimeFunctions() {
Expand Down
38 changes: 29 additions & 9 deletions extensions/images.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package extensions

import (
"bytes"
"image"
"image/color"
"image/draw"
Expand Down Expand Up @@ -156,11 +157,11 @@ func ycbrArrayToRBGAColor(arr []object.Object) (color.RGBA, *object.Error) {
return rgba, nil
}

func createImageFunctions() {
func createImageFunctions() { //nolint:funlen // this is a group of related functions.
// All the functions consistently use args[0] as the image name/reference into the ClientData map.
cdata := make(ImageMap)
imgFn := object.Extension{
Name: "image",
Name: "image.new",
MinArgs: 3,
MaxArgs: 3,
Help: "create a new RGBA image of the name and size, image starts entirely transparent",
Expand All @@ -184,7 +185,7 @@ func createImageFunctions() {
},
}
MustCreate(imgFn)
imgFn.Name = "image_set"
imgFn.Name = "image.set"
imgFn.Help = "img, x, y, color: set a pixel in the named image, color is an array of 3 or 4 elements 0-255"
imgFn.MinArgs = 4
imgFn.MaxArgs = 4
Expand All @@ -201,11 +202,11 @@ func createImageFunctions() {
var color color.RGBA
var oerr *object.Error
switch name {
case "image_set_ycbcr":
case "image.set_ycbcr":
color, oerr = ycbrArrayToRBGAColor(colorArray)
case "image_set_hsl":
case "image.set_hsl":
color, oerr = hslArrayToRBGAColor(colorArray)
case "image_set":
case "image.set":
color, oerr = rgbArrayToRBGAColor(colorArray)
default:
return object.Errorf("unknown image_set function %q", name)
Expand All @@ -217,13 +218,13 @@ func createImageFunctions() {
return args[0]
}
MustCreate(imgFn)
imgFn.Name = "image_set_ycbcr"
imgFn.Name = "image.set_ycbcr"
imgFn.Help = "img, x, y, color: set a pixel in the named image, color Y'CbCr in an array of 3 elements 0-255"
MustCreate(imgFn)
imgFn.Name = "image_set_hsl"
imgFn.Name = "image.set_hsl"
imgFn.Help = "img, x, y, color: set a pixel in the named image, color in an array [Hue (0-360), Sat (0-1), Light (0-1)]"
MustCreate(imgFn)
imgFn.Name = "image_save"
imgFn.Name = "image.save"
imgFn.Help = "save the named image grol.png"
imgFn.MinArgs = 1
imgFn.MaxArgs = 1
Expand All @@ -246,4 +247,23 @@ func createImageFunctions() {
return args[0]
}
MustCreate(imgFn)
imgFn.Name = "image.png"
imgFn.Help = "returns the png data of the named image, suitable for base64"
imgFn.MinArgs = 1
imgFn.MaxArgs = 1
imgFn.ArgTypes = []object.Type{object.STRING}
imgFn.Callback = func(cdata any, _ string, args []object.Object) object.Object {
images := cdata.(ImageMap)
img, ok := images[args[0]]
if !ok {
return object.Errorf("image not found")
}
buf := bytes.Buffer{}
err := png.Encode(&buf, img)
if err != nil {
return object.Errorf("error encoding image: %v", err)
}
return object.String{Value: buf.String()}
}
MustCreate(imgFn)
}
43 changes: 39 additions & 4 deletions object/interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,40 @@ package object

import (
"errors"
"fmt"
"strings"

"grol.io/grol/lexer"
)

type ExtensionMap map[string]Extension

var (
extraFunctions map[string]Extension
extraFunctions ExtensionMap
extraIdentifiers map[string]Object
initDone bool
)

// Init resets the table of extended functions to empty.
// Optional, will be called on demand the first time through CreateFunction.
func Init() {
extraFunctions = make(map[string]Extension)
extraFunctions = make(ExtensionMap)
extraIdentifiers = make(map[string]Object)
initDone = true
}

func ValidIdentifier(name string) bool {
if name == "" {
return false
}
for _, b := range []byte(name) {
if !lexer.IsAlphaNum(b) {
return false
}
}
Comment on lines +31 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually it's better to loop on runes from a string

Unicode characters such as accent, and emoji are badly checked otherwise

Then Unicode package can be used to check the type

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's on purpose (for now at least) only bytes (ascii) is allowed outside of strings

unicode does work fine inside strings (as demoed in many examples)

return true
}

// CreateFunction adds a new function to the table of extended functions.
func CreateFunction(cmd Extension) error {
if !initDone {
Expand All @@ -26,6 +44,19 @@ func CreateFunction(cmd Extension) error {
if cmd.Name == "" {
return errors.New("empty command name")
}
// Only support 1 level of namespace for now.
dotSplit := strings.SplitN(cmd.Name, ".", 2)
ldemailly marked this conversation as resolved.
Show resolved Hide resolved
var ns string
name := cmd.Name
if len(dotSplit) == 2 {
ns, name = dotSplit[0], dotSplit[1]
if !ValidIdentifier(ns) {
return fmt.Errorf("namespace %q not alphanumeric", ns)
}
}
if !ValidIdentifier(name) {
return errors.New(name + ": not alphanumeric")
}
if cmd.MaxArgs != -1 && cmd.MinArgs > cmd.MaxArgs {
return errors.New(cmd.Name + ": min args > max args")
}
Expand All @@ -36,13 +67,17 @@ func CreateFunction(cmd Extension) error {
return errors.New(cmd.Name + ": already defined")
}
cmd.Variadic = (cmd.MaxArgs == -1) || (cmd.MaxArgs > cmd.MinArgs)
// If namespaced, put both at top level (for sake of baseinfo and command completion) and
// in namespace map (for access/ref by eval). We decided to not even have namespaces map
// after all.
extraFunctions[cmd.Name] = cmd
return nil
}

// Returns the table of extended functions to seed the state of an eval.
func ExtraFunctions() map[string]Extension {
return extraFunctions // no need to make a copy as each value need to be set to be changed (map of structs, not pointers).
func ExtraFunctions() ExtensionMap {
// no need to make a copy as each value need to be set to be changed (map of structs, not pointers).
return extraFunctions
}

func IsExtraFunction(name string) bool {
Expand Down
1 change: 1 addition & 0 deletions repl/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ func EvalOne(ctx context.Context, s *eval.State, what string, out io.Writer, opt
}()
}
s.SetContext(ctx, options.MaxDuration)
defer s.Cancel()
Copy link
Member Author

@ldemailly ldemailly Sep 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the fix for the wasm errors! (#204 )

continuation, errs, formatted = evalOne(s, what, out, options)
return
}
Expand Down
23 changes: 20 additions & 3 deletions wasm/grol_wasm.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@
function formatError(error) {
return `Error: ${error.message}`;
}
let isRunning = false
async function run() {
if (isRunning) return; // Prevent running multiple times concurrently
isRunning = true;
document.getElementById("runButton").disabled = true; // Disable button during execution
try {
// console.clear();
console.log('In run')
go.run(inst);
go.run(inst);
var input = document.getElementById('input').value
var compact = document.getElementById('compact').checked
// Call the grol function with the input
Expand All @@ -76,6 +80,11 @@
document.getElementById('output').value = output.result;
document.getElementById('input').value = output.formatted;
document.getElementById('errors').value = output.errors.join("\n");
if (output.image !== undefined) {
document.getElementById('image').src = output.image;
} else {
document.getElementById('image').src = "";
}
} else {
document.getElementById('errors').value = "Unexpected runtime error, see JS console";
}
Expand All @@ -85,15 +94,20 @@
const formattedError = formatError(e);
document.getElementById('errors').value = formattedError;
} finally {
inst = await WebAssembly.instantiate(mod, go.importObject); // reset instance
inst = await WebAssembly.instantiate(mod, go.importObject)
console.log('Instance reset:', inst)
if (isRunning) {
isRunning = false; // Allow running again after reset
document.getElementById("runButton").disabled = false; // Re-enable the button
}
}
resizeTextarea(document.getElementById('input'));
resizeTextarea(document.getElementById('output'));
resizeTextarea(document.getElementById('errors'));
}
document.addEventListener('DOMContentLoaded', (event) => {
document.getElementById('input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
if (e.key === 'Enter' && !isRunning) {
run();
}
});
Expand Down Expand Up @@ -141,6 +155,9 @@
</div>
<div>
<label for="output">Result:</label>
<br />
<img id="image" src="" alt="Image output (if any)">
<br />
<textarea id="output" rows="2" cols="80"></textarea>
</div>
<div>
Expand Down
8 changes: 6 additions & 2 deletions wasm/wasm_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func jsEval(this js.Value, args []js.Value) interface{} {
opts.MaxDuration = WasmMaxDuration
res, errs, formatted := repl.EvalStringWithOption(context.Background(), opts, input)
result := make(map[string]any)
if strings.HasPrefix(res, "data:") {
// special case for data: urls, we don't want to return the data
result["image"] = res
res = ""
}
result["result"] = strings.TrimSuffix(res, "\n")
// transfer errors to []any (!)
anyErrs := make([]any, len(errs))
Expand Down Expand Up @@ -83,7 +88,6 @@ func main() {
}
prev := debug.SetMemoryLimit(WasmMemLimit)
log.Infof("Grol wasm main %s - prev memory limit %d now %d", grolVersion, prev, WasmMemLimit)
done := make(chan struct{}, 0)
global := js.Global()
global.Set("grol", js.FuncOf(jsEval))
global.Set("grolVersion", js.ValueOf(grolVersion))
Expand All @@ -93,5 +97,5 @@ func main() {
if err != nil {
log.Critf("Error initializing extensions: %v", err)
}
<-done
select {}
}