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

feat: introduce connector for js/wasm #4

Merged
merged 10 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ func GnoplsCmd() *cobra.Command {
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
slog.Info("Initializing Server...")
env := &env.Env{
procEnv := &env.Env{
GNOROOT: os.Getenv("GNOROOT"),
GNOHOME: env.GnoHome(),
}
err := lsp.RunServer(cmd.Context(), env)
err := lsp.RunServer(cmd.Context(), procEnv)
if err != nil {
return err
}
Expand Down
12 changes: 12 additions & 0 deletions internal/env/conn_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package env

import (
"context"
"net"

"github.com/gnolang/gnopls/internal/js"
)

func GetConnection(ctx context.Context) (net.Conn, error) {
return js.DialHost(ctx)
}
15 changes: 15 additions & 0 deletions internal/env/conn_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !js

package env

import (
"context"
"net"
"os"

"go.lsp.dev/pkg/fakenet"
)

func GetConnection(_ context.Context) (net.Conn, error) {
return fakenet.NewConn("stdio", os.Stdin, os.Stdout), nil
}
22 changes: 22 additions & 0 deletions internal/js/conn_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Package js provides primitives to integrate language server with javascript environments such as browsers and Node.
package js

import (
"context"
"net"

"go.lsp.dev/pkg/fakenet"
)

// DialHost registers LSP message listener in JavaScript host and returns connection to use by LSP server.
//
// This function should be called only once before starting LSP server.
func DialHost(ctx context.Context) (net.Conn, error) {
reader, err := registerRequestListener(ctx)
if err != nil {
return nil, err
}

conn := fakenet.NewConn("js", reader, messageWriter)
return conn, nil
}
12 changes: 12 additions & 0 deletions internal/js/imports_js.go
x1unix marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package js

import "unsafe"

//go:wasmimport lsp writeMessage
func writeMessage(p unsafe.Pointer)

//go:wasmimport lsp closeWriter
func closeWriter()

//go:wasmimport lsp registerCallback
func registerCallback(callbackID uint32)
96 changes: 96 additions & 0 deletions internal/js/listener_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package js

import (
"context"
"fmt"
"io"
"reflect"
"syscall/js"
)

const (
// jsFieldCallbackID is field name of js.Func struct that contains callback ID of Go func exposed to JS.
jsFieldCallbackID = "id"

// msgBufferSize is size of a buffer of incoming messages from LSP client.
msgBufferSize = 10
)

// registerRequestListener creates and registers LSP request listener at the host.
// This method should only be called once at start of a server.
//
// Context parameter is used to automatically dispose underlying channel and callback.
//
// Returns a reader to consume incoming JSON-RPC messages from host.
// Closing a returned reader acts the same as cancelling an input context.
func registerRequestListener(ctx context.Context) (io.ReadCloser, error) {
chanCtx, cancelFn := context.WithCancel(ctx)

inputEvents := make(chan []byte, msgBufferSize)
callback := js.FuncOf(func(this js.Value, args []js.Value) any {
message := args[0].String()
x1unix marked this conversation as resolved.
Show resolved Hide resolved
inputEvents <- []byte(message)
return nil
})

// As it's impossible to pass JS objects to import funcs.
// Obtain the function callback ID pass it to import.
callbackID, err := getFuncCallbackID(callback)
if err != nil {
cancelFn()
callback.Release()
close(inputEvents)
return nil, fmt.Errorf("failed to prepare callback: %w", err)
}

registerCallback(callbackID)

go func() {
select {
case <-chanCtx.Done():
callback.Release()
close(inputEvents)
}
x1unix marked this conversation as resolved.
Show resolved Hide resolved
}()

reader := NewChannelReader(inputEvents, cancelFn)
return reader, nil
}

// getFuncCallbackID obtains callback ID from wrapped js.Func value.
//
// Internally, Go stores each js.Func handler inside a special lookup table.
// When host calls and wrapped function (js.Func), it resumes Go program and passes a callback ID.
// Go matches the callback ID with the corresponding js.Func handler in the lookup table and calls it.
//
// This flow didn't change since first Go with WASM support initial release.
//
// See: handleEvent in /syscall/js/js.go
func getFuncCallbackID(fn js.Func) (id uint32, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%s", r)
}
}()

// Obtain the callback id from the function.
// Use reflection to capture possible struct layout changes.
field := reflect.ValueOf(fn).FieldByName(jsFieldCallbackID)
if !field.IsValid() {
return 0, fmt.Errorf("cannot find field %q in %T", jsFieldCallbackID, fn)
}

if field.Type().Kind() != reflect.Uint32 {
return 0, fmt.Errorf(
"unexpected %T.%s field type: %s (want: %T)",
fn, jsFieldCallbackID, field.Type(), id,
)
}

id = uint32(field.Uint())
if id == 0 {
return 0, fmt.Errorf("empty callback ID in %T.%s", fn, jsFieldCallbackID)
}

return id, nil
}
62 changes: 62 additions & 0 deletions internal/js/reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package js

