From eadb2693428a1ea9d88c6082695f049cec44771e Mon Sep 17 00:00:00 2001
From: sloane <1699281+sloanelybutsurely@users.noreply.github.com>
Date: Mon, 22 Apr 2024 10:00:51 -0400
Subject: [PATCH] Support 0.3.0 spec (#30)

* replace shell script with elixir script

* update to 0.3.0 spec

* update parser to support 0.3.0 spec

* use binary_part/3 for elixir 1.11 support

* add .exs extension to update_spec script
---
 lib/type_id.ex          | 43 ++++++++++++++++++++++++++++++-----------
 priv/spec/invalid.yml   | 21 +++++++++++++++-----
 priv/spec/valid.yml     |  9 ++++++++-
 scripts/update_spec.exs | 27 ++++++++++++++++++++++++++
 scripts/update_spec.sh  |  4 ----
 5 files changed, 83 insertions(+), 21 deletions(-)
 create mode 100755 scripts/update_spec.exs
 delete mode 100755 scripts/update_spec.sh

diff --git a/lib/type_id.ex b/lib/type_id.ex
index 53eaaee..8661ffc 100644
--- a/lib/type_id.ex
+++ b/lib/type_id.ex
@@ -174,17 +174,23 @@ defmodule TypeID do
   Like `from_string/1` but raises an error if the string is invalid.
   """
   @spec from_string!(String.t()) :: t() | no_return()
+  def from_string!(str) when byte_size(str) <= 26, do: from!("", str)
+
   def from_string!(str) do
-    case String.split(str, <<@seperator>>) do
-      [prefix, suffix] when prefix != "" ->
-        from!(prefix, suffix)
+    size = byte_size(str)
 
-      [suffix] ->
-        from!("", suffix)
+    prefix =
+      str
+      |> binary_part(0, size - 26)
+      |> String.replace(~r/_$/, "")
 
-      _ ->
-        raise ArgumentError, "invalid TypeID"
+    suffix = binary_part(str, size - 26, 26)
+
+    if prefix == "" do
+      raise ArgumentError, "A TypeID without a prefix should not have a leading underscore"
     end
+
+    from!(prefix, suffix)
   end
 
   @doc """
@@ -258,11 +264,26 @@ defmodule TypeID do
   end
 
   defp validate_prefix!(prefix) do
-    unless prefix =~ ~r/^[a-z]{0,63}$/ do
-      raise ArgumentError, "invalid prefix: #{prefix}. prefix should match [a-z]{0,63}"
-    end
+    cond do
+      String.starts_with?(prefix, "_") ->
+        invalid_prefix!(prefix, "cannot start with an underscore")
 
-    :ok
+      String.ends_with?(prefix, "_") ->
+        invalid_prefix!(prefix, "cannot end with an underscore")
+
+      byte_size(prefix) > 63 ->
+        invalid_prefix!(prefix, "cannot be more than 63 characters")
+
+      not Regex.match?(~r/^[a-z_]*$/, prefix) ->
+        invalid_prefix!(prefix, "can contain only lowercase letters and underscores")
+
+      true ->
+        :ok
+    end
+  end
+
+  defp invalid_prefix!(prefix, message) do
+    raise ArgumentError, "invalid prefix: #{prefix}. #{message}"
   end
 
   defp validate_suffix!(suffix) do
diff --git a/priv/spec/invalid.yml b/priv/spec/invalid.yml
index 6a2870b..1e19c33 100644
--- a/priv/spec/invalid.yml
+++ b/priv/spec/invalid.yml
@@ -4,7 +4,7 @@
 # Each example contains an invalid TypeID string. Implementations are expected
 # to throw an error when attempting to parse/validate these strings.
 #
-# Last updated: 2023-07-05
+# Last updated: 2024-04-10 (for version 0.3.0 of the spec)
 
 - name: prefix-uppercase
   typeid: "PREFIX_00000000000000000000000000"
@@ -18,9 +18,10 @@
   typeid: "pre.fix_00000000000000000000000000"
   description: "The prefix can't have symbols, it needs to be alphabetic"
 
-- name: prefix-underscore
-  typeid: "pre_fix_00000000000000000000000000"
-  description: "The prefix can't have symbols, it needs to be alphabetic"
+# Test removed in v0.3.0 – we now allow underscores in the prefix
+# - name: prefix-underscore
+#   typeid: "pre_fix_00000000000000000000000000"
+#   description: "The prefix can't have symbols, it needs to be alphabetic"
 
 - name: prefix-non-ascii
   typeid: "préfix_00000000000000000000000000"
@@ -85,4 +86,14 @@
 - name: suffix-overflow
   # This is the first suffix that overflows into 129 bits
   typeid: "prefix_8zzzzzzzzzzzzzzzzzzzzzzzzz"
-  description: "The should encode at most 128-bits"
+  description: "The suffix should encode at most 128-bits"
+
+# Tests below were added in v0.3.0 when we started allowing '_' within the
+# type prefix.
+- name: prefix-underscore-start
+  typeid: "_prefix_00000000000000000000000000"
+  description: "The prefix can't start with an underscore"
+
+- name: prefix-underscore-end
+  typeid: "prefix__00000000000000000000000000"
+  description: "The prefix can't end with an underscore"
diff --git a/priv/spec/valid.yml b/priv/spec/valid.yml
index 8f63250..47c5328 100644
--- a/priv/spec/valid.yml
+++ b/priv/spec/valid.yml
@@ -23,7 +23,7 @@
 # note that not all of them are UUIDv7s. When *generating* new random typeids,
 # implementations should always use UUIDv7s.
 #
-# Last updated: 2023-07-05
+# Last updated: 2024-04-10 (for version 0.3.0 of the spec)
 
 - name: nil
   typeid: "00000000000000000000000000"
@@ -64,3 +64,10 @@
   typeid: "prefix_01h455vb4pex5vsknk084sn02q"
   prefix: "prefix"
   uuid: "01890a5d-ac96-774b-bcce-b302099a8057"
+
+# Tests below were added in v0.3.0 when we started allowing '_' within the
+# type prefix.
+- name: prefix-underscore
+  typeid: "pre_fix_00000000000000000000000000"
+  prefix: "pre_fix"
+  uuid: "00000000-0000-0000-0000-000000000000"
diff --git a/scripts/update_spec.exs b/scripts/update_spec.exs
new file mode 100755
index 0000000..dc0a11c
--- /dev/null
+++ b/scripts/update_spec.exs
@@ -0,0 +1,27 @@
+#!/usr/bin/env -S ERL_FLAGS=+B elixir
+
+Mix.install(req: "~> 0.4")
+
+files = [
+  {"https://raw.githubusercontent.com/jetpack-io/typeid/main/spec/invalid.yml", "priv/spec/invalid.yml"},
+  {"https://raw.githubusercontent.com/jetpack-io/typeid/main/spec/valid.yml", "priv/spec/valid.yml"}
+]
+
+IO.puts("Updating spec YAML files")
+
+:ok = for {src, dest} <- files, reduce: :ok do
+  :ok ->
+    IO.write("Downloading #{src} to #{dest}... ")
+    with {:ok, io} <- File.open(dest, [:write]),
+      {:ok, _} <- Req.get(src, into: IO.binstream(io, 500)) do
+      IO.puts("OK")
+      :ok
+    else
+      other ->
+        IO.puts("ERROR")
+        other
+    end
+  failure -> failure
+end
+
+IO.puts("Done!")
diff --git a/scripts/update_spec.sh b/scripts/update_spec.sh
deleted file mode 100755
index 0a7678c..0000000
--- a/scripts/update_spec.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash -ex
-
-wget https://raw.githubusercontent.com/jetpack-io/typeid/main/spec/invalid.yml -O priv/spec/invalid.yml
-wget https://raw.githubusercontent.com/jetpack-io/typeid/main/spec/valid.yml -O priv/spec/valid.yml