diff --git a/README.ja.md b/README.ja.md index 27a777d..608b47c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -141,6 +141,13 @@ component boundary も提供します。評価済みの `use_tea` result を recharts = ["PieChart", "Pie"] ``` +Glendix はこの設定を標準 TOML として解析します。パッケージマネージャー、 +互換モード、モジュール名、コンポーネント名は引用符付き TOML 文字列にし、 +各バインディング値は 1 個の文字列または文字列配列にする必要があります。 +不正な TOML、重複キー、引用符のない文字列風の値は部分的に解釈せずエラーに +なります。`gleam.toml` または Glendix テーブルがない場合は、従来どおり +override とバインディングが未設定として扱われます。 + ```gleam import gleam/result import glendix/binding diff --git a/README.ko.md b/README.ko.md index 7189632..98bb572 100644 --- a/README.ko.md +++ b/README.ko.md @@ -136,6 +136,12 @@ component 경계도 만든다. 이미 평가된 `use_tea` 결과를 `redraw.keye recharts = ["PieChart", "Pie"] ``` +Glendix는 이 설정을 표준 TOML로 해석한다. 패키지 매니저, 호환 모드, 모듈 및 +컴포넌트 이름은 따옴표로 감싼 TOML 문자열이어야 하며, 각 바인딩 값은 문자열 +하나 또는 문자열 배열이어야 한다. 잘못된 TOML, 중복 키, 따옴표 없는 문자열 +형태의 값은 일부만 해석하지 않고 오류로 처리한다. `gleam.toml` 또는 Glendix +테이블이 없으면 기존처럼 override와 바인딩이 없는 것으로 처리한다. + ```gleam import gleam/result import glendix/binding diff --git a/README.md b/README.md index 05f432e..30d20af 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,13 @@ recharts = ["PieChart", "Pie"] Install the npm package, then run `gleam run -m glendix/install`. Glendix owns both component lookup and element construction; Mendraw is not required: +Glendix parses this configuration as standard TOML. Package-manager, +compatibility, module, and component names must be quoted TOML strings, and +each binding value must be either one string or an array of strings. Malformed +TOML, duplicate keys, and unquoted string-like values are rejected instead of +being partially interpreted. A missing `gleam.toml` or missing Glendix table +still means that no override or bindings are configured. + ```gleam import gleam/result import glendix/binding diff --git a/gleam.toml b/gleam.toml index 72222b4..b6c9ad8 100644 --- a/gleam.toml +++ b/gleam.toml @@ -21,6 +21,7 @@ lustre = ">= 5.7.1 and < 6.0.0" mendraw = ">= 2.0.0 and < 3.0.0" simplifile = ">= 2.6.0 and < 3.0.0" xmlm = ">= 1.0.1 and < 2.0.0" +tom = ">= 2.1.0 and < 3.0.0" [dev_dependencies] gleeunit = ">= 1.11.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 7986719..3e546ee 100644 --- a/manifest.toml +++ b/manifest.toml @@ -17,6 +17,7 @@ packages = [ { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleam_time", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, { name = "lustre", version = "5.7.1", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" }, @@ -24,6 +25,7 @@ packages = [ { 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" }, + { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, { name = "xmlm", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "xmlm", source = "hex", outer_checksum = "F23155B6F0B22CB8E09DAD41BB6B1CD036725FD0A80EE25B1D1B14138683A81D" }, ] @@ -38,4 +40,5 @@ 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" } +tom = { version = ">= 2.1.0 and < 3.0.0" } xmlm = { version = ">= 1.0.1 and < 2.0.0" } diff --git a/src/glendix/cmd.gleam b/src/glendix/cmd.gleam index 03ea838..7c93397 100644 --- a/src/glendix/cmd.gleam +++ b/src/glendix/cmd.gleam @@ -4,6 +4,7 @@ import gleam/io import gleam/option import gleam/result +import glendix/configuration /// Describes a JavaScript tooling failure. pub type CommandError { @@ -19,18 +20,10 @@ pub fn exec(command command: String) -> Result(Nil, CommandError) { /// Detects the configured or lockfile-selected JavaScript package manager. pub fn detect_package_manager() -> Result(String, CommandError) { - use override <- result.try( - read_pm_override() - |> map_raw_error("read package-manager override"), - ) - case override { - option.Some(name) -> - name - |> parse_package_manager - |> result.map(package_manager_name) - option.None -> - Ok(package_manager_name(detect_package_manager_from_lockfile())) - } + use project_configuration <- result.try(read_configuration( + "read package-manager override", + )) + detect_package_manager_with(project_configuration) } /// Detects the package-manager command used to run JavaScript tools. @@ -58,14 +51,23 @@ pub fn package_manager_commands( /// Runs Pluggable Widgets Tools with the supplied arguments. pub fn run_tool(args args: String) -> Result(Nil, CommandError) { - use package_manager <- result.try(detect_package_manager()) - use experimental_native <- result.try(read_experimental_native_mode()) - case experimental_native { - True -> + use project_configuration <- result.try(read_configuration( + "read tool configuration", + )) + use package_manager <- result.try(detect_package_manager_with( + project_configuration, + )) + use compatibility <- result.try(read_compatibility(project_configuration)) + case compatibility { + configuration.ExperimentalNative -> run_experimental_native_raw(package_manager, args) |> map_raw_error("run experimental-native tool") - False -> { - use runner <- result.try(detect_runner()) + configuration.Standard -> { + use runner <- result.try( + package_manager + |> parse_package_manager + |> result.map(package_manager_runner), + ) exec(runner <> " pluggable-widgets-tools " <> args) } } @@ -73,15 +75,25 @@ pub fn run_tool(args args: String) -> Result(Nil, CommandError) { /// Runs Pluggable Widgets Tools through the Glendix bridge. pub fn run_tool_with_bridge(args args: String) -> Result(Nil, CommandError) { - use package_manager <- result.try(detect_package_manager()) - use experimental_native <- result.try(read_experimental_native_mode()) - case experimental_native { - True -> - run_experimental_native_with_bridge_raw(package_manager, args) + use project_configuration <- result.try(read_configuration( + "read tool configuration", + )) + use package_manager <- result.try(detect_package_manager_with( + project_configuration, + )) + use compatibility <- result.try(read_compatibility(project_configuration)) + use bindings <- result.try(read_bindings(project_configuration)) + case compatibility { + configuration.ExperimentalNative -> + run_experimental_native_with_bridge_raw(package_manager, args, bindings) |> map_raw_error("run experimental-native tool with bridge") - False -> { - use runner <- result.try(detect_runner()) - run_with_bridge(runner <> " pluggable-widgets-tools " <> args) + configuration.Standard -> { + use runner <- result.try( + package_manager + |> parse_package_manager + |> result.map(package_manager_runner), + ) + run_with_bridge(runner <> " pluggable-widgets-tools " <> args, bindings) |> map_raw_error("run tool with bridge") } } @@ -89,15 +101,32 @@ pub fn run_tool_with_bridge(args args: String) -> Result(Nil, CommandError) { /// Runs the web build in development mode through the Glendix bridge. pub fn run_tool_dev() -> Result(Nil, CommandError) { - use package_manager <- result.try(detect_package_manager()) - use experimental_native <- result.try(read_experimental_native_mode()) - case experimental_native { - True -> - run_experimental_native_dev_with_bridge_raw(package_manager, "build:web") + use project_configuration <- result.try(read_configuration( + "read tool configuration", + )) + use package_manager <- result.try(detect_package_manager_with( + project_configuration, + )) + use compatibility <- result.try(read_compatibility(project_configuration)) + use bindings <- result.try(read_bindings(project_configuration)) + case compatibility { + configuration.ExperimentalNative -> + run_experimental_native_dev_with_bridge_raw( + package_manager, + "build:web", + bindings, + ) |> map_raw_error("run experimental-native development tool with bridge") - False -> { - use runner <- result.try(detect_runner()) - run_dev_with_bridge(runner <> " pluggable-widgets-tools build:web") + configuration.Standard -> { + use runner <- result.try( + package_manager + |> parse_package_manager + |> result.map(package_manager_runner), + ) + run_dev_with_bridge( + runner <> " pluggable-widgets-tools build:web", + bindings, + ) |> map_raw_error("run development tool with bridge") } } @@ -105,7 +134,11 @@ pub fn run_tool_dev() -> Result(Nil, CommandError) { /// Generates Mendix widget bindings. pub fn generate_bindings() -> Result(Nil, CommandError) { - generate_bindings_raw() + use project_configuration <- result.try(read_configuration( + "generate JavaScript bindings", + )) + use bindings <- result.try(read_bindings(project_configuration)) + generate_bindings_raw(bindings) |> map_raw_error("generate JavaScript bindings") } @@ -131,6 +164,23 @@ type PackageManager { Deno } +fn detect_package_manager_with( + project_configuration: configuration.Configuration, +) -> Result(String, CommandError) { + use override <- result.try( + configuration.package_manager(project_configuration) + |> map_configuration_error("read package-manager override"), + ) + case override { + option.Some(name) -> + name + |> parse_package_manager + |> result.map(package_manager_name) + option.None -> + Ok(package_manager_name(detect_package_manager_from_lockfile())) + } +} + fn parse_package_manager(name: String) -> Result(PackageManager, CommandError) { case name { "npm" -> Ok(Npm) @@ -198,9 +248,25 @@ fn detect_package_manager_from_lockfile() -> PackageManager { } } -fn read_experimental_native_mode() -> Result(Bool, CommandError) { - read_experimental_native_mode_raw() - |> map_raw_error("read compatibility mode") +fn read_configuration( + operation: String, +) -> Result(configuration.Configuration, CommandError) { + configuration.read() + |> map_configuration_error(operation) +} + +fn read_compatibility( + project_configuration: configuration.Configuration, +) -> Result(configuration.Compatibility, CommandError) { + configuration.compatibility(project_configuration) + |> map_configuration_error("read compatibility mode") +} + +fn read_bindings( + project_configuration: configuration.Configuration, +) -> Result(List(#(String, List(String))), CommandError) { + configuration.bindings(project_configuration) + |> map_configuration_error("read binding configuration") } fn map_raw_error( @@ -216,6 +282,19 @@ fn map_raw_error( }) } +fn map_configuration_error( + configuration_result: Result(value, configuration.ConfigurationError), + operation: String, +) -> Result(value, CommandError) { + configuration_result + |> result.map_error(fn(error) { + CommandFailed( + operation: operation, + reason: configuration.error_message(error), + ) + }) +} + // -- FFI -- @external(javascript, "./cmd_ffi.mjs", "exec") fn exec_raw(command command: String) -> Result(Nil, RawCommandError) @@ -223,20 +302,22 @@ fn exec_raw(command command: String) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "file_exists") fn file_exists(path: String) -> Bool -@external(javascript, "./cmd_ffi.mjs", "read_pm_override") -fn read_pm_override() -> Result(option.Option(String), RawCommandError) - -@external(javascript, "./cmd_ffi.mjs", "read_experimental_native_mode") -fn read_experimental_native_mode_raw() -> Result(Bool, RawCommandError) - @external(javascript, "./cmd_ffi.mjs", "run_with_bridge") -fn run_with_bridge(command: String) -> Result(Nil, RawCommandError) +fn run_with_bridge( + command: String, + bindings: List(#(String, List(String))), +) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "run_dev_with_bridge") -fn run_dev_with_bridge(build_command: String) -> Result(Nil, RawCommandError) +fn run_dev_with_bridge( + build_command: String, + bindings: List(#(String, List(String))), +) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "generate_bindings") -fn generate_bindings_raw() -> Result(Nil, RawCommandError) +fn generate_bindings_raw( + bindings: List(#(String, List(String))), +) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "run_experimental_native") fn run_experimental_native_raw( @@ -248,12 +329,14 @@ fn run_experimental_native_raw( fn run_experimental_native_with_bridge_raw( package_manager: String, args: String, + bindings: List(#(String, List(String))), ) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "run_experimental_native_dev_with_bridge") fn run_experimental_native_dev_with_bridge_raw( package_manager: String, args: String, + bindings: List(#(String, List(String))), ) -> Result(Nil, RawCommandError) @external(javascript, "./cmd_ffi.mjs", "command_error_message") diff --git a/src/glendix/cmd_ffi.mjs b/src/glendix/cmd_ffi.mjs index 47a1fd6..f67114b 100644 --- a/src/glendix/cmd_ffi.mjs +++ b/src/glendix/cmd_ffi.mjs @@ -16,10 +16,8 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { Some, None } from "../../gleam_stdlib/gleam/option.mjs"; import { Ok, Error as GleamError } from "../gleam.mjs"; -const EXPERIMENTAL_NATIVE_MODE = "experimental-native"; const EXPERIMENTAL_NATIVE_RUNNER = "--glendix-experimental-native-runner"; const EXPERIMENTAL_NATIVE_SHIM = "--glendix-experimental-native-shim"; const PWT_NODE_VERSION = "v22.18.0"; @@ -32,84 +30,6 @@ function errorMessage(error) { export function command_error_message(error) { return errorMessage(error); } -function parseTomlValue(raw) { - if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1); - if (raw.startsWith('[') && raw.endsWith(']')) { - const inner = raw.slice(1, -1).trim(); - if (inner === "") return []; - return inner.split(',').map(s => { - s = s.trim(); - if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1); - return s; - }); - } - const num = parseInt(raw, 10); - if (!isNaN(num) && String(num) === raw) return num; - if (raw === "true") return true; - if (raw === "false") return false; - return raw; -} -function parseGlendixToml() { - if (!existsSync("gleam.toml")) return null; - const content = readFileSync("gleam.toml", "utf-8"); - const lines = content.split(/\r?\n/); - const result = { pm: null, compatibility: null, bindings: {} }; - let currentSection = null; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === "" || trimmed.startsWith("#")) continue; - const sectionMatch = trimmed.match(/^\[(.+)\]$/); - if (sectionMatch) { - const path = sectionMatch[1]; - if (path === "tools.glendix") currentSection = "root"; - else if (path === "tools.glendix.bindings") currentSection = "bindings"; - else currentSection = null; - continue; - } - if (!currentSection) continue; - const kvMatch = trimmed.match(/^("(?:[^"\\]|\\.)*"|[A-Za-z0-9_-]+)\s*=\s*(.+)$/); - if (!kvMatch) continue; - let key = kvMatch[1].trim(); - if (key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1); - const value = parseTomlValue(kvMatch[2].trim()); - if (currentSection === "root") { - if (key === "pm") result.pm = value; - if (key === "compatibility") result.compatibility = value; - } else if (currentSection === "bindings") { - result.bindings[key] = value; - } - } - return result; -} -function readPmOverride() { - const config = parseGlendixToml(); - return config?.pm ? new Some(config.pm) : new None(); -} - -export function read_pm_override() { - try { - return new Ok(readPmOverride()); - } catch (error) { - return new GleamError(error); - } -} - -export function read_experimental_native_mode() { - try { - const compatibility = parseGlendixToml()?.compatibility; - if (compatibility === null || compatibility === undefined) { - return new Ok(false); - } - if (compatibility !== EXPERIMENTAL_NATIVE_MODE) { - throw new Error( - `Unsupported [tools.glendix].compatibility value: ${compatibility}`, - ); - } - return new Ok(true); - } catch (error) { - return new GleamError(error); - } -} function filterErlangWarnings(stderr) { const lines = stderr.split(/\r?\n/); const result = []; @@ -169,14 +89,21 @@ export function exec(command) { export function file_exists(path) { return existsSync(path); } -function generateBindingsOrThrow() { - const tomlConfig = parseGlendixToml(); - if (!tomlConfig?.bindings || Object.keys(tomlConfig.bindings).length === 0) { +function configuredBindings(bindings) { + return bindings.toArray().map(([moduleName, components]) => [ + moduleName, + components.toArray(), + ]); +} + +function generateBindingsOrThrow(bindings) { + const configuredEntries = configuredBindings(bindings); + if (configuredEntries.length === 0) { return; } const config = {}; - for (const [pkg, components] of Object.entries(tomlConfig.bindings)) { - config[pkg] = { components: Array.isArray(components) ? components : [components] }; + for (const [moduleName, components] of configuredEntries) { + config[moduleName] = { components }; } const imports = []; const entries = []; @@ -258,9 +185,9 @@ function generateBindingsOrThrow() { } } -export function generate_bindings() { +export function generate_bindings(bindings) { try { - generateBindingsOrThrow(); + generateBindingsOrThrow(bindings); return new Ok(undefined); } catch (error) { return new GleamError(error); @@ -561,8 +488,8 @@ export function fail_process() { process.exitCode = 1; } -function setupBridge() { - generateBindingsOrThrow(); +function setupBridge(bindings) { + generateBindingsOrThrow(bindings); execGleamFiltered("gleam build"); const pkg = JSON.parse(readFileSync("package.json", "utf-8")); const widgetName = pkg.widgetName; @@ -1068,8 +995,8 @@ function startExperimentalNativeRunner(toolPath, args) { require(toolPath); } -function runWithBridgeOrThrow(command) { - const { cleanup } = setupBridge(); +function runWithBridgeOrThrow(command, bindings) { + const { cleanup } = setupBridge(bindings); process.on("SIGINT", () => { cleanup(); process.exit(130); }); try { const result = spawnSync(command, { shell: true, stdio: ["inherit", "pipe", "pipe"] }); @@ -1088,9 +1015,9 @@ function runWithBridgeOrThrow(command) { } } -export function run_with_bridge(command) { +export function run_with_bridge(command, bindings) { try { - runWithBridgeOrThrow(command); + runWithBridgeOrThrow(command, bindings); return new Ok(undefined); } catch (error) { return new GleamError(error); @@ -1106,8 +1033,12 @@ export function run_experimental_native(packageManager, args) { } } -export function run_experimental_native_with_bridge(packageManager, args) { - const { cleanup } = setupBridge(); +export function run_experimental_native_with_bridge( + packageManager, + args, + bindings, +) { + const { cleanup } = setupBridge(bindings); process.on("SIGINT", () => { cleanup(); process.exit(130); }); try { runExperimentalNativeProcessOrThrow(packageManager, args); @@ -1119,8 +1050,8 @@ export function run_experimental_native_with_bridge(packageManager, args) { } } -function runDevWithBridgeUsing(execBuild) { - const { cleanup } = setupBridge(); +function runDevWithBridgeUsing(execBuild, bindings) { + const { cleanup } = setupBridge(bindings); console.log("[glendix] 초기 빌드 시작\n"); try { execBuild(); @@ -1193,7 +1124,7 @@ function runDevWithBridgeUsing(execBuild) { }); } -function runDevWithBridgeOrThrow(buildCommand) { +function runDevWithBridgeOrThrow(buildCommand, bindings) { runDevWithBridgeUsing(() => { const result = spawnSync(buildCommand, { shell: true, @@ -1202,23 +1133,27 @@ function runDevWithBridgeOrThrow(buildCommand) { writeProcessOutput(result); if (result.error) throw result.error; if (result.status !== 0) throw new Error("Build failed"); - }); + }, bindings); } -export function run_dev_with_bridge(buildCommand) { +export function run_dev_with_bridge(buildCommand, bindings) { try { - runDevWithBridgeOrThrow(buildCommand); + runDevWithBridgeOrThrow(buildCommand, bindings); return new Ok(undefined); } catch (error) { return new GleamError(error); } } -export function run_experimental_native_dev_with_bridge(packageManager, args) { +export function run_experimental_native_dev_with_bridge( + packageManager, + args, + bindings, +) { try { runDevWithBridgeUsing(() => { runExperimentalNativeProcessOrThrow(packageManager, args); - }); + }, bindings); return new Ok(undefined); } catch (error) { return new GleamError(error); diff --git a/src/glendix/configuration.gleam b/src/glendix/configuration.gleam new file mode 100644 index 0000000..158f4ea --- /dev/null +++ b/src/glendix/configuration.gleam @@ -0,0 +1,261 @@ +//// Reads and interprets Glendix project configuration from `gleam.toml`. +//// +//// Missing files and missing Glendix tables are valid and produce the same +//// defaults as an empty configuration. Present values are decoded strictly so +//// malformed TOML and schema mismatches cannot silently select tooling. + +import gleam/dict +import gleam/int +import gleam/list +import gleam/option +import gleam/result +import gleam/string +import simplifile +import tom + +/// A parsed project configuration. +pub opaque type Configuration { + Configuration(document: dict.Dict(String, tom.Toml)) +} + +/// The compatibility mode selected by the project. +pub type Compatibility { + /// Uses the package manager's standard command runner. + Standard + /// Runs Pluggable Widgets Tools through Glendix's native runtime adapter. + ExperimentalNative +} + +/// Describes a project configuration failure without discarding its cause. +pub type ConfigurationError { + /// The configuration file exists but could not be read. + ConfigurationCouldNotBeRead(path: String, reason: simplifile.FileError) + /// The configuration file contains malformed TOML. + ConfigurationCouldNotBeParsed(path: String, reason: tom.ParseError) + /// A configured value does not have the schema's required TOML type. + ConfiguredValueHasWrongType(key: List(String), expected: String, got: String) + /// The configured compatibility mode is not supported. + CompatibilityIsUnsupported(value: String) +} + +/// Reads the current project's `gleam.toml`. +/// +/// A missing file is represented by an empty configuration. +@internal +pub fn read() -> Result(Configuration, ConfigurationError) { + read_from(configuration_path) +} + +/// Reads a project configuration from a specific path. +/// +/// This entry point supports focused tests without changing the process working +/// directory. A missing file is represented by an empty configuration. +@internal +pub fn read_from( + path path: String, +) -> Result(Configuration, ConfigurationError) { + case simplifile.read(from: path) { + Ok(content) -> parse_from(content, path) + Error(reason) -> + case reason == simplifile.Enoent { + True -> Ok(empty()) + False -> Error(ConfigurationCouldNotBeRead(path: path, reason: reason)) + } + } +} + +/// Parses a `gleam.toml` document for focused configuration tests. +@internal +pub fn parse( + content content: String, +) -> Result(Configuration, ConfigurationError) { + parse_from(content, configuration_path) +} + +/// Returns the configured package-manager override when present. +@internal +pub fn package_manager( + configuration configuration: Configuration, +) -> Result(option.Option(String), ConfigurationError) { + use configured <- result.try( + optional_string(configuration, ["tools", "glendix", "pm"]), + ) + case configured { + option.Some("") -> Ok(option.None) + option.Some(value) -> Ok(option.Some(value)) + option.None -> Ok(option.None) + } +} + +/// Returns the configured compatibility mode. +@internal +pub fn compatibility( + configuration configuration: Configuration, +) -> Result(Compatibility, ConfigurationError) { + use configured <- result.try( + optional_string(configuration, ["tools", "glendix", "compatibility"]), + ) + case configured { + option.None -> Ok(Standard) + option.Some(value) -> + case value == experimental_native_mode { + True -> Ok(ExperimentalNative) + False -> Error(CompatibilityIsUnsupported(value: value)) + } + } +} + +/// Returns configured JavaScript modules and their component names. +/// +/// A scalar string selects one component. An array preserves component order, +/// and an empty array is retained so the JavaScript generation boundary can +/// preserve its established no-output behavior. +@internal +pub fn bindings( + configuration configuration: Configuration, +) -> Result(List(#(String, List(String))), ConfigurationError) { + let Configuration(document) = configuration + case tom.get_table(document, ["tools", "glendix", "bindings"]) { + Ok(table) -> + table + |> dict.to_list + |> list.sort(by: fn(first, second) { string.compare(first.0, second.0) }) + |> list.try_map(parse_binding) + Error(tom.NotFound(_key)) -> Ok([]) + Error(tom.WrongType(key, expected, got)) -> + Error(ConfiguredValueHasWrongType(key: key, expected: expected, got: got)) + } +} + +/// Formats a configuration error for a command-line failure. +@internal +pub fn error_message(error error: ConfigurationError) -> String { + case error { + ConfigurationCouldNotBeRead(path, reason) -> + "Could not read " <> path <> ": " <> simplifile.describe_error(reason) + ConfigurationCouldNotBeParsed(path, reason) -> + "Could not parse " <> path <> " as TOML: " <> parse_error_message(reason) + ConfiguredValueHasWrongType(key, expected, got) -> + "Expected " + <> string.join(key, ".") + <> " to be " + <> expected + <> ", got " + <> got + CompatibilityIsUnsupported(value) -> + "Unsupported [tools.glendix].compatibility value: " <> value + } +} + +const configuration_path = "gleam.toml" + +const experimental_native_mode = "experimental-native" + +fn empty() -> Configuration { + Configuration(document: dict.new()) +} + +fn parse_from( + content: String, + path: String, +) -> Result(Configuration, ConfigurationError) { + content + |> tom.parse + |> result.map(fn(document) { Configuration(document: document) }) + |> result.map_error(fn(reason) { + ConfigurationCouldNotBeParsed(path: path, reason: reason) + }) +} + +fn optional_string( + configuration: Configuration, + key: List(String), +) -> Result(option.Option(String), ConfigurationError) { + let Configuration(document) = configuration + case tom.get_string(document, key) { + Ok(value) -> Ok(option.Some(value)) + Error(tom.NotFound(_key)) -> Ok(option.None) + Error(tom.WrongType(key, expected, got)) -> + Error(ConfiguredValueHasWrongType(key: key, expected: expected, got: got)) + } +} + +fn parse_binding( + entry: #(String, tom.Toml), +) -> Result(#(String, List(String)), ConfigurationError) { + let #(module_name, configured_components) = entry + let key = ["tools", "glendix", "bindings", module_name] + case configured_components { + tom.String(component) -> Ok(#(module_name, [component])) + tom.Array(components) -> { + use components <- result.try( + components + |> list.index_map(fn(component, index) { #(component, index) }) + |> list.try_map(fn(indexed_component) { + let #(component, index) = indexed_component + string_component(component, list.append(key, [int.to_string(index)])) + }), + ) + Ok(#(module_name, components)) + } + tom.Int(_) -> binding_type_error(key, "Int") + tom.Float(_) -> binding_type_error(key, "Float") + tom.Infinity(_) -> binding_type_error(key, "Infinity") + tom.Nan(_) -> binding_type_error(key, "NaN") + tom.Bool(_) -> binding_type_error(key, "Bool") + tom.Date(_) -> binding_type_error(key, "Date") + tom.Time(_) -> binding_type_error(key, "Time") + tom.DateTime(_, _, _) -> binding_type_error(key, "DateTime") + tom.ArrayOfTables(_) -> binding_type_error(key, "Array") + tom.Table(_) -> binding_type_error(key, "Table") + tom.InlineTable(_) -> binding_type_error(key, "Table") + } +} + +fn string_component( + component: tom.Toml, + key: List(String), +) -> Result(String, ConfigurationError) { + case component { + tom.String(value) -> Ok(value) + tom.Int(_) -> component_type_error(key, "Int") + tom.Float(_) -> component_type_error(key, "Float") + tom.Infinity(_) -> component_type_error(key, "Infinity") + tom.Nan(_) -> component_type_error(key, "NaN") + tom.Bool(_) -> component_type_error(key, "Bool") + tom.Date(_) -> component_type_error(key, "Date") + tom.Time(_) -> component_type_error(key, "Time") + tom.DateTime(_, _, _) -> component_type_error(key, "DateTime") + tom.Array(_) -> component_type_error(key, "Array") + tom.ArrayOfTables(_) -> component_type_error(key, "Array") + tom.Table(_) -> component_type_error(key, "Table") + tom.InlineTable(_) -> component_type_error(key, "Table") + } +} + +fn binding_type_error( + key: List(String), + got: String, +) -> Result(#(String, List(String)), ConfigurationError) { + Error(ConfiguredValueHasWrongType( + key: key, + expected: "String or Array(String)", + got: got, + )) +} + +fn component_type_error( + key: List(String), + got: String, +) -> Result(String, ConfigurationError) { + Error(ConfiguredValueHasWrongType(key: key, expected: "String", got: got)) +} + +fn parse_error_message(error: tom.ParseError) -> String { + case error { + tom.Unexpected(got, expected) -> + "expected " <> expected <> ", got " <> string.inspect(got) + tom.KeyAlreadyInUse(key) -> + "key is already in use: " <> string.join(key, ".") + } +} diff --git a/test/glendix/configuration_test.gleam b/test/glendix/configuration_test.gleam new file mode 100644 index 0000000..5f5ec23 --- /dev/null +++ b/test/glendix/configuration_test.gleam @@ -0,0 +1,156 @@ +//// Exercises strict Glendix project configuration parsing and defaults. + +import gleam/option +import gleam/string +import gleeunit/should +import glendix/configuration + +/// Verifies a missing configuration file keeps all absence defaults. +pub fn missing_configuration_uses_defaults_test() -> Nil { + let parsed = + configuration.read_from("test/fixtures/does-not-exist.toml") + |> should.be_ok + + configuration.package_manager(parsed) + |> should.be_ok + |> should.equal(option.None) + configuration.compatibility(parsed) + |> should.be_ok + |> should.equal(configuration.Standard) + configuration.bindings(parsed) + |> should.be_ok + |> should.equal([]) +} + +/// Verifies unrelated TOML without Glendix tables keeps all absence defaults. +pub fn missing_glendix_tables_use_defaults_test() -> Nil { + let parsed = + configuration.parse("name = \"example\"\nversion = \"1.0.0\"\n") + |> should.be_ok + + configuration.package_manager(parsed) + |> should.be_ok + |> should.equal(option.None) + configuration.compatibility(parsed) + |> should.be_ok + |> should.equal(configuration.Standard) + configuration.bindings(parsed) + |> should.be_ok + |> should.equal([]) +} + +/// Verifies package-manager strings use TOML quoting and comment semantics. +pub fn package_manager_override_is_parsed_with_toml_rules_test() -> Nil { + let parsed = + configuration.parse("[tools.glendix]\npm = \"bun\" # selected runtime\n") + |> should.be_ok + + configuration.package_manager(parsed) + |> should.be_ok + |> should.equal(option.Some("bun")) +} + +/// Verifies an empty override preserves the previous no-override behavior. +pub fn empty_package_manager_override_is_absent_test() -> Nil { + let parsed = + configuration.parse("[tools.glendix]\npm = \"\"\n") + |> should.be_ok + + configuration.package_manager(parsed) + |> should.be_ok + |> should.equal(option.None) +} + +/// Verifies the supported compatibility string selects native execution. +pub fn experimental_native_compatibility_is_parsed_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix]\ncompatibility = 'experimental-native'\n", + ) + |> should.be_ok + + configuration.compatibility(parsed) + |> should.be_ok + |> should.equal(configuration.ExperimentalNative) +} + +/// Verifies scalar, array, quoted-key, and empty binding forms are preserved. +pub fn bindings_are_parsed_and_ordered_deterministically_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\n" + <> "zebra = \"Zebra\"\n" + <> "\"@scope/components\" = [\"Button\", \"Panel\"] # public widgets\n" + <> "empty = []\n", + ) + |> should.be_ok + + configuration.bindings(parsed) + |> should.be_ok + |> should.equal([ + #("@scope/components", ["Button", "Panel"]), + #("empty", []), + #("zebra", ["Zebra"]), + ]) +} + +/// Verifies malformed TOML is rejected instead of being partially interpreted. +pub fn malformed_toml_returns_parse_error_test() -> Nil { + case configuration.parse("[tools.glendix\npm = \"bun\"\n") { + Error(configuration.ConfigurationCouldNotBeParsed(path, _reason)) -> + path |> should.equal("gleam.toml") + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + +/// Verifies present package-manager values must be TOML strings. +pub fn package_manager_with_wrong_type_returns_schema_error_test() -> Nil { + let parsed = + configuration.parse("[tools.glendix]\npm = 42\n") + |> should.be_ok + + case configuration.package_manager(parsed) { + Error(configuration.ConfiguredValueHasWrongType(key, expected, got)) -> { + key |> should.equal(["tools", "glendix", "pm"]) + expected |> should.equal("String") + got |> should.equal("Int") + } + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + +/// Verifies every binding array element must be a TOML string. +pub fn binding_component_with_wrong_type_returns_schema_error_test() -> Nil { + let parsed = + configuration.parse( + "[tools.glendix.bindings]\ncomponents = [\"Button\", 1]\n", + ) + |> should.be_ok + + case configuration.bindings(parsed) { + Error(configuration.ConfiguredValueHasWrongType(key, expected, got)) -> { + key |> should.equal(["tools", "glendix", "bindings", "components", "1"]) + expected |> should.equal("String") + got |> should.equal("Int") + } + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + +/// Verifies unsupported compatibility values remain actionable command errors. +pub fn unsupported_compatibility_has_actionable_message_test() -> Nil { + let parsed = + configuration.parse("[tools.glendix]\ncompatibility = \"future-mode\"\n") + |> should.be_ok + + case configuration.compatibility(parsed) { + Error(error) -> + configuration.error_message(error) + |> string.contains("[tools.glendix].compatibility") + |> should.be_true + Ok(_) -> should.fail() + } +}