Support new FIDL syntax

This CL makes fidlbolt pass `--experimental allow_new_syntax` to allow
the new syntax. It also presents a notice/alert at the top of the page
about the new syntax (which can be dismissed), and offers to
automatically convert FIDL to the new syntax if it detects the user is
using the old syntax.

Change-Id: I4e4f33f177860be8db7a139b9d81192e0bca7d1e
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidlbolt/+/557964
Reviewed-by: Yifei Teng <yifeit@google.com>
diff --git a/backend/server.go b/backend/server.go
index 0fc5556..3d6f2e3 100644
--- a/backend/server.go
+++ b/backend/server.go
@@ -165,6 +165,8 @@
 	Offsets bool `json:"offsets"`
 	// Show ASCII characters at the end of lines like xxd.
 	ASCII bool `json:"ascii"`
+	// Convert FIDL from old to new (RFC-0050) syntax.
+	ConvertSyntax bool `json:"convertSyntax"`
 }
 
 // An annotation is a message targeted at a specific location of a file.
@@ -363,7 +365,7 @@
 			msg := fmt.Sprintf("%s:1:1: error: %s", fidl, err)
 			return response{Ok: false, Content: msg}, nil
 		}
-		args = append(args, "--experimental", "old_syntax_only")
+		args = append(args, "--experimental", "allow_new_syntax")
 		run, err := s.fidlc.run(ctx, append(args, fileArgs...)...)
 		if err != nil {
 			return response{}, err
@@ -389,6 +391,20 @@
 
 	switch r.OutputMode {
 	case FIDL:
+		if r.Options.ConvertSyntax {
+			res, err := fidlc("--convert-syntax", temp.path)
+			if err != nil {
+				return response{}, err
+			}
+			if !res.Ok {
+				return res, nil
+			}
+			res.Content, err = temp.readFile("fidlbolt.fidl.new")
+			if err != nil {
+				return response{}, err
+			}
+			return res, err
+		}
 		prog := s.fidlFormat
 		if r.Options.Lint {
 			prog = s.fidlLint
diff --git a/frontend/src/elm/Editors.elm b/frontend/src/elm/Editors.elm
index a61e929..6d123c1 100644
--- a/frontend/src/elm/Editors.elm
+++ b/frontend/src/elm/Editors.elm
@@ -11,6 +11,7 @@
     , encode
     , init
     , initCmd
+    , subscriptions
     , update
     , view
     )
@@ -34,6 +35,7 @@
 import Json.Decode as Decode
 import Json.Encode as Encode
 import Mode exposing (Mode)
+import News
 import Ports
 
 
@@ -44,6 +46,7 @@
 type alias Model =
     { activeInput : Mode
     , inputMap : Mode.Map InputState
+    , fidlSyntax : FidlSyntax
     }
 
 
@@ -69,6 +72,11 @@
     }
 
 
+type FidlSyntax
+    = UnknownSyntax
+    | OldSyntax
+
+
 init : Model
 init =
     let
@@ -80,6 +88,7 @@
     in
     { activeInput = input
     , inputMap = Mode.singleton input inputState
+    , fidlSyntax = UnknownSyntax
     }
 
 
@@ -155,6 +164,10 @@
     | ActivateOutput Mode
       -- Update options for the current input/output mode combination.
     | UpdateOptions Form.Msg
+      -- Assume FIDL input is using the given syntax.
+    | AssumeSyntax FidlSyntax
+      -- Tell the evaluator to convert to the new FIDL syntax.
+    | ConvertSyntax
 
 
 update : Msg -> Model -> ( Model, Cmd msg )
@@ -174,11 +187,23 @@
                 UpdateOptions formMsg ->
                     updateOptions (Form.update formMsg) model
 
+                AssumeSyntax syntax ->
+                    { model | fidlSyntax = syntax }
+
+                ConvertSyntax ->
+                    model
+
         cmd =
             case msg of
                 NoOp ->
                     Cmd.none
 
+                AssumeSyntax _ ->
+                    Cmd.none
+
+                ConvertSyntax ->
+                    Ports.convertToNewSyntax
+
                 _ ->
                     updateEditors (getActive newModel)
     in
@@ -263,6 +288,15 @@
 
 
 
+------------- SUBSCRIPTIONS ----------------------------------------------------
+
+
+subscriptions : Sub Msg
+subscriptions =
+    Ports.syntaxDetected NoOp (Decode.map AssumeSyntax decodeFidlSyntax)
+
+
+
 ------------- VIEW -------------------------------------------------------------
 
 
@@ -284,7 +318,13 @@
                     , activeMode = active.input
                     , msg = ActivateInput
                     , id = "InputEditor"
-                    , optionsPart = []
+                    , optionsPart =
+                        case ( active.input, model.fidlSyntax ) of
+                            ( Mode.Fidl, OldSyntax ) ->
+                                [ convertToNewSyntaxView toMsg ]
+
+                            _ ->
+                                []
                     }
 
                 Output ->
