diff --git a/gleam.toml b/gleam.toml
index dabe345..d2a2cc6 100644
--- a/gleam.toml
+++ b/gleam.toml
@@ -19,6 +19,7 @@ redraw = ">= 19.2.2 and < 20.0.0"
redraw_dom = ">= 19.2.2 and < 20.0.0"
lustre = ">= 5.7.1 and < 6.0.0"
mendraw = ">= 2.0.0 and < 3.0.0"
+simplifile = ">= 2.6.0 and < 3.0.0"
[dev_dependencies]
gleeunit = ">= 1.11.0 and < 2.0.0"
diff --git a/manifest.toml b/manifest.toml
index e7708ce..9bb98fe 100644
--- a/manifest.toml
+++ b/manifest.toml
@@ -9,6 +9,7 @@
packages = [
{ name = "etch", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "etch", source = "hex", outer_checksum = "A4B4458EE1E7D22753538C03F0D64C930309863365D2D26570E2D2C6B7CFC5EA" },
{ name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" },
+ { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
{ name = "gleam_fetch", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "284CE88E37436699545F9F65D413E1DFB6C1EA3FE3824B6EA2018D0ECF088FFC" },
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
@@ -22,6 +23,7 @@ packages = [
{ name = "mendraw", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "redraw", "redraw_dom"], otp_app = "mendraw", source = "hex", outer_checksum = "BB913054A5FEDC872AAB114FFADDFB791DACFE605B3DCEEAD3620F11BB59D090" },
{ name = "redraw", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "redraw", source = "hex", outer_checksum = "B8CEEB74E8846CE10B8360B924DAD22441B61D947F9449854164F0686C4B8661" },
{ name = "redraw_dom", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_stdlib", "redraw"], otp_app = "redraw_dom", source = "hex", outer_checksum = "80278296AD6E3D4457D6FF6A14FEA3E90696284BEA297BDE10E430FC4CE726B8" },
+ { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" },
]
[requirements]
@@ -34,3 +36,4 @@ lustre = { version = ">= 5.7.1 and < 6.0.0" }
mendraw = { version = ">= 2.0.0 and < 3.0.0" }
redraw = { version = ">= 19.2.2 and < 20.0.0" }
redraw_dom = { version = ">= 19.2.2 and < 20.0.0" }
+simplifile = { version = ">= 2.6.0 and < 3.0.0" }
diff --git a/src/glendix/define/file_boundary.gleam b/src/glendix/define/file_boundary.gleam
index 12d9067..534af7c 100644
--- a/src/glendix/define/file_boundary.gleam
+++ b/src/glendix/define/file_boundary.gleam
@@ -1,7 +1,14 @@
//// Provides typed filesystem operations for the widget definition editor.
////
+import gleam/dynamic
+import gleam/dynamic/decode
+import gleam/json
+import gleam/list
+import gleam/option
import gleam/result
+import gleam/string
+import simplifile
/// Describes a widget definition filesystem failure.
pub type FileError {
@@ -9,22 +16,41 @@ pub type FileError {
WidgetNameWasNotDeclared(path: String)
/// The expected widget XML file does not exist.
WidgetXmlWasNotFound(path: String)
- /// A file could not be read.
+ /// A file could not be read or decoded.
+ ///
+ /// Filesystem reasons come from `simplifile.describe_error`. JSON reasons
+ /// use Glendix's stable description of `gleam_json.DecodeError`.
FileCouldNotBeRead(path: String, reason: String)
/// A file could not be written.
+ ///
+ /// The reason comes from `simplifile.describe_error`.
FileCouldNotBeWritten(path: String, reason: String)
}
/// Finds the widget XML path declared by the current package.
pub fn find_widget_xml() -> Result(String, FileError) {
- find_widget_xml_raw()
- |> result.map_error(map_raw_error)
+ let package_path = "package.json"
+ use package_contents <- result.try(read(package_path))
+ use widget_name <- result.try(widget_name(package_contents, package_path))
+ let widget_path = "src/" <> widget_name <> ".xml"
+
+ case simplifile.is_file(widget_path) {
+ Ok(True) -> Ok(widget_path)
+ Ok(False) -> Error(WidgetXmlWasNotFound(path: widget_path))
+ Error(error) ->
+ Error(FileCouldNotBeRead(
+ path: widget_path,
+ reason: simplifile.describe_error(error),
+ ))
+ }
}
/// Reads a UTF-8 text file.
pub fn read(path path: String) -> Result(String, FileError) {
- read_raw(path)
- |> result.map_error(map_raw_error)
+ simplifile.read(from: path)
+ |> result.map_error(fn(error) {
+ FileCouldNotBeRead(path: path, reason: simplifile.describe_error(error))
+ })
}
/// Writes a UTF-8 text file.
@@ -32,52 +58,70 @@ pub fn write(
path path: String,
content content: String,
) -> Result(Nil, FileError) {
- write_raw(path, content)
- |> result.map_error(map_raw_error)
+ simplifile.write(to: path, contents: content)
+ |> result.map_error(fn(error) {
+ FileCouldNotBeWritten(path: path, reason: simplifile.describe_error(error))
+ })
}
-type RawFileError
-
-fn map_raw_error(error: RawFileError) -> FileError {
- case raw_file_error_kind(error) {
- 1 -> WidgetNameWasNotDeclared(path: raw_file_error_path(error))
- 2 -> WidgetXmlWasNotFound(path: raw_file_error_path(error))
- 3 ->
- FileCouldNotBeRead(
- path: raw_file_error_path(error),
- reason: raw_file_error_reason(error),
- )
- 4 ->
- FileCouldNotBeWritten(
- path: raw_file_error_path(error),
- reason: raw_file_error_reason(error),
- )
- _ ->
- FileCouldNotBeRead(
- path: raw_file_error_path(error),
- reason: raw_file_error_reason(error),
- )
+fn widget_name(
+ package_contents: String,
+ package_path: String,
+) -> Result(String, FileError) {
+ case json.parse(package_contents, package_widget_name_decoder()) {
+ Error(error) ->
+ Error(FileCouldNotBeRead(
+ path: package_path,
+ reason: json_decode_error_reason(error),
+ ))
+ Ok(option.None) -> Error(WidgetNameWasNotDeclared(path: package_path))
+ Ok(option.Some(value)) ->
+ case decode.run(value, decode.string) {
+ Ok(name) ->
+ case string.trim(name) {
+ "" -> Error(WidgetNameWasNotDeclared(path: package_path))
+ _ -> Ok(name)
+ }
+ Error(_) -> Error(WidgetNameWasNotDeclared(path: package_path))
+ }
}
}
-// -- FFI --
-@external(javascript, "./file_boundary_ffi.mjs", "find_widget_xml")
-fn find_widget_xml_raw() -> Result(String, RawFileError)
-
-@external(javascript, "./file_boundary_ffi.mjs", "read_file")
-fn read_raw(path path: String) -> Result(String, RawFileError)
-
-@external(javascript, "./file_boundary_ffi.mjs", "write_file")
-fn write_raw(
- path path: String,
- content content: String,
-) -> Result(Nil, RawFileError)
-
-@external(javascript, "./file_boundary_ffi.mjs", "file_error_kind")
-fn raw_file_error_kind(error: RawFileError) -> Int
+fn package_widget_name_decoder() -> decode.Decoder(
+ option.Option(dynamic.Dynamic),
+) {
+ let object_decoder = {
+ use name <- decode.optional_field(
+ "widgetName",
+ option.None,
+ decode.optional(decode.dynamic),
+ )
+ decode.success(name)
+ }
+ decode.one_of(object_decoder, or: [decode.success(option.None)])
+}
-@external(javascript, "./file_boundary_ffi.mjs", "file_error_path")
-fn raw_file_error_path(error: RawFileError) -> String
+fn json_decode_error_reason(error: json.DecodeError) -> String {
+ case error {
+ json.UnexpectedEndOfInput -> "JSON ended unexpectedly"
+ json.UnexpectedByte(byte) -> "JSON contained unexpected byte: " <> byte
+ json.UnexpectedSequence(sequence) ->
+ "JSON contained unexpected sequence: " <> sequence
+ json.UnableToDecode(errors) -> {
+ let reasons =
+ errors
+ |> list.map(dynamic_decode_error_reason)
+ |> string.join("; ")
+ "JSON value could not be decoded: " <> reasons
+ }
+ }
+}
-@external(javascript, "./file_boundary_ffi.mjs", "file_error_reason")
-fn raw_file_error_reason(error: RawFileError) -> String
+fn dynamic_decode_error_reason(error: decode.DecodeError) -> String {
+ let decode.DecodeError(expected, found, path) = error
+ let location = case path {
+ [] -> "root"
+ [_, ..] -> string.join(path, ".")
+ }
+ location <> " expected " <> expected <> " but found " <> found
+}
diff --git a/src/glendix/define/file_boundary_ffi.mjs b/src/glendix/define/file_boundary_ffi.mjs
deleted file mode 100644
index 29e09ff..0000000
--- a/src/glendix/define/file_boundary_ffi.mjs
+++ /dev/null
@@ -1,84 +0,0 @@
-import { existsSync, readFileSync, writeFileSync } from "node:fs";
-import { Ok, Error as GleamError } from "../../gleam.mjs";
-
-const WIDGET_NAME_WAS_NOT_DECLARED = 1;
-const WIDGET_XML_WAS_NOT_FOUND = 2;
-const FILE_COULD_NOT_BE_READ = 3;
-const FILE_COULD_NOT_BE_WRITTEN = 4;
-
-function errorReason(error) {
- return error instanceof globalThis.Error ? error.message : String(error);
-}
-
-function fileError(kind, path, reason) {
- return { kind, path, reason };
-}
-
-export function find_widget_xml() {
- const packagePath = "package.json";
- let packageConfiguration;
- try {
- packageConfiguration = JSON.parse(readFileSync(packagePath, "utf-8"));
- } catch (error) {
- return new GleamError(
- fileError(FILE_COULD_NOT_BE_READ, packagePath, errorReason(error)),
- );
- }
-
- const widgetName = packageConfiguration.widgetName;
- if (typeof widgetName !== "string" || widgetName.trim() === "") {
- return new GleamError(
- fileError(
- WIDGET_NAME_WAS_NOT_DECLARED,
- packagePath,
- "package.json does not contain a non-empty widgetName",
- ),
- );
- }
-
- const widgetPath = `src/${widgetName}.xml`;
- if (!existsSync(widgetPath)) {
- return new GleamError(
- fileError(
- WIDGET_XML_WAS_NOT_FOUND,
- widgetPath,
- "the declared widget XML file does not exist",
- ),
- );
- }
-
- return new Ok(widgetPath);
-}
-
-export function read_file(path) {
- try {
- return new Ok(readFileSync(path, "utf-8"));
- } catch (error) {
- return new GleamError(
- fileError(FILE_COULD_NOT_BE_READ, path, errorReason(error)),
- );
- }
-}
-
-export function write_file(path, content) {
- try {
- writeFileSync(path, content, "utf-8");
- return new Ok(undefined);
- } catch (error) {
- return new GleamError(
- fileError(FILE_COULD_NOT_BE_WRITTEN, path, errorReason(error)),
- );
- }
-}
-
-export function file_error_kind(error) {
- return error.kind;
-}
-
-export function file_error_path(error) {
- return error.path;
-}
-
-export function file_error_reason(error) {
- return error.reason;
-}
diff --git a/test/file_boundary_test.gleam b/test/file_boundary_test.gleam
new file mode 100644
index 0000000..25f0140
--- /dev/null
+++ b/test/file_boundary_test.gleam
@@ -0,0 +1,198 @@
+//// Exercises the widget-definition filesystem boundary on JavaScript.
+////
+
+import gleam/json
+import gleam/list
+import gleeunit/should
+import glendix/define/file_boundary
+import simplifile
+
+/// Verifies a missing package manifest is reported as a read failure.
+pub fn missing_package_json_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ case file_boundary.find_widget_xml() {
+ Error(file_boundary.FileCouldNotBeRead(path, reason)) -> {
+ path
+ |> should.equal("package.json")
+ reason
+ |> should.equal("No such file or directory")
+ }
+ Ok(_) -> should.fail()
+ Error(_) -> should.fail()
+ }
+ })
+}
+
+/// Verifies an absent widgetName is reported with the public domain error.
+pub fn missing_widget_name_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_package_json("{}")
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.WidgetNameWasNotDeclared(path: "package.json")),
+ )
+ })
+}
+
+/// Verifies empty and whitespace-only widget names remain undeclared.
+pub fn empty_widget_name_contract_test() -> Nil {
+ ["", " \n\t"]
+ |> list.each(fn(name) {
+ in_temporary_directory(fn(_directory) {
+ write_widget_package(name)
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.WidgetNameWasNotDeclared(path: "package.json")),
+ )
+ })
+ })
+}
+
+/// Verifies null and non-string widget names remain undeclared.
+pub fn non_string_widget_name_contract_test() -> Nil {
+ [
+ "{\"widgetName\": null}",
+ "{\"widgetName\": 42}",
+ "{\"widgetName\": false}",
+ ]
+ |> list.each(fn(package_json) {
+ in_temporary_directory(fn(_directory) {
+ write_package_json(package_json)
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.WidgetNameWasNotDeclared(path: "package.json")),
+ )
+ })
+ })
+}
+
+/// Verifies malformed JSON produces a deterministic package read reason.
+pub fn malformed_package_json_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_package_json("{")
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.FileCouldNotBeRead(
+ path: "package.json",
+ reason: "JSON ended unexpectedly",
+ )),
+ )
+ })
+}
+
+/// Verifies non-object package values cannot declare a widget name.
+pub fn non_object_package_json_contract_test() -> Nil {
+ ["null", "[]", "42", "\"text\""]
+ |> list.each(fn(package_json) {
+ in_temporary_directory(fn(_directory) {
+ write_package_json(package_json)
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.WidgetNameWasNotDeclared(path: "package.json")),
+ )
+ })
+ })
+}
+
+/// Verifies a missing declared XML path preserves the computed path.
+pub fn missing_widget_xml_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_widget_package("Example")
+ simplifile.create_directory("src")
+ |> should.be_ok
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.WidgetXmlWasNotFound(path: "src/Example.xml")),
+ )
+ })
+}
+
+/// Verifies a directory at the XML path is not accepted as a widget file.
+pub fn widget_xml_directory_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_widget_package("Example")
+ simplifile.create_directory("src")
+ |> should.be_ok
+ simplifile.create_directory("src/Example.xml")
+ |> should.be_ok
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.WidgetXmlWasNotFound(path: "src/Example.xml")),
+ )
+ })
+}
+
+/// Verifies XML existence-check errors retain the computed path and reason.
+pub fn widget_xml_check_failure_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_widget_package("Example")
+ simplifile.write("src", "not a directory")
+ |> should.be_ok
+ file_boundary.find_widget_xml()
+ |> should.equal(
+ Error(file_boundary.FileCouldNotBeRead(
+ path: "src/Example.xml",
+ reason: "Not a directory",
+ )),
+ )
+ })
+}
+
+/// Verifies a declared widget XML file is found at the existing relative path.
+pub fn find_widget_xml_success_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_widget_package("Example")
+ simplifile.create_directory("src")
+ |> should.be_ok
+ simplifile.write("src/Example.xml", "")
+ |> should.be_ok
+ file_boundary.find_widget_xml()
+ |> should.equal(Ok("src/Example.xml"))
+ })
+}
+
+/// Verifies the public write and read functions preserve UTF-8 contents.
+pub fn file_round_trip_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ let content = "한글\n"
+ file_boundary.write("widget.xml", content)
+ |> should.be_ok
+ file_boundary.read("widget.xml")
+ |> should.equal(Ok(content))
+ })
+}
+
+/// Verifies invalid UTF-8 is mapped through simplifile's stable reason.
+pub fn invalid_utf8_read_contract_test() -> Nil {
+ in_temporary_directory(fn(_directory) {
+ write_invalid_utf8("widget.xml")
+ case file_boundary.read("widget.xml") {
+ Error(file_boundary.FileCouldNotBeRead(path, reason)) -> {
+ path
+ |> should.equal("widget.xml")
+ reason
+ |> should.equal("File not UTF-8 encoded")
+ }
+ Ok(_) -> should.fail()
+ Error(_) -> should.fail()
+ }
+ })
+}
+
+fn write_widget_package(widget_name: String) -> Nil {
+ json.object([#("widgetName", json.string(widget_name))])
+ |> json.to_string
+ |> write_package_json
+}
+
+fn write_package_json(contents: String) -> Nil {
+ simplifile.write("package.json", contents)
+ |> should.be_ok
+}
+
+// -- FFI --
+@external(javascript, "./file_boundary_test_ffi.mjs", "in_temporary_directory")
+fn in_temporary_directory(action: fn(String) -> value) -> value
+
+@external(javascript, "./file_boundary_test_ffi.mjs", "write_invalid_utf8")
+fn write_invalid_utf8(path: String) -> Nil
diff --git a/test/file_boundary_test_ffi.mjs b/test/file_boundary_test_ffi.mjs
new file mode 100644
index 0000000..2c8afb2
--- /dev/null
+++ b/test/file_boundary_test_ffi.mjs
@@ -0,0 +1,19 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+export function in_temporary_directory(action) {
+ const previous_directory = process.cwd();
+ const directory = mkdtempSync(join(tmpdir(), "glendix-file-boundary-test-"));
+ process.chdir(directory);
+ try {
+ return action(directory);
+ } finally {
+ process.chdir(previous_directory);
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+export function write_invalid_utf8(path) {
+ writeFileSync(path, Uint8Array.from([0xff]));
+}
diff --git a/test/glendix_test.gleam b/test/glendix_test.gleam
index 2362c50..a4bf82f 100644
--- a/test/glendix_test.gleam
+++ b/test/glendix_test.gleam
@@ -195,7 +195,7 @@ pub fn define_missing_file_read_contract_test() -> Nil {
error_path
|> should.equal(path)
reason
- |> should.not_equal("")
+ |> should.equal("No such file or directory")
}
Ok(_) -> should.fail()
Error(_) -> should.fail()
@@ -210,7 +210,7 @@ pub fn define_failed_file_write_contract_test() -> Nil {
error_path
|> should.equal(path)
reason
- |> should.not_equal("")
+ |> should.equal("No such file or directory")
}
Ok(_) -> should.fail()
Error(_) -> should.fail()