Skip to content

Instantly share code, notes, and snippets.

@carson-katri
Last active April 6, 2023 20:24
Show Gist options
  • Save carson-katri/e4ca6f68ea95b1a9784b061fbf016734 to your computer and use it in GitHub Desktop.
Save carson-katri/e4ca6f68ea95b1a9784b061fbf016734 to your computer and use it in GitHub Desktop.
Generates a LiveViewNative modifier schema
Mix.install([
{:req, "~> 0.3.6"},
{:inflex, "~> 2.0.0"}
])
defmodule EnumParser do
defstruct name: nil,
stack: []
def push(state, token, opts \\ []) do
%EnumParser{
name: Keyword.get(opts, :name, state.name),
stack: [token | state.stack]
}
end
def parse(
%{ "kind" => "identifier", "text" => name } = token,
%EnumParser{ :stack => [
%{ "kind" => "text", "text" => " " },
%{ "kind" => "keyword", "text" => "case" }
| _
] } = state
) do
push(state, token, name: String.replace(name, "`", ""))
end
def parse(
%{ "kind" => "identifier", "text" => name } = token,
%EnumParser{ :stack => [
%{ "kind" => "text", "text" => " " },
%{ "kind" => "keyword", "text" => "let" },
%{ "kind" => "text", "text" => " " },
%{ "kind" => "keyword", "text" => "static" }
| _
] } = state
) do
push(state, token, name: String.replace(name, "`", ""))
end
def parse(
%{ "kind" => "identifier", "text" => name } = token,
%EnumParser{ :stack => [
%{ "kind" => "text", "text" => " " },
%{ "kind" => "keyword", "text" => "var" },
%{ "kind" => "text", "text" => " " },
%{ "kind" => "keyword", "text" => "static" }
| _
] } = state
) do
push(state, token, name: String.replace(name, "`", ""))
end
def parse(token, state) do
push(state, token)
end
end
defmodule TypeReducer do
def reduce_type(%{ "kind" => "typeIdentifier", "text" => type }, {name, path}) do
{:cont, {name, [type | path]}}
end
def reduce_type(%{ "kind" => "text", "text" => "." }, acc) do
{:cont, acc}
end
def reduce_type(%{ "kind" => "text", "text" => <<":", _::binary>> }, acc) do
{:cont, acc}
end
def reduce_type(%{ "kind" => "internalParam", "text" => name }, {_, path}) do
{:cont, { name, path }}
end
def reduce_type(%{ "kind" => "externalParam", "text" => name }, {acc_name, path}) do
{:cont, { (if name == nil, do: acc_name, else: name), path }}
end
def reduce_type(_, acc) do
{:halt, acc}
end
end
defmodule ModifierParser do
defstruct name: "",
params: [],
generics: %{},
stack: []
def push(state, token, opts \\ []) do
%ModifierParser{
name: Keyword.get(opts, :name, state.name),
params: Keyword.merge(state.params, Keyword.get(opts, :params, [])),
generics: Map.merge(state.generics, Keyword.get(opts, :generics, %{})),
stack: [token | state.stack]
}
end
# Name
def parse(
%{ "kind" => "identifier", "text" => name } = token,
%ModifierParser{ :stack => [%{ "kind" => "text" }, %{ "kind" => "keyword", "text" => "func" } | _] } = state
) do
push(state, token, name: name)
end
# Parameters
def parse(
%{ "kind" => "typeIdentifier", "text" => type, "identifier" => identifier } = token,
%ModifierParser{ :stack => [%{ "kind" => "text", "text" => "." } | _] } = state
) do
link = "https://developer.apple.com/tutorials/data#{String.trim_leading(identifier, "doc://com.apple.SwiftUI")}.json"
case Enum.reduce_while(state.stack, {nil, [type]}, &TypeReducer.reduce_type/2) do
{nil, _} ->
push(state, token)
{param, path} ->
push(state, token, params: [{String.to_atom(param), {Enum.join(path, "."), link}}])
end
end
def parse(
%{ "kind" => "typeIdentifier", "text" => type, "identifier" => identifier } = token,
%ModifierParser{ :stack => [%{ "kind" => "text", "text" => ": " }, %{ "kind" => "internalParam", "text" => name } | _] } = state
) do
link = "https://developer.apple.com/tutorials/data#{String.trim_leading(identifier, "doc://com.apple.SwiftUI")}.json"
push(state, token, params: [{String.to_atom(name), {type, link}}])
end
def parse(
%{ "kind" => "typeIdentifier", "text" => type, "identifier" => identifier } = token,
%ModifierParser{ :stack => [%{ "kind" => "text", "text" => <<":", _::binary>> }, %{ "kind" => "externalParam", "text" => name } | _] } = state
) do
link = "https://developer.apple.com/tutorials/data#{String.trim_leading(identifier, "doc://com.apple.SwiftUI")}.json"
push(state, token, params: [{String.to_atom(name), {type, link}}])
end
def parse(
%{ "kind" => "typeIdentifier", "text" => type } = token,
%ModifierParser{ :stack => [%{ "kind" => "text", "text" => ": " }, %{ "kind" => "internalParam", "text" => name } | _] } = state
) do
push(state, token, params: [{String.to_atom(name), type}])
end
def parse(
%{ "kind" => "typeIdentifier", "text" => type } = token,
%ModifierParser{ :stack => [%{ "kind" => "text", "text" => <<":", _::binary>> }, %{ "kind" => "externalParam", "text" => name } | _] } = state
) do
push(state, token, params: [{String.to_atom(name), type}])
end
# Generic Parameters
def parse(
%{ "kind" => "typeIdentifier", "text" => type } = token,
%ModifierParser{ :stack => [%{ "kind" => "text", "text" => " : " }, %{ "kind" => "typeIdentifier", "text" => name } | _] } = state
) do
push(state, token, generics: %{ name => type })
end
# Catch-all
def parse(token, state) do
push(state, token)
end
# Conversions
def to_type(_, {"Color", _}), do: "Color"
def to_type(_, "String"), do: ":string"
def to_type(_, "Int"), do: ":integer"
def to_type(_, "Double"), do: ":float"
def to_type(_, "Bool"), do: ":boolean"
def to_type(_, "View"), do: ":string"
def to_type(_, "Alignment"), do: "Ecto.Enum, values: ~w(bottom bottom_leading bottom_trailing center leading leading_last_text_baseline top top_leading top_trailing trailing trailing_first_text_baseline)a"
def to_type(state, {type, link}) do
cases = get_cases(link)
if Enum.empty?(cases) do
to_type(state, type)
else
"Ecto.Enum, values: ~w(#{cases |> Enum.map(fn c -> Inflex.underscore(c) end) |> Enum.join(" ")})a"
end
end
def to_type(state, type) do
if Map.has_key?(state.generics, type) do
to_type(state, state.generics[type])
else
type
end
end
def get_cases(link) do
if String.contains?(link, "doc://com.externally.resolved.symbol") do
[]
else
Req.get!(link).body
|> Map.get("references")
|> Map.values()
|> Enum.filter(fn ref -> Map.has_key?(ref, "fragments") end)
|> Enum.map(fn ref -> Enum.reduce(ref["fragments"], %EnumParser{}, &EnumParser.parse/2).name end)
|> Enum.reject(&is_nil/1)
end
end
def to_field(state, { name, type }), do: "field :#{Inflex.underscore(name)}, #{to_type(state, type)}"
def to_property(_, { name, {type, _} }), do: "private let #{name}: #{type}"
def to_property(_, { name, type }), do: "private let #{name}: #{type}"
def to_decode(state, { name, {"Color", _} }), do: to_decode(state, {name, "Color"})
def to_decode(state, { name, {type, link} }) do
cases = get_cases(link)
if Enum.empty?(cases) do
to_decode(state, {name, type})
else
"""
switch try container.decode(String.self, forKey: .#{name}) {
#{cases |> Enum.map(fn c -> "case \"#{Inflex.underscore(c)}\": self.#{name} = .#{c}" end) |> Enum.join("\n ")}
case let `default`: throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "unknown #{name} '\\(`default`)'"))
}
"""
end
end
def to_decode(_, { name, type }), do: "self.#{name} = try container.decode(#{type}.self, forKey: .#{name})"
def to_codingkey(_, { name, _ }) do
underscored = Inflex.underscore(name)
if Atom.to_string(name) == underscored do
"case #{name}"
else
"case #{name} = \"#{underscored}\""
end
end
def to_schema(state) do
"""
defmodule LiveViewNativeSwiftUi.Modifiers.#{Inflex.camelize(state.name)} do
use LiveViewNativePlatform.Modifier
modifier_schema "#{Inflex.underscore(state.name)}" do
#{Enum.map(state.params, fn p -> to_field(state, p) end) |> Enum.join("\n ")}
end
end
"""
end
def to_modifier(state) do
date = Date.utc_today()
"""
//
// #{Inflex.camelize(state.name)}Modifier.swift
// LiveViewNative
//
// Created by <#Name#> on #{date.month}/#{date.day}/#{date.year}.
//
import SwiftUI
/// <#Documentation#>
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
struct #{Inflex.camelize(state.name)}Modifier<R: RootRegistry>: ViewModifier, Decodable {
/// <#Documentation#>
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
#{Enum.map(state.params, fn p -> to_property(state, p) end) |> Enum.join(
~s"""
/// <#Documentation#>
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
""" <> " "
)}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
#{Enum.map(state.params, fn p -> to_decode(state, p) end) |> Enum.join("\n ")}
}
func body(content: Content) -> some View {
content.#{state.name}(
<#Arguments#>
)
}
enum CodingKeys: String, CodingKey {
#{Enum.map(state.params, fn p -> to_codingkey(state, p) end) |> Enum.join("\n ")}
}
}
"""
end
end
defmodule Generator do
def generate() do
{args, _, _} = System.argv() |> OptionParser.parse(strict: [url: :string, schema: :boolean, modifier: :boolean])
url = Keyword.get(args, :url)
url = URI.parse(url)
declaration = Req.get!("https://developer.apple.com/tutorials/data#{url.path}.json").body
|> Map.get("primaryContentSections")
|> Enum.find(nil, fn section -> section["kind"] == "declarations" end)
|> Map.get("declarations")
|> hd()
|> Map.get("tokens")
state = Enum.reduce(declaration, %ModifierParser{}, &ModifierParser.parse/2)
if Keyword.get(args, :schema, false) do
state
|> ModifierParser.to_schema()
|> IO.puts
end
if Keyword.get(args, :modifier, false) do
state
|> ModifierParser.to_modifier()
|> IO.puts
end
end
end
Generator.generate()

