pw_web: Better autocomplete and method arguments for RPC methods

Change-Id: I4b96029685631f942386fcb177bbfa90fe2ae0ec
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/108812
Commit-Queue: Asad Memon <asadmemon@google.com>
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_web/webconsole/components/repl/autocomplete.ts b/pw_web/webconsole/components/repl/autocomplete.ts
index 8490905..24d7efb 100644
--- a/pw_web/webconsole/components/repl/autocomplete.ts
+++ b/pw_web/webconsole/components/repl/autocomplete.ts
@@ -14,6 +14,7 @@
 
 import {CompletionContext} from '@codemirror/autocomplete'
 import {syntaxTree} from '@codemirror/language'
+import {Device} from "pigweedjs";
 
 const completePropertyAfter = ['PropertyName', '.', '?.']
 const dontCompleteIn = [
@@ -23,6 +24,7 @@
   'VariableDefinition',
   'PropertyDefinition'
 ]
+var objectPath = require("object-path");
 
 export function completeFromGlobalScope(context: CompletionContext) {
   let nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1)
@@ -41,6 +43,16 @@
         return completeProperties(from, window[variableName])
       }
     }
+    else if (object?.name == 'MemberExpression') {
+      let from = /\./.test(nodeBefore.name) ? nodeBefore.to : nodeBefore.from
+      let variableName = context.state.sliceDoc(object.from, object.to)
+      let variable = resolveWindowVariable(variableName);
+      // @ts-ignore
+      if (typeof variable == 'object') {
+        // @ts-ignore
+        return completeProperties(from, variable, variableName)
+      }
+    }
   } else if (nodeBefore.name == 'VariableName') {
     return completeProperties(nodeBefore.from, window)
   } else if (context.explicit && !dontCompleteIn.includes(nodeBefore.name)) {
@@ -49,14 +61,26 @@
   return null
 }
 
-function completeProperties(from: number, object: Object) {
+function completeProperties(from: number, object: Object, variableName?: string) {
   let options = []
   for (let name in object) {
-    options.push({
-      label: name,
-      // @ts-ignore
-      type: typeof object[name] == 'function' ? 'function' : 'variable'
-    })
+    // @ts-ignore
+    if (object[name] instanceof Function && variableName) {
+      debugger;
+      options.push({
+        label: name,
+        // @ts-ignore
+        detail: getFunctionDetailText(`${variableName}.${name}`),
+        type: 'function'
+      })
+    }
+    else {
+      options.push({
+        label: name,
+        type: 'variable'
+      })
+    }
+
   }
   return {
     from,
@@ -64,3 +88,20 @@
     validFor: /^[\w$]*$/
   }
 }
+
+function resolveWindowVariable(variableName: string) {
+  if (objectPath.has(window, variableName)) {
+    return objectPath.get(window, variableName);
+  }
+}
+
+function getFunctionDetailText(fullExpression: string): string {
+  if (fullExpression.startsWith("device.rpcs.")) {
+    fullExpression = fullExpression.replace("device.rpcs.", "");
+  }
+  const args = ((window as any).device as Device).getMethodArguments(fullExpression);
+  if (args) {
+    return `(${args.join(", ")})`;
+  }
+  return "";
+}
diff --git a/pw_web/webconsole/package.json b/pw_web/webconsole/package.json
index 77cd836..eb5da8f 100644
--- a/pw_web/webconsole/package.json
+++ b/pw_web/webconsole/package.json
@@ -18,6 +18,7 @@
     "@mui/material": "^5.9.3",
     "codemirror": "^6.0.1",
     "next": "12.2.3",
+    "object-path": "^0.11.8",
     "pigweedjs": "file:../../",
     "react": "18.2.0",
     "react-dom": "18.2.0",
diff --git a/ts/device/index.ts b/ts/device/index.ts
index f5c3575..cb68c7f 100644
--- a/ts/device/index.ts
+++ b/ts/device/index.ts
@@ -28,6 +28,7 @@
   private decoder: Decoder;
   private encoder: Encoder;
   private rpcAddress: number;
+  private nameToMethodArgumentsMap: any;
   client: Client;
   rpcs: any
 
@@ -40,6 +41,7 @@
     this.protoCollection = protoCollection;
     this.decoder = new Decoder();
     this.encoder = new Encoder();
+    this.nameToMethodArgumentsMap = {};
     const channels = [
       new Channel(1, (bytes) => {
         const hdlcBytes = this.encoder.uiFrame(this.rpcAddress, bytes);
@@ -63,6 +65,10 @@
     });
   }
 
+  getMethodArguments(fullPath) {
+    return this.nameToMethodArgumentsMap[fullPath];
+  }
+
   private setupRpcs() {
     let rpcMap = {};
     let channel = this.client.channel();
@@ -101,6 +107,10 @@
         'return this(arguments);'
       );
 
+    // We store field names so REPL can show hints in autocomplete using these.
+    this.nameToMethodArgumentsMap[fullMethodPath] = requestFields
+      .map(field => field.getName());
+
     // We create a new JS function dynamically here that takes
     // proto message fields as arguments and calls the actual RPC method.
     let fn = new Function(...functionArguments).bind((args) => {
diff --git a/ts/device/index_test.ts b/ts/device/index_test.ts
index 6b31cc4..1218dbb 100644
--- a/ts/device/index_test.ts
+++ b/ts/device/index_test.ts
@@ -32,6 +32,11 @@
     expect(device.rpcs.pw.rpc.EchoService.Echo).toBeDefined();
   });
 
+  it('has method arguments data', () => {
+    expect(device.getMethodArguments("pw.rpc.EchoService.Echo")).toStrictEqual(["msg"]);
+    expect(device.getMethodArguments("pw.test2.Alpha.Unary")).toStrictEqual(['magic_number']);
+  });
+
   it('unary rpc sends request to serial', async () => {
     const helloResponse = new Uint8Array([
       126, 165, 3, 42, 7, 10, 5, 104,