@@ -358,6 +398,25 @@
         ]
 
 
+convertToNewSyntaxView : (Msg -> msg) -> Html msg
+convertToNewSyntaxView toMsg =
+    Html.div
+        [ Attributes.class "editor-options" ]
+        [ Html.div [ Attributes.class "form-control" ]
+            [ Html.text "Looks like you are using the "
+            , Html.a
+                [ Attributes.href News.newSyntax2021Url ]
+                [ Html.text "old FIDL syntax" ]
+            , Html.text "."
+            , Html.button
+                [ Attributes.class "button form-button"
+                , Events.onClick (toMsg ConvertSyntax)
+                ]
+                [ Html.text "Convert to new syntax" ]
+            ]
+        ]
+
+
 keyDownEvent : Editor -> Active -> (Msg -> msg) -> Decode.Decoder msg
 keyDownEvent editor active toMsg =
     let
@@ -419,9 +478,10 @@
 
 decode : Decode.Decoder Model
 decode =
-    Decode.map2 Model
+    Decode.map3 Model
         (Decode.field "activeInput" Mode.decode)
         (Decode.field "inputMap" (Mode.decodeMap decodeInputState))
+        (Decode.succeed UnknownSyntax)
 
 
 decodeInputState : Decode.Decoder InputState
@@ -435,3 +495,20 @@
 decodeOutputState =
     Decode.map OutputState
         (Decode.field "options" Form.decode)
+
+
+decodeFidlSyntax : Decode.Decoder FidlSyntax
+decodeFidlSyntax =
+    Decode.string
+        |> Decode.andThen
+            (\string ->
+                case string of
+                    "unknown" ->
+                        Decode.succeed UnknownSyntax
+
+                    "old" ->
+                        Decode.succeed OldSyntax
+
+                    _ ->
+                        Decode.fail ("Invalid syntax name: " ++ string)
+            )
diff --git a/frontend/src/elm/Main.elm b/frontend/src/elm/Main.elm
index a1bfbef..452cad0 100644
--- a/frontend/src/elm/Main.elm
+++ b/frontend/src/elm/Main.elm
@@ -17,6 +17,7 @@
 import Html.Lazy as Lazy
 import Json.Decode as Decode
 import Json.Encode as Encode
+import News
 import Ports
 import Settings
 import SplitPane
@@ -46,6 +47,7 @@
     , editors : Editors.Model
     , settings : Settings.Model
     , split : SplitPane.Model
+    , news : News.Model
     , window : Window.Model Window
     }
 
@@ -112,6 +114,7 @@
     , editors = Editors.init
     , split = SplitPane.init SplitPane.Vertical 0.5 splitId
     , settings = Settings.init scheme
+    , news = News.init
     , window = Window.init
     }
 
@@ -130,6 +133,7 @@
     | EditorMsg Editors.Msg
     | SettingsMsg Settings.Msg
     | SplitMsg SplitPane.Msg
+    | NewsMsg News.Msg
     | WindowMsg (Window.Msg Window)
 
 
@@ -208,6 +212,13 @@
             in
             ( newModel, Cmd.batch cmds )
 
+        NewsMsg newsMsg ->
+            let
+                newModel =
+                    { model | news = News.update newsMsg model.news }
+            in
+            ( newModel, persistModel newModel )
+
         WindowMsg windowMsg ->
             let
                 newWindow =
@@ -244,6 +255,7 @@
 subscriptions model =
     Sub.batch
         [ Sub.map DeploymentMsg Deployment.subscriptions
+        , Sub.map EditorMsg Editors.subscriptions
         , Sub.map SplitMsg (SplitPane.subscriptions model.split)
         , Sub.map WindowMsg (Window.subscriptions model.window)
         ]