Given a URL to a SwiftUI modifier's documentation, this tool will generate the Elixir schema and Swift struct.

For example, the strikethrough(_:pattern:color:) modifier can be generated like so:

elixir modifier_schema_gen.exs --url "https://developer.apple.com/documentation/swiftui/view/strikethrough(_:pattern:color:)" --schema > strikethrough.ex
elixir modifier_schema_gen.exs --url "https://developer.apple.com/documentation/swiftui/view/strikethrough(_:pattern:color:)" --modifier > StrikethroughModifier.swift
See generated code

This creates the following schema:

defmodule LiveViewNativeSwiftUi.Modifiers.Strikethrough do
  use LiveViewNativePlatform.Modifier

  modifier_schema "strikethrough" do
    field :is_active, :boolean
    field :pattern, Ecto.Enum, values: ~w(dash dash_dot dash_dot_dot dot solid)a
    field :color, Color
  end
end

And modifier definition:

//
//  StrikethroughModifier.swift
//  LiveViewNative
//
//  Created by <#Name#> on 3/30/2023.
//

import SwiftUI

/// <#Documentation#>
#if swift(>=5.8)
@_documentation(visibility: public)
#endif
struct StrikethroughModifier<R: RootRegistry>: ViewModifier, Decodable {
    /// <#Documentation#>
    #if swift(>=5.8)
    @_documentation(visibility: public)
    #endif
    private let isActive: Bool

    /// <#Documentation#>
    #if swift(>=5.8)
    @_documentation(visibility: public)
    #endif
    private let pattern: Text.LineStyle.Pattern

    /// <#Documentation#>
    #if swift(>=5.8)
    @_documentation(visibility: public)
    #endif
    private let color: Color

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.isActive = try container.decode(Bool.self, forKey: .isActive)
        switch try container.decode(String.self, forKey: .pattern) {
        case "dash": self.pattern = .dash
        case "dash_dot": self.pattern = .dashDot
        case "dash_dot_dot": self.pattern = .dashDotDot
        case "dot": self.pattern = .dot
        case "solid": self.pattern = .solid
        default: fatalError("Unknown value for pattern")
        }

        self.color = try container.decode(Color.self, forKey: .color)
    }

    func body(content: Content) -> some View {
        content.strikethrough(
            <#Arguments#>
        )
    }

    enum CodingKeys: String, CodingKey {
        case isActive = "is_active"
        case pattern
        case color
    }
}

A few tweaks and this is ready for use!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment