Repository: fika-lang/fika (elixir)

Issue:
Allow Elixir-style string interpolation
Allow using `#{}` inside strings which will be expanded and replaced with the string it contains. The expression inside `#{}` should be of string type.

Pull request description:
Feat/allow string interpolation
Closes #33 

Add support for string interpolation. 

We agreed to support only one expression inside interpolations ATM.

Patch:
diff --git a/lib/fika/erl_translate.ex b/lib/fika/erl_translate.ex
index 85b4a93..bb41552 100644
--- a/lib/fika/erl_translate.ex
+++ b/lib/fika/erl_translate.ex
@@ -94,10 +94,22 @@ defmodule Fika.ErlTranslate do
     {:var, line, name}
   end
 
-  defp translate_exp({:string, {line, _, _}, value}) do
+  defp translate_exp({:string, {line, _, _}, [value]}) when is_binary(value) do
     {:string, line, String.to_charlist(value)}
   end
 
+  defp translate_exp({:string, {line, _, _}, str_elements}) do
+    translated_exps =
+      str_elements
+      |> Enum.map(fn
+        value when is_binary(value) -> {:string, line, String.to_charlist(value)}
+        exp -> translate_exp(exp)
+      end)
+      |> Enum.map(&{:bin_element, line, &1, :default, :default})
+
+    {:bin, line, translated_exps}
+  end
+
   defp translate_exp({:list, {line, _, _}, value}) do
     do_translate_list(value, line)
   end
diff --git a/lib/fika/parser/helper.ex b/lib/fika/parser/helper.ex
index ccc6242..e4db4ef 100644
--- a/lib/fika/parser/helper.ex
+++ b/lib/fika/parser/helper.ex
@@ -104,7 +104,15 @@ defmodule Fika.Parser.Helper do
   end
 
   def do_to_ast({value, line}, :string) do
-    {:string, line, to_string(value)}
+    str =
+      value
+      |> Enum.chunk_by(&is_tuple/1)
+      |> Enum.flat_map(fn
+        [h | _tail] = interpolations when is_tuple(h) -> interpolations
+        charlist -> [to_string(charlist)]
+      end)
+
+    {:string, line, str}
   end
 
   def do_to_ast({result, line}, :exp_list) do
diff --git a/lib/fika/parser/literal_exps.ex b/lib/fika/parser/literal_exps.ex
index 21a01d1..90d6da8 100644
--- a/lib/fika/parser/literal_exps.ex
+++ b/lib/fika/parser/literal_exps.ex
@@ -22,9 +22,14 @@ defmodule Fika.Parser.LiteralExps do
     |> label("boolean")
     |> Helper.to_ast(:boolean)
 
+  interpolation =
+    ignore(string("\#{"))
+    |> concat(exp)
+    |> ignore(string("}"))
+
   string_exp =
     ignore(string("\""))