@@ -283,6 +295,7 @@
             , Html.div
                 [ Attributes.class "wrapper" ]
                 [ headingView
+                , News.view NewsMsg model.news
                 , Html.div
                     [ Attributes.class "container" ]
                     [ splitPane ]
@@ -476,16 +489,18 @@
         [ ( "editors", Editors.encode model.editors )
         , ( "settings", Settings.encode model.settings )
         , ( "split", SplitPane.encode model.split )
+        , ( "news", News.encode model.news )
         ]
 
 
 decode : Decode.Decoder Model
 decode =
-    Decode.map5 Model
+    Decode.map6 Model
         (Decode.succeed Deployment.init)
         (Decode.field "editors" Editors.decode)
         (Decode.field "settings" Settings.decode)
         (Decode.field "split" (SplitPane.decode splitId))
+        (Decode.field "news" News.decode)
         (Decode.succeed Window.init)
 
 
diff --git a/frontend/src/elm/News.elm b/frontend/src/elm/News.elm
new file mode 100644
index 0000000..e39e5a2
--- /dev/null
+++ b/frontend/src/elm/News.elm
@@ -0,0 +1,163 @@
+-- Copyright 2021 The Fuchsia Authors. All rights reserved.
+-- Use of this source code is governed by a BSD-style license that can be
+-- found in the LICENSE file.
+
+
+module News exposing
+    ( Model
+    , Msg
+    , decode
+    , encode
+    , init
+    , newSyntax2021Url
+    , update
+    , view
+    )
+
+import Html exposing (Html)
+import Html.Attributes as Attributes
+import Html.Events as Events
+import Json.Decode as Decode
+import Json.Encode as Encode
+
+
+{-| This module defines news items that display at the top of the page and can
+be dismissed. Dismissal is remembered across sessions.
+-}
+
+
+
+------------- MODEL ------------------------------------------------------------
+
+
+type alias Model =
+    { show : List Item }
+
+
+type Item
+    = NewSyntax2021
+
+
+allItems : List Item
+allItems =
+    [ NewSyntax2021 ]
+
+
+init : Model
+init =
+    { show = allItems }
+
+
+{-| Returns all items not in the given list. We can't use the Set library
+because it doesn't support custom types (Item) nor does it support extracting
+the smallest element.
+-}
+complement : List Item -> List Item
+complement items =
+    List.filter (\x -> not (List.member x items)) allItems
+
+
+
+------------- UPDATE -----------------------------------------------------------
+
+
+type Msg
+    = Dismiss
+
+
+update : Msg -> Model -> Model
+update _ model =
+    { show =
+        case model.show of
+            _ :: rest ->
+                rest
+
+            [] ->
+                []
+    }
+
+
+
+------------- VIEW -------------------------------------------------------------
+
+
+{-| Creates the news view. The toMsg parameter converts a News.Msg to the
+top-level Msg type of the app.
+-}
+view : (Msg -> msg) -> Model -> Html msg
+view toMsg model =
+    let
+        dismissButton =
+            Html.button
+                [ Attributes.class "button dismiss-button"
+                , Events.onClick (toMsg Dismiss)
+                ]
+                [ Html.text "dismiss" ]
+    in
+    case model.show of
+        item :: _ ->
+            Html.div
+                [ Attributes.class "news" ]
+                (itemView item ++ [ dismissButton ])
+
+        [] ->
+            Html.text ""
+
+
+itemView : Item -> List (Html msg)
+itemView item =
+    case item of
+        NewSyntax2021 ->
+            [ Html.text "FIDL has a "
+            , Html.a
+                [ Attributes.href newSyntax2021Url ]
+                [ Html.text "new syntax" ]
+            , Html.text "!"
+            ]
+
+
+newSyntax2021Url : String
+newSyntax2021Url =
+    "https://groups.google.com/a/fuchsia.dev/g/announce/c/D_M8bl_7fEQ/m/I-qhW6iHBAAJ"
+
+
+
+------------- ENCODE / DECODE --------------------------------------------------
+
+
+encode : Model -> Encode.Value
+encode model =
+    Encode.object
+        [ ( "dismissed", Encode.list encodeItem (complement model.show) ) ]
+
+
+decode : Decode.Decoder Model
+decode =
+    Decode.field "dismissed"
+        (Decode.list decodeItem
+            |> Decode.map (List.filterMap identity)
+            |> Decode.map complement
+            |> Decode.map Model
+        )
+
+
+encodeItem : Item -> Encode.Value
+encodeItem item =
+    case item of
+        NewSyntax2021 ->
+            Encode.string "newSyntax2021"
+
+
+decodeItem : Decode.Decoder (Maybe Item)
+decodeItem =
+    Decode.string
+        |> Decode.andThen
+            (\string ->
+                case string of
+                    "newSyntax2021" ->
+                        Decode.succeed (Just NewSyntax2021)
+
+                    -- Ignore unrecognized (old) news items.
+                    _ ->
+                        Decode.succeed Nothing
+            )
diff --git a/frontend/src/elm/Ports.elm b/frontend/src/elm/Ports.elm
index 1e1a7c3..88c0378 100644
--- a/frontend/src/elm/Ports.elm
+++ b/frontend/src/elm/Ports.elm
@@ -6,10 +6,12 @@
 port module Ports exposing
     ( applySettings
     , clearDataAndReload
+    , convertToNewSyntax
     , deploymentUpdated
     , persistModel
     , resizeEditors
     , setMainTabEnabled
+    , syntaxDetected
     , updateEditors
     )
 
@@ -73,6 +75,11 @@
     command "resizeEditors" Encode.null
 
 
+convertToNewSyntax : Cmd msg
+convertToNewSyntax =
+    command "convertToNewSyntax" Encode.null
+
+
 
 ------------- INCOMING ---------------------------------------------------------
 
@@ -110,3 +117,8 @@
 deploymentUpdated : msg -> Decode.Decoder msg -> Sub msg
 deploymentUpdated =
     subscribe "deploymentUpdated"
+
+
+syntaxDetected : msg -> Decode.Decoder msg -> Sub msg
+syntaxDetected =
+    subscribe "syntaxDetected"
diff --git a/frontend/src/evaluator.ts b/frontend/src/evaluator.ts
index c48902c..53f3415 100644
--- a/frontend/src/evaluator.ts
+++ b/frontend/src/evaluator.ts
@@ -13,6 +13,13 @@
 // milliseconds. This should be at least as long as the CSS .loader transition.
 const LOADER_DISPLAY_NONE_DELAY = 300;
 
+// Regexes for FIDL errors that likely indicate the user is writing the old
+// syntax and fidlc is attempting to parse the new syntax.
+const OLD_FIDL_SYNTAX_ERROR_REGEXES = [
+  /^fidlbolt.fidl:\d+:\d+: error: invalid declaration type (?:bits|enum|struct|table|union|strict|flexible|resource|)/,
+  /^fidlbolt.fidl:\d+:\d+: error: unexpected token Identifier, was expecting RightParen/,
+];
+
 // A request sent to the server.
 interface Request {
   inputMode: InputMode;
@@ -42,6 +49,10 @@
 // to specify its fields here because we just forward it to Elm.
 type Deployment = object;
 
+// FIDL syntax is detected as 'old' if compilation fails due to an error in
+// OLD_FIDL_SYNTAX_ERROR_REGEXES and conversion to the new syntax succeeds.
+type FidlSyntax = 'unknown' | 'old';
+
 // Evaluator sends requests to the server to update the output.
 export class Evaluator {
   readonly editors: Editors;
@@ -53,7 +64,9 @@
   lastInput: InputMap;
   active?: Omit<Request, 'content'>;
   loaderTimeout?: Timeout;
+  syntaxConversionResult?: string;
   deploymentCallback?: (deployment: Deployment) => void;
+  syntaxCallback?: (syntax: FidlSyntax) => void;
 
   constructor(editors: Editors, saveFn: VoidFunction) {
     this.editors = editors;
@@ -64,13 +77,19 @@
     this.stopFn = undefined;
     this.loaderElement = document.getElementById('Loader') as HTMLElement;
     this.loaderTimeout = undefined;
+    this.syntaxConversionResult = undefined;
     this.deploymentCallback = undefined;
+    this.syntaxCallback = undefined;
   }
 
   onDeloymentUpdated(callback: (deployment: Deployment) => void) {
     this.deploymentCallback = callback;
   }
 
+  onSyntaxDetected(callback: (syntax: FidlSyntax) => void) {
+    this.syntaxCallback = callback;
+  }
+
   setActive(inputMode: InputMode, outputMode: OutputMode, options: Options) {
     this.active = {inputMode, outputMode, options};
   }
@@ -167,6 +186,7 @@
       return;
     }
 
+    this.resetSyntaxConversion();
     this.startLoadingAnimation();
     const finish = (
       content: string,
@@ -185,6 +205,17 @@
         error
       );
       this.endLoadingAnimation();
+
+      if (
+        request.inputMode === 'fidl' &&
+        error === 'Error' &&
+        // If the input includes "deprecated_syntax;", then fidlc should
+        // recognize it as old syntax and conversion is unnecessary.
+        !request.content.includes('deprecated_syntax;') &&
+        OLD_FIDL_SYNTAX_ERROR_REGEXES.some(re => re.test(content))
+      ) {
+        this.tryOfferingSyntaxConversion(request.content);
+      }
     };
 
     this.saveFn();
@@ -225,6 +256,71 @@
     }
   }
 
