Initial commit

This commit is contained in:
Shadowfacts 2020-07-18 15:40:47 -04:00
commit 18da6d23e1
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
9 changed files with 258 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

31
.gitignore vendored Normal file
View File

@ -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

21
README.md Normal file
View File

@ -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).

101
lib/gemini.ex Normal file
View File

@ -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

62
lib/gemini/response.ex Normal file
View File

@ -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(<<status::binary-size(2), " ", rest::binary>>) 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(<<c::binary-size(1), rest::binary>>, acc, length) do
parse_meta(rest, [c | acc], length + 1)
end
end

27
mix.exs Normal file
View File

@ -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

3
mix.lock Normal file
View File

@ -0,0 +1,3 @@
%{
"socket": {:hex, :socket, "0.3.13", "98a2ab20ce17f95fb512c5cadddba32b57273e0d2dba2d2e5f976c5969d0c632", [:mix], [], "hexpm", "f82ea9833ef49dde272e6568ab8aac657a636acb4cf44a7de8a935acb8957c2e"},
}

8
test/gemini_test.exs Normal file
View File

@ -0,0 +1,8 @@
defmodule GeminiTest do
use ExUnit.Case
doctest Gemini
test "greets the world" do
assert Gemini.hello() == :world
end
end

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()