-    |> repeat(choice([string("\\\""), utf8_char(not: ?")]))
+    |> repeat(choice([interpolation, string("\\\""), utf8_char(not: ?")]))
     |> ignore(string("\""))
     |> Helper.to_ast(:string)
 
diff --git a/lib/fika/type_checker.ex b/lib/fika/type_checker.ex
index 5b704b3..9fc711a 100644
--- a/lib/fika/type_checker.ex
+++ b/lib/fika/type_checker.ex
@@ -152,9 +152,29 @@ defmodule Fika.TypeChecker do
   end
 
   # String
-  def infer_exp(env, {:string, _line, string}) do
-    Logger.debug("String #{string} found. Type: String")
-    {:ok, "String", env}
+  def infer_exp(env, {:string, _line, string_parts}) do
+    Enum.reduce_while(string_parts, env, fn
+      string, env when is_binary(string) ->
+        Logger.debug("String #{string} found. Type: String")
+        {:cont, {:ok, "String", env}}
+
+      exp, env ->
+        Logger.debug("String interpolation found. Inferring type of expression")
+
+        case infer_exp(env, exp) do
+          {:ok, "String", _env} = result ->
+            {:cont, result}
+
+          {:ok, other_type, _env} ->
+            message =
+              "Expression used in string interpolation expected to be String, got #{other_type}"
+
+            {:halt, {:error, message}}
+
+          error ->
+            {:halt, error}
+        end
+    end)
   end
 
   # List


Test patch:
diff --git a/test/fika/erl_translate_test.exs b/test/fika/erl_translate_test.exs
index d758f10..d03d31e 100644
--- a/test/fika/erl_translate_test.exs
+++ b/test/fika/erl_translate_test.exs
@@ -165,6 +165,40 @@ defmodule Fika.ErlTranslateTest do
             ]} = result
   end
 
+  describe "string interpolation" do
+    test "replaces with string" do
+      str = ~S"""
+      "#{"Hello"} #{"World"}"
+      """
+
+      ast = TestParser.expression!(str)
+      result = ErlTranslate.translate_expression(ast)
+
+      assert {
+               :bin,
+               1,
+               [
+                 {:bin_element, 1, {:string, 1, 'Hello'}, :default, :default},
+                 {:bin_element, 1, {:string, 1, ' '}, :default, :default},
+                 {:bin_element, 1, {:string, 1, 'World'}, :default, :default}
+               ]
+             } = result
+    end
+
+    test "parses known variable in string interpolation" do
+      str = ~S"""
+      hello = "Hello"
+      "#{hello}"
+      """
+
+      {:ok, [_, interpolation], _, _, _, _} = TestParser.exps(str)
+
+      result = ErlTranslate.translate_expression(interpolation)
+
+      assert {:bin, 2, [{:bin_element, 2, {:var, 2, :hello}, :default, :default}]} = result
+    end
+  end
+
   describe "tuple" do
     test "single element tuple" do
       str = "{1}"
diff --git a/test/fika/parser_test.exs b/test/fika/parser_test.exs
index 6d6295b..bdb1e5e 100644
--- a/test/fika/parser_test.exs
+++ b/test/fika/parser_test.exs
@@ -434,7 +434,7 @@ defmodule Fika.ParserTest do
 
       result = TestParser.expression!(str)
 
-      assert result == {:string, {1, 0, 13}, "Hello world"}
+      assert result == {:string, {1, 0, 13}, ["Hello world"]}
     end
 
     test "parses a string with escaped double quotes" do
@@ -444,7 +444,35 @@ defmodule Fika.ParserTest do
 
       result = TestParser.expression!(str)
 
-      assert result == {:string, {1, 0, 17}, "Hello \\\"world\\\""}
+      assert result == {:string, {1, 0, 17}, ["Hello \\\"world\\\""]}
+    end
+
+    test "parses interpolated string" do
+      str = ~S"""
+      "Hello #{world}"
+      """
+
+      result = TestParser.expression!(str)
+
+      assert result == {:string, {1, 0, 16}, ["Hello ", {:identifier, {1, 0, 14}, :world}]}
+    end
+
+    test "parses multiple interpolations" do
+      str = ~S"""
+      "#{hello} #{"World"}"
+      """
+
+      result = TestParser.expression!(str)
+
+      assert result == {
+               :string,
+               {1, 0, 21},
+               [
+                 {:identifier, {1, 0, 8}, :hello},
+                 " ",
+                 {:string, {1, 0, 19}, ["World"]}
+               ]
+             }
     end
   end
 
@@ -519,7 +547,7 @@ defmodule Fika.ParserTest do
       assert result ==
                {:record, {1, 0, 26}, nil,
                 [
-                  {{:identifier, {1, 0, 6}, :hello}, {:string, {1, 0, 15}, "World"}},
+                  {{:identifier, {1, 0, 6}, :hello}, {:string, {1, 0, 15}, ["World"]}},
                   {{:identifier, {1, 0, 20}, :foo}, {:integer, {1, 0, 25}, 123}}
                 ]}
     end
@@ -731,7 +759,7 @@ defmodule Fika.ParserTest do
 
       assert result == [
                {:function, [position: {3, 22, 25}],
-                {:foo, [], {:type, {1, 0, 6}, "Nothing"}, [{:string, {2, 10, 21}, "foo#bar"}]}}
+                {:foo, [], {:type, {1, 0, 6}, "Nothing"}, [{:string, {2, 10, 21}, ["foo#bar"]}]}}
              ]
     end
   end
diff --git a/test/fika/type_checker_test.exs b/test/fika/type_checker_test.exs
index 10f42d9..6a26924 100644
--- a/test/fika/type_checker_test.exs
+++ b/test/fika/type_checker_test.exs
@@ -168,12 +168,60 @@ defmodule Fika.TypeCheckerTest do
     assert {:ok, "Float", _} = TypeChecker.check(function, env)
   end
 
-  test "string" do
-    str = "\"Hello world\""
+  describe "string" do
+    test "string" do
+      str = "\"Hello world\""
 
-    ast = TestParser.expression!(str)
+      ast = TestParser.expression!(str)
+
+      assert {:ok, "String", _} = TypeChecker.infer_exp(Env.init(), ast)
+    end
+
+    test "parses string inside interpolation" do
+      str = ~S"""
+      "#{"test"}"
+      """
+
+      ast = TestParser.expression!(str)
 
-    assert {:ok, "String", _} = TypeChecker.infer_exp(Env.init(), ast)
+      assert {:ok, "String", _} = TypeChecker.infer_exp(Env.init(), ast)
+    end
+
+    test "returns error when unknown variable in string interpolation" do
+      str = ~S"""
+      "#{x}"
+      """
+
+      ast = TestParser.expression!(str)
+
+      assert {:error, "Unknown variable: x"} = TypeChecker.infer_exp(Env.init(), ast)
+    end
+
+    test "parses known variable in string interpolation" do
+      str = ~S"""
+      hello = "Hello"
+      "#{hello}"
+      """
+
+      {:ok, ast, _, _, _, _} = TestParser.exps(str)
+
+      env = Env.init_module_env(Env.init(), "test", ast)
+
+      assert {:ok, "String", _} = TypeChecker.infer_block(env, ast)
+    end
+
+    test "accepts only strings in interpolation" do
+      str = ~S"""
+      "#{1}"
+      """
+
+      ast = TestParser.expression!(str)
+
+      assert {
+               :error,
+               "Expression used in string interpolation expected to be String, got Int"
+             } = TypeChecker.infer_exp(Env.init(), ast)
+    end
   end
 
   describe "lists" do