+  resetSyntaxConversion() {
+    if (this.syntaxConversionResult != undefined) {
+      this.syntaxConversionResult = undefined;
+      if (this.syntaxCallback != undefined) {
+        this.syntaxCallback('unknown');
+      }
+    }
+  }
+
+  // Try converting fidlContent to the new FIDL syntax and offer it to the user
+  // on a best-effort basis (nothing user-visible happens if it fails).
+  async tryOfferingSyntaxConversion(fidlContent: string) {
+    let response;
+    try {
+      response = await withTimeout(
+        POST_REQUEST_TIMEOUT,
+        'Timed out waiting for server to respond',
+        fetch('/convert', {
+          method: 'POST',
+          headers: {'Content-Type': 'application/json'},
+          body: JSON.stringify({
+            inputMode: 'fidl',
+            outputMode: 'fidl',
+            options: {convertSyntax: true},
+            content: 'deprecated_syntax;\n' + fidlContent,
+          }),
+        })
+      );
+    } catch (error) {
+      return;
+    }
+    if (!response.ok) {
+      return;
+    }
+    const json = await response.json();
+    if (!json.ok) {
+      return;
+    }
+    if (this.syntaxCallback != undefined) {
+      this.syntaxConversionResult = json.content;
+      this.syntaxCallback('old');
+    }
+  }
+
+  convertToNewSyntax() {
+    const result = this.syntaxConversionResult;
+    this.resetSyntaxConversion();
+    if (
+      this.active == undefined ||
+      this.active.inputMode != 'fidl' ||
+      result == undefined
+    ) {
+      return;
+    }
+    const request = {
+      ...this.active,
+      content: this.editors.inputEditor.getValue(),
+    };
+    if (this.needsRefresh(request)) {
+      // The FIDL has changed since we converted it.
+      return;
+    }
+    this.editors.inputEditor.setValue(result);
+  }
+
   startLoadingAnimation() {
     clearTimeout(this.loaderTimeout);
     this.loaderElement.style.display = 'block';
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index cf9dd6e..714e383 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -50,6 +50,12 @@
       payload: deployment,
     });
   });
