Skip to main content

src/mesv/format.gleam

//// The module containing the functions for building the `Formatter`, and for using a

//// `Formatter(a)` to transform `List(a)` into a `String`, which can be directly written to file.

//// 

//// ## Examples

//// A basic example of formatting data

//// ```gleam

//// import gleam/int

//// import mesv/format

//// 

//// const data: List(#(String, Int, Bool)) = [

////   #("Adam", 20, True),

////   #("Beatrice", 25, True),

////   #("Colin", 2, False),

//// ]

//// 

//// pub fn main() -> Nil {

////   let formatter =

////     // First create a formatter

////     format.build(fn(val: #(String, Int, Bool)) -> List(String) {

////       let #(name, age, adult) = val

////       [

////         name,

////         int.to_string(age),

////         case adult {

////           True -> "true"

////           False -> "false"

////         },

////       ]

////     })

//// 

////   // Then, use that formatter on the data you want to format

////   let formatted_data = format.format(formatter, data)

//// 

////   // By default, the formatter uses the comma as a column separator,

////   // newline as the row separator, and doublequotes for escaping cells

////   assert formatted_data == "Adam,20,true\nBeatrice,25,true\nColin,2,false"

//// }

//// ```

//// A cool party trick to impress your friends - computing data *just in time*

//// when converting to string, minimizing the memory required!

//// ```gleam

//// // [...]

//// const data: List(#(String, Int)) = [

////   #("Alex", 20),

////   #("Betty", 25),

////   #("Conrad", 2),

//// ]

//// 

//// pub fn main() -> Nil {

////   let formatted_data =

////     format.build(fn(val: #(String, Int)) -> List(String) {

////       let #(name, age) = val

////       [

////         name,

////         int.to_string(age),

////         bool.to_string(age >= 18),

////       ]

////     })

////     |> format.format(data)

//// 

////   assert formatted_data == "Alex,20,True\nBetty,25,True\nConrad,2,False"

//// }

//// ```

//// 


import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string

/// The type describing how to convert a specified data type `a` into String form.

/// 

/// To create it, use the `build` function and the provided transformation functions

/// (`set_row_sep`, `set_col_sep`, `set_headers`, `set_escaper`) to configure

/// the specific behaviour.

/// 

/// Once you have the required `Formatter(a)`, use the `format` function to

/// convert a `List(a)` into a String.

/// 

pub opaque type Formatter(a) {
  Formatter(
    column_separator: String,
    row_separator: String,
    escaper: String,
    escape_all: Bool,
    headers: Option(List(String)),
    formatter: fn(a) -> List(String),
  )
}

/// Function for directly building a `Formatter` that outputs the specified

/// elements in an exact order.

/// 

pub fn build(f: fn(a) -> List(String)) -> Formatter(a) {
  Formatter(
    column_separator: ",",
    row_separator: "\n",
    escaper: "\"",
    escape_all: False,
    headers: None,
    formatter: f,
  )
}

/// Function to set a specific row separator, instead of the default newline (`\n`)

/// 

/// If the row separator chosen is longer than a single character, it might cause problems

/// with performance later during parsing.

/// 

pub fn set_row_sep(
  formatter: Formatter(a),
  new_row_separator: String,
) -> Formatter(a) {
  Formatter(..formatter, row_separator: new_row_separator)
}

/// Function to set a specific column separator, instead of the default comma (`,`)

/// 

/// If the column separator chosen is longer than a single character, it might cause problems

/// with performance later during parsing.

/// 

pub fn set_col_sep(
  formatter: Formatter(a),
  new_column_separator: String,
) -> Formatter(a) {
  Formatter(..formatter, column_separator: new_column_separator)
}

/// Function to manually set column headers in a particular order.

/// 

/// By default, no headers will be written to output String, and the first row will

/// directly be the formatted data.

/// 

pub fn set_headers(
  formatter: Formatter(a),
  new_headers: List(String),
) -> Formatter(a) {
  Formatter(..formatter, headers: Some(new_headers))
}

/// Function to set custom escaper (character that wraps the value if its'

/// string contains row or column separators, or the escaper itself)

/// 

pub fn set_escaper(
  formatter: Formatter(a),
  new_escaper: String,
) -> Formatter(a) {
  Formatter(..formatter, escaper: new_escaper)
}

/// Function to specify whether to wrap each value in an escaper, regardles of necessity.

/// 

/// By default false.

/// 

pub fn set_escape_all(parser: Formatter(a), escape_all: Bool) -> Formatter(a) {
  Formatter(..parser, escape_all: escape_all)
}

/// Internal helper function for creating a function that checks if a specific element needs

/// to be escaped (wrapped in escaper, which by default is `"`) before being written to file.

/// 

/// It's a curried function because I like functional programming, and because it *should*

/// give some performance improvements if I create such a function before any looping instead

/// of constructing one for each iteration.

/// 

fn needs_escaping(prohibited: List(String)) -> fn(String) -> Bool {
  fn(el: String) -> Bool {
    prohibited |> list.any(fn(s: String) -> Bool { string.contains(el, s) })
  }
}

/// Internal helper function for creating a function for 'escaping' an element (for each `rule`,

/// replacing the first element in the tuple with the second).

/// 

/// It's a curried function because I like functional programming, and because it *should*

/// give some performance improvements if I create such a function before any looping instead

/// of constructing one for each iteration.

/// 

fn escape(rules: List(#(String, String))) -> fn(String) -> String {
  fn(el: String) -> String {
    rules
    |> list.map(fn(rule: #(String, String)) -> fn(String) -> String {
      string.replace(each: rule.0, with: rule.1, in: _)
    })
    |> list.fold(el, fn(acc: String, rule: fn(String) -> String) -> String {
      rule(acc)
    })
  }
}

/// Internal helper function for creating a function that wraps a String in the specified

/// 'escaper' String.

/// 

/// It's a curried function because I like functional programming, and because it *should*

/// give some performance improvements if I create such a function before any looping instead

/// of constructing one for each iteration.

/// 

fn wrap(in in: String) -> fn(String) -> String {
  fn(el: String) -> String { in <> el <> in }
}

/// Execution function that takes in a `Formatter(a)` as well as a `List(a)`,

/// and encodes it into a String.

/// 

/// All of the configuration options need to be set when building the `Formatter`,

/// so this function is very simple to understand.

/// 

pub fn format(formatter: Formatter(a), elements: List(a)) -> String {
  let Formatter(
    column_separator,
    row_separator,
    escaper,
    escape_all,
    maybe_headers,
    to_string,
  ) = formatter

  // For each separate element (column value in specific row) replace the first String with the second

  let rules = [#(escaper, escaper <> escaper)]
  let escapeify = fn(el: String) -> String {
    el
    |> string.trim()
    |> escape(rules)
    |> wrap(in: escaper)
  }
  let to_escape =
    needs_escaping([
      column_separator,
      row_separator,
      escaper,
      "\n",
      "\r",
    ])

  let ensafeify = fn(val: String) -> String {
    case escape_all || to_escape(val) {
      True -> escapeify(val)
      False -> val
    }
  }

  case maybe_headers {
    Some(headers) -> [headers, ..elements |> list.map(to_string)]
    None -> elements |> list.map(to_string)
  }
  |> list.map(fn(values: List(String)) -> String {
    values
    |> list.map(string.trim)
    |> list.map(ensafeify)
    |> string.join(column_separator)
  })
  |> string.join(row_separator)
}