Current section
Files
Jump to
Current section
Files
src/friendly_id.gleam
import friendly_id/words
import gleam/function
import gleam/int
import gleam/list
import gleam/string
import glearray
/// Generates a friendly ID from a Generator record.
///
/// # Examples
///
/// ## Create a generator with defaults, then generate an ID
///
/// ```gleam
/// let generator = friendly_id.new_generator()
/// echo friendly_id.generate(generator)
/// ```
pub fn generate(generator: Generator) -> String {
[take_random_element(generator.objects)]
|> prepend_predicates(
0,
generator.predicate_count,
generator.predicates,
)
|> list.map(generator.transform_fn)
|> string.join(generator.separator)
}
fn take_random_element(array: glearray.Array(value)) -> value {
let index =
glearray.length(array)
|> int.random()
let assert Ok(element) = glearray.get(in: array, at: index)
element
}
fn prepend_predicates(
acc: List(String),
count: Int,
max: Int,
predicates: glearray.Array(String),
) -> List(String) {
case count == max {
True -> acc
False -> {
prepend_predicates(
[take_random_element(predicates), ..acc],
count + 1,
max,
predicates,
)
}
}
}
// encoding
const offset: Int = 1337
pub type EncodingError {
OutOfBounds
}
/// Encodes an int to a Friendly ID. This is a deterministic operation, which means an int will always return the same ID, and guarantees no collisions.
///
/// # Examples
///
/// ## Create a generator with defaults, then generate an ID by encoding an int
///
/// ```gleam
/// let generator = friendly_id.new_generator()
/// echo friendly_id.encode_int(generator, 23)
/// ```
pub fn encode_int(
generator: Generator,
id: Int,
) -> Result(String, EncodingError) {
let max_id = encoder_max_int(generator)
case id < 0 || id > max_id {
True -> Error(OutOfBounds)
False -> {
let objects = generator.objects
let predicates = generator.predicates
let obj_count = glearray.length(objects)
let pred_count = glearray.length(predicates)
let raw_obj = id % obj_count
let remaining_id = id / obj_count
let raw_preds =
extract_raw_indices(
remaining_id,
pred_count,
generator.predicate_count,
[],
)
let scrambled_obj =
{ raw_obj * generator.obj_multiplier + offset }
% obj_count
let #(_cascade, scrambled_preds) =
list.map_fold(
over: raw_preds,
from: scrambled_obj,
with: fn(cascade, raw_pred) {
let scrambled_pred =
{
raw_pred
* generator.pred_multiplier
+ offset
+ cascade
}
% pred_count
#(scrambled_pred, scrambled_pred)
},
)
let assert Ok(object_word) = glearray.get(objects, scrambled_obj)
let pred_words =
list.map(list.reverse(scrambled_preds), fn(idx) {
let assert Ok(pred_word) = glearray.get(predicates, idx)
pred_word
})
let parts = list.append(pred_words, [object_word])
let transformed_parts =
list.map(parts, generator.transform_fn)
Ok(string.join(transformed_parts, generator.separator))
}
}
}
pub fn encoder_max_int(generator: Generator) -> Int {
let obj_count = glearray.length(generator.objects)
let pred_count = glearray.length(generator.predicates)
let total_combinations =
obj_count * int_power(pred_count, generator.predicate_count)
total_combinations - 1
}
fn extract_raw_indices(
remaining_id: Int,
pred_count: Int,
count_left: Int,
acc: List(Int),
) -> List(Int) {
case count_left <= 0 {
True -> list.reverse(acc)
False -> {
let pred_index = remaining_id % pred_count
let next_remaining = remaining_id / pred_count
extract_raw_indices(next_remaining, pred_count, count_left - 1, [
pred_index,
..acc
])
}
}
}
fn int_power(base: Int, exponent: Int) -> Int {
int_power_loop(base, exponent, 1)
}
fn int_power_loop(base: Int, exponent: Int, acc: Int) -> Int {
case exponent <= 0 {
True -> acc
False -> int_power_loop(base, exponent - 1, acc * base)
}
}
// generator
/// This record contains the objects and predicates arrays, needed to generate a friendly ID.
/// Should only be initialized once, then passed as a dependency.
pub opaque type Generator {
Generator(
objects: glearray.Array(String),
predicates: glearray.Array(String),
predicate_count: Int,
transform_fn: fn(String) -> String,
separator: String,
obj_multiplier: Int,
pred_multiplier: Int,
)
}
pub type GeneratorError {
NegativePredicateCount
}
/// Create a `Generator` record with the following defaults:
/// - Predicate count: 1
/// - No separator
/// - No transformation
/// - Provided word lists
///
/// # Examples
///
/// ```gleam
/// let generator = friendly_id.new_generator()
/// ```
pub fn new_generator() -> Generator {
let objects = words.get_objects()
let predicates = words.get_predicates()
let obj_multiplier = find_coprime(glearray.length(objects), 859)
let pred_multiplier = find_coprime(glearray.length(predicates), 859)
Generator(
objects:,
predicates:,
predicate_count: 1,
transform_fn: function.identity,
separator: "",
obj_multiplier:,
pred_multiplier:,
)
}
/// Create a `Generator` record with the word lists passed to it and the following defaults:
/// - Predicate count: 1
/// - No separator
/// - No transformation
///
/// # Examples
///
/// ```gleam
/// let objects = glearray.from_list(["apple, potato"])
/// let predicates = glearray.from_list(["brave, insightful"])
/// let generator = friendly_id.new_generator_with_words(objects:, predicates:)
/// ```
pub fn new_generator_with_words(
objects objects: glearray.Array(String),
predicates predicates: glearray.Array(String),
) -> Generator {
let obj_multiplier = find_coprime(glearray.length(objects), 859)
let pred_multiplier = find_coprime(glearray.length(predicates), 859)
Generator(
objects:,
predicates:,
predicate_count: 1,
transform_fn: function.identity,
separator: "",
obj_multiplier:,
pred_multiplier:,
)
}
pub fn set_generator_predicate_count(
generator: Generator,
predicate_count: Int,
) -> Result(Generator, GeneratorError) {
case predicate_count >= 0 {
True -> Ok(Generator(..generator, predicate_count:))
False -> Error(NegativePredicateCount)
}
}
pub fn set_generator_transform_fn(
generator: Generator,
transform_fn: fn(String) -> String,
) -> Generator {
Generator(..generator, transform_fn:)
}
pub fn set_generator_separator(generator: Generator, separator: String) -> Generator {
Generator(..generator, separator:)
}
fn gcd(a: Int, b: Int) -> Int {
case b == 0 {
True -> a
False -> gcd(b, a % b)
}
}
fn find_coprime(length: Int, candidate: Int) -> Int {
case gcd(length, candidate) == 1 {
True -> candidate
False -> find_coprime(length, candidate + 1)
}
}