import (
"context"
"io"
)

var _ io.Reader = (*ChannelReader)(nil)

// ChannelReader is a reader that reads from a channel of bytes.
type ChannelReader struct {
buff []byte
source <-chan []byte
cancelFn context.CancelFunc
}

// NewChannelReader creates a new ChannelReader from a channel.
//
// Second argument is optional function that will be called when `Close` method is called.
func NewChannelReader(source <-chan []byte, cancelFn context.CancelFunc) *ChannelReader {
return &ChannelReader{
source: source,
x1unix marked this conversation as resolved.
Show resolved Hide resolved
}
}

func (listener *ChannelReader) Read(b []byte) (n int, err error) {
if len(b) == 0 {
return 0, nil
}

if len(listener.buff) == 0 {
if err := listener.fetchMore(); err != nil {
return 0, err
}
}

readCount := min(len(listener.buff), len(b))

copy(b, listener.buff[:readCount])
listener.buff = listener.buff[readCount:]
return readCount, nil
}

func (listener *ChannelReader) Close() error {
if listener.cancelFn != nil {
listener.cancelFn()
}

return nil
}

func (listener *ChannelReader) fetchMore() error {
select {
case message, ok := <-listener.source:
if !ok {
return io.EOF
}

listener.buff = message
return nil
}
x1unix marked this conversation as resolved.
Show resolved Hide resolved
}
27 changes: 27 additions & 0 deletions internal/js/writer_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package js

import "unsafe"

// messageWriter passes JSON-RPC messages to WASM host.
var messageWriter = wasmWriter{
writeFunc: writeMessage,
closeFunc: closeWriter,
}

type wasmWriter struct {
writeFunc func(p unsafe.Pointer)
closeFunc func()
}

func (w wasmWriter) Write(p []byte) (n int, err error) {
w.writeFunc(unsafe.Pointer(&p))
return len(p), nil
}

func (w wasmWriter) Close() error {
if w.closeFunc != nil {
w.closeFunc()
}

return nil
}
15 changes: 9 additions & 6 deletions internal/lsp/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@ import (
"context"
"errors"
"io"
"os"

"github.com/gnolang/gnopls/internal/env"
"go.lsp.dev/jsonrpc2"
"go.lsp.dev/pkg/fakenet"
)

func RunServer(ctx context.Context, env *env.Env) error {
conn := jsonrpc2.NewConn(jsonrpc2.NewStream(fakenet.NewConn("stdio", os.Stdin, os.Stdout)))
handler := BuildServerHandler(conn, env)
func RunServer(ctx context.Context, e *env.Env) error {
conn, err := env.GetConnection(ctx)
if err != nil {
return err
}

rpcConn := jsonrpc2.NewConn(jsonrpc2.NewStream(conn))
handler := BuildServerHandler(rpcConn, e)
stream := jsonrpc2.HandlerServer(handler)
err := stream.ServeStream(ctx, conn)
err = stream.ServeStream(ctx, rpcConn)
if errors.Is(err, io.EOF) {
return nil
}
Expand Down
Loading