+  evaluator.onSyntaxDetected(syntax => {
+    app.ports.fromJS.send({
+      command: 'syntaxDetected',
+      payload: syntax,
+    });
+  });
 
   app.ports.toJS.subscribe(({command, payload}: ElmMessage) => {
     switch (command) {
@@ -103,6 +109,10 @@
         });
         break;
       }
+      case 'convertToNewSyntax': {
+        evaluator.convertToNewSyntax();
+        break;
+      }
     }
   });
 
diff --git a/frontend/src/mode.ts b/frontend/src/mode.ts
index d1edb65..ac207f4 100644
--- a/frontend/src/mode.ts
+++ b/frontend/src/mode.ts
@@ -26,7 +26,7 @@
 // Type your code here!
 
 protocol Calculator {
-    Square(int64 x) -> (int64 result);
+    Square(struct { x int64; }) -> (struct { result int64; });
 };
 `,
 
diff --git a/frontend/src/style.css b/frontend/src/style.css
index c870cdb..74342b9 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -12,6 +12,7 @@
   --intense-hover: #e3e3e3;
   --border: #666;
   --link: #06d;
+  --attention: #eeffe1;
 
   font: 16px Helvetica, sans-serif;
   color: var(--fg);
@@ -27,6 +28,7 @@
   --intense-hover: #444;
   --border: #aaa;
   --link: #6af;
+  --attention: #26470e;
 }
 
 h1 {
@@ -252,22 +254,26 @@
   position: absolute;
   top: 23px;
   right: 20px;
-}
+} 
 
 .controls-button {
-  background: var(--intense);
-  padding: 10px;
-  border: 1px solid var(--border);
-  border-radius: 5px;
-  cursor: pointer;
-  display: inline-block;
   margin-left: 10px;
-  user-select: none;
-  -webkit-user-select: none;
+  padding: 10px;
 }
 
-.controls-button:hover {
-  background: var(--intense-hover);
+.news {
+  background: var(--attention);
+  position: absolute;
+  top: 29px;
+  left: 20px;
+  border: 1px solid var(--border);
+  padding: 5px 5px 5px 10px;
+}
+
+.dismiss-button {
+  margin-left: 10px;
+  padding: 1px 6px;
+  font-size: 14px;
 }
 
 .modal-container {
@@ -356,6 +362,11 @@
     right: 0;
     text-align: center;
   }
+
+  .news {
+    position: static;
+    margin: -10px 0 12px;
+  }
 }
 
 /* https://loading.io/css/ */