Skip to content

Commit

Permalink
Implement Parser / Composer
Browse files Browse the repository at this point in the history
  • Loading branch information
maennchen committed Apr 1, 2023
1 parent eb40b96 commit 417e48a
Show file tree
Hide file tree
Showing 15 changed files with 893 additions and 14 deletions.
14 changes: 11 additions & 3 deletions .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
Expand Down Expand Up @@ -82,7 +81,6 @@
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},

#
## Warnings
Expand Down Expand Up @@ -123,7 +121,6 @@
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, [include_defp: true]},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.IoPuts, []},
Expand All @@ -134,6 +131,16 @@
{Credo.Check.Warning.UnsafeToAtom, []}
],
disabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},

#
## Refactoring Opportunities
#
{Credo.Check.Refactor.RedundantWithClauseResult, []},

#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
Expand All @@ -144,6 +151,7 @@
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/part_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ jobs:

steps:
- uses: actions/checkout@v3
with:
submodules: 'true'
- uses: erlef/setup-beam@v1
id: setupBEAM
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "test/spec"]
path = test/spec
url = git@github.com:package-url/purl-spec.git
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ See [the documentation][docs].

## Usage

TODO
```elixir
iex> Purl.new("pkg:hex/purl")
{:ok, %Purl{type: "hex", name: "purl"}}

iex> Purl.to_string(%Purl{type: "hex", name: "purl"})
"pkg:hex/purl"
```

## Installation

Expand Down
237 changes: 230 additions & 7 deletions lib/purl.ex
Original file line number Diff line number Diff line change
@@ -1,19 +1,242 @@
defmodule Purl do
@moduledoc """
Documentation for `Purl`.
Elixir Implementation of the purl (package url) specification.
## Specification
https://github.com/package-url/purl-spec
**Format**: `pkg:type/namespace/name@version?qualifiers#subpath`
> #### License {: .neutral}
>
> A lot of the documentation was taken directly from the specification. It is
> licensed under the MIT License:
> ```
> Copyright (c) the purl authors
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of
> this software and associated documentation files (the "Software"), to deal in
> the Software without restriction, including without limitation the rights to
> use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
> the Software, and to permit persons to whom the Software is furnished to do so,
> subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
> FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
> COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
> IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
> CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
> ```
"""

alias Purl.Composer
alias Purl.Parser
alias Purl.SpecialCase

# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@type parse_error ::
%URI.Error{}
| Purl.Error.InvalidField.t()
| Purl.Error.DuplicateQualifier.t()
| Purl.Error.InvalidScheme.t()

@typedoc """
the package "type" or package "protocol" such as `maven`, `npm`, `nuget`,
`gem`, `pypi`, etc.
Known types: https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst
## Validation
* The package type is composed only of ASCII letters and numbers, '.', '+' and '-' (period, plus, and dash)
* The type cannot start with a number
* The type cannot contains spaces
* The type must NOT be percent-encoded
* The type is case insensitive. The canonical form is lowercase
"""
@type type :: String.t()

@typedoc """
Segment of the namespace
## Validation
* must not contain a '/'
* must not be empty
* A URL host or Authority must NOT be used as a namespace. Use instead a
`repository_url` qualifier. Note however that for some types, the namespace
may look like a host.
"""
@type namespace_segment :: String.t()

@typedoc """
some name prefix such as a Maven groupid, a Docker image owner, a GitHub user
or organization
The values are type-specific.
"""
@type namespace :: [namespace_segment()]

@typedoc """
the name of the package
"""
@type name :: String.t()

@typedoc """
the version of the package
A version is a plain and opaque string. Some package types use versioning
conventions such as semver for NPMs or nevra conventions for RPMS. A type may
define a procedure to compare and sort versions, but there is no reliable and
uniform way to do such comparison consistently.
"""
@type version :: Version.t() | String.t()

@typedoc """
qualifier key
## Validation
* The key must be composed only of ASCII letters and numbers, '.', '-' and '_' (period, dash and underscore)
* A key cannot start with a number
* A key must NOT be percent-encoded
* A key is case insensitive. The canonical form is lowercase
* A key cannot contains spaces
"""
@type qualifier_key :: String.t()

@typedoc """
qualifier value
## Validation
* value cannot be an empty string: a key=value pair with an empty value is the
same as no key/value at all for this key
"""
@type qualifier_value :: String.t()

@typedoc """
extra qualifying data for a package such as an OS, architecture, a distro,
etc.
The values are type-specific.
## Validation
* key must be unique within the keys of the qualifiers string
"""
@type qualifiers :: %{optional(qualifier_key()) => qualifier_value()}

