Repository: fika-lang/fika
Pull request #57 (2020-12-01 07:54:30)

Add effect type and IO module
Changes coming in as part of this PR:

1. Added "fika/" directory as the place to write ".fi" files for our stdlib. Added a file "io.fi" there which wraps Elixir.IO.
2. Ability to write ".beam" files after compiling Fika code. This is used for generating beam files of our stdlib by hooking into mix compile.
3. Added the "Effect" type to our type system.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d57486..73fbfe5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,56 +33,56 @@ jobs:
         run: MIX_ENV=test mix compile --warnings-as-errors
       - name: Run Tests
         run: mix test
-  release:
-    needs: build_and_test
-    if: github.ref == 'refs/heads/main'
-    runs-on: ${{ matrix.os }}
-    strategy:
-      matrix:
-        elixir: [1.10.4]
-        otp: [23.1.1]
-        os: ["ubuntu-latest"]
-    steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-elixir@v1
-        with:
-          otp-version: ${{ matrix.otp }}
-          elixir-version: ${{ matrix.elixir }}
-      - name: Retrieve Mix Dependencies Cache
-        uses: actions/cache@v1
-        id: mix-cache # id to use in retrieve action
-        with:
-          path: deps
-          key: ${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
-      - name: Install Mix Dependencies
-        if: steps.mix-cache.outputs.cache-hit != 'true'
-        run: |
-          mix local.rebar --force
-          mix local.hex --force
-          mix deps.get
-      - name: Generate Release
-        run: mix release
-      - name: Rename Executable
-        run: cp fika fika_${{ matrix.os }}
-      - name: Save Executable
-        uses: actions/upload-artifact@v2
-        with:
-          name: fika_${{ matrix.os }}
-          path: fika_${{ matrix.os }}
-  publish:
-    needs: release
-    if: github.ref == 'refs/heads/main'
-    runs-on: ubuntu-latest
-    steps:
-      - name: Fetch release artifacts
-        uses: actions/download-artifact@v2
-        with:
-          name: fika_ubuntu-latest
-      - name: Upload Artifacts
-        uses: "marvinpinto/action-automatic-releases@latest"
-        with:
-          repo_token: "${{ secrets.GITHUB_TOKEN }}"
-          automatic_release_tag: "latest"
-          prerelease: true
-          title: "Development Build"
-          files: fika_ubuntu-latest
+  #release:
+    #needs: build_and_test
+    #if: github.ref == 'refs/heads/main'
+    #runs-on: ${{ matrix.os }}
+    #strategy:
+      #matrix:
+        #elixir: [1.10.4]
+        #otp: [23.1.1]
+        #os: ["ubuntu-latest"]
+    #steps:
+      #- uses: actions/checkout@v2
+      #- uses: actions/setup-elixir@v1
+        #with:
+          #otp-version: ${{ matrix.otp }}
+          #elixir-version: ${{ matrix.elixir }}
+      #- name: Retrieve Mix Dependencies Cache
+        #uses: actions/cache@v1
+        #id: mix-cache # id to use in retrieve action
+        #with:
+          #path: deps
+          #key: ${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
+      #- name: Install Mix Dependencies
+        #if: steps.mix-cache.outputs.cache-hit != 'true'
+        #run: |
+          #mix local.rebar --force
+          #mix local.hex --force
+          #mix deps.get
+      #- name: Generate Release
+        #run: mix release
+      #- name: Rename Executable
+        #run: cp fika fika_${{ matrix.os }}
+      #- name: Save Executable
+        #uses: actions/upload-artifact@v2
+        #with:
+          #name: fika_${{ matrix.os }}
+          #path: fika_${{ matrix.os }}
+  #publish:
+    #needs: release
+    #if: github.ref == 'refs/heads/main'
+    #runs-on: ubuntu-latest
+    #steps:
+      #- name: Fetch release artifacts
+        #uses: actions/download-artifact@v2
+        #with:
+          #name: fika_ubuntu-latest
+      #- name: Upload Artifacts
+        #uses: "marvinpinto/action-automatic-releases@latest"
+        #with:
+          #repo_token: "${{ secrets.GITHUB_TOKEN }}"
+          #automatic_release_tag: "latest"
+          #prerelease: true
+          #title: "Development Build"
+          #files: fika_ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 6a7c3be..785e25e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,3 @@ erl_crash.dump
 
 # Ignore package tarball (built via "mix hex.build").
 fika-*.tar
-
-# Name of the autogenerated executable
-/fika
diff --git a/example.fi b/example.fi
index 54b1862..ccd4bc5 100644
--- a/example.fi
+++ b/example.fi
@@ -125,3 +125,9 @@ end
 fn list_length : Int do
   list_size([1, 2, 3])
 end
+
+# Side effect
+fn greet : Effect(String) do
+  name = io.gets("What's your name? ")
+  io.puts("Hello #{name}")
+end
diff --git a/fika/io.fi b/fika/io.fi
new file mode 100644
index 0000000..ba5836f
--- /dev/null
+++ b/fika/io.fi
@@ -0,0 +1,2 @@
+ext gets(prompt: String) : Effect(String) = {"Elixir.IO", "gets", [prompt]}
+ext puts(str: String) : Effect(:ok) = {"Elixir.IO", "puts", [str]}
diff --git a/lib/fika/code.ex b/lib/fika/code.ex
index c0fdf2f..d529449 100644
--- a/lib/fika/code.ex
+++ b/lib/fika/code.ex
@@ -13,7 +13,19 @@ defmodule Fika.Code do
 
     case CodeServer.compile_module(module_name_str) do
       {:ok, _} ->
-        {:ok, CodeServer.load()}
+        {:ok, CodeServer.load_binaries()}
+
+      error ->
+        error
+    end
+  end
+
+  def compile_to_path(module_name_str, dest) do
+    CodeServer.reset()
+
+    case CodeServer.compile_module(module_name_str) do
+      {:ok, _} ->
+        {:ok, CodeServer.write_binaries(dest)}
 
       error ->
         error
diff --git a/lib/fika/compiler/code_server.ex b/lib/fika/compiler/code_server.ex
index 2ad0b22..2fb7209 100644
--- a/lib/fika/compiler/code_server.ex
+++ b/lib/fika/compiler/code_server.ex
@@ -4,6 +4,7 @@ defmodule Fika.Compiler.CodeServer do
   require Logger
 
   alias Fika.Compiler.{
+    DefaultTypes,
     ModuleCompiler,
     ErlTranslate
   }
@@ -39,8 +40,12 @@ defmodule Fika.Compiler.CodeServer do
   # Loads the accumulated binaries which were collected as a result of
   # parallel ModuleCompiler.compile.
   # Returns the list [{:ok, <module>}, {:error, <module>, <reason>}, ...]
-  def load do
-    GenServer.call(__MODULE__, :load)
+  def load_binaries do
+    GenServer.call(__MODULE__, :load_binaries)
+  end
+
+  def write_binaries(dest) do
+    GenServer.call(__MODULE__, {:write_binaries, dest})
   end
 
   def init(_) do
@@ -124,7 +129,9 @@ defmodule Fika.Compiler.CodeServer do
     {:noreply, state}
   end
 
-  def handle_call(:load, _from, state) do
+  def handle_call(:load_binaries, _from, state) do
+    Logger.debug("Loading binaries into beam")
+
     result =
       Enum.map(state.binaries, fn {module, file, binary} ->
         module_name = ErlTranslate.erl_module_name(module)
@@ -138,6 +145,26 @@ defmodule Fika.Compiler.CodeServer do
     {:reply, result, reset_binaries(state)}
   end
 
+  def handle_call({:write_binaries, dest}, _from, state) do
+    Logger.debug("Writing binaries to beam files")
+
+    result =
+      Enum.map(state.binaries, fn {module, _file, binary} ->
+        full_path = Path.join(dest, beam_filename(module))
+        File.write!(full_path, binary)
+        module
+      end)
+
+    {:reply, result, reset_binaries(state)}
+  end
+
+  defp beam_filename(module) do
+    module
+    |> ErlTranslate.erl_module_name()
+    |> to_string()
+    |> Kernel.<>(".beam")
+  end
+
   defp set_type(state, module, signature, result) do
     update_in(state, [:public_functions, module], fn
       nil -> %{signature => result}
@@ -210,7 +237,8 @@ defmodule Fika.Compiler.CodeServer do
 
   defp default_functions do
     %{
-      "fika/kernel" => insert_ok(Fika.Kernel.types())
+      "fika/kernel" => insert_ok(DefaultTypes.kernel()),
+      "fika/io" => insert_ok(DefaultTypes.io())
     }
   end
 
diff --git a/lib/fika/kernel.ex b/lib/fika/compiler/default_types.ex
similarity index 69%
rename from lib/fika/kernel.ex
rename to lib/fika/compiler/default_types.ex
index f3462f4..2e320ed 100644
--- a/lib/fika/kernel.ex
+++ b/lib/fika/compiler/default_types.ex
@@ -1,11 +1,12 @@
-defmodule Fika.Kernel do
+defmodule Fika.Compiler.DefaultTypes do
   @moduledoc """
-  This module right now only has a function named types which returns a map
-  of known function types belonging to kernel. In the future, this module will
-  be moved to a .fi file.
+  This module holds the type signatures of the functions which are included
+  by default in Fika, but are defined externally in erlang or elixir.
   """
 
-  def types do
+  alias Fika.Compiler.TypeChecker.Types, as: T
+
+  def kernel do
     %{
       "+(Int, Int)" => :Int,
       "+(Int, Float)" => :Float,
@@ -35,4 +36,11 @@ defmodule Fika.Kernel do
       "!=(Int, Int)" => :Bool
     }
   end
+
+  def io do
+    %{
+      "gets(String)" => %T.Effect{type: :String},
+      "puts(String)" => %T.Effect{type: :String}
+    }
+  end
 end
diff --git a/lib/fika/compiler/parser/helper.ex b/lib/fika/compiler/parser/helper.ex
index e0c11ed..ef54581 100644
--- a/lib/fika/compiler/parser/helper.ex
+++ b/lib/fika/compiler/parser/helper.ex
@@ -113,6 +113,14 @@ defmodule Fika.Compiler.Parser.Helper do
     %T.List{type: inner_type}
   end
 
+  def do_to_ast({[{_, _, inner_type}], _line}, :effect_type) when is_struct(inner_type) do
+    %T.Effect{type: inner_type}
+  end
+
+  def do_to_ast({[inner_type], _line}, :effect_type) do
+    %T.Effect{type: inner_type}
+  end
+
   def do_to_ast({inner_types, _line}, :tuple_type) do
     %T.Tuple{elements: inner_types}
   end
@@ -225,6 +233,10 @@ defmodule Fika.Compiler.Parser.Helper do
     {value, line}
   end
 
+  def do_to_ast({str, _line}, :ext_atom) do
+    List.to_atom(str)
+  end
+
   def do_to_ast({ast, line}, context, :function_ref) do
     case ast do
       [[], function, arg_types] ->
@@ -264,10 +276,10 @@ defmodule Fika.Compiler.Parser.Helper do
 
   defp expand_module({:module_name, line, module}, context) do
     module =
-      if module == "fika/kernel" do
-        "fika/kernel"
-      else
-        context[module]
+      case module do
+        "kernel" -> "fika/kernel"
+        "io" -> "fika/io"
+        _ -> context[module]
       end
 
     module && {:identifier, line, module}
diff --git a/lib/fika/compiler/parser/types.ex b/lib/fika/compiler/parser/types.ex
index e673dbb..3e45d10 100644
--- a/lib/fika/compiler/parser/types.ex
+++ b/lib/fika/compiler/parser/types.ex
@@ -83,6 +83,13 @@ defmodule Fika.Compiler.Parser.Types do
     |> label("list type")
     |> Helper.to_ast(:list_type)
 
+  effect_type =
+    ignore(string("Effect("))
+    |> concat(parsec(:type))
+    |> ignore(string(")"))
+    |> label("effect type")
+    |> Helper.to_ast(:effect_type)
+
   string_type =
     string("String")
     |> label("string")
@@ -118,6 +125,7 @@ defmodule Fika.Compiler.Parser.Types do
       bool_type,
       function_type,
       list_type,
+      effect_type,
       record_type,
       map_type,
       tuple_type
diff --git a/lib/fika/compiler/type_checker.ex b/lib/fika/compiler/type_checker.ex
index 71e104d..385f6a1 100644
--- a/lib/fika/compiler/type_checker.ex
+++ b/lib/fika/compiler/type_checker.ex
@@ -34,10 +34,18 @@ defmodule Fika.Compiler.TypeChecker do
     env =
       env
       |> Map.put(:scope, %{})
+      |> Map.put(:has_effect, false)
       |> add_args_to_scope(args)
 
     case infer_block(env, exprs) do
-      {:ok, type, _env} ->
+      {:ok, type, env} ->
+        type =
+          if env[:has_effect] do
+            %T.Effect{type: type}
+          else
+            type
+          end
+
         {:ok, type}
 
       error ->
@@ -354,6 +362,10 @@ defmodule Fika.Compiler.TypeChecker do
         signature = get_function_signature(exp.name, type_acc)
 
         case get_type(module, signature, env) do
+          {:ok, %T.Effect{type: type}} ->
+            env = Map.put(env, :has_effect, true)
+            {:ok, type, env}
+
           {:ok, type} ->
             {:ok, type, env}
 
diff --git a/lib/fika/compiler/type_checker/types/effect.ex b/lib/fika/compiler/type_checker/types/effect.ex
new file mode 100644
index 0000000..3b6cbd6
--- /dev/null
+++ b/lib/fika/compiler/type_checker/types/effect.ex
@@ -0,0 +1,11 @@
+defmodule Fika.Compiler.TypeChecker.Types.Effect do
+  defstruct type: :Nothing
+
+  alias Fika.Compiler.TypeChecker.Types, as: T
+
+  defimpl String.Chars, for: T.Effect do
+    def to_string(%{type: type}) do
+      "Effect(#{type})"
+    end
+  end
+end
diff --git a/lib/mix/tasks/compile.fika.ex b/lib/mix/tasks/compile.fika.ex
new file mode 100644
index 0000000..68ec763
--- /dev/null
+++ b/lib/mix/tasks/compile.fika.ex
@@ -0,0 +1,25 @@
+defmodule Mix.Tasks.Compile.Fika do
+  use Mix.Task.Compiler
+
+  alias Fika.Code
+  require Logger
+
+  def run(_args) do
+    level = Logger.level()
+    Logger.configure(level: :warn)
+    {:ok, _} = Application.ensure_all_started(:fika)
+
+    dest = Mix.Project.compile_path()
+    default_modules = ["fika/io"]
+
+    Enum.each(default_modules, fn module ->
+      Logger.info("Compiling #{module}")
+      Code.compile_to_path(module, dest)
+    end)
+
+    Application.stop(:fika)
+    Logger.configure(level: level)
+
+    :ok
+  end
+end
diff --git a/mix.exs b/mix.exs
index a0d07bf..b3e2cc1 100644
--- a/mix.exs
+++ b/mix.exs
@@ -14,7 +14,8 @@ defmodule Fika.MixProject do
       xref: [exclude: [:router]],
       elixirc_paths: elixirc_paths(Mix.env()),
       releases: [{@app, release()}],
-      preferred_cli_env: [release: :prod]
+      preferred_cli_env: [release: :prod],
+      compilers: Mix.compilers() ++ [:fika]
     ]
   end
 


Test patch:
diff --git a/test/fika/compiler/type_checker_test.exs b/test/fika/compiler/type_checker_test.exs
index 72ca346..907036f 100644
--- a/test/fika/compiler/type_checker_test.exs
+++ b/test/fika/compiler/type_checker_test.exs
@@ -625,6 +625,73 @@ defmodule Fika.Compiler.TypeCheckerTest do
     end
   end
 
+  describe "effect" do
+    test "io.gets" do
+      str = "io.gets(\"Hello\")"
+
+      ast = TestParser.expression!(str)
+
+      assert {:ok, :String, %{has_effect: true}} = TypeChecker.infer_exp(%{}, ast)
+    end
+
+    test "functions with effects inside them become effectful" do
+      str = """
+      fn foo : Effect(String) do
+        x = io.gets("Enter your name")
+        "Hello #\{x}"
+      end
+      """
+
+      {:ok, ast} = Parser.parse_module(str)
+
+      [function] = ast[:function_defs]
+
+      assert {:ok, %T.Effect{type: :String}} = TypeChecker.infer(function, %{})
+      assert {:ok, %T.Effect{type: :String}} = TypeChecker.check(function, %{})
+    end
+
+    test "function with multiple effects" do
+      str = """
+      use test
+
+      fn foo : Effect(Int) do
+        x = io.gets("Enter your name")
+        test.to_int(x)
+      end
+      """
+
+      {:ok, ast} = Parser.parse_module(str)
+      CodeServer.reset()
+      CodeServer.set_type("test", "to_int(String)", {:ok, %T.Effect{type: :Int}})
+
+      [function] = ast[:function_defs]
+
+      assert {:ok, %T.Effect{type: :Int}} = TypeChecker.infer(function, %{})
+      assert {:ok, %T.Effect{type: :Int}} = TypeChecker.check(function, %{})
+    end
+
+    test "effectful function ref call" do
+      str = """
+      use test2
+
+      fn foo : Effect(String) do
+        f = &test2.bar
+        f.()
+      end
+      """
+
+      {:ok, ast} = Parser.parse_module(str)
+      [function] = ast[:function_defs]
+      env = TypeChecker.init_env(ast)
+
+      CodeServer.reset()
+      CodeServer.set_type("test2", "bar()", {:ok, %T.Effect{type: :String}})
+
+      assert {:ok, %T.Effect{type: :String}} = TypeChecker.infer(function, env)
+      assert {:ok, %T.Effect{type: :String}} = TypeChecker.check(function, env)
+    end
+  end
+
   describe "anonymous function" do
     test "with arg types" do
       str = """