Gleam-inspired typed configuration language (POC)
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: Glint configuration language POC

Gleam-inspired typed config language with lexer, parser, typecheck,
and evaluation pipeline, plus Nix flake packaging.

author
nandi
date (Jul 25, 2026, 10:48 PM -0700) commit 028896c7
+1805
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v6 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "29" 18 + gleam-version: "1.17.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+7
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + /result 6 + result-* 7 + .direnv
+91
README.md
··· 1 + # Glint 2 + 3 + A **Gleam-inspired configuration language** (proof of concept). 4 + 5 + Configs are typed values — you declare shapes, then construct one root `pub let config`. 6 + 7 + ```glint 8 + type Mode { 9 + Dev 10 + Prod 11 + } 12 + 13 + type Config { 14 + Config( 15 + name: String, 16 + mode: Mode, 17 + port: Int, 18 + ) 19 + } 20 + 21 + pub let config = Config( 22 + name: "hello", 23 + mode: Dev, 24 + port: 3000, 25 + ) 26 + ``` 27 + 28 + ## Features (POC) 29 + 30 + - Custom types: unit variants and labeled records 31 + - `let` / `pub let` bindings 32 + - Built-ins: `String`, `Int`, `Float`, `Bool`, `List(a)`, `Option(a)` 33 + - `True` / `False`, `None` / `Some(x)` 34 + - `//` comments, trailing commas, numeric underscores (`1_000`) 35 + - Typecheck + evaluate → dump as Glint syntax or JSON 36 + 37 + **Not yet:** imports, env helpers, floats in all edge cases, anonymous maps. 38 + 39 + ## CLI 40 + 41 + ```bash 42 + cd glint 43 + 44 + # Erlang target (default) if you have the BEAM installed: 45 + gleam run -- check examples/hello.glint 46 + gleam run -- dump examples/hello.glint 47 + gleam run -- dump examples/hello.glint --json 48 + 49 + # Or JavaScript target: 50 + gleam run --target javascript -- check examples/hello.glint 51 + gleam test --target javascript 52 + ``` 53 + 54 + ## Library 55 + 56 + ```gleam 57 + import glint 58 + import glint/value 59 + 60 + pub fn example() { 61 + let assert Ok(checked) = glint.load(source) 62 + value.to_json(checked.config) 63 + } 64 + ``` 65 + 66 + ## Design 67 + 68 + | Idea | In Glint | 69 + |------|----------| 70 + | Named types first | `type Config { Config(...) }` | 71 + | One root export | `pub let config = ...` | 72 + | No null | `Option(a)` with `None` / `Some` | 73 + | Variants over string enums | `Dev` / `Prod` | 74 + | Labeled fields | `host: "localhost"` | 75 + 76 + ## Layout 77 + 78 + ``` 79 + src/glint.gleam CLI + library entry 80 + src/glint/ 81 + token.gleam tokens 82 + lexer.gleam lexer 83 + ast.gleam AST 84 + parser.gleam parser 85 + check.gleam typecheck + evaluate 86 + value.gleam runtime values + printers 87 + pipeline.gleam source → checked config 88 + examples/ 89 + hello.glint 90 + app.glint 91 + ```
+61
examples/app.glint
··· 1 + // Fuller example: nested records, variants, Option, List. 2 + 3 + type LogLevel { 4 + Debug 5 + Info 6 + Warn 7 + Error 8 + } 9 + 10 + type Database { 11 + Database( 12 + host: String, 13 + port: Int, 14 + name: String, 15 + ssl: Bool, 16 + password: Option(String), 17 + ) 18 + } 19 + 20 + type Server { 21 + Server( 22 + host: String, 23 + port: Int, 24 + workers: Int, 25 + ) 26 + } 27 + 28 + type Config { 29 + Config( 30 + name: String, 31 + log_level: LogLevel, 32 + server: Server, 33 + database: Database, 34 + cors_origins: List(String), 35 + ) 36 + } 37 + 38 + let database = Database( 39 + host: "localhost", 40 + port: 5432, 41 + name: "myapp", 42 + ssl: True, 43 + password: None, 44 + ) 45 + 46 + let server = Server( 47 + host: "0.0.0.0", 48 + port: 8080, 49 + workers: 4, 50 + ) 51 + 52 + pub let config = Config( 53 + name: "my-service", 54 + log_level: Info, 55 + server: server, 56 + database: database, 57 + cors_origins: [ 58 + "https://example.com", 59 + "http://localhost:3000", 60 + ], 61 + )
+20
examples/hello.glint
··· 1 + // Minimal Glint config — the clean style. 2 + 3 + type Mode { 4 + Dev 5 + Prod 6 + } 7 + 8 + type Config { 9 + Config( 10 + name: String, 11 + mode: Mode, 12 + port: Int, 13 + ) 14 + } 15 + 16 + pub let config = Config( 17 + name: "hello", 18 + mode: Dev, 19 + port: 3000, 20 + )
+43
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1784796856, 6 + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs", 22 + "systems": "systems" 23 + } 24 + }, 25 + "systems": { 26 + "locked": { 27 + "lastModified": 1681028828, 28 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 + "owner": "nix-systems", 30 + "repo": "default", 31 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 + "type": "github" 33 + }, 34 + "original": { 35 + "owner": "nix-systems", 36 + "repo": "default", 37 + "type": "github" 38 + } 39 + } 40 + }, 41 + "root": "root", 42 + "version": 7 43 + }
+163
flake.nix
··· 1 + { 2 + description = "Glint — a Gleam-inspired typed configuration language (POC)"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + systems.url = "github:nix-systems/default"; 7 + }; 8 + 9 + outputs = 10 + { 11 + self, 12 + nixpkgs, 13 + systems, 14 + ... 15 + }: 16 + let 17 + forEachSystem = nixpkgs.lib.genAttrs (import systems); 18 + in 19 + { 20 + packages = forEachSystem ( 21 + system: 22 + let 23 + pkgs = nixpkgs.legacyPackages.${system}; 24 + inherit (pkgs) lib; 25 + 26 + gleamToml = lib.importTOML ./gleam.toml; 27 + manifestToml = lib.importTOML ./manifest.toml; 28 + 29 + hexPackages = builtins.filter (p: p.source == "hex") manifestToml.packages; 30 + 31 + packagesTOML = lib.concatStringsSep "\n" ( 32 + [ "[packages]" ] ++ map (p: "${p.name} = \"${p.version}\"") hexPackages 33 + ); 34 + 35 + # Self-contained store package: Erlang shipment + fixed-path wrapper. 36 + glint = pkgs.stdenv.mkDerivation { 37 + pname = "glint"; 38 + version = gleamToml.version; 39 + 40 + src = lib.cleanSourceWith { 41 + src = ./.; 42 + filter = 43 + path: type: 44 + let 45 + base = baseNameOf path; 46 + in 47 + lib.cleanSourceFilter path type 48 + && base != "build" 49 + && base != "result" 50 + && base != "erl_crash.dump" 51 + && !(lib.hasSuffix ".escript" base); 52 + }; 53 + 54 + nativeBuildInputs = [ 55 + pkgs.gleam 56 + pkgs.beamPackages.erlang 57 + pkgs.rebar3 58 + pkgs.beamPackages.hex 59 + pkgs.rsync 60 + ]; 61 + 62 + configurePhase = '' 63 + runHook preConfigure 64 + 65 + mkdir -p build/packages 66 + cat <<EOF > build/packages/packages.toml 67 + ${packagesTOML} 68 + EOF 69 + 70 + # Vendor Hex deps so the pure Nix build never hits the network. 71 + ${lib.concatMapStringsSep "\n" (p: '' 72 + rsync --chmod=Du=rwx,Dg=rx,Do=rx,Fu=rw,Fg=r,Fo=r -r ${ 73 + pkgs.fetchHex { 74 + pkg = p.name; 75 + version = p.version; 76 + sha256 = p.outer_checksum; 77 + } 78 + }/* build/packages/${p.name}/ 79 + '') hexPackages} 80 + 81 + runHook postConfigure 82 + ''; 83 + 84 + buildPhase = '' 85 + runHook preBuild 86 + export REBAR_CACHE_DIR="$TMP/.rebar-cache" 87 + gleam export erlang-shipment 88 + runHook postBuild 89 + ''; 90 + 91 + installPhase = '' 92 + runHook preInstall 93 + 94 + mkdir -p $out/{bin,lib/glint} 95 + rsync --exclude=entrypoint.sh --exclude=entrypoint.ps1 \ 96 + -r build/erlang-shipment/* $out/lib/glint/ 97 + 98 + # Bake -pa paths at install time (each ebin needs its own -pa). 99 + ebin_args=() 100 + for d in $out/lib/glint/*/ebin; do 101 + ebin_args+=(-pa "$d") 102 + done 103 + 104 + { 105 + echo "#!${pkgs.runtimeShell}" 106 + echo "set -eu" 107 + echo "exec ${pkgs.beamPackages.erlang}/bin/erl ''${ebin_args[*]} -eval \"glint@@main:run(glint)\" -noshell -extra \"\$@\"" 108 + } > $out/bin/glint 109 + chmod +x $out/bin/glint 110 + 111 + runHook postInstall 112 + ''; 113 + 114 + meta = { 115 + description = gleamToml.description; 116 + mainProgram = "glint"; 117 + license = lib.licenses.asl20; 118 + }; 119 + }; 120 + in 121 + { 122 + default = glint; 123 + inherit glint; 124 + } 125 + ); 126 + 127 + apps = forEachSystem (system: { 128 + default = { 129 + type = "app"; 130 + program = "${self.packages.${system}.glint}/bin/glint"; 131 + }; 132 + }); 133 + 134 + devShells = forEachSystem ( 135 + system: 136 + let 137 + pkgs = nixpkgs.legacyPackages.${system}; 138 + in 139 + { 140 + default = pkgs.mkShell { 141 + packages = with pkgs; [ 142 + gleam 143 + beamPackages.erlang 144 + rebar3 145 + # Optional JS target: gleam run --target javascript 146 + nodejs 147 + ]; 148 + shellHook = '' 149 + echo "glint dev shell" 150 + echo " gleam run -- check examples/hello.glint" 151 + echo " gleam test" 152 + ''; 153 + }; 154 + } 155 + ); 156 + 157 + checks = forEachSystem (system: { 158 + package = self.packages.${system}.glint; 159 + }); 160 + 161 + formatter = forEachSystem (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style); 162 + }; 163 + }
+11
gleam.toml
··· 1 + name = "glint" 2 + version = "0.1.0" 3 + description = "Glint — a Gleam-inspired typed configuration language (POC)" 4 + 5 + [dependencies] 6 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 7 + simplifile = ">= 2.6.0 and < 3.0.0" 8 + argv = ">= 1.1.0 and < 2.0.0" 9 + 10 + [dev_dependencies] 11 + gleeunit = ">= 1.0.0 and < 2.0.0"
+21
manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, 11 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 12 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 13 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 14 + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, 15 + ] 16 + 17 + [requirements] 18 + argv = { version = ">= 1.1.0 and < 2.0.0" } 19 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 20 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 21 + simplifile = { version = ">= 2.6.0 and < 3.0.0" }
+104
src/glint.gleam
··· 1 + //// Glint — a Gleam-inspired configuration language (POC). 2 + //// 3 + //// Usage: 4 + //// gleam run -- check <file.glint> 5 + //// gleam run -- dump <file.glint> 6 + //// gleam run -- dump <file.glint> --json 7 + 8 + import argv 9 + import gleam/io 10 + import gleam/string 11 + import glint/check 12 + import glint/pipeline 13 + import glint/value 14 + import simplifile 15 + 16 + pub fn main() -> Nil { 17 + case argv.load().arguments { 18 + ["check", path] -> run_check(path) 19 + ["dump", path] -> run_dump(path, False) 20 + ["dump", path, "--json"] -> run_dump(path, True) 21 + ["help"] | ["--help"] | ["-h"] -> print_usage() 22 + [] -> print_usage() 23 + args -> { 24 + io.println("unknown arguments: " <> string.join(args, " ")) 25 + print_usage() 26 + } 27 + } 28 + } 29 + 30 + fn print_usage() -> Nil { 31 + io.println( 32 + "Glint POC — typed config language 33 + 34 + Usage: 35 + gleam run -- check <file.glint> 36 + gleam run -- dump <file.glint> 37 + gleam run -- dump <file.glint> --json 38 + ", 39 + ) 40 + } 41 + 42 + fn run_check(path: String) -> Nil { 43 + case load_file(path) { 44 + Error(msg) -> io.println_error(msg) 45 + Ok(checked) -> 46 + io.println( 47 + "ok config: " <> check.type_to_string(checked.config_type), 48 + ) 49 + } 50 + } 51 + 52 + fn run_dump(path: String, as_json: Bool) -> Nil { 53 + case load_file(path) { 54 + Error(msg) -> io.println_error(msg) 55 + Ok(checked) -> 56 + case as_json { 57 + True -> io.println(value.to_json(checked.config)) 58 + False -> io.println(value.to_glint(checked.config)) 59 + } 60 + } 61 + } 62 + 63 + fn load_file(path: String) -> Result(check.Checked, String) { 64 + case simplifile.read(path) { 65 + Error(e) -> 66 + Error( 67 + "could not read " 68 + <> path 69 + <> ": " 70 + <> simplifile.describe_error(e), 71 + ) 72 + Ok(source) -> 73 + case pipeline.load(source) { 74 + Error(e) -> Error(pipeline.error_to_string(e)) 75 + Ok(checked) -> Ok(checked) 76 + } 77 + } 78 + } 79 + 80 + /// Library entry: load and check a source string. 81 + pub fn load(source: String) -> Result(check.Checked, String) { 82 + case pipeline.load(source) { 83 + Error(e) -> Error(pipeline.error_to_string(e)) 84 + Ok(checked) -> Ok(checked) 85 + } 86 + } 87 + 88 + /// Convenience for tests / embedding. 89 + pub fn dump(source: String) -> Result(String, String) { 90 + use checked <- result_map(load(source)) 91 + value.to_glint(checked.config) 92 + } 93 + 94 + pub fn dump_json(source: String) -> Result(String, String) { 95 + use checked <- result_map(load(source)) 96 + value.to_json(checked.config) 97 + } 98 + 99 + fn result_map(r: Result(a, e), f: fn(a) -> b) -> Result(b, e) { 100 + case r { 101 + Ok(a) -> Ok(f(a)) 102 + Error(e) -> Error(e) 103 + } 104 + }
+38
src/glint/ast.gleam
··· 1 + /// Abstract syntax tree for Glint (clean subset). 2 + 3 + pub type Program { 4 + Program(statements: List(Stmt)) 5 + } 6 + 7 + pub type Stmt { 8 + TypeDef(name: String, constructors: List(Constructor)) 9 + Let(public: Bool, name: String, value: Expr) 10 + } 11 + 12 + pub type Constructor { 13 + Constructor(name: String, fields: List(Field)) 14 + } 15 + 16 + pub type Field { 17 + Field(name: String, type_: TypeExpr) 18 + } 19 + 20 + pub type TypeExpr { 21 + NamedType(String) 22 + ListType(TypeExpr) 23 + OptionType(TypeExpr) 24 + } 25 + 26 + pub type Expr { 27 + StringLit(String) 28 + IntLit(Int) 29 + FloatLit(Float) 30 + BoolLit(Bool) 31 + NoneLit 32 + SomeExpr(Expr) 33 + /// Bare name: variable reference or unit variant. 34 + Name(String) 35 + /// Labeled constructor application: `Database(host: "x", port: 1)`. 36 + Construct(name: String, fields: List(#(String, Expr))) 37 + ListLit(List(Expr)) 38 + }
+335
src/glint/check.gleam
··· 1 + //// Typecheck and evaluate a Glint program. 2 + //// Returns the value of `pub let config`. 3 + 4 + import gleam/dict.{type Dict} 5 + import gleam/list 6 + import gleam/result 7 + import gleam/string 8 + import glint/ast.{ 9 + type Constructor, type Expr, type Program, type Stmt, type TypeExpr, 10 + } 11 + import glint/value.{type Value} 12 + 13 + pub type CheckError { 14 + CheckError(message: String) 15 + } 16 + 17 + pub type Type { 18 + TString 19 + TInt 20 + TFloat 21 + TBool 22 + TOption(Type) 23 + TList(Type) 24 + /// Nominal type by name (user-defined). 25 + TNamed(String) 26 + } 27 + 28 + type CtorInfo { 29 + CtorInfo(type_name: String, fields: List(#(String, Type))) 30 + } 31 + 32 + type Env { 33 + Env( 34 + /// Variable bindings: name → (type, value) 35 + vars: Dict(String, #(Type, Value)), 36 + /// Constructor name → info 37 + ctors: Dict(String, CtorInfo), 38 + /// Defined type names 39 + types: Dict(String, List(String)), 40 + ) 41 + } 42 + 43 + pub type Checked { 44 + Checked(config: Value, config_type: Type) 45 + } 46 + 47 + pub fn check(program: Program) -> Result(Checked, CheckError) { 48 + use env <- result.try(collect_types(program.statements, empty_env())) 49 + use env <- result.try(eval_lets(program.statements, env)) 50 + case dict.get(env.vars, "config") { 51 + Ok(#(ty, val)) -> Ok(Checked(config: val, config_type: ty)) 52 + Error(_) -> 53 + Error(CheckError("missing root export: `pub let config` is required")) 54 + } 55 + } 56 + 57 + fn empty_env() -> Env { 58 + Env(vars: dict.new(), ctors: dict.new(), types: dict.new()) 59 + } 60 + 61 + fn collect_types( 62 + stmts: List(Stmt), 63 + env: Env, 64 + ) -> Result(Env, CheckError) { 65 + list.try_fold(stmts, env, fn(env, stmt) { 66 + case stmt { 67 + ast.TypeDef(name, constructors) -> register_type(env, name, constructors) 68 + ast.Let(..) -> Ok(env) 69 + } 70 + }) 71 + } 72 + 73 + fn register_type( 74 + env: Env, 75 + type_name: String, 76 + constructors: List(Constructor), 77 + ) -> Result(Env, CheckError) { 78 + case dict.has_key(env.types, type_name) { 79 + True -> Error(CheckError("type `" <> type_name <> "` is already defined")) 80 + False -> { 81 + use env <- result.try( 82 + list.try_fold(constructors, env, fn(env, ctor) { 83 + register_ctor(env, type_name, ctor) 84 + }), 85 + ) 86 + let ctor_names = list.map(constructors, fn(c) { c.name }) 87 + Ok( 88 + Env( 89 + ..env, 90 + types: dict.insert(env.types, type_name, ctor_names), 91 + ), 92 + ) 93 + } 94 + } 95 + } 96 + 97 + fn register_ctor( 98 + env: Env, 99 + type_name: String, 100 + ctor: Constructor, 101 + ) -> Result(Env, CheckError) { 102 + case dict.has_key(env.ctors, ctor.name) { 103 + True -> 104 + Error(CheckError( 105 + "constructor `" <> ctor.name <> "` is already defined", 106 + )) 107 + False -> { 108 + use fields <- result.try( 109 + list.try_map(ctor.fields, fn(field) { 110 + use ty <- result.try(resolve_type_expr(env, field.type_)) 111 + Ok(#(field.name, ty)) 112 + }), 113 + ) 114 + let info = CtorInfo(type_name:, fields:) 115 + Ok(Env(..env, ctors: dict.insert(env.ctors, ctor.name, info))) 116 + } 117 + } 118 + } 119 + 120 + fn resolve_type_expr(env: Env, te: TypeExpr) -> Result(Type, CheckError) { 121 + case te { 122 + ast.NamedType("String") -> Ok(TString) 123 + ast.NamedType("Int") -> Ok(TInt) 124 + ast.NamedType("Float") -> Ok(TFloat) 125 + ast.NamedType("Bool") -> Ok(TBool) 126 + ast.NamedType(name) -> 127 + case dict.has_key(env.types, name) { 128 + True -> Ok(TNamed(name)) 129 + False -> Error(CheckError("unknown type `" <> name <> "`")) 130 + } 131 + ast.ListType(inner) -> { 132 + use t <- result.try(resolve_type_expr(env, inner)) 133 + Ok(TList(t)) 134 + } 135 + ast.OptionType(inner) -> { 136 + use t <- result.try(resolve_type_expr(env, inner)) 137 + Ok(TOption(t)) 138 + } 139 + } 140 + } 141 + 142 + fn eval_lets(stmts: List(Stmt), env: Env) -> Result(Env, CheckError) { 143 + list.try_fold(stmts, env, fn(env, stmt) { 144 + case stmt { 145 + ast.TypeDef(..) -> Ok(env) 146 + ast.Let(_public, name, value) -> { 147 + case dict.has_key(env.vars, name) { 148 + True -> 149 + Error(CheckError("`" <> name <> "` is already bound")) 150 + False -> { 151 + use #(ty, val) <- result.try(infer(env, value)) 152 + Ok( 153 + Env( 154 + ..env, 155 + vars: dict.insert(env.vars, name, #(ty, val)), 156 + ), 157 + ) 158 + } 159 + } 160 + } 161 + } 162 + }) 163 + } 164 + 165 + fn infer(env: Env, expr: Expr) -> Result(#(Type, Value), CheckError) { 166 + case expr { 167 + ast.StringLit(s) -> Ok(#(TString, value.VString(s))) 168 + ast.IntLit(n) -> Ok(#(TInt, value.VInt(n))) 169 + ast.FloatLit(f) -> Ok(#(TFloat, value.VFloat(f))) 170 + ast.BoolLit(b) -> Ok(#(TBool, value.VBool(b))) 171 + ast.NoneLit -> 172 + Error(CheckError( 173 + "`None` needs a type context (use it in an Option field, or write Some(...))", 174 + )) 175 + ast.SomeExpr(inner) -> { 176 + use #(ty, val) <- result.try(infer(env, inner)) 177 + Ok(#(TOption(ty), value.VSome(val))) 178 + } 179 + ast.Name(name) -> resolve_name(env, name) 180 + ast.Construct(name, fields) -> infer_construct(env, name, fields) 181 + ast.ListLit(items) -> infer_list(env, items) 182 + } 183 + } 184 + 185 + fn resolve_name(env: Env, name: String) -> Result(#(Type, Value), CheckError) { 186 + case dict.get(env.vars, name) { 187 + Ok(pair) -> Ok(pair) 188 + Error(_) -> 189 + case dict.get(env.ctors, name) { 190 + Ok(CtorInfo(type_name, [])) -> 191 + Ok(#(TNamed(type_name), value.VVariant(name, []))) 192 + Ok(CtorInfo(_, fields)) -> 193 + Error(CheckError( 194 + "constructor `" 195 + <> name 196 + <> "` requires fields: " 197 + <> fields 198 + |> list.map(fn(f) { f.0 }) 199 + |> string.join(", "), 200 + )) 201 + Error(_) -> Error(CheckError("unknown name `" <> name <> "`")) 202 + } 203 + } 204 + } 205 + 206 + fn infer_construct( 207 + env: Env, 208 + name: String, 209 + fields: List(#(String, Expr)), 210 + ) -> Result(#(Type, Value), CheckError) { 211 + case dict.get(env.ctors, name) { 212 + Error(_) -> Error(CheckError("unknown constructor `" <> name <> "`")) 213 + Ok(CtorInfo(type_name, expected_fields)) -> { 214 + use field_vals <- result.try( 215 + list.try_map(expected_fields, fn(expected) { 216 + let #(label, expected_ty) = expected 217 + case list.find(fields, fn(f) { f.0 == label }) { 218 + Error(_) -> 219 + Error(CheckError( 220 + "constructor `" 221 + <> name 222 + <> "` missing field `" 223 + <> label 224 + <> "`", 225 + )) 226 + Ok(#(_, expr)) -> { 227 + use #(ty, val) <- result.try( 228 + check_expr(env, expr, expected_ty), 229 + ) 230 + // ty already matches 231 + let _ = ty 232 + Ok(#(label, val)) 233 + } 234 + } 235 + }), 236 + ) 237 + // reject unknown fields 238 + use _ <- result.try( 239 + list.try_map(fields, fn(f) { 240 + let #(label, _) = f 241 + case list.find(expected_fields, fn(e) { e.0 == label }) { 242 + Ok(_) -> Ok(Nil) 243 + Error(_) -> 244 + Error(CheckError( 245 + "constructor `" 246 + <> name 247 + <> "` has unknown field `" 248 + <> label 249 + <> "`", 250 + )) 251 + } 252 + }), 253 + ) 254 + Ok(#( 255 + TNamed(type_name), 256 + value.VVariant(name, field_vals), 257 + )) 258 + } 259 + } 260 + } 261 + 262 + fn infer_list( 263 + env: Env, 264 + items: List(Expr), 265 + ) -> Result(#(Type, Value), CheckError) { 266 + case items { 267 + [] -> 268 + Error(CheckError( 269 + "empty list needs a type context (not supported in POC; add an element)", 270 + )) 271 + [first, ..rest] -> { 272 + use #(ty, first_val) <- result.try(infer(env, first)) 273 + use rest_vals <- result.try( 274 + list.try_map(rest, fn(item) { 275 + use #(_ty, val) <- result.try(check_expr(env, item, ty)) 276 + Ok(val) 277 + }), 278 + ) 279 + Ok(#(TList(ty), value.VList([first_val, ..rest_vals]))) 280 + } 281 + } 282 + } 283 + 284 + fn check_expr( 285 + env: Env, 286 + expr: Expr, 287 + expected: Type, 288 + ) -> Result(#(Type, Value), CheckError) { 289 + case expr, expected { 290 + ast.NoneLit, TOption(inner) -> Ok(#(TOption(inner), value.VNone)) 291 + ast.SomeExpr(inner), TOption(want_inner) -> { 292 + use #(_ty, val) <- result.try(check_expr(env, inner, want_inner)) 293 + Ok(#(TOption(want_inner), value.VSome(val))) 294 + } 295 + ast.ListLit([]), TList(inner) -> Ok(#(TList(inner), value.VList([]))) 296 + _, _ -> { 297 + use #(got, val) <- result.try(infer(env, expr)) 298 + case types_equal(got, expected) { 299 + True -> Ok(#(got, val)) 300 + False -> 301 + Error(CheckError( 302 + "type mismatch: expected " 303 + <> type_to_string(expected) 304 + <> ", got " 305 + <> type_to_string(got), 306 + )) 307 + } 308 + } 309 + } 310 + } 311 + 312 + fn types_equal(a: Type, b: Type) -> Bool { 313 + case a, b { 314 + TString, TString -> True 315 + TInt, TInt -> True 316 + TFloat, TFloat -> True 317 + TBool, TBool -> True 318 + TOption(x), TOption(y) -> types_equal(x, y) 319 + TList(x), TList(y) -> types_equal(x, y) 320 + TNamed(x), TNamed(y) -> x == y 321 + _, _ -> False 322 + } 323 + } 324 + 325 + pub fn type_to_string(ty: Type) -> String { 326 + case ty { 327 + TString -> "String" 328 + TInt -> "Int" 329 + TFloat -> "Float" 330 + TBool -> "Bool" 331 + TOption(inner) -> "Option(" <> type_to_string(inner) <> ")" 332 + TList(inner) -> "List(" <> type_to_string(inner) <> ")" 333 + TNamed(name) -> name 334 + } 335 + }
+208
src/glint/lexer.gleam
··· 1 + //// Lexer: source text → tokens. 2 + 3 + import gleam/int 4 + import gleam/list 5 + import gleam/result 6 + import gleam/string 7 + import glint/token.{type Token} 8 + 9 + pub type LexError { 10 + LexError(message: String, position: Int) 11 + } 12 + 13 + pub fn lex(source: String) -> Result(List(Token), LexError) { 14 + do_lex(string.to_graphemes(source), 0, []) 15 + } 16 + 17 + fn do_lex( 18 + chars: List(String), 19 + pos: Int, 20 + acc: List(Token), 21 + ) -> Result(List(Token), LexError) { 22 + case chars { 23 + [] -> Ok(list.reverse([token.Eof, ..acc])) 24 + 25 + ["/", "/", ..rest] -> { 26 + let #(rest2, skipped) = skip_line_comment(rest) 27 + do_lex(rest2, pos + 2 + skipped, acc) 28 + } 29 + 30 + [" ", ..rest] 31 + | ["\t", ..rest] 32 + | ["\n", ..rest] 33 + | ["\r", ..rest] -> do_lex(rest, pos + 1, acc) 34 + 35 + ["{", ..rest] -> do_lex(rest, pos + 1, [token.LBrace, ..acc]) 36 + ["}", ..rest] -> do_lex(rest, pos + 1, [token.RBrace, ..acc]) 37 + ["(", ..rest] -> do_lex(rest, pos + 1, [token.LParen, ..acc]) 38 + [")", ..rest] -> do_lex(rest, pos + 1, [token.RParen, ..acc]) 39 + ["[", ..rest] -> do_lex(rest, pos + 1, [token.LBracket, ..acc]) 40 + ["]", ..rest] -> do_lex(rest, pos + 1, [token.RBracket, ..acc]) 41 + [":", ..rest] -> do_lex(rest, pos + 1, [token.Colon, ..acc]) 42 + ["=", ..rest] -> do_lex(rest, pos + 1, [token.Equal, ..acc]) 43 + [",", ..rest] -> do_lex(rest, pos + 1, [token.Comma, ..acc]) 44 + 45 + ["\"", ..rest] -> { 46 + case take_string(rest, pos + 1, "") { 47 + Ok(#(s, rest2, end_pos)) -> 48 + do_lex(rest2, end_pos, [token.String(s), ..acc]) 49 + Error(e) -> Error(e) 50 + } 51 + } 52 + 53 + [c, ..] -> { 54 + case is_digit(c) { 55 + True -> { 56 + let #(num_chars, rest2) = take_while(chars, is_number_char) 57 + let raw = string.concat(num_chars) 58 + case parse_number(raw) { 59 + Ok(tok) -> 60 + do_lex(rest2, pos + list.length(num_chars), [tok, ..acc]) 61 + Error(msg) -> Error(LexError(msg, pos)) 62 + } 63 + } 64 + False -> 65 + case is_ident_start(c) { 66 + True -> { 67 + let #(id_chars, rest2) = take_while(chars, is_ident_char) 68 + let name = string.concat(id_chars) 69 + let tok = keyword_or_ident(name) 70 + do_lex(rest2, pos + list.length(id_chars), [tok, ..acc]) 71 + } 72 + False -> 73 + Error(LexError("unexpected character `" <> c <> "`", pos)) 74 + } 75 + } 76 + } 77 + } 78 + } 79 + 80 + fn skip_line_comment(chars: List(String)) -> #(List(String), Int) { 81 + case chars { 82 + [] -> #([], 0) 83 + ["\n", ..rest] -> #(rest, 1) 84 + [_, ..rest] -> { 85 + let #(rest2, n) = skip_line_comment(rest) 86 + #(rest2, n + 1) 87 + } 88 + } 89 + } 90 + 91 + fn take_string( 92 + chars: List(String), 93 + pos: Int, 94 + acc: String, 95 + ) -> Result(#(String, List(String), Int), LexError) { 96 + case chars { 97 + [] -> Error(LexError("unterminated string", pos)) 98 + ["\"", ..rest] -> Ok(#(acc, rest, pos + 1)) 99 + ["\\", "n", ..rest] -> take_string(rest, pos + 2, acc <> "\n") 100 + ["\\", "t", ..rest] -> take_string(rest, pos + 2, acc <> "\t") 101 + ["\\", "\"", ..rest] -> take_string(rest, pos + 2, acc <> "\"") 102 + ["\\", "\\", ..rest] -> take_string(rest, pos + 2, acc <> "\\") 103 + ["\\", c, ..] -> 104 + Error(LexError("unknown escape sequence `\\" <> c <> "`", pos)) 105 + [c, ..rest] -> take_string(rest, pos + 1, acc <> c) 106 + } 107 + } 108 + 109 + fn take_while( 110 + chars: List(String), 111 + pred: fn(String) -> Bool, 112 + ) -> #(List(String), List(String)) { 113 + case chars { 114 + [c, ..rest] -> 115 + case pred(c) { 116 + True -> { 117 + let #(taken, rest2) = take_while(rest, pred) 118 + #([c, ..taken], rest2) 119 + } 120 + False -> #([], chars) 121 + } 122 + [] -> #([], []) 123 + } 124 + } 125 + 126 + fn is_digit(c: String) -> Bool { 127 + case c { 128 + "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 129 + _ -> False 130 + } 131 + } 132 + 133 + fn is_number_char(c: String) -> Bool { 134 + is_digit(c) || c == "_" || c == "." 135 + } 136 + 137 + fn is_ident_start(c: String) -> Bool { 138 + case c { 139 + "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" 140 + | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" 141 + | "y" | "z" | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" 142 + | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" 143 + | "W" | "X" | "Y" | "Z" | "_" -> True 144 + _ -> False 145 + } 146 + } 147 + 148 + fn is_ident_char(c: String) -> Bool { 149 + is_ident_start(c) || is_digit(c) 150 + } 151 + 152 + fn parse_number(raw: String) -> Result(Token, String) { 153 + let cleaned = 154 + string.replace(raw, each: "_", with: "") 155 + case string.contains(cleaned, ".") { 156 + True -> 157 + case float_parse(cleaned) { 158 + Ok(f) -> Ok(token.Float(f)) 159 + Error(_) -> Error("invalid float `" <> raw <> "`") 160 + } 161 + False -> 162 + case int.parse(cleaned) { 163 + Ok(n) -> Ok(token.Int(n)) 164 + Error(_) -> Error("invalid integer `" <> raw <> "`") 165 + } 166 + } 167 + } 168 + 169 + fn float_parse(s: String) -> Result(Float, Nil) { 170 + // gleam/float has parse in recent stdlib 171 + case string.split(s, on: ".") { 172 + [whole, frac] -> { 173 + use w <- result.try(int.parse(whole) |> result.replace_error(Nil)) 174 + use f <- result.try(int.parse(frac) |> result.replace_error(Nil)) 175 + let denom = pow10(string.length(frac)) 176 + let sign = case w < 0 { 177 + True -> -1.0 178 + False -> 1.0 179 + } 180 + let w_abs = int.absolute_value(w) 181 + Ok( 182 + sign 183 + *. { int.to_float(w_abs) +. int.to_float(f) /. int.to_float(denom) }, 184 + ) 185 + } 186 + _ -> Error(Nil) 187 + } 188 + } 189 + 190 + fn pow10(n: Int) -> Int { 191 + case n { 192 + 0 -> 1 193 + _ -> 10 * pow10(n - 1) 194 + } 195 + } 196 + 197 + fn keyword_or_ident(name: String) -> Token { 198 + case name { 199 + "type" -> token.Type 200 + "let" -> token.Let 201 + "pub" -> token.Pub 202 + "True" -> token.True 203 + "False" -> token.False 204 + "None" -> token.None 205 + "Some" -> token.Some 206 + _ -> token.Ident(name) 207 + } 208 + }
+321
src/glint/parser.gleam
··· 1 + //// Recursive-descent parser for the clean Glint subset. 2 + 3 + import gleam/list 4 + import gleam/result 5 + import glint/ast 6 + import glint/token.{type Token} 7 + 8 + pub type ParseError { 9 + ParseError(message: String) 10 + } 11 + 12 + type Parser { 13 + Parser(tokens: List(Token)) 14 + } 15 + 16 + pub fn parse(tokens: List(Token)) -> Result(ast.Program, ParseError) { 17 + let p = Parser(tokens) 18 + case parse_program(p) { 19 + Ok(#(program, Parser([token.Eof]))) -> Ok(program) 20 + Ok(#(_, Parser([tok, ..]))) -> 21 + Error(ParseError( 22 + "unexpected token after program: " <> token.to_string(tok), 23 + )) 24 + Ok(#(program, Parser([]))) -> Ok(program) 25 + Error(e) -> Error(e) 26 + } 27 + } 28 + 29 + fn parse_program(p: Parser) -> Result(#(ast.Program, Parser), ParseError) { 30 + parse_stmts(p, []) 31 + } 32 + 33 + fn parse_stmts( 34 + p: Parser, 35 + acc: List(ast.Stmt), 36 + ) -> Result(#(ast.Program, Parser), ParseError) { 37 + case peek(p) { 38 + token.Eof -> Ok(#(ast.Program(list.reverse(acc)), p)) 39 + token.Type -> { 40 + use #(stmt, p2) <- result.try(parse_type_def(p)) 41 + parse_stmts(p2, [stmt, ..acc]) 42 + } 43 + token.Pub | token.Let -> { 44 + use #(stmt, p2) <- result.try(parse_let(p)) 45 + parse_stmts(p2, [stmt, ..acc]) 46 + } 47 + tok -> 48 + Error(ParseError( 49 + "expected `type` or `let`, got " <> token.to_string(tok), 50 + )) 51 + } 52 + } 53 + 54 + fn parse_type_def(p: Parser) -> Result(#(ast.Stmt, Parser), ParseError) { 55 + use p <- result.try(expect(p, token.Type)) 56 + use #(name, p) <- result.try(expect_ident(p)) 57 + use p <- result.try(expect(p, token.LBrace)) 58 + use #(ctors, p) <- result.try(parse_constructors(p, [])) 59 + use p <- result.try(expect(p, token.RBrace)) 60 + Ok(#(ast.TypeDef(name:, constructors: ctors), p)) 61 + } 62 + 63 + fn parse_constructors( 64 + p: Parser, 65 + acc: List(ast.Constructor), 66 + ) -> Result(#(List(ast.Constructor), Parser), ParseError) { 67 + case peek(p) { 68 + token.RBrace -> Ok(#(list.reverse(acc), p)) 69 + token.Ident(_) | token.Some | token.None | token.True | token.False -> { 70 + use #(ctor, p2) <- result.try(parse_constructor(p)) 71 + parse_constructors(p2, [ctor, ..acc]) 72 + } 73 + tok -> 74 + Error(ParseError( 75 + "expected constructor name, got " <> token.to_string(tok), 76 + )) 77 + } 78 + } 79 + 80 + fn parse_constructor( 81 + p: Parser, 82 + ) -> Result(#(ast.Constructor, Parser), ParseError) { 83 + use #(name, p) <- result.try(expect_name_like(p)) 84 + case peek(p) { 85 + token.LParen -> { 86 + use p <- result.try(expect(p, token.LParen)) 87 + use #(fields, p) <- result.try(parse_fields(p, [])) 88 + use p <- result.try(expect(p, token.RParen)) 89 + Ok(#(ast.Constructor(name:, fields:), p)) 90 + } 91 + _ -> Ok(#(ast.Constructor(name:, fields: []), p)) 92 + } 93 + } 94 + 95 + fn parse_fields( 96 + p: Parser, 97 + acc: List(ast.Field), 98 + ) -> Result(#(List(ast.Field), Parser), ParseError) { 99 + case peek(p) { 100 + token.RParen -> Ok(#(list.reverse(acc), p)) 101 + _ -> { 102 + use #(field, p) <- result.try(parse_field(p)) 103 + case peek(p) { 104 + token.Comma -> { 105 + use p <- result.try(expect(p, token.Comma)) 106 + case peek(p) { 107 + token.RParen -> Ok(#(list.reverse([field, ..acc]), p)) 108 + _ -> parse_fields(p, [field, ..acc]) 109 + } 110 + } 111 + _ -> Ok(#(list.reverse([field, ..acc]), p)) 112 + } 113 + } 114 + } 115 + } 116 + 117 + fn parse_field(p: Parser) -> Result(#(ast.Field, Parser), ParseError) { 118 + use #(name, p) <- result.try(expect_ident(p)) 119 + use p <- result.try(expect(p, token.Colon)) 120 + use #(ty, p) <- result.try(parse_type_expr(p)) 121 + Ok(#(ast.Field(name:, type_: ty), p)) 122 + } 123 + 124 + fn parse_type_expr(p: Parser) -> Result(#(ast.TypeExpr, Parser), ParseError) { 125 + use #(name, p) <- result.try(expect_name_like(p)) 126 + case name { 127 + "List" -> { 128 + use p <- result.try(expect(p, token.LParen)) 129 + use #(inner, p) <- result.try(parse_type_expr(p)) 130 + use p <- result.try(expect(p, token.RParen)) 131 + Ok(#(ast.ListType(inner), p)) 132 + } 133 + "Option" -> { 134 + use p <- result.try(expect(p, token.LParen)) 135 + use #(inner, p) <- result.try(parse_type_expr(p)) 136 + use p <- result.try(expect(p, token.RParen)) 137 + Ok(#(ast.OptionType(inner), p)) 138 + } 139 + _ -> Ok(#(ast.NamedType(name), p)) 140 + } 141 + } 142 + 143 + fn parse_let(p: Parser) -> Result(#(ast.Stmt, Parser), ParseError) { 144 + use #(public, p) <- result.try(case peek(p) { 145 + token.Pub -> { 146 + use p <- result.try(advance(p)) 147 + Ok(#(True, p)) 148 + } 149 + _ -> Ok(#(False, p)) 150 + }) 151 + use p <- result.try(expect(p, token.Let)) 152 + use #(name, p) <- result.try(expect_ident(p)) 153 + use p <- result.try(expect(p, token.Equal)) 154 + use #(value, p) <- result.try(parse_expr(p)) 155 + Ok(#(ast.Let(public:, name:, value:), p)) 156 + } 157 + 158 + fn parse_expr(p: Parser) -> Result(#(ast.Expr, Parser), ParseError) { 159 + case peek(p) { 160 + token.String(s) -> { 161 + use p <- result.try(advance(p)) 162 + Ok(#(ast.StringLit(s), p)) 163 + } 164 + token.Int(n) -> { 165 + use p <- result.try(advance(p)) 166 + Ok(#(ast.IntLit(n), p)) 167 + } 168 + token.Float(f) -> { 169 + use p <- result.try(advance(p)) 170 + Ok(#(ast.FloatLit(f), p)) 171 + } 172 + token.True -> { 173 + use p <- result.try(advance(p)) 174 + Ok(#(ast.BoolLit(True), p)) 175 + } 176 + token.False -> { 177 + use p <- result.try(advance(p)) 178 + Ok(#(ast.BoolLit(False), p)) 179 + } 180 + token.None -> { 181 + use p <- result.try(advance(p)) 182 + Ok(#(ast.NoneLit, p)) 183 + } 184 + token.Some -> { 185 + use p <- result.try(advance(p)) 186 + use p <- result.try(expect(p, token.LParen)) 187 + use #(inner, p) <- result.try(parse_expr(p)) 188 + use p <- result.try(expect(p, token.RParen)) 189 + Ok(#(ast.SomeExpr(inner), p)) 190 + } 191 + token.LBracket -> parse_list(p) 192 + token.Ident(name) -> parse_name_or_construct(p, name) 193 + tok -> 194 + Error(ParseError("expected expression, got " <> token.to_string(tok))) 195 + } 196 + } 197 + 198 + fn parse_name_or_construct( 199 + p: Parser, 200 + name: String, 201 + ) -> Result(#(ast.Expr, Parser), ParseError) { 202 + use p <- result.try(advance(p)) 203 + case peek(p) { 204 + token.LParen -> { 205 + use p <- result.try(expect(p, token.LParen)) 206 + use #(fields, p) <- result.try(parse_labeled_args(p, [])) 207 + use p <- result.try(expect(p, token.RParen)) 208 + Ok(#(ast.Construct(name:, fields:), p)) 209 + } 210 + _ -> Ok(#(ast.Name(name), p)) 211 + } 212 + } 213 + 214 + fn parse_labeled_args( 215 + p: Parser, 216 + acc: List(#(String, ast.Expr)), 217 + ) -> Result(#(List(#(String, ast.Expr)), Parser), ParseError) { 218 + case peek(p) { 219 + token.RParen -> Ok(#(list.reverse(acc), p)) 220 + _ -> { 221 + use #(label, p) <- result.try(expect_ident(p)) 222 + use p <- result.try(expect(p, token.Colon)) 223 + use #(value, p) <- result.try(parse_expr(p)) 224 + case peek(p) { 225 + token.Comma -> { 226 + use p <- result.try(expect(p, token.Comma)) 227 + case peek(p) { 228 + token.RParen -> Ok(#(list.reverse([#(label, value), ..acc]), p)) 229 + _ -> parse_labeled_args(p, [#(label, value), ..acc]) 230 + } 231 + } 232 + _ -> Ok(#(list.reverse([#(label, value), ..acc]), p)) 233 + } 234 + } 235 + } 236 + } 237 + 238 + fn parse_list(p: Parser) -> Result(#(ast.Expr, Parser), ParseError) { 239 + use p <- result.try(expect(p, token.LBracket)) 240 + use #(items, p) <- result.try(parse_list_items(p, [])) 241 + use p <- result.try(expect(p, token.RBracket)) 242 + Ok(#(ast.ListLit(items), p)) 243 + } 244 + 245 + fn parse_list_items( 246 + p: Parser, 247 + acc: List(ast.Expr), 248 + ) -> Result(#(List(ast.Expr), Parser), ParseError) { 249 + case peek(p) { 250 + token.RBracket -> Ok(#(list.reverse(acc), p)) 251 + _ -> { 252 + use #(item, p) <- result.try(parse_expr(p)) 253 + case peek(p) { 254 + token.Comma -> { 255 + use p <- result.try(expect(p, token.Comma)) 256 + case peek(p) { 257 + token.RBracket -> Ok(#(list.reverse([item, ..acc]), p)) 258 + _ -> parse_list_items(p, [item, ..acc]) 259 + } 260 + } 261 + _ -> Ok(#(list.reverse([item, ..acc]), p)) 262 + } 263 + } 264 + } 265 + } 266 + 267 + // --- parser helpers --- 268 + 269 + fn peek(p: Parser) -> Token { 270 + case p.tokens { 271 + [t, ..] -> t 272 + [] -> token.Eof 273 + } 274 + } 275 + 276 + fn advance(p: Parser) -> Result(Parser, ParseError) { 277 + case p.tokens { 278 + [_, ..rest] -> Ok(Parser(rest)) 279 + [] -> Error(ParseError("unexpected end of input")) 280 + } 281 + } 282 + 283 + fn expect(p: Parser, expected: Token) -> Result(Parser, ParseError) { 284 + case p.tokens { 285 + [tok, ..rest] if tok == expected -> Ok(Parser(rest)) 286 + [tok, ..] -> 287 + Error(ParseError( 288 + "expected " 289 + <> token.to_string(expected) 290 + <> ", got " 291 + <> token.to_string(tok), 292 + )) 293 + [] -> 294 + Error(ParseError( 295 + "expected " <> token.to_string(expected) <> ", got end of file", 296 + )) 297 + } 298 + } 299 + 300 + fn expect_ident(p: Parser) -> Result(#(String, Parser), ParseError) { 301 + case p.tokens { 302 + [token.Ident(name), ..rest] -> Ok(#(name, Parser(rest))) 303 + [tok, ..] -> 304 + Error(ParseError("expected identifier, got " <> token.to_string(tok))) 305 + [] -> Error(ParseError("expected identifier, got end of file")) 306 + } 307 + } 308 + 309 + /// Accept Ident or keyword tokens that can appear as type/ctor names. 310 + fn expect_name_like(p: Parser) -> Result(#(String, Parser), ParseError) { 311 + case p.tokens { 312 + [token.Ident(name), ..rest] -> Ok(#(name, Parser(rest))) 313 + [token.Some, ..rest] -> Ok(#("Some", Parser(rest))) 314 + [token.None, ..rest] -> Ok(#("None", Parser(rest))) 315 + [token.True, ..rest] -> Ok(#("True", Parser(rest))) 316 + [token.False, ..rest] -> Ok(#("False", Parser(rest))) 317 + [tok, ..] -> 318 + Error(ParseError("expected name, got " <> token.to_string(tok))) 319 + [] -> Error(ParseError("expected name, got end of file")) 320 + } 321 + }
+40
src/glint/pipeline.gleam
··· 1 + //// Source → checked config pipeline. 2 + 3 + import gleam/int as gleam_int 4 + import glint/check.{type CheckError, type Checked} 5 + import glint/lexer.{type LexError} 6 + import glint/parser.{type ParseError} 7 + 8 + pub type Error { 9 + Lex(LexError) 10 + Parse(ParseError) 11 + Check(CheckError) 12 + } 13 + 14 + pub fn load(source: String) -> Result(Checked, Error) { 15 + case lexer.lex(source) { 16 + Error(e) -> Error(Lex(e)) 17 + Ok(tokens) -> 18 + case parser.parse(tokens) { 19 + Error(e) -> Error(Parse(e)) 20 + Ok(program) -> 21 + case check.check(program) { 22 + Error(e) -> Error(Check(e)) 23 + Ok(checked) -> Ok(checked) 24 + } 25 + } 26 + } 27 + } 28 + 29 + pub fn error_to_string(err: Error) -> String { 30 + case err { 31 + Lex(lexer.LexError(message, position)) -> 32 + "lex error at " <> int_to_string(position) <> ": " <> message 33 + Parse(parser.ParseError(message)) -> "parse error: " <> message 34 + Check(check.CheckError(message)) -> "type error: " <> message 35 + } 36 + } 37 + 38 + fn int_to_string(n: Int) -> String { 39 + gleam_int.to_string(n) 40 + }
+68
src/glint/token.gleam
··· 1 + /// Lexical tokens for Glint. 2 + 3 + import gleam/float as gleam_float 4 + import gleam/int as gleam_int 5 + 6 + pub type Token { 7 + // Keywords 8 + Type 9 + Let 10 + Pub 11 + True 12 + False 13 + None 14 + Some 15 + 16 + // Literals & names 17 + Ident(String) 18 + String(String) 19 + Int(Int) 20 + Float(Float) 21 + 22 + // Symbols 23 + LBrace 24 + RBrace 25 + LParen 26 + RParen 27 + LBracket 28 + RBracket 29 + Colon 30 + Equal 31 + Comma 32 + 33 + Eof 34 + } 35 + 36 + pub fn to_string(token: Token) -> String { 37 + case token { 38 + Type -> "type" 39 + Let -> "let" 40 + Pub -> "pub" 41 + True -> "True" 42 + False -> "False" 43 + None -> "None" 44 + Some -> "Some" 45 + Ident(name) -> name 46 + String(s) -> "\"" <> s <> "\"" 47 + Int(n) -> int_to_string(n) 48 + Float(f) -> float_to_string(f) 49 + LBrace -> "{" 50 + RBrace -> "}" 51 + LParen -> "(" 52 + RParen -> ")" 53 + LBracket -> "[" 54 + RBracket -> "]" 55 + Colon -> ":" 56 + Equal -> "=" 57 + Comma -> "," 58 + Eof -> "end of file" 59 + } 60 + } 61 + 62 + fn int_to_string(n: Int) -> String { 63 + gleam_int.to_string(n) 64 + } 65 + 66 + fn float_to_string(f: Float) -> String { 67 + gleam_float.to_string(f) 68 + }
+95
src/glint/value.gleam
··· 1 + /// Runtime values produced by evaluating a Glint config. 2 + 3 + import gleam/float 4 + import gleam/int 5 + import gleam/list 6 + import gleam/string 7 + 8 + pub type Value { 9 + VString(String) 10 + VInt(Int) 11 + VFloat(Float) 12 + VBool(Bool) 13 + VNone 14 + VSome(Value) 15 + /// Unit variant or record constructor. 16 + VVariant(name: String, fields: List(#(String, Value))) 17 + VList(List(Value)) 18 + } 19 + 20 + /// Pretty-print a value in Glint-like syntax. 21 + pub fn to_glint(value: Value) -> String { 22 + case value { 23 + VString(s) -> "\"" <> escape(s) <> "\"" 24 + VInt(n) -> int.to_string(n) 25 + VFloat(f) -> float.to_string(f) 26 + VBool(True) -> "True" 27 + VBool(False) -> "False" 28 + VNone -> "None" 29 + VSome(inner) -> "Some(" <> to_glint(inner) <> ")" 30 + VVariant(name, []) -> name 31 + VVariant(name, fields) -> { 32 + let body = 33 + fields 34 + |> list.map(fn(pair) { 35 + let #(label, v) = pair 36 + label <> ": " <> to_glint(v) 37 + }) 38 + |> string.join(", ") 39 + name <> "(" <> body <> ")" 40 + } 41 + VList(items) -> { 42 + let body = 43 + items 44 + |> list.map(to_glint) 45 + |> string.join(", ") 46 + "[" <> body <> "]" 47 + } 48 + } 49 + } 50 + 51 + /// Serialize a value as JSON. 52 + pub fn to_json(value: Value) -> String { 53 + case value { 54 + VString(s) -> "\"" <> escape_json(s) <> "\"" 55 + VInt(n) -> int.to_string(n) 56 + VFloat(f) -> float.to_string(f) 57 + VBool(True) -> "true" 58 + VBool(False) -> "false" 59 + VNone -> "null" 60 + VSome(inner) -> to_json(inner) 61 + VVariant(name, []) -> "\"" <> escape_json(name) <> "\"" 62 + VVariant(name, fields) -> { 63 + let field_json = 64 + fields 65 + |> list.map(fn(pair) { 66 + let #(label, v) = pair 67 + "\"" <> escape_json(label) <> "\": " <> to_json(v) 68 + }) 69 + let with_tag = [ 70 + "\"_tag\": \"" <> escape_json(name) <> "\"", 71 + ..field_json 72 + ] 73 + "{" <> string.join(with_tag, ", ") <> "}" 74 + } 75 + VList(items) -> { 76 + let body = 77 + items 78 + |> list.map(to_json) 79 + |> string.join(", ") 80 + "[" <> body <> "]" 81 + } 82 + } 83 + } 84 + 85 + fn escape(s: String) -> String { 86 + s 87 + |> string.replace(each: "\\", with: "\\\\") 88 + |> string.replace(each: "\"", with: "\\\"") 89 + |> string.replace(each: "\n", with: "\\n") 90 + |> string.replace(each: "\t", with: "\\t") 91 + } 92 + 93 + fn escape_json(s: String) -> String { 94 + escape(s) 95 + }
+156
test/glint_test.gleam
··· 1 + import gleam/string 2 + import gleeunit 3 + import glint 4 + import glint/check 5 + import glint/pipeline 6 + import glint/value 7 + 8 + pub fn main() -> Nil { 9 + gleeunit.main() 10 + } 11 + 12 + pub fn hello_loads_test() { 13 + let source = 14 + " 15 + type Mode { 16 + Dev 17 + Prod 18 + } 19 + 20 + type Config { 21 + Config( 22 + name: String, 23 + mode: Mode, 24 + port: Int, 25 + ) 26 + } 27 + 28 + pub let config = Config( 29 + name: \"hello\", 30 + mode: Dev, 31 + port: 3000, 32 + ) 33 + " 34 + 35 + let assert Ok(checked) = pipeline.load(source) 36 + let assert "Config" = check.type_to_string(checked.config_type) 37 + let assert True = 38 + string.contains(value.to_glint(checked.config), "\"hello\"") 39 + let assert True = string.contains(value.to_json(checked.config), "hello") 40 + } 41 + 42 + pub fn app_example_test() { 43 + let source = 44 + " 45 + type LogLevel { 46 + Debug 47 + Info 48 + } 49 + 50 + type Database { 51 + Database( 52 + host: String, 53 + port: Int, 54 + password: Option(String), 55 + ) 56 + } 57 + 58 + type Config { 59 + Config( 60 + log_level: LogLevel, 61 + database: Database, 62 + origins: List(String), 63 + ) 64 + } 65 + 66 + let database = Database( 67 + host: \"localhost\", 68 + port: 5432, 69 + password: None, 70 + ) 71 + 72 + pub let config = Config( 73 + log_level: Info, 74 + database: database, 75 + origins: [\"https://example.com\"], 76 + ) 77 + " 78 + 79 + let assert Ok(checked) = glint.load(source) 80 + let assert value.VVariant("Config", fields) = checked.config 81 + let assert True = list_has_tag(fields, "log_level") 82 + } 83 + 84 + pub fn type_mismatch_test() { 85 + let source = 86 + " 87 + type Config { 88 + Config(port: Int) 89 + } 90 + 91 + pub let config = Config(port: \"nope\") 92 + " 93 + let assert Error(msg) = glint.load(source) 94 + let assert True = string.contains(msg, "type mismatch") 95 + } 96 + 97 + pub fn missing_config_test() { 98 + let source = 99 + " 100 + type Mode { 101 + Dev 102 + } 103 + 104 + let mode = Dev 105 + " 106 + let assert Error(msg) = glint.load(source) 107 + let assert True = string.contains(msg, "pub let config") 108 + } 109 + 110 + pub fn unknown_constructor_test() { 111 + let source = 112 + " 113 + type Config { 114 + Config(name: String) 115 + } 116 + 117 + pub let config = Config(name: \"x\", extra: 1) 118 + " 119 + let assert Error(msg) = glint.load(source) 120 + let assert True = string.contains(msg, "unknown field") 121 + } 122 + 123 + pub fn comments_and_trailing_commas_test() { 124 + let source = 125 + " 126 + // leading comment 127 + type Mode { 128 + Dev 129 + Prod 130 + } 131 + 132 + type Config { 133 + Config( 134 + mode: Mode, 135 + port: Int, 136 + ) 137 + } 138 + 139 + pub let config = Config( 140 + mode: Dev, 141 + port: 1_000, 142 + ) 143 + " 144 + let assert Ok(_) = glint.load(source) 145 + } 146 + 147 + fn list_has_tag(fields: List(#(String, value.Value)), label: String) -> Bool { 148 + case fields { 149 + [] -> False 150 + [#(l, _), ..rest] -> 151 + case l == label { 152 + True -> True 153 + False -> list_has_tag(rest, label) 154 + } 155 + } 156 + }