Skip to main content

src/internal/module.gleam

import gleam/list
import gleam/result
import gleam/string
import shellout
import simplifile.{type FileError, File}

pub type ModuleChange {
  Added(filename: String)
  Updated(filename: String)
}

pub type ModuleError {
  ModuleIsNotAFileError(String)
  CannotReadModule(String, FileError)
  CannotWriteModule(String, FileError)
  CannotFormatError(String, #(Int, String))
}

const default_content: String = "// This file is auto-generated by iconify_lustre.

import gleam/list
import lustre/attribute.{type Attribute, attribute}
import lustre/element.{type Element}
import lustre/element/svg

fn add_defaults(attributes: List(Attribute(msg)),
  defaults: List(Attribute(msg)),
) -> List(Attribute(msg)) {
  list.fold(defaults, attributes, fn(accumulator, default) {
    case list.any(accumulator, fn(item) { item.name == default.name }) {
      False -> [default, ..accumulator]
      True -> accumulator
    }
  })
}"

pub fn change_to_string(change: ModuleChange) {
  case change {
    Added(filename) -> "added to " <> filename
    Updated(filename) -> "updated in " <> filename
  }
}

pub fn error_to_string(error: ModuleError) {
  case error {
    ModuleIsNotAFileError(filename) ->
      "Module '" <> filename <> "' exists but is not a file"
    CannotReadModule(filename, error) ->
      "Cannot read '" <> filename <> "', " <> simplifile.describe_error(error)
    CannotWriteModule(filename, error) ->
      "Cannot write '" <> filename <> "', " <> simplifile.describe_error(error)
    CannotFormatError(filename, #(_, error)) ->
      "Cannot format '" <> filename <> "', " <> error
  }
}

// I'd love to use glance and glance_printer, but the latter isn't updated regularly,
// so I fall back to the original source: gleam and text processing (without explicit
// knowledge of the file-structure).
pub fn adjust(
  filename: String,
  fn_name: String,
  fn_body: String,
) -> Result(ModuleChange, ModuleError) {
  ensure_module(filename)
  |> result.try(fn(_) { format_module(filename) })
  |> result.try(fn(_) { update_module(fn_body, normalize(fn_name), filename) })
  |> result.try(fn(result) { format_module(filename) |> result.replace(result) })
}

fn ensure_module(filename: String) -> Result(Nil, ModuleError) {
  case simplifile.file_info(filename) {
    Ok(info) ->
      case simplifile.file_info_type(info) {
        File -> Ok(Nil)
        _ -> Error(ModuleIsNotAFileError(filename))
      }
    Error(_) ->
      simplifile.write(filename, default_content)
      |> result.map_error(CannotWriteModule(filename, _))
  }
}

fn format_module(filename: String) -> Result(Nil, ModuleError) {
  shellout.command("gleam", ["format", filename], in: ".", opt: [])
  |> result.replace(Nil)
  |> result.map_error(CannotFormatError(filename, _))
}

fn update_module(
  fn_body: String,
  fn_name: String,
  filename: String,
) -> Result(ModuleChange, ModuleError) {
  filename
  |> read_lines()
  |> result.map_error(CannotReadModule(filename, _))
  |> result.map(split_module(fn_name, _))
  |> result.try(fn(split) {
    let #(before_function, function, after_function, epilogue) = split
    [
      before_function,
      [
        "pub fn "
          <> fn_name
          <> "(attributes: List(Attribute(msg))) -> Element(msg) {",
        fn_body,
        "}",
      ],
      after_function,
      epilogue,
    ]
    |> list.flatten()
    |> string.join("\n")
    |> simplifile.write(filename, _)
    |> result.map_error(CannotWriteModule(filename, _))
    |> result.map(fn(_) {
      case function {
        [] -> Added(filename)
        _ -> Updated(filename)
      }
    })
  })
}

fn read_lines(filename: String) -> Result(List(String), FileError) {
  filename
  |> simplifile.read()
  |> result.map(fn(content) {
    content
    |> string.split("\n")
    |> list.map(string.trim_end)
  })
}

fn split_module(
  fn_name: String,
  content: List(String),
) -> #(List(String), List(String), List(String), List(String)) {
  let #(rest, epilogue) =
    list.split_while(content, fn(line) {
      !string.starts_with(line, "fn add_defaults(")
    })
  let #(before_function, rest) =
    list.split_while(rest, fn(line) {
      !string.starts_with(line, "pub fn " <> fn_name <> "(")
    })
  let #(function, after_function) =
    list.split_while(rest, fn(line) {
      !string.starts_with(line, "pub fn")
      || string.starts_with(line, "pub fn " <> fn_name <> "(")
    })

  #(before_function, function, after_function, epilogue)
}

fn normalize(fn_name: String) -> String {
  let normalized_name =
    fn_name
    |> string.lowercase()
    |> string.split("")
    |> list.map(fn(char) {
      case char {
        "a"
        | "b"
        | "c"
        | "d"
        | "e"
        | "f"
        | "g"
        | "h"
        | "i"
        | "j"
        | "k"
        | "l"
        | "m"
        | "n"
        | "o"
        | "p"
        | "q"
        | "r"
        | "s"
        | "t"
        | "u"
        | "v"
        | "w"
        | "x"
        | "y"
        | "z" -> char
        _ -> "_"
      }
    })
    |> string.join("")

  case normalized_name {
    "as"
    | "assert"
    | "auto"
    | "case"
    | "delegate"
    | "derive"
    | "echo"
    | "else"
    | "fn"
    | "if"
    | "implement"
    | "import"
    | "let"
    | "macro"
    | "opaque"
    | "panic"
    | "pub"
    | "test"
    | "todo"
    | "type"
    | "use" -> normalized_name <> "_"
    _ -> normalized_name
  }
}