@typedoc """
subpath segment
## Validation
* must not contain a '/'
* must not be any of '..' or '.'
* must not be empty
"""
@type subpath_segment :: String.t()

@typedoc """
extra subpath within a package, relative to the package root
"""
@type subpath :: [subpath_segment()]

@typedoc """
Package URL struct
"""
@type t :: %__MODULE__{
type: type(),
namespace: namespace(),
name: name(),
version: version() | nil,
qualifiers: qualifiers(),
subpath: subpath()
}

@enforce_keys [:type, :name]
defstruct [:type, :name, namespace: [], version: nil, qualifiers: %{}, subpath: []]

@doc """
Formats purl as string
## Examples
iex> Purl.to_string(%Purl{type: "hex", name: "purl"})
"pkg:hex/purl"
"""
@spec to_string(purl :: t()) :: String.t()
def to_string(%Purl{} = purl), do: purl |> to_uri() |> URI.to_string()

# @doc """
# Converts a purl to a `URI`

# ## Examples

# iex> Purl.to_uri(%Purl{type: "hex", name: "purl"})
# %URI{scheme: "pkg", path: "hex/purl"}

# """
@spec to_uri(purl :: t()) :: URI.t()
defdelegate to_uri(purl), to: Composer, as: :compose_uri

@doc """
Hello world.
Creates a new purl struct from a `Purl`, `URI` or string.
## Examples
iex> Purl.hello()
:world
iex> Purl.new("pkg:hex/purl")
{:ok, %Purl{type: "hex", name: "purl"}}
"""
@spec hello :: :world
def hello do
:world
@spec new(purl :: String.t() | URI.t() | Purl.t()) ::
{:ok, Purl.t()} | {:error, parse_error() | Purl.Error.SpecialCaseFailed.t()}
def new(purl) do
with {:ok, purl} <- Parser.parse(purl),
{:ok, purl} <- SpecialCase.apply(purl) do
{:ok, purl}
end
end

@doc """
Similar to `new/1` but raises `URI.Error`, `Purl.Error.InvalidField` or
`Purl.Error.InvalidURI` if an invalid input is given.
## Examples
iex> Purl.new!("pkg:hex/purl")
%Purl{type: "hex", name: "purl"}
iex> Purl.new!(">pkg:hex/purl")
** (URI.Error) cannot parse due to reason invalid_uri: ">"
iex> Purl.new!("pkg:hex*/purl")
** (Purl.Error.InvalidField) invalid field type, \"hex*\" given
"""
@spec new!(purl :: String.t() | URI.t() | Purl.t()) :: Purl.t()
def new!(purl) do
case new(purl) do
{:ok, purl} -> purl
{:error, reason} -> raise reason
end
end

defimpl String.Chars do
@impl String.Chars
def to_string(%Purl{} = purl), do: Purl.to_string(purl)
end

defimpl Inspect do
import Inspect.Algebra

@impl Inspect
def inspect(%Purl{} = purl, opts) do
concat(["Purl.parse!(", to_doc(Purl.to_string(purl), opts), ")"])
end
end
end
56 changes: 56 additions & 0 deletions lib/purl/composer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule Purl.Composer do
@moduledoc false

@spec compose_uri(purl :: Purl.t()) :: URI.t()
def compose_uri(purl)

def compose_uri(%Purl{version: %Version{} = version} = purl),
do: compose_uri(%Purl{purl | version: Version.to_string(version)})

def compose_uri(%Purl{
type: type,
namespace: namespace,
name: name,
version: version,
qualifiers: qualifiers,
subpath: subpath
}) do
%URI{
scheme: "pkg",
path:
Enum.join(
[type | encode_namespace(namespace)] ++
[
case version do
nil -> name
version -> "#{name}@#{URI.encode(version)}"
end
],
"/"
),
query:
unless qualifiers == %{} do
encode_qualifiers(qualifiers)
end,
fragment:
unless subpath == [] do
Enum.join(subpath, "/")
end
}
end

@spec encode_namespace(namespace :: Purl.namespace()) :: [String.t()]
defp encode_namespace(namespace) do
Enum.map(namespace, fn namespace_segment ->
URI.encode(namespace_segment, &(&1 != ?@ and URI.char_unescaped?(&1)))
end)
end

@spec encode_qualifiers(qualifiers :: Purl.qualifiers()) :: String.t()
defp encode_qualifiers(qualifiers) do
Enum.map_join(qualifiers, "&", fn {key, value} ->
URI.encode(key, &URI.char_unreserved?/1) <>
"=" <> URI.encode(value, &(&1 == ?/ or URI.char_unescaped?(&1)))
end)
end
end
Loading

0 comments on commit 417e48a

Please sign in to comment.