Current section
Files
Jump to
Current section
Files
src/lustre_dev_tools/project.gleam
// IMPORTS ---------------------------------------------------------------------
import filepath
import gleam/bool
import gleam/dict.{type Dict}
import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError}
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, Some, None}
import gleam/package_interface.{type Type, Fn, Named, Tuple, Variable}
import gleam/pair
import gleam/regex.{type Match, Match}
import gleam/result
import gleam/set
import gleam/string
import lustre_dev_tools/cmd
import lustre_dev_tools/error.{type Error, BuildError}
import simplifile
import tom.{type Toml}
// CONFIG ----------------------------------------------------------------------
pub const needed_node_modules = ["quill", "tailwind-merge"]
pub const needed_dev_node_modules = ["jsdom"]
// TYPES -----------------------------------------------------------------------
pub type Config {
Config(name: String, version: String, toml: Dict(String, Toml))
}
pub type Interface {
Interface(name: String, version: String, modules: Dict(String, Module))
}
pub type Module {
Module(constants: Dict(String, Type), functions: Dict(String, Function))
}
pub type Function {
Function(parameters: List(Type), return: Type)
}
pub type PackageJson {
PackageJson(
dependencies: Option(Dict(String, String)),
dev_dependencies: Option(Dict(String, String)),
)
}
// COMMANDS --------------------------------------------------------------------
pub fn otp_version() -> Int {
let version = do_otp_version()
case int.parse(version) {
Ok(version) -> version
Error(_) -> panic as { "unexpected version number format: " <> version }
}
}
@external(erlang, "lustre_dev_tools_ffi", "otp_version")
fn do_otp_version() -> String
/// Compile the current project running the `gleam build` command.
///
pub fn build() -> Result(Nil, Error) {
cmd.exec(run: "gleam", in: ".", with: ["build", "--target", "javascript"])
|> result.map_error(fn(err) { BuildError(pair.second(err)) })
|> result.replace(Nil)
}
pub fn interface() -> Result(Interface, Error) {
use Config(name, ..) <- result.try(config())
// Gleam currently has a bug with the `export package-interface` command that
// means cached modules are not emitted. This is, obviously, a problem if a
// you try and export the interface multiple times (which happens regularly
// for us).
//
// We clear build files for *just* the user's application (because we don't
// actually care about the dependencies) before running the export command.
// This forces Gleam to recompile them and properly emit the interface.
//
let caches = [
"build/prod/javascript", "build/prod/erlang", "build/dev/javascript",
"build/dev/erlang",
]
list.each(caches, fn(cache) {
filepath.join(root(), cache)
|> filepath.join(name)
|> simplifile.delete
})
let dir = filepath.join(root(), "build/.lustre")
let out = filepath.join(dir, "package-interface.json")
let args = ["export", "package-interface", "--out", out]
cmd.exec(run: "gleam", in: ".", with: args)
|> result.map_error(fn(err) { BuildError(pair.second(err)) })
|> result.then(fn(_) {
let assert Ok(json) = simplifile.read(out)
let assert Ok(interface) = json.decode(json, interface_decoder)
Ok(interface)
})
}
/// Read the project configuration in the `gleam.toml` file.
///
pub fn config() -> Result(Config, Error) {
// Since we made sure that the project could compile we're sure that there is
// bound to be a `gleam.toml` file somewhere in the current directory (or in
// its parent directories). So we can safely call `root()` without
// it looping indefinitely.
let configuration_path = filepath.join(root(), "gleam.toml")
// All these operations are safe to assert because the Gleam project wouldn't
// compile if any of this stuff was invalid.
let assert Ok(configuration) = simplifile.read(configuration_path)
let assert Ok(toml) = tom.parse(configuration)
let assert Ok(name) = tom.get_string(toml, ["name"])
let assert Ok(version) = tom.get_string(toml, ["version"])
Ok(Config(name: name, version: version, toml: toml))
}
pub fn all_node_modules_installed() -> Bool {
case package_json() {
Error(_) -> {
False
}
Ok(package_json) -> {
let dependencies = package_json.dependencies
let dev_dependencies = package_json.dev_dependencies
let has_dependencies =
check_if_all_dependencies_installed(dependencies, needed_node_modules)
let has_dev_dependencies =
check_if_all_dependencies_installed(
dev_dependencies,
needed_dev_node_modules,
)
has_dependencies && has_dev_dependencies
}
}
}
fn check_if_all_dependencies_installed(
installed: Option(Dict(String, String)),
needed: List(String),
) {
case installed {
None -> False
Some(installed) ->
list.all(needed, fn(dep) { dict.has_key(installed, dep) })
}
}
pub fn package_json() -> Result(PackageJson, simplifile.FileError) {
use json <- result.try(simplifile.read("package.json"))
let assert Ok(package_json) = json.decode(json, package_json_decoder)
Ok(package_json)
}
// UTILS -----------------------------------------------------------------------
/// Finds the path leading to the project's root folder. This recursively walks
/// up from the current directory until it finds a `gleam.toml`.
///
pub fn root() -> String {
find_root(".")
}
fn find_root(path: String) -> String {
let toml = filepath.join(path, "gleam.toml")
case simplifile.is_file(toml) {
Ok(False) | Error(_) -> find_root(filepath.join("..", path))
Ok(True) -> path
}
}
pub fn build_dir(is_prod: Bool) -> String {
case is_prod {
True -> filepath.join(root(), "dist")
False -> filepath.join(root(), "build/dev/static")
}
}
pub fn type_to_string(type_: Type) -> String {
case type_ {
Tuple(elements) -> {
let elements = list.map(elements, type_to_string)
"#(" <> string.join(elements, with: ", ") <> ")"
}
Fn(params, return) -> {
let params = list.map(params, type_to_string)
let return = type_to_string(return)
"fn(" <> string.join(params, with: ", ") <> ") -> " <> return
}
Named(name, _package, _module, []) -> name
Named(name, _package, _module, params) -> {
let params = list.map(params, type_to_string)
name <> "(" <> string.join(params, with: ", ") <> ")"
}
Variable(id) -> "a_" <> int.to_string(id)
}
}
pub fn all_node_modules() -> List(String) {
let src_dir = filepath.join(root(), "build/dev/javascript")
let assert Ok(files) = simplifile.get_files(src_dir)
{
use modules, file <- list.fold(files, set.new())
use <- bool.guard(!is_js(file), modules)
let assert Ok(src) = simplifile.read(file)
used_node_modules(src)
|> set.from_list
|> set.union(modules)
}
|> set.to_list
}
fn is_js(file: String) -> Bool {
case file |> filepath.extension |> result.unwrap("") {
"js" | "mjs" | "ts" ->
!{ file |> filepath.base_name |> string.contains("test") }
_ -> False
}
}
pub fn replace_node_modules_with_relative_path(src: String) -> String {
let modules = node_modules_matches(src)
let replacements = {
use module <- list.map(modules)
let assert Match(full, [Some(name)]) = module
let replacement = string.replace(full, name, "/modules/" <> name <> ".mjs")
#(name, replacement)
}
use src, replacement <- list.fold(replacements, src)
let assert Ok(to_replace) =
regex.from_string(
"(?:from|import) (?:\"|')(" <> replacement.0 <> ")(?:\"|')",
)
regex.replace(to_replace, src, replacement.1)
}
fn used_node_modules(src: String) -> List(String) {
let modules = node_modules_matches(src)
use module <- list.map(modules)
let assert Match(_, [Some(name)]) = module
name
}
fn node_modules_matches(src: String) -> List(Match) {
let assert Ok(modules) =
regex.from_string("(?:from|import) (?:\"|')([\\w|-]*)(?:\"|')")
regex.scan(modules, src)
}
// DECODERS --------------------------------------------------------------------
fn interface_decoder(dyn: Dynamic) -> Result(Interface, List(DecodeError)) {
dynamic.decode3(
Interface,
dynamic.field("name", dynamic.string),
dynamic.field("version", dynamic.string),
dynamic.field("modules", string_dict(module_decoder)),
)(dyn)
}
fn module_decoder(dyn: Dynamic) -> Result(Module, List(DecodeError)) {
dynamic.decode2(
Module,
dynamic.field(
"constants",
string_dict(dynamic.field("type", package_interface.type_decoder)),
),
dynamic.field("functions", string_dict(function_decoder)),
)(dyn)
}
fn function_decoder(dyn: Dynamic) -> Result(Function, List(DecodeError)) {
dynamic.decode2(
Function,
dynamic.field("parameters", dynamic.list(labelled_argument_decoder)),
dynamic.field("return", package_interface.type_decoder),
)(dyn)
}
fn labelled_argument_decoder(dyn: Dynamic) -> Result(Type, List(DecodeError)) {
// In this case we don't really care about the label, so we're just ignoring
// it and returning the argument's type.
dynamic.field("type", package_interface.type_decoder)(dyn)
}
fn string_dict(values: Decoder(a)) -> Decoder(Dict(String, a)) {
dynamic.dict(dynamic.string, values)
}
fn package_json_decoder(dyn: Dynamic) -> Result(PackageJson, List(DecodeError)) {
dynamic.decode2(
PackageJson,
dynamic.optional_field("dependencies", string_dict(dynamic.string)),
dynamic.optional_field("devDependencies", string_dict(dynamic.string)),
)(dyn)
}