From 18da6d23e1b4d80fa0d6da031eca39d7609b1f14 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 18 Jul 2020 15:40:47 -0400 Subject: [PATCH] Initial commit --- .formatter.exs | 4 ++ .gitignore | 31 +++++++++++++ README.md | 21 +++++++++ lib/gemini.ex | 101 +++++++++++++++++++++++++++++++++++++++++ lib/gemini/response.ex | 62 +++++++++++++++++++++++++ mix.exs | 27 +++++++++++ mix.lock | 3 ++ test/gemini_test.exs | 8 ++++ test/test_helper.exs | 1 + 9 files changed, 258 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/gemini.ex create mode 100644 lib/gemini/response.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/gemini_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..176f1b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +frenzy-*.tar + +# Files matching config/*.secret.exs pattern contain sensitive +# data and you should not commit them into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets files as long as you replace their contents by environment +# variables. +/config/*.secret.exs diff --git a/README.md b/README.md new file mode 100644 index 0000000..38447f7 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Gemini + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `gemini` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:gemini, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/gemini](https://hexdocs.pm/gemini). + diff --git a/lib/gemini.ex b/lib/gemini.ex new file mode 100644 index 0000000..75e80aa --- /dev/null +++ b/lib/gemini.ex @@ -0,0 +1,101 @@ +defmodule Gemini do + @moduledoc """ + Documentation for `Gemini`. + """ + + @spec request(URI.t() | String.t()) :: {:ok, Gemini.Response.t()} | {:error, term()} + @doc """ + Sends a Gemini request to the given URI. + """ + def request(uri) when is_binary(uri) do + request(URI.parse(uri)) + end + + def request(%URI{host: host} = uri) do + port = gemini_port(uri) + + case Socket.SSL.connect(host, port) do + {:error, reason} -> + {:error, reason} + + {:ok, sock} -> + Socket.Stream.send(sock, "#{URI.to_string(uri)}\r\n") + + case Socket.Stream.recv(sock) do + {:error, reason} -> + {:error, reason} + + {:ok, data} -> + Gemini.Response.parse(data) + end + end + end + + defp gemini_port(%URI{scheme: "gemini", port: nil}), do: 1965 + defp gemini_port(%URI{scheme: "gemini", port: port}), do: port + + @type line :: + {:text, String.t()} + | {:link, URI.t(), String.t() | nil} + | {:preformatted, String.t(), String.t() | nil} + | {:heading, String.t(), 1 | 2 | 3} + | {:list_item, String.t()} + | {:quoted, String.t()} + + @link_line_regex ~r/\s*([^\s]+)(?:\s+([^\s]+))?/ + + @spec parse(String.t()) :: [line()] + + def parse(doc) do + {lines, _, _} = + doc + |> String.split("\n") + |> Enum.reduce({[], false, nil}, fn line, {lines, in_preformatting, preformatting_alt} -> + preformatting_toggle = match?("```" <> _, line) + + cond do + preformatting_toggle && in_preformatting -> + {lines, false, nil} + + preformatting_toggle && !in_preformatting -> + "```" <> alt = line + {lines, true, alt} + + in_preformatting -> + {[{:preformatted, line, preformatting_alt} | lines], true, preformatting_alt} + + true -> + case line do + "=>" <> rest -> + {link, text} = + case Regex.run(@link_line_regex, rest) do + [_, link] -> {link, nil} + [_, link, text] -> {link, text} + end + + {[{:link, URI.parse(link), text} | lines], false, nil} + + "###" <> rest -> + {[{:heading, String.trim(rest), 3} | lines], false, nil} + + "##" <> rest -> + {[{:heading, String.trim(rest), 2} | lines], false, nil} + + "#" <> rest -> + {[{:heading, String.trim(rest), 1} | lines], false, nil} + + "* " <> rest -> + {[{:list_item, String.trim(rest)} | lines], false, nil} + + ">" <> rest -> + {[{:quoted, String.trim(rest)} | rest], false, nil} + + line -> + {[{:text, line} | lines], false, nil} + end + end + end) + + lines + end +end diff --git a/lib/gemini/response.ex b/lib/gemini/response.ex new file mode 100644 index 0000000..ac5a9a9 --- /dev/null +++ b/lib/gemini/response.ex @@ -0,0 +1,62 @@ +defmodule Gemini.Response do + @moduledoc """ + A response to a Gemini protocol request. + """ + + @enforce_keys [:status, :meta] + defstruct [:status, :meta, :body] + + @type t :: %__MODULE__{ + status: integer(), + meta: String.t(), + body: nil | binary() + } + + @spec parse(data :: binary()) :: {:ok, t()} | {:error, term()} + + @doc """ + Parse a Gemini response from the given data. + """ + def parse(<>) do + status = String.to_integer(status) + + case parse_meta(rest) do + {:error, reason} -> + {:error, reason} + + {:ok, meta, body} -> + { + :ok, + %__MODULE__{ + status: status, + meta: meta, + body: body + } + } + end + end + + def parse(_) do + {:error, "Expected Gemini response to begin with two digit status code and space"} + end + + defp parse_meta(data, acc \\ [], length \\ 0) + + defp parse_meta(<<"\r\n", rest::binary>>, acc, _length) do + { + :ok, + acc + |> Enum.reverse() + |> :erlang.list_to_binary(), + rest + } + end + + defp parse_meta(_data, _acc, 1024) do + {:error, "Expected meta string in Gemini response to be no longer than 1024 bytes"} + end + + defp parse_meta(<>, acc, length) do + parse_meta(rest, [c | acc], length + 1) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..c7db471 --- /dev/null +++ b/mix.exs @@ -0,0 +1,27 @@ +defmodule Gemini.MixProject do + use Mix.Project + + def project do + [ + app: :gemini, + version: "0.1.0", + elixir: "~> 1.10", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:socket, "~> 0.3.13"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..1c0d4c8 --- /dev/null +++ b/mix.lock @@ -0,0 +1,3 @@ +%{ + "socket": {:hex, :socket, "0.3.13", "98a2ab20ce17f95fb512c5cadddba32b57273e0d2dba2d2e5f976c5969d0c632", [:mix], [], "hexpm", "f82ea9833ef49dde272e6568ab8aac657a636acb4cf44a7de8a935acb8957c2e"}, +} diff --git a/test/gemini_test.exs b/test/gemini_test.exs new file mode 100644 index 0000000..6cfe934 --- /dev/null +++ b/test/gemini_test.exs @@ -0,0 +1,8 @@ +defmodule GeminiTest do + use ExUnit.Case + doctest Gemini + + test "greets the world" do + assert Gemini.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()