diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ef2185..75f3258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,19 @@ jobs: steps: - uses: actions/checkout@v4 + + # Set up Rust - name: Setup Rust and Cargo uses: moonrepo/setup-rust@v1.1.0 + + # Set up formatters + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + - run: npm install + + # Test - name: Test run: cargo test - name: Test README files diff --git a/.gitignore b/.gitignore index 867e994..cf3f399 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ +/node_modules /result - - -# Added by cargo - /target diff --git a/Cargo.lock b/Cargo.lock index 568ca76..0b8ee34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ dependencies = [ "jtd", "serde", "serde_json", + "serde_yaml", "tracing", "trycmd", ] @@ -681,6 +682,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -849,6 +863,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index ea27918..ba16371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ eyre = "0.6.12" jtd = "0.3.1" serde = { version = "1.0.199", features = ["derive"] } serde_json = "1.0.116" +serde_yaml = "0.9.34" tracing = "0.1.40" [profile.dev.package.backtrace] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f2d954 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Brian Hicks + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + diff --git a/README.md b/README.md index 04da896..e17adb0 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,84 @@ # elm-duet -Elm is great, and TypeScript is great, but the flags and ports between them are hard to use safely. -They're the only part of the a system between those two languages that aren't typed by default. +I like Elm and TypeScript for building apps, but I find it annoying to make the boundary between them type-safe. -You can get around this in various ways, of course, either by maintaining a definitions by hand or generating one side from the other. +I can get around this in various ways, of course, either by maintaining a definitions by hand or generating one side from the other. In general, though, you run into a couple different issues: - It's easy for one side or the other to get out of date and errors to slip through CI and code review into production. -- Definitions in one language may not be translatable to the other (despite the two type systems having pretty good overlap.) +- Definitions in one language may not be translatable to the other (despite the two type systems having a high degree of overlap.) `elm-duet` tries to get around this by creating a single source of truth to generate both TypeScript definitions and Elm types with decoders. We use [JSON Type Definitions](https://jsontypedef.com/) (JTD, [five-minute tutorial](https://jsontypedef.com/docs/jtd-in-5-minutes/)) to say precisely what we want and generate ergonomic types on both sides (plus helpers like encoders to make testing easy!) -Here's an example for an app that stores a [jwt](https://jwt.io/) in `localStorage` or similar to present to Elm: +In addition, `elm-duet` produces files that make following good practices around interop easier! +I'll call those out as we get to them. -```json {source=examples/jwt_schema.json} -{ - "modules": { - "Main": { - "flags": { - "properties": { - "currentJwt": { - "type": "string", - "nullable": true - } - } - }, - "ports": { - "newJwt": { - "metadata": { - "direction": "ElmToJs" - }, - "type": "string" - }, - "logout": { - "metadata": { - "direction": "ElmToJs" - } - } - } - } - } -} +## Example 1: JWTs + +Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorage`: + +```yaml {source=examples/jwt_schema.yaml} +# An example schema that uses JWTs to manage authentication. Imagine that the +# JWTs are stored in localStorage so that they can persist across sessions. The +# lifecycle of this app might look like: +# +# 1. On init, the JS side passes the Elm runtime the current value of the JWT +# (or `null`, if unset) +# 2. Elm is responsible for authentication and for telling JS when it gets a +# new JWT (for example, when the user logs in) + +# For elm-duet, we start by defining our data types. In this case, we're just +# going to keep things simple and define a "jwt" that will just be an alias to +# a string. +definitions: + jwt: + type: string + +# Now we say how to use it. Each key in this object is a module in your Elm +# application, in which we can define our flags and ports. +modules: + Main: + # First we'll define flags. As we mentioned above, that's either a JWT or + # null. We'll define it by referring to `jwt` from above. + # + # If your app doesn't use flags, you can omit this key. + flags: + properties: + currentJwt: + ref: jwt + nullable: true + + # Next we'll do ports. As with flags, if your app doesn't use ports, you + # can omit this key. + ports: + # Like flags, ports are specified with a JTD schema. In this case, we + # want a non-nullable version of the same JWT as earlier. + # + # Ports also have a direction. We specify this in the + # `metadata.direction` key, either `ElmToJs` or `JsToElm`. + newJwt: + metadata: + direction: ElmToJs + ref: jwt ``` -You can generate code from this by calling `elm-duet path/to/your/schema.json`: +(We're using YAML in this example so we can use comments, but JSON schemas also work just fine.) + +You can generate code from this by calling `elm-duet path/to/your/schema.(yaml|json)`: ```console -$ elm-duet examples/jwt_schema.json --typescript-dest examples/jwt_schema.ts --elm-dest examples/jwt_schema +$ elm-duet examples/jwt_schema.yaml --typescript-dest examples/jwt_schema.ts --elm-dest examples/jwt_schema wrote examples/jwt_schema.ts wrote examples/jwt_schema/Main/Flags.elm wrote examples/jwt_schema/Main/Ports.elm +formatted TypeScript +formatted Elm ``` -Which results in this schema: +This produces this TypeScript file: ```typescript {source=examples/jwt_schema.ts} // Warning: this file is automatically generated. Don't edit by hand! @@ -64,28 +87,25 @@ declare module Elm { namespace Main { type Flags = { currentJwt: string | null; - } - + }; + type Ports = { - logout: { - subscribe: (callback: (value: Record) => void) => void; - }; newJwt: { subscribe: (callback: (value: string) => void) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } } + ``` -And these Elm flags: +This should be flexible enough to use both if you're embedding your Elm app (e.g. with `esbuild`) or referring to it as an external JS file. + +We also get this file containing Elm flags: ```elm {source=examples/jwt_schema/Main/Flags.elm} module Main.Flags exposing (..) @@ -116,7 +136,7 @@ encodeFlags flags = , case flags.currentJwt of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -124,7 +144,13 @@ encodeFlags flags = ``` -And these for the ports: +In your `init`, you can accept a `Json.Decode.Value` and call `Decode.decodeValue Main.Flags.flagsDecoder flags` to get complete control over the error experience. +It also lets you custom types in your flags, since you're specifying the decoder. + +Note that `elm-duet` creates both decoders and encoders for all the types it generates. +This is to make your life easier during testing: you can hook up tools like [elm-program-test](https://package.elm-lang.org/packages/avh4/elm-program-test/latest/) without having to redo your data encoding. + +Finally, we have the ports: ```elm {source=examples/jwt_schema/Main/Ports.elm} port module Main.Ports exposing (..) @@ -137,20 +163,6 @@ import Json.Decode.Pipeline import Json.Encode -type alias Logout = - () - - -logoutDecoder : Decoder Logout -logoutDecoder = - Json.Decode.null () - - -encodeLogout : Logout -> Json.Encode.Value -encodeLogout logout = - Json.Encode.null - - type alias NewJwt = String @@ -165,23 +177,186 @@ encodeNewJwt newJwt = Json.Encode.string newJwt -port logout : Value -> Cmd msg - - -logout_ : Logout -> Cmd msg -logout_ value = - logout (encodeLogout value) - - port newJwt : Value -> Cmd msg -newJwt_ : NewJwt -> Cmd msg -newJwt_ value = +sendNewJwt : NewJwt -> Cmd msg +sendNewJwt value = newJwt (encodeNewJwt value) ``` +You'll notice that in addition to decoders and encoders, `elm-duet` generates type-safe wrappers around the ports. +This is, again, to let you send custom types through the ports in a way we control: if you specify an `enum`, for example, we ensure that both Elm and TypeScript have enough information to take advantage of the best parts of their respective type systems without falling back to plain strings. + +Other than those helpers, we don't try to generate any particular helpers for constructing values. +Every app is going to have different needs there, and we expose everything you need to construct your own. + +## Example 2: An All-in-One Port + +Some people like to define an all-in-one port for their application to make sure that they only have a single place to hook up new messages. +`elm-duet` and JDT support this with discriminators and mappings: + +```yaml {source=examples/all_in_one.yaml} +# Let's imagine that we're writing an app which handles some websocket messages +# that we want Elm to react to. We'll leave it up to Elm to interpret the data +# inside the messages, but we can use elm-duet to ensure that we have all the +# right types for each port event set up. +# +# For this example, we're going to define a port named `toWorld` that sends all +# our messages to JS in the same place, and the same for `fromWorld` for +# subscriptions. We could do this across many ports as well, of course, but if +# you prefer to put all your communication in one port, here's how you do it! +modules: + Main: + ports: + toWorld: + metadata: + direction: ElmToJs + + # JTD lets us distinguish between different types of an object by using + # the discriminator/mapping case. The first step is to define the field + # that "tags" the message. In this case, we'll literally use `tag`: + discriminator: tag + + # Next, tell JTD all the possible values of `tag` and what types are + # associated with them. We'll use the same option as in the JWT schema + # example, but all in one port. Note that all the options need to be + # non-nullable objects, since we can't set a tag otherwise. + mapping: + connect: + properties: + url: + type: string + protocols: + elements: + type: string + nullable: true + + send: + properties: + message: + type: string + + close: + properties: + code: + type: uint8 + reason: + type: string + + # since we're managing the websocket in JS, we need to pass events back + # into Elm. Like `toWorld`, we'll use discriminator/mapping from JTD. + fromWorld: + metadata: + direction: JsToElm + discriminator: tag + + mapping: + # There isn't any data in the `open` or `error` events, but we still + # care about knowing that it happened. In this case, we specify an + # empty object to signify that there is no additional data. + open: {} + error: {} + + close: + properties: + code: + type: uint32 + reason: + type: string + wasClean: + type: boolean + + message: + properties: + data: + type: string + origin: + type: string + +``` + +Again, we generate everything in `examples`: + +```console +$ elm-duet examples/all_in_one.yaml --typescript-dest examples/all_in_one.ts --elm-dest examples/all_in_one +wrote examples/all_in_one.ts +wrote examples/all_in_one/Main/Ports.elm +formatted TypeScript +formatted Elm + +``` + +We get this in TypeScript: + +```typescript {source=examples/all_in_one.ts} +// Warning: this file is automatically generated. Don't edit by hand! + +declare module Elm { + namespace Main { + type Flags = Record; + + type Ports = { + fromWorld: { + send: ( + value: + | { + code: number; + reason: string; + tag: "close"; + wasClean: boolean; + } + | { + tag: "error"; + } + | { + data: string; + origin: string; + tag: "message"; + } + | { + tag: "open"; + }, + ) => void; + }; + toWorld: { + subscribe: ( + callback: ( + value: + | { + code: number; + reason: string; + tag: "close"; + } + | { + protocols: string[] | null; + tag: "connect"; + url: string; + } + | { + message: string; + tag: "send"; + }, + ) => void, + ) => void; + }; + }; + + function init(config: { flags: Flags; node: HTMLElement }): { + ports: Ports; + }; + } +} + +``` + +The Elm for this example is too long to reasonably include in a README. +See it at `examples/all_in_one/Main/Ports.elm`. +Like the previous example, you get all the data types and ports you need, plus some wrappers around the ports that will do the decoding for you. + +## The Full Help + Here's the full help to give you an idea of what you can do with the tool: ```console @@ -194,9 +369,23 @@ Arguments: Location of the definition file Options: - --typescript-dest Destination for TypeScript types [default: elm.ts] - --elm-dest Destination for Elm types [default: src/] - -h, --help Print help - -V, --version Print version + --typescript-dest + Destination for TypeScript types [default: elm.ts] + --elm-dest + Destination for Elm types [default: src/] + --no-format + Turn off automatic formatting discovery + --ts-formatter + What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in place.) [default: prettier] + --elm-formatter + What formatter should I use for Elm? (Assumed to take a `--yes` flag to modify files in place without confirmation.) [default: elm-format] + -h, --help + Print help + -V, --version + Print version ``` + +## License + +BSD 3-Clause, same as Elm. diff --git a/elm.ts b/elm.ts deleted file mode 100644 index fcd08a9..0000000 --- a/elm.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Warning: this file is automatically generated. Don't edit by hand! - -declare module Elm { - namespace Foo { - namespace Bar { - namespace Main { - type Flags = { - currentTimeMillis: number; - notificationPermission: "default" | "denied" | "granted"; - } - - type Ports = { - changeDocument: { - subscribe: (callback: (value: { - tag: "AddNewPingAt"; - value: number; - } | { - tag: "SetMinutesPerPing"; - value: number; - } | { - index: number; - tag: "SetTagForPing"; - value: string; - }) => void) => void; - }; - docFromAutomerge: { - send: (value: { - }) => void; - }; - notificationPermission: { - send: (value: "default" | "denied" | "granted") => void; - }; - requestNotificationPermission: { - subscribe: (callback: (value: Record) => void) => void; - }; - sendNotification: { - subscribe: (callback: (value: { - options: { - badge: string | null; - body: string | null; - icon: string | null; - lang: string | null; - requireInteraction: bool | null; - silent: bool | null; - tag: string | null; - }; - title: string; - }) => void) => void; - }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): void - } - } - } -} \ No newline at end of file diff --git a/examples/all_in_one.ts b/examples/all_in_one.ts new file mode 100644 index 0000000..f725d5e --- /dev/null +++ b/examples/all_in_one.ts @@ -0,0 +1,57 @@ +// Warning: this file is automatically generated. Don't edit by hand! + +declare module Elm { + namespace Main { + type Flags = Record; + + type Ports = { + fromWorld: { + send: ( + value: + | { + code: number; + reason: string; + tag: "close"; + wasClean: boolean; + } + | { + tag: "error"; + } + | { + data: string; + origin: string; + tag: "message"; + } + | { + tag: "open"; + }, + ) => void; + }; + toWorld: { + subscribe: ( + callback: ( + value: + | { + code: number; + reason: string; + tag: "close"; + } + | { + protocols: string[] | null; + tag: "connect"; + url: string; + } + | { + message: string; + tag: "send"; + }, + ) => void, + ) => void; + }; + }; + + function init(config: { flags: Flags; node: HTMLElement }): { + ports: Ports; + }; + } +} diff --git a/examples/all_in_one.yaml b/examples/all_in_one.yaml new file mode 100644 index 0000000..b2d608f --- /dev/null +++ b/examples/all_in_one.yaml @@ -0,0 +1,76 @@ +# Let's imagine that we're writing an app which handles some websocket messages +# that we want Elm to react to. We'll leave it up to Elm to interpret the data +# inside the messages, but we can use elm-duet to ensure that we have all the +# right types for each port event set up. +# +# For this example, we're going to define a port named `toWorld` that sends all +# our messages to JS in the same place, and the same for `fromWorld` for +# subscriptions. We could do this across many ports as well, of course, but if +# you prefer to put all your communication in one port, here's how you do it! +modules: + Main: + ports: + toWorld: + metadata: + direction: ElmToJs + + # JTD lets us distinguish between different types of an object by using + # the discriminator/mapping case. The first step is to define the field + # that "tags" the message. In this case, we'll literally use `tag`: + discriminator: tag + + # Next, tell JTD all the possible values of `tag` and what types are + # associated with them. We'll use the same option as in the JWT schema + # example, but all in one port. Note that all the options need to be + # non-nullable objects, since we can't set a tag otherwise. + mapping: + connect: + properties: + url: + type: string + protocols: + elements: + type: string + nullable: true + + send: + properties: + message: + type: string + + close: + properties: + code: + type: uint8 + reason: + type: string + + # since we're managing the websocket in JS, we need to pass events back + # into Elm. Like `toWorld`, we'll use discriminator/mapping from JTD. + fromWorld: + metadata: + direction: JsToElm + discriminator: tag + + mapping: + # There isn't any data in the `open` or `error` events, but we still + # care about knowing that it happened. In this case, we specify an + # empty object to signify that there is no additional data. + open: {} + error: {} + + close: + properties: + code: + type: uint32 + reason: + type: string + wasClean: + type: boolean + + message: + properties: + data: + type: string + origin: + type: string diff --git a/examples/all_in_one/Main/Ports.elm b/examples/all_in_one/Main/Ports.elm new file mode 100644 index 0000000..5f3b948 --- /dev/null +++ b/examples/all_in_one/Main/Ports.elm @@ -0,0 +1,252 @@ +port module Main.Ports exposing (..) + +{-| Warning: this file is automatically generated. Don't edit by hand! +-} + +import Json.Decode +import Json.Decode.Pipeline +import Json.Encode + + +type alias Close = + { code : Int + , reason : String + , wasClean : Bool + } + + +closeDecoder : Decoder Close +closeDecoder = + Json.Decode.succeed Close + |> Json.Decode.Pipeline.required "code" Json.Decode.int + |> Json.Decode.Pipeline.required "reason" Json.Decode.string + |> Json.Decode.Pipeline.required "wasClean" Json.Decode.bool + + +encodeClose : Close -> Json.Encode.Value +encodeClose close = + Json.Encode.object + [ ( "code", Json.Encode.int close.code ) + , ( "reason", Json.Encode.string close.reason ) + , ( "wasClean", Json.Encode.bool close.wasClean ) + , ( "tag", Json.Encode.string "close" ) + ] + + +type alias TagError = + {} + + +tagErrorDecoder : Decoder TagError +tagErrorDecoder = + Json.Decode.succeed TagError + + +encodeTagError : TagError -> Json.Encode.Value +encodeTagError tagError = + Json.Encode.object + [ ( "tag", Json.Encode.string "error" ) + ] + + +type alias Message = + { data : String + , origin : String + } + + +messageDecoder : Decoder Message +messageDecoder = + Json.Decode.succeed Message + |> Json.Decode.Pipeline.required "data" Json.Decode.string + |> Json.Decode.Pipeline.required "origin" Json.Decode.string + + +encodeMessage : Message -> Json.Encode.Value +encodeMessage message = + Json.Encode.object + [ ( "data", Json.Encode.string message.data ) + , ( "origin", Json.Encode.string message.origin ) + , ( "tag", Json.Encode.string "message" ) + ] + + +type alias TagOpen = + {} + + +tagOpenDecoder : Decoder TagOpen +tagOpenDecoder = + Json.Decode.succeed TagOpen + + +encodeTagOpen : TagOpen -> Json.Encode.Value +encodeTagOpen tagOpen = + Json.Encode.object + [ ( "tag", Json.Encode.string "open" ) + ] + + +type FromWorld + = Close Close + | Error TagError + | Message Message + | Open TagOpen + + +fromWorldDecoder : Decoder FromWorld +fromWorldDecoder = + Json.Decode.andThen + (\tag -> + case tag of + "close" -> + Json.Decode.map Close closeDecoder + + "error" -> + Json.Decode.map Error tagErrorDecoder + + "message" -> + Json.Decode.map Message messageDecoder + + "open" -> + Json.Decode.map Open tagOpenDecoder + ) + (Json.Decode.field "tag" Json.Decode.string) + + +encodeFromWorld : FromWorld -> Json.Encode.Value +encodeFromWorld fromWorld = + case fromWorld of + Close close -> + encodeClose close + + Error error -> + encodeTagError error + + Message message -> + encodeMessage message + + Open open -> + encodeTagOpen open + + +type alias Close = + { code : Int + , reason : String + } + + +closeDecoder : Decoder Close +closeDecoder = + Json.Decode.succeed Close + |> Json.Decode.Pipeline.required "code" Json.Decode.int + |> Json.Decode.Pipeline.required "reason" Json.Decode.string + + +encodeClose : Close -> Json.Encode.Value +encodeClose close = + Json.Encode.object + [ ( "code", Json.Encode.int close.code ) + , ( "reason", Json.Encode.string close.reason ) + , ( "tag", Json.Encode.string "close" ) + ] + + +type alias Connect = + { protocols : Maybe (List String) + , url : String + } + + +connectDecoder : Decoder Connect +connectDecoder = + Json.Decode.succeed Connect + |> Json.Decode.Pipeline.required "protocols" (Json.Decode.nullable (Json.Decode.list Json.Decode.string)) + |> Json.Decode.Pipeline.required "url" Json.Decode.string + + +encodeConnect : Connect -> Json.Encode.Value +encodeConnect connect = + Json.Encode.object + [ ( "protocols" + , case connect.protocols of + Just value -> + Json.Encode.list (\value -> Json.Encode.string value) value + + Nothing -> + Json.Encode.null + ) + , ( "url", Json.Encode.string connect.url ) + , ( "tag", Json.Encode.string "connect" ) + ] + + +type alias Send = + { message : String + } + + +sendDecoder : Decoder Send +sendDecoder = + Json.Decode.succeed Send + |> Json.Decode.Pipeline.required "message" Json.Decode.string + + +encodeSend : Send -> Json.Encode.Value +encodeSend send = + Json.Encode.object + [ ( "message", Json.Encode.string send.message ) + , ( "tag", Json.Encode.string "send" ) + ] + + +type ToWorld + = Close Close + | Connect Connect + | Send Send + + +toWorldDecoder : Decoder ToWorld +toWorldDecoder = + Json.Decode.andThen + (\tag -> + case tag of + "close" -> + Json.Decode.map Close closeDecoder + + "connect" -> + Json.Decode.map Connect connectDecoder + + "send" -> + Json.Decode.map Send sendDecoder + ) + (Json.Decode.field "tag" Json.Decode.string) + + +encodeToWorld : ToWorld -> Json.Encode.Value +encodeToWorld toWorld = + case toWorld of + Close close -> + encodeClose close + + Connect connect -> + encodeConnect connect + + Send send -> + encodeSend send + + +port fromWorld : (Value -> msg) -> Sub msg + + +subscribeToFromWorld : (Result Json.Decode.Error FromWorld -> msg) -> Sub msg +subscribeToFromWorld toMsg = + fromWorld (\value -> toMsg (Json.Decode.decodeValue value fromWorldDecoder)) + + +port toWorld : Value -> Cmd msg + + +sendToWorld : ToWorld -> Cmd msg +sendToWorld value = + toWorld (encodeToWorld value) diff --git a/examples/jwt_schema.json b/examples/jwt_schema.json deleted file mode 100644 index 60cfcce..0000000 --- a/examples/jwt_schema.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "modules": { - "Main": { - "flags": { - "properties": { - "currentJwt": { - "type": "string", - "nullable": true - } - } - }, - "ports": { - "newJwt": { - "metadata": { - "direction": "ElmToJs" - }, - "type": "string" - }, - "logout": { - "metadata": { - "direction": "ElmToJs" - } - } - } - } - } -} diff --git a/examples/jwt_schema.ts b/examples/jwt_schema.ts index 3dffedd..c117c94 100644 --- a/examples/jwt_schema.ts +++ b/examples/jwt_schema.ts @@ -4,22 +4,16 @@ declare module Elm { namespace Main { type Flags = { currentJwt: string | null; - } - + }; + type Ports = { - logout: { - subscribe: (callback: (value: Record) => void) => void; - }; newJwt: { subscribe: (callback: (value: string) => void) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/examples/jwt_schema.yaml b/examples/jwt_schema.yaml new file mode 100644 index 0000000..fb25d7d --- /dev/null +++ b/examples/jwt_schema.yaml @@ -0,0 +1,42 @@ +# An example schema that uses JWTs to manage authentication. Imagine that the +# JWTs are stored in localStorage so that they can persist across sessions. The +# lifecycle of this app might look like: +# +# 1. On init, the JS side passes the Elm runtime the current value of the JWT +# (or `null`, if unset) +# 2. Elm is responsible for authentication and for telling JS when it gets a +# new JWT (for example, when the user logs in) + +# For elm-duet, we start by defining our data types. In this case, we're just +# going to keep things simple and define a "jwt" that will just be an alias to +# a string. +definitions: + jwt: + type: string + +# Now we say how to use it. Each key in this object is a module in your Elm +# application, in which we can define our flags and ports. +modules: + Main: + # First we'll define flags. As we mentioned above, that's either a JWT or + # null. We'll define it by referring to `jwt` from above. + # + # If your app doesn't use flags, you can omit this key. + flags: + properties: + currentJwt: + ref: jwt + nullable: true + + # Next we'll do ports. As with flags, if your app doesn't use ports, you + # can omit this key. + ports: + # Like flags, ports are specified with a JTD schema. In this case, we + # want a non-nullable version of the same JWT as earlier. + # + # Ports also have a direction. We specify this in the + # `metadata.direction` key, either `ElmToJs` or `JsToElm`. + newJwt: + metadata: + direction: ElmToJs + ref: jwt diff --git a/examples/jwt_schema/Main/Flags.elm b/examples/jwt_schema/Main/Flags.elm index 32656bc..4c19b29 100644 --- a/examples/jwt_schema/Main/Flags.elm +++ b/examples/jwt_schema/Main/Flags.elm @@ -26,7 +26,7 @@ encodeFlags flags = , case flags.currentJwt of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) diff --git a/examples/jwt_schema/Main/Ports.elm b/examples/jwt_schema/Main/Ports.elm index 95d694c..c752460 100644 --- a/examples/jwt_schema/Main/Ports.elm +++ b/examples/jwt_schema/Main/Ports.elm @@ -8,20 +8,6 @@ import Json.Decode.Pipeline import Json.Encode -type alias Logout = - () - - -logoutDecoder : Decoder Logout -logoutDecoder = - Json.Decode.null () - - -encodeLogout : Logout -> Json.Encode.Value -encodeLogout logout = - Json.Encode.null - - type alias NewJwt = String @@ -36,17 +22,9 @@ encodeNewJwt newJwt = Json.Encode.string newJwt -port logout : Value -> Cmd msg - - -logout_ : Logout -> Cmd msg -logout_ value = - logout (encodeLogout value) - - port newJwt : Value -> Cmd msg -newJwt_ : NewJwt -> Cmd msg -newJwt_ value = +sendNewJwt : NewJwt -> Cmd msg +sendNewJwt value = newJwt (encodeNewJwt value) diff --git a/flake.nix b/flake.nix index 8317155..639f40b 100644 --- a/flake.nix +++ b/flake.nix @@ -20,9 +20,11 @@ pkgs.rustfmt # formatters - pkgs.elmPackages.elm-format - pkgs.nodePackages.prettier pkgs.typos + + # formatters + pkgs.nodePackages.npm + pkgs.nodejs ]; }; } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6c28278 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "elm-duet", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elm-duet", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "elm-format": "^0.8.7", + "prettier": "^3.2.5" + } + }, + "node_modules/@avh4/elm-format-darwin-arm64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-darwin-arm64/-/elm-format-darwin-arm64-0.8.7-2.tgz", + "integrity": "sha512-F5JD44mJ3KX960J5GkXMfh1/dtkXuPcQpX2EToHQKjLTZUfnhZ++ytQQt0gAvrJ0bzoOvhNzjNjUHDA1ruTVbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@avh4/elm-format-darwin-x64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-darwin-x64/-/elm-format-darwin-x64-0.8.7-2.tgz", + "integrity": "sha512-4pfF1cl0KyTion+7Mg4XKM3yi4Yc7vP76Kt/DotLVGJOSag4ISGic1og2mt8RZZ7XArybBmHNyYkiUbe/cEiCw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@avh4/elm-format-linux-arm64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-linux-arm64/-/elm-format-linux-arm64-0.8.7-2.tgz", + "integrity": "sha512-WkVmuce2zU6s9dupHhqPc886Vaqpea8dZlxv2fpZ4wSzPUbiiKHoHZzoVndMIMTUL0TZukP3Ps0n/lWO5R5+FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@avh4/elm-format-linux-x64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-linux-x64/-/elm-format-linux-x64-0.8.7-2.tgz", + "integrity": "sha512-kmncfJrTBjVT94JtQvMf4M5Pn2Yl0sZt3wo7AzgFiDnB/CiZ+KjJyXuWM64NeGiv4MQqzPq65tsFXUH1CIJeiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@avh4/elm-format-win32-x64": { + "version": "0.8.7-2", + "resolved": "https://registry.npmjs.org/@avh4/elm-format-win32-x64/-/elm-format-win32-x64-0.8.7-2.tgz", + "integrity": "sha512-sBdMBGq/8mD8Y5C+fIr5vlb3N50yB7S1MfgeAq2QEbvkr/sKrCZI540i43lZDH9gWsfA1w2W8wCe0penFYzsGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/elm-format": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/elm-format/-/elm-format-0.8.7.tgz", + "integrity": "sha512-sVzFXfWnb+6rzXK+q3e3Ccgr6/uS5mFbFk1VSmigC+x2XZ28QycAa7lS8owl009ALPhRQk+pZ95Eq5ANjpEZsQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "elm-format": "bin/elm-format" + }, + "optionalDependencies": { + "@avh4/elm-format-darwin-arm64": "0.8.7-2", + "@avh4/elm-format-darwin-x64": "0.8.7-2", + "@avh4/elm-format-linux-arm64": "0.8.7-2", + "@avh4/elm-format-linux-x64": "0.8.7-2", + "@avh4/elm-format-win32-x64": "0.8.7-2" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..884e27f --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "elm-duet", + "version": "1.0.0", + "main": "index.js", + "directories": { + "example": "examples", + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "elm-format": "^0.8.7", + "prettier": "^3.2.5" + } +} diff --git a/src/elm.rs b/src/elm.rs index 8e3b1bc..e76bcc4 100644 --- a/src/elm.rs +++ b/src/elm.rs @@ -267,11 +267,12 @@ impl Type { schema: Schema, name_suggestion: Option, globals: &BTreeMap, + discriminator: Option<(String, String)>, ) -> Result<(Self, Vec)> { let mut is_nullable = false; let mut decls = Vec::new(); - let base = match schema { + let mut base = match schema { Schema::Empty { .. } => Self::Unit, Schema::Ref { definitions, @@ -286,6 +287,7 @@ impl Type { schema.clone(), name_suggestion.or_else(|| Some(ref_.to_string())), globals, + discriminator.clone(), ) .wrap_err_with(|| format!("could not convert the value of ref `{ref_}`"))?; @@ -353,6 +355,7 @@ impl Type { *elements, name_suggestion.map(|n| format!("{n}Elements")), globals, + discriminator.clone(), ) .wrap_err("could not convert elements of a list")?; @@ -375,11 +378,15 @@ impl Type { let mut fields = BTreeMap::new(); for (field_name, field_schema) in properties { - let (field_type, field_decls) = - Self::from_schema(field_schema, Some(field_name.clone()), globals) - .wrap_err_with(|| { - format!("could not convert the type of `{field_name}`") - })?; + let (field_type, field_decls) = Self::from_schema( + field_schema, + Some(field_name.clone()), + globals, + None, // We'll actually use this in the unified handler below! + ) + .wrap_err_with(|| { + format!("could not convert the type of `{field_name}`") + })?; decls.extend(field_decls); fields.insert(field_name.into(), field_type); @@ -404,6 +411,7 @@ impl Type { *values, name_suggestion.map(|n| format!("{n}Values")), globals, + discriminator.clone(), ) .wrap_err("could not convert elements of a list")?; @@ -415,7 +423,7 @@ impl Type { metadata, nullable, mapping, - discriminator, + discriminator: discriminator_field, .. } => match metadata .get("name") @@ -427,28 +435,20 @@ impl Type { let mut cases = BTreeMap::new(); for (tag, tag_schema) in mapping { - let (value_type, mut value_decls) = - Self::from_schema(tag_schema, Some(tag.to_string()), globals) - .wrap_err_with(|| { - format!("could not convert mapping for `{tag}`") - })?; - - // tell the referred decl (if we got one) that it needs to include the - // discriminator tag in its encoder - if let Type::Ref(ref_name) = &value_type { - for decl in &mut value_decls { - if decl.name() == ref_name { - decl.add_discriminator(discriminator.clone(), tag.clone())?; - } - } - } + let (value_type, value_decls) = Self::from_schema( + tag_schema, + Some(tag.to_string()), + globals, + Some((discriminator_field.clone(), tag.to_string())), + ) + .wrap_err_with(|| format!("could not convert mapping for `{tag}`"))?; decls.extend(value_decls); cases.insert(tag.into(), Some(value_type)); } decls.push(Decl::CustomTypeEnum { name: name.into(), - discriminator: Some(discriminator), + discriminator: Some(discriminator_field), constructor_prefix: metadata .get("constructorPrefix") .and_then(|n| n.as_str()) @@ -463,6 +463,42 @@ impl Type { }, }; + if let Some((discriminator_tag, discriminator_value)) = discriminator { + match &base { + Self::Unit => { + // TODO: this doesn't include any constructor prefixs. This could be a problem? + // It might also be fine? We'll have to see. + let name = InflectedString::from(format!("{discriminator_tag}_{discriminator_value}")); + + decls.push(Decl::TypeAlias { + name: name.clone(), + type_: Type::Record(BTreeMap::new()), + discriminator: Some((discriminator_tag, discriminator_value)), + }); + + base = Self::Ref(name); + } + Self::Ref(ref_name) => { + for decl in &mut decls { + if decl.name() == ref_name { + decl.add_discriminator( + discriminator_tag.clone(), + discriminator_value.clone(), + )?; + } + } + } + Self::Int => bail!("I can't add a discriminator to an int"), + Self::Float => bail!("I can't add a discriminator to an float"), + Self::Bool => bail!("I can't add a discriminator to a bool"), + Self::String => bail!("I can't add a discriminator to a string"), + Self::Maybe(_) => bail!("I can't add a discriminator to a maybe"), + Self::DictWithStringKeys(_) => bail!("I can't add a discriminator to a dict"), + Self::List(_) => bail!("I can't add a discriminator to a list"), + Self::Record(_) => bail!("As silly as it seems, I can't add a discriminator to a record type directly. That has to be done at the decl level, and I don't know which decl to reference."), + } + } + Ok(( if is_nullable { Self::Maybe(Box::new(base)) @@ -710,7 +746,11 @@ impl Type { } if let Some((discriminator_name, discriminator_value)) = discriminator_field_opt { - out.push_str(" , ( \""); + if fields.is_empty() { + out.push_str(" [ ( \""); + } else { + out.push_str(" , ( \""); + } out.push_str(discriminator_name); out.push_str("\", Json.Encode.string \""); out.push_str(discriminator_value); @@ -739,18 +779,10 @@ pub enum PortDirection { } impl Port { - pub fn new_send(name: String, type_: Decl) -> Self { + pub fn new(name: String, direction: PortDirection, type_: Decl) -> Self { Self { name, - direction: PortDirection::Send, - type_, - } - } - - pub fn new_subscribe(name: String, type_: Decl) -> Self { - Self { - name, - direction: PortDirection::Subscribe, + direction, type_, } } @@ -765,11 +797,23 @@ impl Port { PortDirection::Subscribe => out.push_str("(Value -> msg) -> Sub msg\n\n\n"), } - out.push_str(&self.name); - out.push_str("_ : "); - let type_ref = self.type_.name(); + let type_safe_name = { + let mut out = String::new(); + + match self.direction { + PortDirection::Send => out.push_str("send"), + PortDirection::Subscribe => out.push_str("subscribeTo"), + } + out.push_str(&InflectedString::from(self.name.clone()).to_pascal_case()?); + + out + }; + + out.push_str(&type_safe_name); + out.push_str(" : "); + match self.direction { PortDirection::Send => { out.push_str(&type_ref.to_pascal_case()?); @@ -791,8 +835,8 @@ impl Port { } } - out.push_str(&self.name); - out.push_str("_ "); + out.push_str(&type_safe_name); + out.push(' '); match self.direction { PortDirection::Send => { @@ -807,7 +851,7 @@ impl Port { out.push_str(&self.name); out.push_str(" (\\value -> toMsg (Json.Decode.decodeValue value "); out.push_str(&self.type_.decoder_name()?); - out.push(')'); + out.push_str("))"); } } @@ -837,7 +881,7 @@ impl Module { name_suggestion: Option, globals: &BTreeMap, ) -> Result { - let (type_, decls) = Type::from_schema(schema, name_suggestion.clone(), globals)?; + let (type_, decls) = Type::from_schema(schema, name_suggestion.clone(), globals, None)?; self.decls.extend(decls); @@ -923,7 +967,7 @@ mod tests { } fn from_schema(value: Value) -> (Type, Vec) { - Type::from_schema(from_json(value), None, &BTreeMap::new()) + Type::from_schema(from_json(value), None, &BTreeMap::new(), None) .expect("valid schema from JSON value") } @@ -1078,6 +1122,7 @@ mod tests { })), None, &BTreeMap::new(), + None, ) .unwrap_err(); @@ -1124,6 +1169,7 @@ mod tests { })), None, &BTreeMap::new(), + None, ) .unwrap_err(); @@ -1236,6 +1282,7 @@ mod tests { })), None, &BTreeMap::from([("foo".into(), from_json(json!({"type": "string"})))]), + None, ) .unwrap(); @@ -1251,6 +1298,7 @@ mod tests { })), None, &BTreeMap::from([("foo".into(), from_json(json!({"properties": {}})))]), + None, ) .unwrap(); diff --git a/src/formatting.rs b/src/formatting.rs new file mode 100644 index 0000000..c6c2861 --- /dev/null +++ b/src/formatting.rs @@ -0,0 +1,66 @@ +use color_eyre::{Help, SectionExt}; +use eyre::{eyre, Result, WrapErr}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub struct Formatter { + name: String, + command: PathBuf, +} + +impl Formatter { + pub fn discover(binary_name: &str) -> Result> { + // first look in PATH + for source in std::env::var("PATH")?.split(':') { + let command = PathBuf::from(source).join(binary_name); + if command.exists() { + return Ok(Some(Self { + name: binary_name.to_owned(), + command, + })); + } + } + + // then search for node_modules up the cwd tree + let cwd = std::env::current_dir()?; + let mut search = Some(cwd.as_path()); + while let Some(dir) = search { + let command = dir.join("node_modules").join(".bin").join(binary_name); + if command.exists() { + return Ok(Some(Self { + name: binary_name.to_owned(), + command, + })); + } + + search = dir.parent() + } + + Ok(None) + } + + pub(crate) fn format(&self, args: &[&str], files: &Vec) -> Result<()> { + let process = Command::new(&self.command) + .args(args) + .args(files) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .wrap_err_with(|| format!("could not start `{}`", self.name))?; + + let out = process + .wait_with_output() + .wrap_err_with(|| format!("could not get output for `{}`", self.name))?; + + if !out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + return Err(eyre!("cmd exited with non-zero status code")) + .with_section(move || stdout.trim().to_string().header("Stdout:")) + .with_section(move || stderr.trim().to_string().header("Stderr:")); + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 90eb385..a2d8b31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ mod elm; +mod formatting; mod inflected_string; mod schema; mod typescript; +use crate::formatting::Formatter; use clap::Parser; use color_eyre::Result; use eyre::WrapErr; @@ -21,6 +23,20 @@ struct Cli { /// Destination for Elm types #[clap(long, default_value = "src/")] elm_dest: PathBuf, + + /// Turn off automatic formatting discovery + #[clap(long)] + no_format: bool, + + /// What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in + /// place.) + #[clap(long, default_value = "prettier")] + ts_formatter: String, + + /// What formatter should I use for Elm? (Assumed to take a `--yes` flag to modify files in + /// place without confirmation.) + #[clap(long, default_value = "elm-format")] + elm_formatter: String, } impl Cli { @@ -29,9 +45,10 @@ impl Cli { // TODO: better error message in all of this - std::fs::write(&self.typescript_dest, schema.flags_to_ts()?)?; + std::fs::write(&self.typescript_dest, schema.to_ts()?)?; println!("wrote {}", self.typescript_dest.display()); + let mut elm_files = Vec::new(); for (name, contents) in schema.to_elm()? { let dest = self.elm_dest.join(name); if let Some(parent) = dest.parent() { @@ -42,6 +59,28 @@ impl Cli { std::fs::write(&dest, contents)?; println!("wrote {}", dest.display()); + elm_files.push(dest); + } + + if !self.no_format { + if let Some(ts_formatter) = Formatter::discover(&self.ts_formatter)? { + ts_formatter + // this is a silly clone but it doesn't matter much from a performance + // perspective. If it bugs you, feel free to refactor it but know in advance + // it'll just be for ergonomics or cleanliness. + .format(&["-w"], &Vec::from([self.typescript_dest.clone()])) + .wrap_err("could not format TypeScript")?; + + println!("formatted TypeScript") + } + + if let Some(elm_formatter) = Formatter::discover(&self.elm_formatter)? { + elm_formatter + .format(&["--yes"], &elm_files) + .wrap_err("could not format Elm")?; + + println!("formatted Elm") + } } Ok(()) diff --git a/src/schema.rs b/src/schema.rs index a133a36..564a2e0 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -2,7 +2,7 @@ use crate::elm; use crate::typescript::NamespaceBuilder; use crate::typescript::TSType; use color_eyre::Result; -use eyre::WrapErr; +use eyre::{bail, WrapErr}; use serde::Deserialize; use std::collections::BTreeMap; use std::path::Path; @@ -43,8 +43,21 @@ pub enum PortDirection { impl Schema { pub fn from_fs(path: &Path) -> Result { let bytes = std::fs::read(path).wrap_err_with(|| format!("could not read {path:?}"))?; - serde_json::from_slice(&bytes) - .wrap_err_with(|| format!("could not read schema from {path:?}")) + + match path.extension().and_then(std::ffi::OsStr::to_str) { + Some("json") => serde_json::from_slice(&bytes) + .wrap_err_with(|| format!("could not read schema from {path:?}")), + Some("yaml") => serde_yaml::from_slice(&bytes) + .wrap_err_with(|| format!("could not read schema from {path:?}")), + Some(_) => bail!( + "I can't deserialize a schema from a {:?} file", + path.extension() + ), + None => bail!( + "I couldn't figure out what kind of file {} is because it doesn't have an extension!", + path.display(), + ), + } } fn globals(&self) -> Result> { @@ -61,7 +74,8 @@ impl Schema { Ok(out) } - pub fn flags_to_ts(&self) -> Result { + // TODO: audit how much work this does and consider moving responsibility into the TS module + pub fn to_ts(&self) -> Result { let mut builder = NamespaceBuilder::root("Elm"); let globals = self.globals()?; @@ -184,12 +198,14 @@ impl Schema { ) .wrap_err_with(|| format!("could not convert the `{port}` port to Elm"))?; - ports_module.insert_port(match port_schema.metadata.direction { - PortDirection::ElmToJs => elm::Port::new_send(port.to_owned(), port_type), - PortDirection::JsToElm => { - elm::Port::new_subscribe(port.to_owned(), port_type) - } - }) + ports_module.insert_port(elm::Port::new( + port.to_owned(), + match port_schema.metadata.direction { + PortDirection::ElmToJs => elm::PortDirection::Send, + PortDirection::JsToElm => elm::PortDirection::Subscribe, + }, + port_type, + )) } files.insert( diff --git a/src/typescript.rs b/src/typescript.rs index 7e4e138..aa33ff0 100644 --- a/src/typescript.rs +++ b/src/typescript.rs @@ -143,6 +143,11 @@ impl TSType { let mut value_type = Self::from_schema(value, globals) .wrap_err_with(|| format!("could not convert the {tag} tag"))?; + // This happens if the payload is empty in a mapping field. + if value_type == Self::NeverObject { + value_type = Self::new_object(BTreeMap::new()) + } + value_type .add_key_to_object(&discriminator, Self::StringScalar(tag)) .wrap_err("jtd discriminator should have enforced that the value type must be an object")?; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index f19f060..74448c4 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,6 +1,18 @@ #[test] fn cli_tests() { trycmd::TestCases::new() + .env( + "PATH", + format!( + "{}:{}", + std::env::current_dir() + .expect("a current dir should exist") + .join("node_modules") + .join(".bin") + .display(), + std::env!("PATH") + ), + ) .case("tests/cmd/*.toml") .case("README.md"); } diff --git a/tests/cmd/huge_record.out/elm.ts b/tests/cmd/huge_record.out/elm.ts index c40a709..0d864c8 100644 --- a/tests/cmd/huge_record.out/elm.ts +++ b/tests/cmd/huge_record.out/elm.ts @@ -15,15 +15,12 @@ declare module Elm { three: string; twelve: string; two: string; - } - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/tests/cmd/huge_record.stdout b/tests/cmd/huge_record.stdout index 06abd7c..e49b390 100644 --- a/tests/cmd/huge_record.stdout +++ b/tests/cmd/huge_record.stdout @@ -1,2 +1,4 @@ wrote elm.ts wrote src/Main/Flags.elm +formatted TypeScript +formatted Elm diff --git a/tests/cmd/multi_module.out/elm.ts b/tests/cmd/multi_module.out/elm.ts index 75d1fcc..d2e911c 100644 --- a/tests/cmd/multi_module.out/elm.ts +++ b/tests/cmd/multi_module.out/elm.ts @@ -2,41 +2,32 @@ declare module Elm { namespace A { - type Flags = string - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + type Flags = string; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } namespace B { - type Flags = string - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + type Flags = string; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } - + }; + namespace B2 { - type Flags = string - - type Ports = Record - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + type Flags = string; + + type Ports = Record; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } } -} \ No newline at end of file +} diff --git a/tests/cmd/multi_module.stdout b/tests/cmd/multi_module.stdout index c8abc40..e67cbbd 100644 --- a/tests/cmd/multi_module.stdout +++ b/tests/cmd/multi_module.stdout @@ -2,3 +2,5 @@ wrote elm.ts wrote src/A/Flags.elm wrote src/B/B2/Flags.elm wrote src/B/Flags.elm +formatted TypeScript +formatted Elm diff --git a/tests/cmd/port_roundtrip.out/elm.ts b/tests/cmd/port_roundtrip.out/elm.ts index 7bb6ce7..0f9d42f 100644 --- a/tests/cmd/port_roundtrip.out/elm.ts +++ b/tests/cmd/port_roundtrip.out/elm.ts @@ -2,26 +2,19 @@ declare module Elm { namespace Main { - type Flags = Record - + type Flags = Record; + type Ports = { elmToJs: { - subscribe: (callback: (value: { - a: string; - }) => void) => void; + subscribe: (callback: (value: { a: string }) => void) => void; }; jsToElm: { - send: (value: { - a: string; - }) => void; + send: (value: { a: string }) => void; }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/tests/cmd/port_roundtrip.out/src/Main/Ports.elm b/tests/cmd/port_roundtrip.out/src/Main/Ports.elm index de46ecc..e2705cb 100644 --- a/tests/cmd/port_roundtrip.out/src/Main/Ports.elm +++ b/tests/cmd/port_roundtrip.out/src/Main/Ports.elm @@ -47,14 +47,14 @@ encodeJsToElm jsToElm = port elmToJs : Value -> Cmd msg -elmToJs_ : ElmToJs -> Cmd msg -elmToJs_ value = +sendElmToJs : ElmToJs -> Cmd msg +sendElmToJs value = elmToJs (encodeElmToJs value) port jsToElm : (Value -> msg) -> Sub msg -jsToElm_ : (Result Json.Decode.Error JsToElm -> msg) -> Sub msg -jsToElm_ toMsg = - jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder) +subscribeToJsToElm : (Result Json.Decode.Error JsToElm -> msg) -> Sub msg +subscribeToJsToElm toMsg = + jsToElm (/value -> toMsg (Json.Decode.decodeValue value jsToElmDecoder)) diff --git a/tests/cmd/port_roundtrip.stdout b/tests/cmd/port_roundtrip.stdout index 9d0c2b9..d58ffbf 100644 --- a/tests/cmd/port_roundtrip.stdout +++ b/tests/cmd/port_roundtrip.stdout @@ -1,2 +1,4 @@ wrote elm.ts wrote src/Main/Ports.elm +formatted TypeScript +formatted Elm diff --git a/tests/cmd/tinyping.in/schema.json b/tests/cmd/tinyping.in/schema.json index 095e992..61feac6 100644 --- a/tests/cmd/tinyping.in/schema.json +++ b/tests/cmd/tinyping.in/schema.json @@ -31,7 +31,7 @@ "granted" ] }, - "sendNotification": { + "newNotification": { "metadata": { "direction": "ElmToJs" }, diff --git a/tests/cmd/tinyping.out/elm.ts b/tests/cmd/tinyping.out/elm.ts index 0afc2e1..9783c5d 100644 --- a/tests/cmd/tinyping.out/elm.ts +++ b/tests/cmd/tinyping.out/elm.ts @@ -5,30 +5,37 @@ declare module Elm { type Flags = { currentTimeMillis: number; notificationPermission: "default" | "denied" | "granted"; - } - + }; + type Ports = { changeDocument: { - subscribe: (callback: (value: { - tag: "AddNewPingAt"; - value: number; - } | { - tag: "SetMinutesPerPing"; - value: number; - } | { - index: number; - tag: "SetTagForPing"; - value: string | null; - }) => void) => void; + subscribe: ( + callback: ( + value: + | { + tag: "AddNewPingAt"; + value: number; + } + | { + tag: "SetMinutesPerPing"; + value: number; + } + | { + index: number; + tag: "SetTagForPing"; + value: string | null; + }, + ) => void, + ) => void; }; docFromAutomerge: { send: (value: { - pings: ({ + pings: { custom: Record; tag: string | null; time: number; version: "v1"; - })[]; + }[]; settings: { minutesPerPing: number; version: "v1"; @@ -36,33 +43,32 @@ declare module Elm { version: "v1"; }) => void; }; + newNotification: { + subscribe: ( + callback: (value: { + options: { + badge: string | null; + body: string | null; + icon: string | null; + lang: string | null; + requireInteraction: boolean | null; + silent: boolean | null; + tag: string | null; + }; + title: string; + }) => void, + ) => void; + }; notificationPermission: { send: (value: "default" | "denied" | "granted") => void; }; requestNotificationPermission: { subscribe: (callback: (value: Record) => void) => void; }; - sendNotification: { - subscribe: (callback: (value: { - options: { - badge: string | null; - body: string | null; - icon: string | null; - lang: string | null; - requireInteraction: boolean | null; - silent: boolean | null; - tag: string | null; - }; - title: string; - }) => void) => void; - }; - } - - function init(config: { - flags: Flags; - node: HTMLElement; - }): { + }; + + function init(config: { flags: Flags; node: HTMLElement }): { ports: Ports; - } + }; } -} \ No newline at end of file +} diff --git a/tests/cmd/tinyping.out/src/Main/Flags.elm b/tests/cmd/tinyping.out/src/Main/Flags.elm index dbff8a6..bde5f7c 100644 --- a/tests/cmd/tinyping.out/src/Main/Flags.elm +++ b/tests/cmd/tinyping.out/src/Main/Flags.elm @@ -14,7 +14,6 @@ type NotificationPermission | Granted - notificationPermissionDecoder : Decoder NotificationPermission notificationPermissionDecoder = Json.Decode.andThen diff --git a/tests/cmd/tinyping.out/src/Main/Ports.elm b/tests/cmd/tinyping.out/src/Main/Ports.elm index 0e63799..ee21771 100644 --- a/tests/cmd/tinyping.out/src/Main/Ports.elm +++ b/tests/cmd/tinyping.out/src/Main/Ports.elm @@ -67,7 +67,7 @@ encodeSetTagForPing setTagForPing = , case setTagForPing.value of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -81,7 +81,6 @@ type ChangeDocument | SetTagForPing SetTagForPing - changeDocumentDecoder : Decoder ChangeDocument changeDocumentDecoder = Json.Decode.andThen @@ -135,7 +134,7 @@ encodePingV1 pingV1 = , case pingV1.tag of Just value -> Json.Encode.string value - + Nothing -> Json.Encode.null ) @@ -148,7 +147,6 @@ type PingsElements = PingV1 PingV1 - pingsElementsDecoder : Decoder PingsElements pingsElementsDecoder = Json.Decode.andThen @@ -190,7 +188,6 @@ type Settings = SettingsV1 SettingsV1 - settingsDecoder : Decoder Settings settingsDecoder = Json.Decode.andThen @@ -235,7 +232,6 @@ type DocFromAutomerge = DocV1 DocV1 - docFromAutomergeDecoder : Decoder DocFromAutomerge docFromAutomergeDecoder = Json.Decode.andThen @@ -254,13 +250,118 @@ encodeDocFromAutomerge docFromAutomerge = encodeDocV1 docV1 +type alias NotificationOptions = + { badge : Maybe String + , body : Maybe String + , icon : Maybe String + , lang : Maybe String + , requireInteraction : Maybe Bool + , silent : Maybe Bool + , tag : Maybe String + } + + +notificationOptionsDecoder : Decoder NotificationOptions +notificationOptionsDecoder = + Json.Decode.succeed NotificationOptions + |> Json.Decode.Pipeline.required "badge" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "body" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "icon" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "lang" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "requireInteraction" (Json.Decode.nullable Json.Decode.bool) + |> Json.Decode.Pipeline.required "silent" (Json.Decode.nullable Json.Decode.bool) + |> Json.Decode.Pipeline.required "tag" (Json.Decode.nullable Json.Decode.string) + + +encodeNotificationOptions : NotificationOptions -> Json.Encode.Value +encodeNotificationOptions notificationOptions = + Json.Encode.object + [ ( "badge" + , case notificationOptions.badge of + Just value -> + Json.Encode.string value + + Nothing -> + Json.Encode.null + ) + , ( "body" + , case notificationOptions.body of + Just value -> + Json.Encode.string value + + Nothing -> + Json.Encode.null + ) + , ( "icon" + , case notificationOptions.icon of + Just value -> + Json.Encode.string value + + Nothing -> + Json.Encode.null + ) + , ( "lang" + , case notificationOptions.lang of + Just value -> + Json.Encode.string value + + Nothing -> + Json.Encode.null + ) + , ( "requireInteraction" + , case notificationOptions.requireInteraction of + Just value -> + Json.Encode.bool value + + Nothing -> + Json.Encode.null + ) + , ( "silent" + , case notificationOptions.silent of + Just value -> + Json.Encode.bool value + + Nothing -> + Json.Encode.null + ) + , ( "tag" + , case notificationOptions.tag of + Just value -> + Json.Encode.string value + + Nothing -> + Json.Encode.null + ) + ] + + +type alias NewNotification = + { options : NotificationOptions + , title : String + } + + +newNotificationDecoder : Decoder NewNotification +newNotificationDecoder = + Json.Decode.succeed NewNotification + |> Json.Decode.Pipeline.required "options" notificationOptionsDecoder + |> Json.Decode.Pipeline.required "title" Json.Decode.string + + +encodeNewNotification : NewNotification -> Json.Encode.Value +encodeNewNotification newNotification = + Json.Encode.object + [ ( "options", encodeNotificationOptions newNotification.options ) + , ( "title", Json.Encode.string newNotification.title ) + ] + + type NotificationPermission = Default | Denied | Granted - notificationPermissionDecoder : Decoder NotificationPermission notificationPermissionDecoder = Json.Decode.andThen @@ -305,147 +406,41 @@ encodeRequestNotificationPermission requestNotificationPermission = Json.Encode.null -type alias NotificationOptions = - { badge : Maybe String - , body : Maybe String - , icon : Maybe String - , lang : Maybe String - , requireInteraction : Maybe Bool - , silent : Maybe Bool - , tag : Maybe String - } - - -notificationOptionsDecoder : Decoder NotificationOptions -notificationOptionsDecoder = - Json.Decode.succeed NotificationOptions - |> Json.Decode.Pipeline.required "badge" (Json.Decode.nullable Json.Decode.string) - |> Json.Decode.Pipeline.required "body" (Json.Decode.nullable Json.Decode.string) - |> Json.Decode.Pipeline.required "icon" (Json.Decode.nullable Json.Decode.string) - |> Json.Decode.Pipeline.required "lang" (Json.Decode.nullable Json.Decode.string) - |> Json.Decode.Pipeline.required "requireInteraction" (Json.Decode.nullable Json.Decode.bool) - |> Json.Decode.Pipeline.required "silent" (Json.Decode.nullable Json.Decode.bool) - |> Json.Decode.Pipeline.required "tag" (Json.Decode.nullable Json.Decode.string) - - -encodeNotificationOptions : NotificationOptions -> Json.Encode.Value -encodeNotificationOptions notificationOptions = - Json.Encode.object - [ ( "badge" - , case notificationOptions.badge of - Just value -> - Json.Encode.string value - - Nothing -> - Json.Encode.null - ) - , ( "body" - , case notificationOptions.body of - Just value -> - Json.Encode.string value - - Nothing -> - Json.Encode.null - ) - , ( "icon" - , case notificationOptions.icon of - Just value -> - Json.Encode.string value - - Nothing -> - Json.Encode.null - ) - , ( "lang" - , case notificationOptions.lang of - Just value -> - Json.Encode.string value - - Nothing -> - Json.Encode.null - ) - , ( "requireInteraction" - , case notificationOptions.requireInteraction of - Just value -> - Json.Encode.bool value - - Nothing -> - Json.Encode.null - ) - , ( "silent" - , case notificationOptions.silent of - Just value -> - Json.Encode.bool value - - Nothing -> - Json.Encode.null - ) - , ( "tag" - , case notificationOptions.tag of - Just value -> - Json.Encode.string value - - Nothing -> - Json.Encode.null - ) - ] - - -type alias SendNotification = - { options : NotificationOptions - , title : String - } - - -sendNotificationDecoder : Decoder SendNotification -sendNotificationDecoder = - Json.Decode.succeed SendNotification - |> Json.Decode.Pipeline.required "options" notificationOptionsDecoder - |> Json.Decode.Pipeline.required "title" Json.Decode.string - - -encodeSendNotification : SendNotification -> Json.Encode.Value -encodeSendNotification sendNotification = - Json.Encode.object - [ ( "options", encodeNotificationOptions sendNotification.options ) - , ( "title", Json.Encode.string sendNotification.title ) - ] - - port changeDocument : Value -> Cmd msg -changeDocument_ : ChangeDocument -> Cmd msg -changeDocument_ value = +sendChangeDocument : ChangeDocument -> Cmd msg +sendChangeDocument value = changeDocument (encodeChangeDocument value) port docFromAutomerge : (Value -> msg) -> Sub msg -docFromAutomerge_ : (Result Json.Decode.Error DocFromAutomerge -> msg) -> Sub msg -docFromAutomerge_ toMsg = - docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder) +subscribeToDocFromAutomerge : (Result Json.Decode.Error DocFromAutomerge -> msg) -> Sub msg +subscribeToDocFromAutomerge toMsg = + docFromAutomerge (/value -> toMsg (Json.Decode.decodeValue value docFromAutomergeDecoder)) + + +port newNotification : Value -> Cmd msg + + +sendNewNotification : NewNotification -> Cmd msg +sendNewNotification value = + newNotification (encodeNewNotification value) port notificationPermission : (Value -> msg) -> Sub msg -notificationPermission_ : (Result Json.Decode.Error NotificationPermission -> msg) -> Sub msg -notificationPermission_ toMsg = - notificationPermission (/value -> toMsg (Json.Decode.decodeValue value notificationPermissionDecoder) +subscribeToNotificationPermission : (Result Json.Decode.Error NotificationPermission -> msg) -> Sub msg +subscribeToNotificationPermission toMsg = + notificationPermission (/value -> toMsg (Json.Decode.decodeValue value notificationPermissionDecoder)) port requestNotificationPermission : Value -> Cmd msg -requestNotificationPermission_ : RequestNotificationPermission -> Cmd msg -requestNotificationPermission_ value = +sendRequestNotificationPermission : RequestNotificationPermission -> Cmd msg +sendRequestNotificationPermission value = requestNotificationPermission (encodeRequestNotificationPermission value) - - -port sendNotification : Value -> Cmd msg - - -sendNotification_ : SendNotification -> Cmd msg -sendNotification_ value = - sendNotification (encodeSendNotification value) diff --git a/tests/cmd/tinyping.stdout b/tests/cmd/tinyping.stdout index 85131cf..9541b6d 100644 --- a/tests/cmd/tinyping.stdout +++ b/tests/cmd/tinyping.stdout @@ -1,3 +1,5 @@ wrote elm.ts wrote src/Main/Flags.elm wrote src/Main/Ports.elm +formatted TypeScript +formatted Elm