From 9f6c2d2778574dc0d8c684461ebba6d35988a585 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 7 Sep 2026 11:53:21 +0900 Subject: [PATCH] fix: replace widget file FFI with simplifile --- gleam.toml | 1 + manifest.toml | 3 + src/glendix/define/file_boundary.gleam | 138 ++++++++++------ src/glendix/define/file_boundary_ffi.mjs | 84 ---------- test/file_boundary_test.gleam | 198 +++++++++++++++++++++++ test/file_boundary_test_ffi.mjs | 19 +++ test/glendix_test.gleam | 4 +- 7 files changed, 314 insertions(+), 133 deletions(-) delete mode 100644 src/glendix/define/file_boundary_ffi.mjs create mode 100644 test/file_boundary_test.gleam create mode 100644 test/file_boundary_test_ffi.mjs 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()