A human-friendly DSL for ATProto Lexicons

Change in syntax and semantics

Changed files
+1589 -685
mlf-cli
src
generate
mlf-codegen
mlf-diagnostics
src
mlf-lang
mlf-validation
src
mlf-wasm
src
resources
tree-sitter-mlf
website
content
docs
sass
static
js
syntaxes
templates
+2
.gitignore
··· 16 16 # Claude Code 17 17 CLAUDE.md 18 18 .claude/*.local.json 19 + 20 + output-testing
+23
Cargo.lock
··· 175 175 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 176 176 177 177 [[package]] 178 + name = "equivalent" 179 + version = "1.0.2" 180 + source = "registry+https://github.com/rust-lang/crates.io-index" 181 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 182 + 183 + [[package]] 178 184 name = "errno" 179 185 version = "0.3.14" 180 186 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 203 209 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 204 210 205 211 [[package]] 212 + name = "hashbrown" 213 + version = "0.16.0" 214 + source = "registry+https://github.com/rust-lang/crates.io-index" 215 + checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 216 + 217 + [[package]] 206 218 name = "heck" 207 219 version = "0.5.0" 208 220 source = "registry+https://github.com/rust-lang/crates.io-index" 209 221 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 222 + 223 + [[package]] 224 + name = "indexmap" 225 + version = "2.11.4" 226 + source = "registry+https://github.com/rust-lang/crates.io-index" 227 + checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 228 + dependencies = [ 229 + "equivalent", 230 + "hashbrown", 231 + ] 210 232 211 233 [[package]] 212 234 name = "is_ci" ··· 540 562 source = "registry+https://github.com/rust-lang/crates.io-index" 541 563 checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 542 564 dependencies = [ 565 + "indexmap", 543 566 "itoa", 544 567 "memchr", 545 568 "ryu",
+219 -170
SPEC.md
··· 24 24 The `#` character is reserved for shebangs only and is not used elsewhere in the syntax. 25 25 26 26 ### File Naming Convention 27 - Files should follow the lexicon NSID: 27 + The file path determines the lexicon NSID. Files should follow the lexicon NSID structure: 28 28 - `app.bsky.feed.post.mlf` → Lexicon NSID: `app.bsky.feed.post` 29 29 - `sh.tangled.repo.issue.mlf` → Lexicon NSID: `sh.tangled.repo.issue` 30 + 31 + The lexicon NSID is derived solely from the filename, not from any internal namespace declarations. 30 32 31 33 ## Core Concepts 32 34 ··· 50 52 1. **Local (same file)**: Just use the name 51 53 ```mlf 52 54 record myRecord { 53 - field: myAlias, // References alias in same file 55 + field: myType // References type in same file 54 56 } 55 57 56 - alias myAlias = { /* ... */ }; 58 + def type myType = { /* ... */ } 57 59 ``` 58 60 59 61 2. **Cross-file (different lexicon)**: Use full dotted path 60 62 ```mlf 61 63 record myRecord { 62 - profile: app.bsky.actor.profile, // References app/bsky/actor/profile.mlf 63 - author: com.example.user.author, // References com/example/user/author.mlf 64 + profile: app.bsky.actor.profile // References app/bsky/actor/profile.mlf 65 + author: com.example.user.author // References com/example/user/author.mlf 64 66 } 65 67 ``` 66 68 67 69 **Note**: The `#` character is NOT used for references. All references use dotted notation. 68 70 71 + ### Syntax Rules 72 + 73 + #### Semicolons 74 + 75 + - **Records** do NOT have semicolons after the closing brace `}` 76 + - All other definitions require semicolons: 77 + - `use` statements end with `;` 78 + - `token` definitions end with `;` 79 + - `inline type` definitions end with `;` 80 + - `def type` definitions end with `;` 81 + - `query` definitions end with `;` 82 + - `procedure` definitions end with `;` 83 + - `subscription` definitions end with `;` 84 + 85 + #### Commas 86 + 87 + Commas are **required** between items, with **trailing commas allowed**: 88 + 89 + - **Record fields**: Commas required between fields, trailing comma allowed 90 + ```mlf 91 + record example { 92 + field1: string, 93 + field2: integer, // trailing comma allowed 94 + } 95 + ``` 96 + 97 + - **Constraints**: Commas required between constraint properties, trailing comma allowed 98 + ```mlf 99 + title: string constrained { 100 + maxLength: 200, 101 + minLength: 1, // trailing comma allowed 102 + } 103 + ``` 104 + 105 + - **Error definitions**: Commas required between errors, trailing comma allowed 106 + ```mlf 107 + query getThread(): thread | error { 108 + NotFound, 109 + BadRequest, // trailing comma allowed 110 + } 111 + ``` 112 + 69 113 ## Type System 70 114 71 115 ### Primitive Types ··· 106 150 With constraints: 107 151 ```mlf 108 152 avatar: blob constrained { 109 - accept: ["image/png", "image/jpeg"], 110 - maxSize: 1000000, // bytes 153 + accept: ["image/png", "image/jpeg"] 154 + maxSize: 1000000 // bytes 111 155 } 112 156 ``` 113 157 ··· 126 170 ```mlf 127 171 record post { 128 172 text: string constrained { 129 - maxLength: 300, 130 - maxGraphemes: 300, 131 - }, 132 - createdAt: Datetime, 133 - reply?: replyRef, // Optional field 173 + maxLength: 300 174 + maxGraphemes: 300 175 + } 176 + createdAt: Datetime 177 + reply?: replyRef // Optional field 134 178 } 135 179 ``` 136 180 137 - ### Aliases 181 + ### Type Definitions 182 + 183 + MLF supports two kinds of type definitions: 184 + 185 + **Inline Types** - Expanded at the point of use, never appear in generated lexicon defs: 186 + 187 + ```mlf 188 + inline type AtIdentifier = string constrained { 189 + format "at-identifier" 190 + }; 191 + ``` 138 192 139 - Type aliases define reusable object shapes: 193 + **Def Types** - Become named definitions in the lexicon's defs block: 140 194 141 195 ```mlf 142 - alias replyRef = { 143 - root: AtUri, 144 - parent: AtUri, 196 + def type ReplyRef = { 197 + root: AtUri 198 + parent: AtUri 145 199 }; 146 200 ``` 147 201 148 - If used in multiple places, they will be hoisted to a def. If only used in a single place, they will be inlined. 202 + Use `inline type` for type aliases that should be expanded inline (like primitive type wrappers). Use `def type` for types that should be referenced by name in the generated lexicon. 149 203 150 204 ### Tokens 151 205 ··· 161 215 record issue { 162 216 state: string constrained { 163 217 knownValues: [ 164 - open, // References token defined above 165 - closed, 166 - ], 167 - default: "open", 168 - }, 218 + open // References token defined above 219 + closed 220 + ] 221 + default: "open" 222 + } 169 223 } 170 224 ``` 171 225 ··· 179 233 /// Get a user profile 180 234 query getProfile( 181 235 /// The actor's DID or handle 182 - actor: AtIdentifier, 236 + actor: AtIdentifier 183 237 /// Optional viewer context 184 - viewer?: Did, 238 + viewer?: Did 185 239 ): profileView | error { 186 240 /// Profile not found 187 - ProfileNotFound, 241 + ProfileNotFound 188 242 /// Invalid request parameters 189 - BadRequest, 243 + BadRequest 190 244 }; 191 245 ``` 192 246 ··· 197 251 ```mlf 198 252 /// Create a new post 199 253 procedure createPost( 200 - text: string, 201 - createdAt: Datetime, 254 + text: string 255 + createdAt: Datetime 202 256 ): { 203 - uri: AtUri, 204 - cid: Cid, 257 + uri: AtUri 258 + cid: Cid 205 259 } | error { 206 260 /// Text exceeds maximum length 207 - TextTooLong, 261 + TextTooLong 208 262 }; 209 263 ``` 210 264 ··· 216 270 /// Subscribe to repository events 217 271 subscription subscribeRepos( 218 272 /// Optional cursor for resuming from a specific point 219 - cursor?: integer, 273 + cursor?: integer 220 274 ): commit | identity | handle | migrate | tombstone | info; 221 275 ``` 222 276 223 - **Message definitions** for subscriptions are defined as aliases or records: 277 + **Message definitions** for subscriptions are defined as def types or records: 224 278 225 279 ```mlf 226 280 /// Commit message emitted by subscribeRepos 227 - alias commit = { 228 - seq: integer, 229 - rebase: boolean, 230 - tooBig: boolean, 231 - repo: Did, 232 - commit: Cid, 233 - rev: string, 234 - since: string, 235 - blocks: bytes, 236 - ops: repoOp[], 237 - blobs: Cid[], 238 - time: Datetime, 281 + def type commit = { 282 + seq: integer 283 + rebase: boolean 284 + tooBig: boolean 285 + repo: Did 286 + commit: Cid 287 + rev: string 288 + since: string 289 + blocks: bytes 290 + ops: repoOp[] 291 + blobs: Cid[] 292 + time: Datetime 239 293 }; 240 294 241 295 /// Info message 242 - alias info = { 243 - name: string, 244 - message?: string, 296 + def type info = { 297 + name: string 298 + message?: string 245 299 }; 246 300 ``` 247 301 ··· 249 303 250 304 - Parameters: Like queries, subscriptions can have parameters 251 305 - Return type: A union of message types that can be emitted 252 - - Each message type must be defined as an alias or record 306 + - Each message type must be defined as a def type or record 253 307 - Message types can be local or imported from other lexicons 254 308 - Subscriptions are long-lived WebSocket connections 255 309 - No error block (errors are handled at the WebSocket protocol level) ··· 260 314 /// Subscribe to chat messages for a stream 261 315 subscription subscribeChat( 262 316 /// The DID of the streamer 263 - streamer: Did, 317 + streamer: Did 264 318 /// Optional cursor to resume from 265 - cursor?: string, 319 + cursor?: string 266 320 ): message | delete | join | leave; 267 321 268 322 /// Chat message payload 269 - alias message = { 270 - id: string, 271 - text: string, 272 - author: Did, 273 - createdAt: Datetime, 323 + def type message = { 324 + id: string 325 + text: string 326 + author: Did 327 + createdAt: Datetime 274 328 }; 275 329 276 330 /// Delete event payload 277 - alias delete = { 278 - id: string, 331 + def type delete = { 332 + id: string 279 333 }; 280 334 281 335 /// Join event payload 282 - alias join = { 283 - user: Did, 336 + def type join = { 337 + user: Did 284 338 }; 285 339 286 340 /// Leave event payload 287 - alias leave = { 288 - user: Did, 341 + def type leave = { 342 + user: Did 289 343 }; 290 344 ``` 291 345 ··· 304 358 305 359 ```mlf 306 360 record example { 307 - required: string, 308 - optional?: string, 361 + required: string 362 + optional?: string 309 363 } 310 364 ``` 311 365 ··· 313 367 314 368 ```mlf 315 369 record example { 316 - tags: string[], 370 + tags: string[] 317 371 items: string[] constrained { 318 - minLength: 1, 319 - maxLength: 10, 320 - }, 372 + minLength: 1 373 + maxLength: 10 374 + } 321 375 } 322 376 ``` 323 377 ··· 328 382 ```mlf 329 383 record example { 330 384 // Closed union (only these types) 331 - content: text | image | video, 385 + content: text | image | video 332 386 333 387 // Union of tokens 334 - state: open | closed | pending, 388 + state: open | closed | pending 335 389 } 336 390 ``` 337 391 ··· 340 394 ```mlf 341 395 record example { 342 396 // Open union (can include unknown types) 343 - content: text | image | _, 397 + content: text | image | _ 344 398 } 345 399 ``` 346 400 ··· 351 405 ```mlf 352 406 // Local reference (same file) 353 407 record post { 354 - author: author, // References 'alias author' in same file 408 + author: author // References 'def type author' in same file 355 409 } 356 410 357 411 // Cross-file reference 358 412 record post { 359 - profile: app.bsky.actor.profile, // References app/bsky/actor/profile.mlf 413 + profile: app.bsky.actor.profile // References app/bsky/actor/profile.mlf 360 414 } 361 415 ``` 362 416 ··· 370 424 371 425 ```mlf 372 426 // Valid: More restrictive constraints 373 - alias shortString = string constrained { 374 - maxLength: 100, 427 + def type shortString = string constrained { 428 + maxLength: 100 375 429 }; 376 430 377 431 record post { 378 432 // Can further constrain to 50 (more restrictive than 100) 379 433 title: shortString constrained { 380 - maxLength: 50, // ✓ Valid: 50 ≤ 100 381 - }, 434 + maxLength: 50 // ✓ Valid: 50 ≤ 100 435 + } 382 436 } 383 437 384 438 // Invalid: Less restrictive constraints 385 439 record invalid { 386 440 // ERROR: Cannot expand to 200 (less restrictive than 100) 387 441 content: shortString constrained { 388 - maxLength: 200, // ✗ Invalid: 200 > 100 389 - }, 442 + maxLength: 200 // ✗ Invalid: 200 > 100 443 + } 390 444 } 391 445 ``` 392 446 ··· 403 457 404 458 ```mlf 405 459 field: string constrained { 406 - minLength: 1, // Minimum byte length 407 - maxLength: 1000, // Maximum byte length 408 - minGraphemes: 1, // Minimum grapheme clusters 409 - maxGraphemes: 100, // Maximum grapheme clusters 410 - format: "uri", // Format validation 411 - enum: ["a", "b", "c"], // Allowed values (closed set) 412 - knownValues: [ // Known values (extensible set) 413 - value1, 414 - value2, 415 - ], 416 - default: "defaultValue", // Default value 460 + minLength: 1 // Minimum byte length 461 + maxLength: 1000 // Maximum byte length 462 + minGraphemes: 1 // Minimum grapheme clusters 463 + maxGraphemes: 100 // Maximum grapheme clusters 464 + format: "uri" // Format validation 465 + enum: ["a", "b", "c"] // Allowed values (closed set) - string literals 466 + knownValues: [ // Known values (extensible set) - can be string literals OR token references 467 + value1 // Token reference 468 + "value2" // String literal 469 + ] 470 + default: "defaultValue" // Default value 417 471 } 418 472 ``` 419 473 474 + **Note**: `enum`, `knownValues`, and `default` can accept either: 475 + - **Literals**: `"open"`, `42`, `true` (string, integer, or boolean) 476 + - **References**: `open`, `myType` (references to tokens, records, types, etc.) 477 + 478 + When using references, the identifier will be resolved to its string representation in the generated lexicon. 479 + 420 480 ### Integer Constraints 421 481 422 482 ```mlf 423 483 field: integer constrained { 424 - minimum: 0, 425 - maximum: 100, 426 - enum: [1, 2, 3], 427 - default: 1, 484 + minimum: 0 485 + maximum: 100 486 + enum: [1, 2, 3] 487 + default: 1 428 488 } 429 489 ``` 430 490 ··· 432 492 433 493 ```mlf 434 494 field: string[] constrained { 435 - minLength: 1, 436 - maxLength: 10, 495 + minLength: 1 496 + maxLength: 10 437 497 } 438 498 ``` 439 499 ··· 441 501 442 502 ```mlf 443 503 field: blob constrained { 444 - accept: ["image/png", "image/jpeg"], // MIME types 445 - maxSize: 1000000, // Bytes 504 + accept: ["image/png", "image/jpeg"] // MIME types 505 + maxSize: 1000000 // Bytes 446 506 } 447 507 ``` 448 508 ··· 450 510 451 511 ```mlf 452 512 field: boolean constrained { 453 - default: false, 513 + default: false 454 514 } 455 515 ``` 456 516 ··· 464 524 /// A user profile record 465 525 record profile { 466 526 /// The user's display name 467 - displayName?: string, 527 + displayName?: string 468 528 } 469 529 ``` 470 530 ··· 484 544 ```mlf 485 545 @deprecated 486 546 record oldRecord { 487 - field: string, 547 + field: string 488 548 } 489 549 ``` 490 550 ··· 493 553 @since(1, 2, 0) 494 554 @doc("https://example.com/docs") 495 555 record example { 496 - field: string, 556 + field: string 497 557 } 498 558 ``` 499 559 ··· 507 567 @validate(min: 0, max: 100, strict: true) 508 568 @codegen(language: "rust", derive: "Debug, Clone") 509 569 record example { 510 - field: integer, 570 + field: integer 511 571 } 512 572 ``` 513 573 ··· 515 575 516 576 Annotations can be placed on: 517 577 - Records 518 - - Aliases 578 + - Inline Types 579 + - Def Types 519 580 - Tokens 520 581 - Queries 521 582 - Procedures 522 583 - Subscriptions 523 - - Fields within records/aliases 584 + - Fields within records/types 524 585 525 586 ```mlf 526 587 /// A user profile ··· 528 589 record profile { 529 590 /// User's DID 530 591 @indexed 531 - did: Did, 592 + did: Did 532 593 533 594 /// Display name 534 595 @sensitive(pii: true) 535 - displayName?: string, 596 + displayName?: string 536 597 } 537 598 ``` 538 599 ··· 567 628 568 629 **Note:** The interpretation of annotations is entirely up to the tooling consuming the MLF. Different tools may support different annotation sets. 569 630 570 - ## Namespaces 571 - 572 - Organize related definitions within namespaces: 573 - 574 - ```mlf 575 - namespace .actor { 576 - record profile { 577 - displayName?: string, 578 - } 579 - 580 - query getProfile( 581 - actor: AtIdentifier, 582 - ): profile; 583 - } 584 - 585 - namespace .feed { 586 - record post { 587 - text: string, 588 - } 589 - } 590 - ``` 591 - 592 631 ## Use Statements 593 632 594 633 Import definitions from other lexicons: ··· 614 653 use app.bsky.actor.profile; 615 654 616 655 record myThing { 617 - author: profile, // Instead of app.bsky.actor.profile 656 + author: profile // Instead of app.bsky.actor.profile 618 657 } 619 658 ``` 620 659 ··· 641 680 642 681 ### File Path Convention 643 682 644 - Lexicons follow a directory structure matching their NSID: 683 + The lexicon NSID is determined by the file path. Lexicons can follow a directory structure matching their NSID: 645 684 646 685 ``` 647 686 lexicons/ ··· 656 695 thing.mlf → com.example.thing 657 696 ``` 658 697 659 - Or flat with dots in filename: 698 + Or use a flat structure with dots in the filename: 660 699 ``` 661 700 lexicons/ 662 701 app.bsky.actor.profile.mlf ··· 664 703 com.example.thing.mlf 665 704 ``` 666 705 706 + In both cases, the NSID is derived from the file path, not from internal declarations. 707 + 667 708 ## CLI Commands 668 709 669 710 ```bash ··· 701 742 /// An issue in a repository 702 743 record issue { 703 744 /// The repository this issue belongs to 704 - repo: AtUri, 745 + repo: AtUri 705 746 /// Issue title 706 747 title: string constrained { 707 - minGraphemes: 1, 708 - maxGraphemes: 200, 709 - }, 748 + minGraphemes: 1 749 + maxGraphemes: 200 750 + } 710 751 /// Issue body (markdown) 711 752 body?: string constrained { 712 - maxGraphemes: 10000, 713 - }, 753 + maxGraphemes: 10000 754 + } 714 755 /// Issue state 715 756 state: string constrained { 716 757 knownValues: [ 717 - open, 718 - closed, 719 - ], 720 - default: "open", 721 - }, 758 + open 759 + closed 760 + ] 761 + default: "open" 762 + } 722 763 /// Creation timestamp 723 - createdAt: Datetime, 764 + createdAt: Datetime 724 765 } 725 766 726 767 /// A comment on an issue 727 768 record comment { 728 769 /// The issue this comment belongs to 729 - issue: AtUri, 770 + issue: AtUri 730 771 /// Comment body (markdown) 731 772 body: string constrained { 732 - minGraphemes: 1, 733 - maxGraphemes: 10000, 734 - }, 773 + minGraphemes: 1 774 + maxGraphemes: 10000 775 + } 735 776 /// Creation timestamp 736 - createdAt: Datetime, 777 + createdAt: Datetime 737 778 /// Optional reply target 738 - replyTo?: AtUri, 779 + replyTo?: AtUri 739 780 } 740 781 741 782 /// Get an issue by URI 742 783 query getIssue( 743 784 /// Issue AT-URI 744 - uri: AtUri, 785 + uri: AtUri 745 786 ): issue | error { 746 787 /// Issue not found 747 - NotFound, 788 + NotFound 748 789 }; 749 790 750 791 /// Create a new issue 751 792 procedure createIssue( 752 - repo: AtUri, 753 - title: string, 754 - body?: string, 793 + repo: AtUri 794 + title: string 795 + body?: string 755 796 ): { 756 - uri: AtUri, 757 - cid: Cid, 797 + uri: AtUri 798 + cid: Cid 758 799 } | error { 759 800 /// Repository not found 760 - RepoNotFound, 801 + RepoNotFound 761 802 /// Title too long 762 - TitleTooLong, 803 + TitleTooLong 763 804 }; 764 805 ``` 765 806 ··· 773 814 ```mlf 774 815 record post { 775 816 text: string constrained { 776 - maxLength: 300, 777 - }, 778 - createdAt: Datetime, 817 + maxLength: 300 818 + } 819 + createdAt: Datetime 779 820 } 780 821 ``` 781 822 ··· 812 853 **MLF:** 813 854 ```mlf 814 855 subscription subscribeRepos( 815 - cursor?: integer, 856 + cursor?: integer 816 857 ): commit | identity; 817 858 ``` 818 859 ··· 872 913 ### Reserved Keywords 873 914 874 915 ``` 875 - alias, as, blob, boolean, bytes, constrained, error, integer, 876 - namespace, null, number, procedure, query, record, string, 877 - subscription, token, unknown, use 916 + as, blob, boolean, bytes, constrained, def, error, inline, integer, 917 + null, number, procedure, query, record, string, subscription, token, 918 + type, unknown, use 919 + ``` 920 + 921 + ### Reserved Names 922 + 923 + The following names cannot be used as item names: 924 + 925 + ``` 926 + main, defs 878 927 ``` 879 928 880 929 ### Raw Identifiers ··· 882 931 To use a reserved keyword as an identifier, wrap it in backticks: 883 932 884 933 ```mlf 885 - alias `record` = { 886 - `record`: com.atproto.repo.strongRef, 887 - `error`: string, 934 + def type `record` = { 935 + `record`: com.atproto.repo.strongRef 936 + `error`: string 888 937 }; 889 938 ``` 890 939
+25 -14
mlf-cli/src/generate/lexicon.rs
··· 86 86 } 87 87 }; 88 88 89 - let namespace = extract_namespace(&file_path, &lexicon); 89 + let namespace = extract_namespace(&file_path); 90 + 91 + // Create workspace with prelude for inline type resolution 92 + let mut workspace = match mlf_lang::Workspace::with_prelude() { 93 + Ok(ws) => ws, 94 + Err(e) => { 95 + errors.push((file_path.display().to_string(), format!("Failed to load prelude: {:?}", e))); 96 + continue; 97 + } 98 + }; 99 + 100 + // Add the module to the workspace 101 + if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { 102 + errors.push((file_path.display().to_string(), format!("Failed to add module: {:?}", e))); 103 + continue; 104 + } 90 105 91 - let json_lexicon = mlf_codegen::generate_lexicon(&namespace, &lexicon); 106 + // Resolve types 107 + if let Err(e) = workspace.resolve() { 108 + errors.push((file_path.display().to_string(), format!("Type resolution error: {:?}", e))); 109 + continue; 110 + } 111 + 112 + let json_lexicon = mlf_codegen::generate_lexicon(&namespace, &lexicon, &workspace); 92 113 93 114 let output_path = if flat { 94 115 output_dir.join(format!("{}.json", namespace)) ··· 131 152 Ok(()) 132 153 } 133 154 134 - fn extract_namespace(file_path: &Path, lexicon: &mlf_lang::ast::Lexicon) -> String { 135 - use mlf_lang::ast::Item; 136 - 137 - for item in &lexicon.items { 138 - if let Item::Namespace(ns) = item { 139 - if ns.name.name.starts_with('.') { 140 - continue; 141 - } 142 - return ns.name.name.clone(); 143 - } 144 - } 145 - 155 + fn extract_namespace(file_path: &Path) -> String { 156 + // Namespace is derived solely from the filename 146 157 file_path 147 158 .file_stem() 148 159 .and_then(|s| s.to_str())
+1 -1
mlf-codegen/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 mlf-lang = { path = "../mlf-lang" } 8 - serde_json = "1" 8 + serde_json = { version = "1", features = ["preserve_order"] }
+188 -110
mlf-codegen/src/lib.rs
··· 1 1 use mlf_lang::ast::*; 2 + use mlf_lang::Workspace; 2 3 use serde_json::{json, Map, Value}; 3 4 use std::collections::HashMap; 4 5 5 - pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon) -> Value { 6 + pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon, workspace: &Workspace) -> Value { 6 7 let usage_counts = analyze_type_usage(lexicon); 7 8 9 + // Extract the last segment of the namespace to determine main 10 + let namespace_parts: Vec<&str> = namespace.split('.').collect(); 11 + let expected_main_name = namespace_parts.last().copied().unwrap_or(""); 12 + let is_defs_namespace = expected_main_name == "defs"; 13 + 14 + // Count main-eligible items (records, queries, procedures, subscriptions) 15 + let main_eligible_count = lexicon.items.iter().filter(|item| { 16 + matches!(item, Item::Record(_) | Item::Query(_) | Item::Procedure(_) | Item::Subscription(_)) 17 + }).count(); 18 + 8 19 let mut defs = Map::new(); 9 - let mut main_def: Option<Value> = None; 10 20 11 21 for item in &lexicon.items { 12 22 match item { 13 23 Item::Record(record) => { 14 - let record_json = generate_record_json(record, &usage_counts); 15 - main_def = Some(record_json); 24 + let record_json = generate_record_json(record, &usage_counts, workspace, namespace); 25 + // If there's only one main-eligible item, it becomes "main" 26 + if main_eligible_count == 1 || (!is_defs_namespace && record.name.name == expected_main_name) { 27 + defs.insert("main".to_string(), record_json); 28 + } else { 29 + defs.insert(record.name.name.clone(), record_json); 30 + } 16 31 } 17 32 Item::Query(query) => { 18 - let query_json = generate_query_json(query, &usage_counts); 19 - main_def = Some(query_json); 33 + let query_json = generate_query_json(query, &usage_counts, workspace, namespace); 34 + if main_eligible_count == 1 || (!is_defs_namespace && query.name.name == expected_main_name) { 35 + defs.insert("main".to_string(), query_json); 36 + } else { 37 + defs.insert(query.name.name.clone(), query_json); 38 + } 20 39 } 21 40 Item::Procedure(procedure) => { 22 - let procedure_json = generate_procedure_json(procedure, &usage_counts); 23 - main_def = Some(procedure_json); 41 + let procedure_json = generate_procedure_json(procedure, &usage_counts, workspace, namespace); 42 + if main_eligible_count == 1 || (!is_defs_namespace && procedure.name.name == expected_main_name) { 43 + defs.insert("main".to_string(), procedure_json); 44 + } else { 45 + defs.insert(procedure.name.name.clone(), procedure_json); 46 + } 24 47 } 25 48 Item::Subscription(subscription) => { 26 - let subscription_json = generate_subscription_json(subscription, &usage_counts); 27 - main_def = Some(subscription_json); 49 + let subscription_json = generate_subscription_json(subscription, &usage_counts, workspace, namespace); 50 + if main_eligible_count == 1 || (!is_defs_namespace && subscription.name.name == expected_main_name) { 51 + defs.insert("main".to_string(), subscription_json); 52 + } else { 53 + defs.insert(subscription.name.name.clone(), subscription_json); 54 + } 28 55 } 29 - Item::Alias(alias) => { 30 - if should_hoist_alias(&alias.name.name, &usage_counts) { 31 - let alias_json = generate_alias_json(alias, &usage_counts); 32 - defs.insert(alias.name.name.clone(), alias_json); 33 - } 56 + Item::DefType(def_type) => { 57 + let def_type_json = generate_def_type_json(def_type, &usage_counts, workspace, namespace); 58 + defs.insert(def_type.name.name.clone(), def_type_json); 59 + } 60 + Item::InlineType(_) => { 61 + // Inline types are never added to defs - they expand at point of use 62 + // TODO: inline expansion will be handled by workspace/cross-file resolution 34 63 } 35 64 Item::Token(token) => { 36 65 let token_json = json!({ ··· 43 72 } 44 73 } 45 74 46 - if let Some(main) = main_def { 47 - defs.insert("main".to_string(), main); 48 - } 49 - 50 - json!({ 51 - "lexicon": 1, 52 - "id": namespace, 53 - "defs": defs 54 - }) 75 + let mut root = Map::new(); 76 + root.insert("lexicon".to_string(), json!(1)); 77 + root.insert("id".to_string(), json!(namespace)); 78 + root.insert("defs".to_string(), json!(defs)); 79 + Value::Object(root) 55 80 } 56 81 57 82 fn analyze_type_usage(lexicon: &Lexicon) -> HashMap<String, usize> { ··· 92 117 } 93 118 count_type_references(&subscription.messages, &mut usage_counts); 94 119 } 95 - Item::Alias(alias) => { 96 - count_type_references(&alias.ty, &mut usage_counts); 120 + Item::InlineType(inline_type) => { 121 + count_type_references(&inline_type.ty, &mut usage_counts); 122 + } 123 + Item::DefType(def_type) => { 124 + count_type_references(&def_type.ty, &mut usage_counts); 97 125 } 98 126 _ => {} 99 127 } ··· 121 149 count_type_references(&field.ty, counts); 122 150 } 123 151 } 152 + Type::Parenthesized { inner, .. } => count_type_references(inner, counts), 124 153 Type::Constrained { base, .. } => count_type_references(base, counts), 125 154 _ => {} 126 155 } 127 - } 128 - 129 - fn should_hoist_alias(name: &str, usage_counts: &HashMap<String, usize>) -> bool { 130 - usage_counts.get(name).map_or(false, |&count| count > 1) 131 156 } 132 157 133 158 fn extract_docs(docs: &[DocComment]) -> String { ··· 137 162 .join("\n") 138 163 } 139 164 140 - fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>) -> Value { 165 + fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 141 166 let mut required = Vec::new(); 142 167 let mut properties = Map::new(); 143 168 ··· 146 171 required.push(field.name.name.clone()); 147 172 } 148 173 149 - let field_json = generate_type_json(&field.ty, usage_counts); 174 + let field_json = generate_type_json(&field.ty, usage_counts, workspace, current_namespace); 150 175 properties.insert(field.name.name.clone(), field_json); 151 176 } 152 177 ··· 164 189 }) 165 190 } 166 191 167 - fn generate_query_json(query: &Query, usage_counts: &HashMap<String, usize>) -> Value { 192 + fn generate_query_json(query: &Query, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 168 193 let mut params_properties = Map::new(); 169 194 let mut params_required = Vec::new(); 170 195 ··· 172 197 if !param.optional { 173 198 params_required.push(param.name.name.clone()); 174 199 } 175 - let param_json = generate_type_json(&param.ty, usage_counts); 200 + let mut param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 201 + // Add description if the parameter has doc comments 202 + if !param.docs.is_empty() { 203 + if let Some(obj) = param_json.as_object_mut() { 204 + obj.insert("description".to_string(), json!(extract_docs(&param.docs))); 205 + } 206 + } 176 207 params_properties.insert(param.name.name.clone(), param_json); 177 208 } 178 209 179 210 let params = if !params_properties.is_empty() { 180 - json!({ 181 - "type": "params", 182 - "required": params_required, 183 - "properties": params_properties 184 - }) 211 + let mut params_obj = Map::new(); 212 + params_obj.insert("type".to_string(), json!("params")); 213 + params_obj.insert("required".to_string(), json!(params_required)); 214 + params_obj.insert("properties".to_string(), json!(params_properties)); 215 + Value::Object(params_obj) 185 216 } else { 186 - json!({ 187 - "type": "params", 188 - "properties": {} 189 - }) 217 + let mut params_obj = Map::new(); 218 + params_obj.insert("type".to_string(), json!("params")); 219 + params_obj.insert("properties".to_string(), json!({})); 220 + Value::Object(params_obj) 190 221 }; 191 222 192 223 let output = match &query.returns { 193 224 ReturnType::Type(ty) => { 194 - json!({ 195 - "encoding": "application/json", 196 - "schema": generate_type_json(ty, usage_counts) 197 - }) 225 + let mut output_obj = Map::new(); 226 + output_obj.insert("encoding".to_string(), json!("application/json")); 227 + output_obj.insert("schema".to_string(), generate_type_json(ty, usage_counts, workspace, current_namespace)); 228 + Value::Object(output_obj) 198 229 } 199 230 ReturnType::TypeWithErrors { success, errors, .. } => { 200 231 let mut error_defs = Map::new(); ··· 207 238 ); 208 239 } 209 240 210 - json!({ 211 - "encoding": "application/json", 212 - "schema": generate_type_json(success, usage_counts), 213 - "errors": error_defs 214 - }) 241 + let mut output_obj = Map::new(); 242 + output_obj.insert("encoding".to_string(), json!("application/json")); 243 + output_obj.insert("schema".to_string(), generate_type_json(success, usage_counts, workspace, current_namespace)); 244 + output_obj.insert("errors".to_string(), json!(error_defs)); 245 + Value::Object(output_obj) 215 246 } 216 247 }; 217 248 218 - json!({ 219 - "type": "query", 220 - "description": extract_docs(&query.docs), 221 - "parameters": params, 222 - "output": output 223 - }) 249 + let mut query_obj = Map::new(); 250 + query_obj.insert("type".to_string(), json!("query")); 251 + query_obj.insert("description".to_string(), json!(extract_docs(&query.docs))); 252 + query_obj.insert("parameters".to_string(), params); 253 + query_obj.insert("output".to_string(), output); 254 + Value::Object(query_obj) 224 255 } 225 256 226 - fn generate_procedure_json(procedure: &Procedure, usage_counts: &HashMap<String, usize>) -> Value { 257 + fn generate_procedure_json(procedure: &Procedure, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 227 258 let mut params_properties = Map::new(); 228 259 let mut params_required = Vec::new(); 229 260 ··· 231 262 if !param.optional { 232 263 params_required.push(param.name.name.clone()); 233 264 } 234 - let param_json = generate_type_json(&param.ty, usage_counts); 265 + let param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 235 266 params_properties.insert(param.name.name.clone(), param_json); 236 267 } 237 268 238 269 let input = if !params_properties.is_empty() { 239 - json!({ 240 - "encoding": "application/json", 241 - "schema": { 242 - "type": "object", 243 - "required": params_required, 244 - "properties": params_properties 245 - } 246 - }) 270 + let mut schema_obj = Map::new(); 271 + schema_obj.insert("type".to_string(), json!("object")); 272 + schema_obj.insert("required".to_string(), json!(params_required)); 273 + schema_obj.insert("properties".to_string(), json!(params_properties)); 274 + 275 + let mut input_obj = Map::new(); 276 + input_obj.insert("encoding".to_string(), json!("application/json")); 277 + input_obj.insert("schema".to_string(), Value::Object(schema_obj)); 278 + Some(Value::Object(input_obj)) 247 279 } else { 248 - Value::Null 280 + None 249 281 }; 250 282 251 283 let output = match &procedure.returns { 252 284 ReturnType::Type(ty) => { 253 - json!({ 254 - "encoding": "application/json", 255 - "schema": generate_type_json(ty, usage_counts) 256 - }) 285 + let mut output_obj = Map::new(); 286 + output_obj.insert("encoding".to_string(), json!("application/json")); 287 + output_obj.insert("schema".to_string(), generate_type_json(ty, usage_counts, workspace, current_namespace)); 288 + Value::Object(output_obj) 257 289 } 258 290 ReturnType::TypeWithErrors { success, errors, .. } => { 259 291 let mut error_defs = Map::new(); ··· 266 298 ); 267 299 } 268 300 269 - json!({ 270 - "encoding": "application/json", 271 - "schema": generate_type_json(success, usage_counts), 272 - "errors": error_defs 273 - }) 301 + let mut output_obj = Map::new(); 302 + output_obj.insert("encoding".to_string(), json!("application/json")); 303 + output_obj.insert("schema".to_string(), generate_type_json(success, usage_counts, workspace, current_namespace)); 304 + output_obj.insert("errors".to_string(), json!(error_defs)); 305 + Value::Object(output_obj) 274 306 } 275 307 }; 276 308 277 - let mut result = json!({ 278 - "type": "procedure", 279 - "description": extract_docs(&procedure.docs), 280 - "output": output 281 - }); 282 - 283 - if !input.is_null() { 284 - result["input"] = input; 309 + let mut result = Map::new(); 310 + result.insert("type".to_string(), json!("procedure")); 311 + result.insert("description".to_string(), json!(extract_docs(&procedure.docs))); 312 + if let Some(input_val) = input { 313 + result.insert("input".to_string(), input_val); 285 314 } 286 - 287 - result 315 + result.insert("output".to_string(), output); 316 + Value::Object(result) 288 317 } 289 318 290 319 fn generate_subscription_json( 291 320 subscription: &Subscription, 292 321 usage_counts: &HashMap<String, usize>, 322 + workspace: &Workspace, 323 + current_namespace: &str, 293 324 ) -> Value { 294 325 let mut params_properties = Map::new(); 295 326 let mut params_required = Vec::new(); ··· 298 329 if !param.optional { 299 330 params_required.push(param.name.name.clone()); 300 331 } 301 - let param_json = generate_type_json(&param.ty, usage_counts); 332 + let param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 302 333 params_properties.insert(param.name.name.clone(), param_json); 303 334 } 304 335 ··· 313 344 }; 314 345 315 346 let message = json!({ 316 - "schema": generate_type_json(&subscription.messages, usage_counts) 347 + "schema": generate_type_json(&subscription.messages, usage_counts, workspace, current_namespace) 317 348 }); 318 349 319 350 let mut result = json!({ ··· 329 360 result 330 361 } 331 362 332 - fn generate_alias_json(alias: &Alias, usage_counts: &HashMap<String, usize>) -> Value { 333 - generate_type_json(&alias.ty, usage_counts) 363 + fn generate_def_type_json(def_type: &DefType, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 364 + generate_type_json(&def_type.ty, usage_counts, workspace, current_namespace) 334 365 } 335 366 336 - fn generate_type_json(ty: &Type, usage_counts: &HashMap<String, usize>) -> Value { 367 + fn generate_type_json(ty: &Type, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 337 368 match ty { 338 369 Type::Primitive { kind, .. } => generate_primitive_json(*kind), 339 370 Type::Reference { path, .. } => { 371 + // Try to resolve this reference in the workspace 372 + if let Some(resolved_ty) = workspace.resolve_type_reference(path) { 373 + // Check if this is an inline type by looking in the workspace 374 + if workspace.is_inline_type(path) { 375 + // Inline type: expand it recursively 376 + return generate_type_json(&resolved_ty, usage_counts, workspace, current_namespace); 377 + } 378 + } 379 + 380 + // Not an inline type (or couldn't resolve) - generate a ref 340 381 if path.segments.len() == 1 { 341 382 let name = &path.segments[0].name; 342 - if should_hoist_alias(name, usage_counts) { 343 - json!({ "ref": format!("#{}", name) }) 344 - } else { 345 - json!({ "ref": format!("#{}", name) }) 346 - } 383 + json!({ 384 + "type": "ref", 385 + "ref": format!("#{}", name) 386 + }) 347 387 } else { 348 - json!({ "ref": path.to_string() }) 388 + // Multi-segment path ref 389 + let namespace = path.segments[..path.segments.len()-1] 390 + .iter() 391 + .map(|s| s.name.as_str()) 392 + .collect::<Vec<_>>() 393 + .join("."); 394 + let def_name = &path.segments.last().unwrap().name; 395 + 396 + json!({ 397 + "type": "ref", 398 + "ref": format!("{}#{}", namespace, def_name) 399 + }) 349 400 } 350 401 } 351 402 Type::Array { inner, .. } => { 352 403 json!({ 353 404 "type": "array", 354 - "items": generate_type_json(inner, usage_counts) 405 + "items": generate_type_json(inner, usage_counts, workspace, current_namespace) 355 406 }) 356 407 } 357 408 Type::Union { types, .. } => { 358 409 let refs: Vec<Value> = types 359 410 .iter() 360 - .map(|t| generate_type_json(t, usage_counts)) 411 + .map(|t| generate_type_json(t, usage_counts, workspace, current_namespace)) 361 412 .collect(); 362 413 json!({ 363 414 "type": "union", ··· 374 425 } 375 426 properties.insert( 376 427 field.name.name.clone(), 377 - generate_type_json(&field.ty, usage_counts), 428 + generate_type_json(&field.ty, usage_counts, workspace, current_namespace), 378 429 ); 379 430 } 380 431 381 - json!({ 382 - "type": "object", 383 - "required": required, 384 - "properties": properties 385 - }) 432 + let mut obj = Map::new(); 433 + obj.insert("type".to_string(), json!("object")); 434 + obj.insert("required".to_string(), json!(required)); 435 + obj.insert("properties".to_string(), json!(properties)); 436 + Value::Object(obj) 437 + } 438 + Type::Parenthesized { inner, .. } => { 439 + // Parentheses are just for grouping - unwrap and process inner type 440 + generate_type_json(inner, usage_counts, workspace, current_namespace) 386 441 } 387 442 Type::Constrained { base, constraints, .. } => { 388 - let mut base_json = generate_type_json(base, usage_counts); 443 + let mut base_json = generate_type_json(base, usage_counts, workspace, current_namespace); 389 444 390 445 if let Some(obj) = base_json.as_object_mut() { 391 446 for constraint in constraints { ··· 437 492 obj.insert("format".to_string(), json!(value)); 438 493 } 439 494 Constraint::Enum { values, .. } => { 440 - obj.insert("enum".to_string(), json!(values)); 495 + let enum_vals: Vec<String> = values 496 + .iter() 497 + .map(|v| match v { 498 + mlf_lang::ast::ValueRef::Literal(s) => s.clone(), 499 + mlf_lang::ast::ValueRef::Reference(path) => path.to_string(), 500 + }) 501 + .collect(); 502 + obj.insert("enum".to_string(), json!(enum_vals)); 441 503 } 442 504 Constraint::KnownValues { values, .. } => { 443 - let known_vals: Vec<String> = values.iter().map(|path| path.to_string()).collect(); 505 + let known_vals: Vec<String> = values 506 + .iter() 507 + .map(|v| match v { 508 + mlf_lang::ast::ValueRef::Literal(s) => s.clone(), 509 + mlf_lang::ast::ValueRef::Reference(path) => path.to_string(), 510 + }) 511 + .collect(); 444 512 obj.insert("knownValues".to_string(), json!(known_vals)); 445 513 } 446 514 Constraint::Accept { mimes, .. } => { ··· 454 522 ConstraintValue::String(s) => json!(s), 455 523 ConstraintValue::Integer(i) => json!(i), 456 524 ConstraintValue::Boolean(b) => json!(b), 525 + ConstraintValue::Reference(path) => json!(path.to_string()), 457 526 }; 458 527 obj.insert("default".to_string(), default_val); 528 + } 529 + Constraint::Const { value, .. } => { 530 + let const_val = match value { 531 + ConstraintValue::String(s) => json!(s), 532 + ConstraintValue::Integer(i) => json!(i), 533 + ConstraintValue::Boolean(b) => json!(b), 534 + ConstraintValue::Reference(path) => json!(path.to_string()), 535 + }; 536 + obj.insert("const".to_string(), const_val); 459 537 } 460 538 } 461 539 }
+8
mlf-diagnostics/src/lib.rs
··· 150 150 ValidationError::ConstraintTooPermissive { message, .. } => { 151 151 write!(f, "Constraint is too permissive: {}", message) 152 152 } 153 + ValidationError::ReservedName { name, .. } => { 154 + write!(f, "Reserved name '{}' cannot be used as an item name", name) 155 + } 153 156 } 154 157 } 155 158 ··· 160 163 ValidationError::InvalidConstraint { .. } => "mlf::invalid_constraint", 161 164 ValidationError::TypeMismatch { .. } => "mlf::type_mismatch", 162 165 ValidationError::ConstraintTooPermissive { .. } => "mlf::constraint_too_permissive", 166 + ValidationError::ReservedName { .. } => "mlf::reserved_name", 163 167 } 164 168 } 165 169 ··· 192 196 ValidationError::ConstraintTooPermissive { span, message } => { 193 197 vec![LabeledSpan::at(span.start..span.end, message.clone())] 194 198 } 199 + ValidationError::ReservedName { span, name } => vec![LabeledSpan::at( 200 + span.start..span.end, 201 + format!("'{}' is a reserved name and cannot be used", name), 202 + )], 195 203 } 196 204 } 197 205
+54 -18
mlf-lang/src/ast.rs
··· 31 31 #[derive(Debug, Clone, PartialEq)] 32 32 pub enum Item { 33 33 Record(Record), 34 - Alias(Alias), 34 + InlineType(InlineType), 35 + DefType(DefType), 35 36 Token(Token), 36 37 Query(Query), 37 38 Procedure(Procedure), 38 39 Subscription(Subscription), 39 - Namespace(Namespace), 40 40 Use(Use), 41 41 } 42 42 ··· 44 44 fn span(&self) -> Span { 45 45 match self { 46 46 Item::Record(r) => r.span, 47 - Item::Alias(a) => a.span, 47 + Item::InlineType(i) => i.span, 48 + Item::DefType(d) => d.span, 48 49 Item::Token(t) => t.span, 49 50 Item::Query(q) => q.span, 50 51 Item::Procedure(p) => p.span, 51 52 Item::Subscription(s) => s.span, 52 - Item::Namespace(n) => n.span, 53 53 Item::Use(u) => u.span, 54 54 } 55 55 } ··· 106 106 pub span: Span, 107 107 } 108 108 109 - /// A type alias 109 + /// An inline type definition (expands at point of use) 110 110 #[derive(Debug, Clone, PartialEq)] 111 - pub struct Alias { 111 + pub struct InlineType { 112 + pub docs: Vec<DocComment>, 113 + pub annotations: Vec<Annotation>, 114 + pub name: Ident, 115 + pub ty: Type, 116 + pub span: Span, 117 + } 118 + 119 + /// A def type definition (becomes a named def in lexicon) 120 + #[derive(Debug, Clone, PartialEq)] 121 + pub struct DefType { 112 122 pub docs: Vec<DocComment>, 113 123 pub annotations: Vec<Annotation>, 114 124 pub name: Ident, ··· 171 181 }, 172 182 } 173 183 184 + impl ReturnType { 185 + pub fn span(&self) -> Span { 186 + match self { 187 + ReturnType::Type(ty) => ty.span(), 188 + ReturnType::TypeWithErrors { span, .. } => *span, 189 + } 190 + } 191 + } 192 + 174 193 /// An error definition in a query/procedure 175 194 #[derive(Debug, Clone, PartialEq)] 176 195 pub struct ErrorDef { ··· 179 198 pub span: Span, 180 199 } 181 200 182 - /// A namespace block 183 - #[derive(Debug, Clone, PartialEq)] 184 - pub struct Namespace { 185 - pub name: Ident, // e.g., ".actor" 186 - pub items: Vec<Item>, 187 - pub span: Span, 188 - } 189 - 190 201 /// A use statement 191 202 #[derive(Debug, Clone, PartialEq)] 192 203 pub struct Use { ··· 241 252 Union { types: Vec<Type>, span: Span }, 242 253 /// Object type (inline) 243 254 Object { fields: Vec<Field>, span: Span }, 255 + /// Parenthesized type (for grouping, e.g., (A | B)[]) 256 + Parenthesized { inner: Box<Type>, span: Span }, 244 257 /// Constrained type 245 258 Constrained { 246 259 base: Box<Type>, ··· 259 272 Type::Array { span, .. } => *span, 260 273 Type::Union { span, .. } => *span, 261 274 Type::Object { span, .. } => *span, 275 + Type::Parenthesized { span, .. } => *span, 262 276 Type::Constrained { span, .. } => *span, 263 277 Type::Unknown { span } => *span, 264 278 } ··· 286 300 MinGraphemes { value: usize, span: Span }, 287 301 MaxGraphemes { value: usize, span: Span }, 288 302 Format { value: String, span: Span }, 289 - Enum { values: Vec<String>, span: Span }, 290 - KnownValues { values: Vec<Path>, span: Span }, 303 + Enum { values: Vec<ValueRef>, span: Span }, 304 + KnownValues { values: Vec<ValueRef>, span: Span }, 291 305 292 306 // Numeric constraints 293 307 Minimum { value: i64, span: Span }, ··· 297 311 Accept { mimes: Vec<String>, span: Span }, 298 312 MaxSize { value: usize, span: Span }, 299 313 300 - // Default value 314 + // Value constraints 301 315 Default { value: ConstraintValue, span: Span }, 316 + Const { value: ConstraintValue, span: Span }, 302 317 } 303 318 304 319 impl Spanned for Constraint { ··· 316 331 Constraint::Accept { span, .. } => *span, 317 332 Constraint::MaxSize { span, .. } => *span, 318 333 Constraint::Default { span, .. } => *span, 334 + Constraint::Const { span, .. } => *span, 319 335 } 320 336 } 321 337 } 322 338 323 - /// A value in a constraint default 339 + /// A value in a constraint default - can be a literal or reference 324 340 #[derive(Debug, Clone, PartialEq)] 325 341 pub enum ConstraintValue { 326 342 String(String), 327 343 Integer(i64), 328 344 Boolean(bool), 345 + /// Reference to a named item (token, record, alias, etc.) 346 + Reference(Path), 347 + } 348 + 349 + /// A value reference in enum/knownValues constraints - can be a string literal or reference to any named item 350 + #[derive(Debug, Clone, PartialEq)] 351 + pub enum ValueRef { 352 + /// String literal (e.g., "open") 353 + Literal(String), 354 + /// Reference to a named item - token, record, alias, etc. (e.g., open) 355 + Reference(Path), 356 + } 357 + 358 + impl core::fmt::Display for ValueRef { 359 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 360 + match self { 361 + ValueRef::Literal(s) => write!(f, "\"{}\"", s), 362 + ValueRef::Reference(path) => write!(f, "{}", path.to_string()), 363 + } 364 + } 329 365 }
+1
mlf-lang/src/error.rs
··· 17 17 InvalidConstraint { message: String, span: Span }, 18 18 TypeMismatch { expected: String, found: String, span: Span }, 19 19 ConstraintTooPermissive { message: String, span: Span }, 20 + ReservedName { name: String, span: Span }, 20 21 } 21 22 22 23 #[derive(Debug, Clone, Default)]
+9 -3
mlf-lang/src/lexer.rs
··· 15 15 #[derive(Debug, Clone, PartialEq)] 16 16 pub enum Token { 17 17 // Keywords 18 - Alias, 19 18 As, 20 19 Blob, 21 20 Boolean, 22 21 Bytes, 23 22 Constrained, 23 + Def, 24 24 Error, 25 + Inline, 25 26 Integer, 26 27 Namespace, 27 28 Null, ··· 32 33 String, 33 34 Subscription, 34 35 Token, 36 + Type, 35 37 Unknown, 36 38 Use, 37 39 ··· 68 70 impl core::fmt::Display for Token { 69 71 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 70 72 match self { 71 - Token::Alias => write!(f, "alias"), 72 73 Token::As => write!(f, "as"), 73 74 Token::Blob => write!(f, "blob"), 74 75 Token::Boolean => write!(f, "boolean"), 75 76 Token::Bytes => write!(f, "bytes"), 76 77 Token::Constrained => write!(f, "constrained"), 78 + Token::Def => write!(f, "def"), 77 79 Token::Error => write!(f, "error"), 80 + Token::Inline => write!(f, "inline"), 78 81 Token::Integer => write!(f, "integer"), 79 82 Token::Namespace => write!(f, "namespace"), 80 83 Token::Null => write!(f, "null"), ··· 85 88 Token::String => write!(f, "string"), 86 89 Token::Subscription => write!(f, "subscription"), 87 90 Token::Token => write!(f, "token"), 91 + Token::Type => write!(f, "type"), 88 92 Token::Unknown => write!(f, "unknown"), 89 93 Token::Use => write!(f, "use"), 90 94 Token::Ident(s) => write!(f, "{}", s), ··· 139 143 )).parse(input)?; 140 144 141 145 let token = match name { 142 - "alias" => Token::Alias, 143 146 "as" => Token::As, 144 147 "blob" => Token::Blob, 145 148 "boolean" => Token::Boolean, 146 149 "bytes" => Token::Bytes, 147 150 "constrained" => Token::Constrained, 151 + "def" => Token::Def, 148 152 "error" => Token::Error, 149 153 "false" => Token::False, 154 + "inline" => Token::Inline, 150 155 "integer" => Token::Integer, 151 156 "namespace" => Token::Namespace, 152 157 "null" => Token::Null, ··· 158 163 "subscription" => Token::Subscription, 159 164 "token" => Token::Token, 160 165 "true" => Token::True, 166 + "type" => Token::Type, 161 167 "unknown" => Token::Unknown, 162 168 "use" => Token::Use, 163 169 _ => Token::Ident(name.into()),
+243 -81
mlf-lang/src/parser.rs
··· 61 61 } 62 62 } 63 63 64 + fn parse_field_name(&mut self) -> Result<Ident, ParseError> { 65 + let current = self.current(); 66 + // Field names can be identifiers or keywords 67 + let name = match &current.token { 68 + LexToken::Ident(n) => n.clone(), 69 + LexToken::Record => "record".into(), 70 + LexToken::Token => "token".into(), 71 + LexToken::Inline => "inline".into(), 72 + LexToken::Def => "def".into(), 73 + LexToken::Type => "type".into(), 74 + LexToken::Query => "query".into(), 75 + LexToken::Procedure => "procedure".into(), 76 + LexToken::Subscription => "subscription".into(), 77 + LexToken::Error => "error".into(), 78 + LexToken::Use => "use".into(), 79 + LexToken::As => "as".into(), 80 + LexToken::String => "string".into(), 81 + LexToken::Integer => "integer".into(), 82 + LexToken::Number => "number".into(), 83 + LexToken::Boolean => "boolean".into(), 84 + LexToken::Null => "null".into(), 85 + LexToken::Unknown => "unknown".into(), 86 + LexToken::Constrained => "constrained".into(), 87 + LexToken::True => "true".into(), 88 + LexToken::False => "false".into(), 89 + _ => { 90 + return Err(ParseError::Syntax { 91 + message: alloc::format!("Expected field name, found {}", current.token), 92 + span: current.span, 93 + }); 94 + } 95 + }; 96 + let ident = Ident { 97 + name, 98 + span: current.span, 99 + }; 100 + self.advance(); 101 + Ok(ident) 102 + } 103 + 64 104 fn parse_path(&mut self) -> Result<Path, ParseError> { 65 105 let mut segments = Vec::new(); 66 106 let start = self.current().span.start; ··· 105 145 106 146 impl Parser { 107 147 fn parse_item(&mut self) -> Result<Item, ParseError> { 108 - while matches!(self.current().token, LexToken::DocComment(_)) { 148 + let mut doc_comments = Vec::new(); 149 + while let LexToken::DocComment(comment) = &self.current().token { 150 + let span = self.current().span; 151 + doc_comments.push(DocComment { 152 + text: comment.clone(), 153 + span, 154 + }); 109 155 self.advance(); 110 156 } 111 157 112 158 let annotations = self.parse_annotations()?; 113 159 114 160 match &self.current().token { 115 - LexToken::Record => self.parse_record(annotations), 116 - LexToken::Alias => self.parse_alias(annotations), 117 - LexToken::Token => self.parse_token(annotations), 118 - LexToken::Query => self.parse_query(annotations), 119 - LexToken::Procedure => self.parse_procedure(annotations), 120 - LexToken::Subscription => self.parse_subscription(annotations), 121 - LexToken::Namespace => self.parse_namespace(), 161 + LexToken::Record => self.parse_record(doc_comments, annotations), 162 + LexToken::Inline => self.parse_inline_type(doc_comments, annotations), 163 + LexToken::Def => self.parse_def_type(doc_comments, annotations), 164 + LexToken::Token => self.parse_token(doc_comments, annotations), 165 + LexToken::Query => self.parse_query(doc_comments, annotations), 166 + LexToken::Procedure => self.parse_procedure(doc_comments, annotations), 167 + LexToken::Subscription => self.parse_subscription(doc_comments, annotations), 122 168 LexToken::Use => self.parse_use(), 123 169 _ => Err(ParseError::Syntax { 124 170 message: alloc::format!("Expected item definition, found {}", self.current().token), ··· 219 265 } 220 266 } 221 267 222 - fn parse_record(&mut self, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 268 + fn parse_record(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 223 269 let start = self.expect(LexToken::Record)?; 224 270 let name = self.parse_ident()?; 225 271 self.expect(LexToken::LeftBrace)?; 226 272 227 273 let mut fields = Vec::new(); 228 - let mut doc_comments = Vec::new(); 274 + let mut field_docs = Vec::new(); 229 275 230 276 while !matches!(self.current().token, LexToken::RightBrace) { 231 277 if let LexToken::DocComment(comment) = &self.current().token { 232 278 let span = self.current().span; 233 - doc_comments.push(DocComment { 279 + field_docs.push(DocComment { 234 280 text: comment.clone(), 235 281 span, 236 282 }); 237 283 self.advance(); 238 284 } else { 239 - fields.push(self.parse_field(doc_comments.clone())?); 240 - doc_comments.clear(); 285 + fields.push(self.parse_field(field_docs.clone())?); 286 + field_docs.clear(); 241 287 } 242 288 } 243 289 244 290 let end = self.expect(LexToken::RightBrace)?; 245 - self.expect(LexToken::Semicolon)?; 246 291 247 292 Ok(Item::Record(Record { 248 - docs: Vec::new(), 293 + docs, 249 294 annotations, 250 295 name, 251 296 fields, ··· 255 300 256 301 fn parse_field(&mut self, docs: Vec<DocComment>) -> Result<Field, ParseError> { 257 302 let annotations = self.parse_annotations()?; 258 - let name = self.parse_ident()?; 303 + let name = self.parse_field_name()?; 259 304 260 305 let optional = if matches!(self.current().token, LexToken::Question) { 261 306 self.advance(); ··· 266 311 267 312 self.expect(LexToken::Colon)?; 268 313 let ty = self.parse_type()?; 269 - self.expect(LexToken::Comma)?; 314 + 315 + // Comma is required (unless we're at the end) 316 + if !matches!(self.current().token, LexToken::RightBrace) { 317 + self.expect(LexToken::Comma)?; 318 + } else if matches!(self.current().token, LexToken::Comma) { 319 + // Allow trailing comma 320 + self.advance(); 321 + } 270 322 271 323 let span = Span::new(name.span.start, ty.span().end); 272 324 ··· 280 332 }) 281 333 } 282 334 283 - fn parse_alias(&mut self, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 284 - let start = self.expect(LexToken::Alias)?; 335 + fn parse_inline_type(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 336 + let start = self.expect(LexToken::Inline)?; 337 + self.expect(LexToken::Type)?; 338 + let name = self.parse_ident()?; 339 + self.expect(LexToken::Equals)?; 340 + let ty = self.parse_type()?; 341 + let end = self.expect(LexToken::Semicolon)?; 342 + 343 + Ok(Item::InlineType(InlineType { 344 + docs, 345 + annotations, 346 + name, 347 + ty, 348 + span: Span::new(start.start, end.end), 349 + })) 350 + } 351 + 352 + fn parse_def_type(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 353 + let start = self.expect(LexToken::Def)?; 354 + self.expect(LexToken::Type)?; 285 355 let name = self.parse_ident()?; 286 356 self.expect(LexToken::Equals)?; 287 357 let ty = self.parse_type()?; 288 358 let end = self.expect(LexToken::Semicolon)?; 289 359 290 - Ok(Item::Alias(Alias { 291 - docs: Vec::new(), 360 + Ok(Item::DefType(DefType { 361 + docs, 292 362 annotations, 293 363 name, 294 364 ty, ··· 296 366 })) 297 367 } 298 368 299 - fn parse_token(&mut self, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 369 + fn parse_token(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 300 370 let start = self.expect(LexToken::Token)?; 301 371 let name = self.parse_ident()?; 302 372 let end = self.expect(LexToken::Semicolon)?; 303 373 304 374 Ok(Item::Token(Token { 305 - docs: Vec::new(), 375 + docs, 306 376 annotations, 307 377 name, 308 378 span: Span::new(start.start, end.end), 309 379 })) 310 380 } 311 381 312 - fn parse_query(&mut self, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 382 + fn parse_query(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 313 383 let start = self.expect(LexToken::Query)?; 314 384 let name = self.parse_ident()?; 315 385 self.expect(LexToken::LeftParen)?; ··· 351 421 let end = self.expect(LexToken::Semicolon)?; 352 422 353 423 Ok(Item::Query(Query { 354 - docs: Vec::new(), 424 + docs, 355 425 annotations, 356 426 name, 357 427 params, ··· 360 430 })) 361 431 } 362 432 363 - fn parse_procedure(&mut self, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 433 + fn parse_procedure(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 364 434 let start = self.expect(LexToken::Procedure)?; 365 435 let name = self.parse_ident()?; 366 436 self.expect(LexToken::LeftParen)?; ··· 402 472 let end = self.expect(LexToken::Semicolon)?; 403 473 404 474 Ok(Item::Procedure(Procedure { 405 - docs: Vec::new(), 475 + docs, 406 476 annotations, 407 477 name, 408 478 params, ··· 411 481 })) 412 482 } 413 483 414 - fn parse_subscription(&mut self, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 484 + fn parse_subscription(&mut self, docs: Vec<DocComment>, annotations: Vec<Annotation>) -> Result<Item, ParseError> { 415 485 let start = self.expect(LexToken::Subscription)?; 416 486 let name = self.parse_ident()?; 417 487 self.expect(LexToken::LeftParen)?; ··· 426 496 let end = self.expect(LexToken::Semicolon)?; 427 497 428 498 Ok(Item::Subscription(Subscription { 429 - docs: Vec::new(), 499 + docs, 430 500 annotations, 431 501 name, 432 502 params, ··· 435 505 })) 436 506 } 437 507 438 - fn parse_namespace(&mut self) -> Result<Item, ParseError> { 439 - let start = self.expect(LexToken::Namespace)?; 440 - let path = self.parse_path()?; 441 - 442 - // Convert path to a single identifier with dotted name 443 - let name = Ident { 444 - name: path.segments.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join("."), 445 - span: path.span, 446 - }; 447 - 448 - let end = self.expect(LexToken::Semicolon)?; 449 - 450 - Ok(Item::Namespace(Namespace { 451 - name, 452 - items: Vec::new(), 453 - span: Span::new(start.start, end.end), 454 - })) 455 - } 456 - 457 508 fn parse_use(&mut self) -> Result<Item, ParseError> { 458 509 let start = self.expect(LexToken::Use)?; 459 510 let path = self.parse_path()?; ··· 480 531 481 532 fn parse_params(&mut self) -> Result<Vec<Field>, ParseError> { 482 533 let mut params = Vec::new(); 534 + let mut doc_comments = Vec::new(); 483 535 484 536 while !matches!(self.current().token, LexToken::RightParen) { 485 - let annotations = self.parse_annotations()?; 486 - let name = self.parse_ident()?; 487 - 488 - let optional = if matches!(self.current().token, LexToken::Question) { 537 + if let LexToken::DocComment(comment) = &self.current().token { 538 + let span = self.current().span; 539 + doc_comments.push(DocComment { 540 + text: comment.clone(), 541 + span, 542 + }); 489 543 self.advance(); 490 - true 491 544 } else { 492 - false 493 - }; 545 + let annotations = self.parse_annotations()?; 546 + let name = self.parse_ident()?; 494 547 495 - self.expect(LexToken::Colon)?; 496 - let ty = self.parse_type()?; 548 + let optional = if matches!(self.current().token, LexToken::Question) { 549 + self.advance(); 550 + true 551 + } else { 552 + false 553 + }; 554 + 555 + self.expect(LexToken::Colon)?; 556 + let ty = self.parse_type()?; 497 557 498 - let span = Span::new(name.span.start, ty.span().end); 558 + let span = Span::new(name.span.start, ty.span().end); 499 559 500 - params.push(Field { 501 - docs: Vec::new(), 502 - annotations, 503 - name, 504 - ty, 505 - optional, 506 - span, 507 - }); 560 + params.push(Field { 561 + docs: doc_comments.clone(), 562 + annotations, 563 + name, 564 + ty, 565 + optional, 566 + span, 567 + }); 568 + doc_comments.clear(); 508 569 509 - if matches!(self.current().token, LexToken::Comma) { 510 - self.advance(); 511 - } else { 512 - break; 570 + if matches!(self.current().token, LexToken::Comma) { 571 + self.advance(); 572 + } else { 573 + break; 574 + } 513 575 } 514 576 } 515 577 ··· 533 595 } else { 534 596 let name = self.parse_ident()?; 535 597 let span = name.span; 536 - self.expect(LexToken::Comma)?; 598 + 599 + // Comma is required (unless we're at the end) 600 + if !matches!(self.current().token, LexToken::RightBrace) { 601 + self.expect(LexToken::Comma)?; 602 + } else if matches!(self.current().token, LexToken::Comma) { 603 + // Allow trailing comma 604 + self.advance(); 605 + } 606 + 537 607 errors.push(ErrorDef { 538 608 docs: doc_comments.clone(), 539 609 name, ··· 644 714 LexToken::LeftBrace => { 645 715 self.advance(); 646 716 let mut fields = Vec::new(); 717 + let mut doc_comments = Vec::new(); 647 718 648 719 while !matches!(self.current().token, LexToken::RightBrace) { 649 - fields.push(self.parse_field(Vec::new())?); 720 + if let LexToken::DocComment(comment) = &self.current().token { 721 + let span = self.current().span; 722 + doc_comments.push(DocComment { 723 + text: comment.clone(), 724 + span, 725 + }); 726 + self.advance(); 727 + } else { 728 + fields.push(self.parse_field(doc_comments.clone())?); 729 + doc_comments.clear(); 730 + } 650 731 } 651 732 652 733 let end = self.expect(LexToken::RightBrace)?; 653 734 Type::Object { 654 735 fields, 736 + span: Span::new(start, end.end), 737 + } 738 + } 739 + LexToken::LeftParen => { 740 + self.advance(); 741 + let inner = self.parse_type()?; 742 + let end = self.expect(LexToken::RightParen)?; 743 + Type::Parenthesized { 744 + inner: alloc::boxed::Box::new(inner), 655 745 span: Span::new(start, end.end), 656 746 } 657 747 } ··· 682 772 while !matches!(self.current().token, LexToken::RightBrace) { 683 773 constraints.push(self.parse_constraint()?); 684 774 685 - if matches!(self.current().token, LexToken::Comma) { 775 + // Comma is required (unless we're at the end) 776 + if !matches!(self.current().token, LexToken::RightBrace) { 777 + self.expect(LexToken::Comma)?; 778 + } else if matches!(self.current().token, LexToken::Comma) { 779 + // Allow trailing comma 686 780 self.advance(); 687 - } else { 688 - break; 689 781 } 690 782 } 691 783 ··· 777 869 778 870 while !matches!(self.current().token, LexToken::RightBracket) { 779 871 let current = self.current(); 780 - match &current.token { 872 + // Accept either string literals or identifier paths 873 + let value_ref = match &current.token { 781 874 LexToken::StringLit(s) => { 782 - values.push(s.clone()); 875 + let v = ValueRef::Literal(s.clone()); 783 876 self.advance(); 877 + v 878 + } 879 + LexToken::Ident(_) => { 880 + let path = self.parse_path()?; 881 + ValueRef::Reference(path) 784 882 } 785 883 _ => { 786 884 return Err(ParseError::Syntax { 787 - message: alloc::format!("Expected string literal in enum"), 885 + message: alloc::format!("Expected string literal or identifier in enum"), 788 886 span: current.span, 789 887 }); 790 888 } 791 - } 889 + }; 890 + values.push(value_ref); 792 891 793 892 if matches!(self.current().token, LexToken::Comma) { 794 893 self.advance(); ··· 914 1013 let mut values = Vec::new(); 915 1014 916 1015 while !matches!(self.current().token, LexToken::RightBracket) { 917 - values.push(self.parse_path()?); 1016 + let current = self.current(); 1017 + // Accept either string literals or identifier paths 1018 + let value_ref = match &current.token { 1019 + LexToken::StringLit(s) => { 1020 + let v = ValueRef::Literal(s.clone()); 1021 + self.advance(); 1022 + v 1023 + } 1024 + LexToken::Ident(_) => { 1025 + let path = self.parse_path()?; 1026 + ValueRef::Reference(path) 1027 + } 1028 + _ => { 1029 + return Err(ParseError::Syntax { 1030 + message: alloc::format!("Expected string literal or identifier in knownValues"), 1031 + span: current.span, 1032 + }); 1033 + } 1034 + }; 1035 + values.push(value_ref); 918 1036 919 1037 if matches!(self.current().token, LexToken::Comma) { 920 1038 self.advance(); ··· 959 1077 self.advance(); 960 1078 v 961 1079 } 1080 + LexToken::Ident(_) => { 1081 + let path = self.parse_path()?; 1082 + ConstraintValue::Reference(path) 1083 + } 962 1084 _ => { 963 1085 return Err(ParseError::Syntax { 964 - message: alloc::format!("Expected string, integer, or boolean for default"), 1086 + message: alloc::format!("Expected string, integer, boolean, or identifier for default"), 965 1087 span: current_span, 966 1088 }); 967 1089 } 968 1090 }; 969 1091 Constraint::Default { 1092 + value, 1093 + span: Span::new(start, end_span), 1094 + } 1095 + } 1096 + "const" => { 1097 + use crate::ast::ConstraintValue; 1098 + let end_span = current_span.end; 1099 + let value = match &current.token { 1100 + LexToken::StringLit(s) => { 1101 + let v = ConstraintValue::String(s.clone()); 1102 + self.advance(); 1103 + v 1104 + } 1105 + LexToken::IntLit(i) => { 1106 + let v = ConstraintValue::Integer(*i); 1107 + self.advance(); 1108 + v 1109 + } 1110 + LexToken::True => { 1111 + let v = ConstraintValue::Boolean(true); 1112 + self.advance(); 1113 + v 1114 + } 1115 + LexToken::False => { 1116 + let v = ConstraintValue::Boolean(false); 1117 + self.advance(); 1118 + v 1119 + } 1120 + LexToken::Ident(_) => { 1121 + let path = self.parse_path()?; 1122 + ConstraintValue::Reference(path) 1123 + } 1124 + _ => { 1125 + return Err(ParseError::Syntax { 1126 + message: alloc::format!("Expected string, integer, boolean, or identifier for const"), 1127 + span: current_span, 1128 + }); 1129 + } 1130 + }; 1131 + Constraint::Const { 970 1132 value, 971 1133 span: Span::new(start, end_span), 972 1134 }
+159 -24
mlf-lang/src/workspace.rs
··· 141 141 142 142 fn typecheck_item(&self, namespace: &str, item: &Item) -> Result<(), ValidationErrors> { 143 143 match item { 144 - Item::Alias(a) => self.typecheck_alias(namespace, a), 144 + Item::InlineType(i) => self.typecheck_inline_type(namespace, i), 145 + Item::DefType(d) => self.typecheck_def_type(namespace, d), 145 146 Item::Record(r) => self.typecheck_record(namespace, r), 146 147 _ => Ok(()), 147 148 } 148 149 } 149 150 150 - fn typecheck_alias(&self, namespace: &str, alias: &Alias) -> Result<(), ValidationErrors> { 151 - self.typecheck_type(namespace, &alias.ty) 151 + fn typecheck_inline_type(&self, namespace: &str, inline_type: &InlineType) -> Result<(), ValidationErrors> { 152 + self.typecheck_type(namespace, &inline_type.ty) 153 + } 154 + 155 + fn typecheck_def_type(&self, namespace: &str, def_type: &DefType) -> Result<(), ValidationErrors> { 156 + self.typecheck_type(namespace, &def_type.ty) 152 157 } 153 158 154 159 fn typecheck_record(&self, namespace: &str, record: &Record) -> Result<(), ValidationErrors> { ··· 209 214 Err(errors) 210 215 } 211 216 } 217 + Type::Parenthesized { inner, .. } => self.typecheck_type(namespace, inner), 212 218 Type::Constrained { base, constraints, span } => { 213 219 let mut errors = ValidationErrors::new(); 214 220 ··· 273 279 } 274 280 Constraint::KnownValues { .. } => {} 275 281 Constraint::Default { .. } => {} 282 + Constraint::Const { .. } => {} 276 283 } 277 284 } 278 285 ··· 434 441 all_constraints.extend(self.get_base_constraints(base)); 435 442 all_constraints 436 443 } 444 + Type::Parenthesized { inner, .. } => self.get_base_constraints(inner), 437 445 Type::Reference { path, .. } => { 438 446 if let Some(resolved_ty) = self.resolve_type_reference(path) { 439 447 self.get_base_constraints(&resolved_ty) ··· 449 457 match ty { 450 458 Type::Primitive { kind, .. } => Some(*kind), 451 459 Type::Constrained { base, .. } => self.get_base_primitive(base), 460 + Type::Parenthesized { inner, .. } => self.get_base_primitive(inner), 452 461 Type::Reference { path, .. } => { 453 462 if let Some(resolved_ty) = self.resolve_type_reference(path) { 454 463 self.get_base_primitive(&resolved_ty) ··· 460 469 } 461 470 } 462 471 463 - fn resolve_type_reference(&self, path: &Path) -> Option<Type> { 472 + pub fn resolve_type_reference(&self, path: &Path) -> Option<Type> { 464 473 if path.segments.len() == 1 { 465 474 let name = &path.segments[0].name; 466 475 467 476 for (_, module) in &self.modules { 468 477 if let Some(Symbol::Alias { .. }) = module.symbols.types.get(name) { 469 478 for item in &module.lexicon.items { 470 - if let Item::Alias(a) = item { 471 - if a.name.name == *name { 472 - return Some(a.ty.clone()); 479 + match item { 480 + Item::InlineType(i) if i.name.name == *name => { 481 + return Some(i.ty.clone()); 473 482 } 483 + Item::DefType(d) if d.name.name == *name => { 484 + return Some(d.ty.clone()); 485 + } 486 + _ => {} 474 487 } 475 488 } 476 489 } ··· 485 498 486 499 if let Some(module) = self.modules.get(&target_namespace) { 487 500 for item in &module.lexicon.items { 488 - if let Item::Alias(a) = item { 489 - if a.name.name == *type_name { 490 - return Some(a.ty.clone()); 501 + match item { 502 + Item::InlineType(i) if i.name.name == *type_name => { 503 + return Some(i.ty.clone()); 504 + } 505 + Item::DefType(d) if d.name.name == *type_name => { 506 + return Some(d.ty.clone()); 491 507 } 508 + _ => {} 492 509 } 493 510 } 494 511 } ··· 497 514 None 498 515 } 499 516 517 + pub fn is_inline_type(&self, path: &Path) -> bool { 518 + if path.segments.len() == 1 { 519 + let name = &path.segments[0].name; 520 + 521 + for (_, module) in &self.modules { 522 + if let Some(Symbol::Alias { .. }) = module.symbols.types.get(name) { 523 + for item in &module.lexicon.items { 524 + if let Item::InlineType(i) = item { 525 + if i.name.name == *name { 526 + return true; 527 + } 528 + } 529 + } 530 + } 531 + } 532 + } else { 533 + let target_namespace = path.segments[..path.segments.len() - 1] 534 + .iter() 535 + .map(|s| s.name.as_str()) 536 + .collect::<Vec<_>>() 537 + .join("."); 538 + let type_name = &path.segments[path.segments.len() - 1].name; 539 + 540 + if let Some(module) = self.modules.get(&target_namespace) { 541 + for item in &module.lexicon.items { 542 + if let Item::InlineType(i) = item { 543 + if i.name.name == *type_name { 544 + return true; 545 + } 546 + } 547 + } 548 + } 549 + } 550 + 551 + false 552 + } 553 + 500 554 fn resolve_imports(&mut self) -> Result<(), ValidationErrors> { 501 555 let mut errors = ValidationErrors::new(); 502 556 ··· 624 678 for item in &lexicon.items { 625 679 match item { 626 680 Item::Record(r) => { 681 + // Check for reserved names 682 + if r.name.name == "main" || r.name.name == "defs" { 683 + errors.push(crate::error::ValidationError::ReservedName { 684 + name: r.name.name.clone(), 685 + span: r.name.span, 686 + }); 687 + } 688 + 627 689 if let Some(existing) = symbols.types.get(&r.name.name) { 628 690 errors.push(crate::error::ValidationError::DuplicateDefinition { 629 691 name: r.name.name.clone(), ··· 640 702 ); 641 703 } 642 704 } 643 - Item::Alias(a) => { 644 - if let Some(existing) = symbols.types.get(&a.name.name) { 705 + Item::InlineType(i) => { 706 + // Check for reserved names 707 + if i.name.name == "main" || i.name.name == "defs" { 708 + errors.push(crate::error::ValidationError::ReservedName { 709 + name: i.name.name.clone(), 710 + span: i.name.span, 711 + }); 712 + } 713 + 714 + if let Some(existing) = symbols.types.get(&i.name.name) { 645 715 errors.push(crate::error::ValidationError::DuplicateDefinition { 646 - name: a.name.name.clone(), 716 + name: i.name.name.clone(), 647 717 first_span: existing.span(), 648 - second_span: a.name.span, 718 + second_span: i.name.span, 649 719 }); 650 720 } else { 651 721 symbols.types.insert( 652 - a.name.name.clone(), 722 + i.name.name.clone(), 653 723 Symbol::Alias { 654 - name: a.name.name.clone(), 655 - span: a.name.span, 724 + name: i.name.name.clone(), 725 + span: i.name.span, 726 + }, 727 + ); 728 + } 729 + } 730 + Item::DefType(d) => { 731 + // Check for reserved names 732 + if d.name.name == "main" || d.name.name == "defs" { 733 + errors.push(crate::error::ValidationError::ReservedName { 734 + name: d.name.name.clone(), 735 + span: d.name.span, 736 + }); 737 + } 738 + 739 + if let Some(existing) = symbols.types.get(&d.name.name) { 740 + errors.push(crate::error::ValidationError::DuplicateDefinition { 741 + name: d.name.name.clone(), 742 + first_span: existing.span(), 743 + second_span: d.name.span, 744 + }); 745 + } else { 746 + symbols.types.insert( 747 + d.name.name.clone(), 748 + Symbol::Alias { 749 + name: d.name.name.clone(), 750 + span: d.name.span, 656 751 }, 657 752 ); 658 753 } 659 754 } 660 755 Item::Token(t) => { 756 + // Check for reserved names 757 + if t.name.name == "main" || t.name.name == "defs" { 758 + errors.push(crate::error::ValidationError::ReservedName { 759 + name: t.name.name.clone(), 760 + span: t.name.span, 761 + }); 762 + } 763 + 661 764 if let Some(existing) = symbols.types.get(&t.name.name) { 662 765 errors.push(crate::error::ValidationError::DuplicateDefinition { 663 766 name: t.name.name.clone(), ··· 674 777 ); 675 778 } 676 779 } 677 - Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => { 678 - // These don't define types, so skip 780 + Item::Query(q) => { 781 + // Check for reserved names 782 + if q.name.name == "main" || q.name.name == "defs" { 783 + errors.push(crate::error::ValidationError::ReservedName { 784 + name: q.name.name.clone(), 785 + span: q.name.span, 786 + }); 787 + } 788 + } 789 + Item::Procedure(p) => { 790 + // Check for reserved names 791 + if p.name.name == "main" || p.name.name == "defs" { 792 + errors.push(crate::error::ValidationError::ReservedName { 793 + name: p.name.name.clone(), 794 + span: p.name.span, 795 + }); 796 + } 679 797 } 680 - Item::Namespace(_) | Item::Use(_) => { 798 + Item::Subscription(s) => { 799 + // Check for reserved names 800 + if s.name.name == "main" || s.name.name == "defs" { 801 + errors.push(crate::error::ValidationError::ReservedName { 802 + name: s.name.name.clone(), 803 + span: s.name.span, 804 + }); 805 + } 806 + } 807 + Item::Use(_) => { 681 808 // Handled separately 682 809 } 683 810 } ··· 709 836 fn resolve_item(&self, namespace: &str, item: &Item) -> Result<(), ValidationErrors> { 710 837 match item { 711 838 Item::Record(r) => self.resolve_record(namespace, r), 712 - Item::Alias(a) => self.resolve_alias(namespace, a), 839 + Item::InlineType(i) => self.resolve_inline_type(namespace, i), 840 + Item::DefType(d) => self.resolve_def_type(namespace, d), 713 841 Item::Query(q) => self.resolve_query(namespace, q), 714 842 Item::Procedure(p) => self.resolve_procedure(namespace, p), 715 843 Item::Subscription(s) => self.resolve_subscription(namespace, s), 716 - Item::Token(_) | Item::Namespace(_) | Item::Use(_) => Ok(()), 844 + Item::Token(_) | Item::Use(_) => Ok(()), 717 845 } 718 846 } 719 847 ··· 733 861 } 734 862 } 735 863 736 - fn resolve_alias(&self, namespace: &str, alias: &Alias) -> Result<(), ValidationErrors> { 737 - self.resolve_type(namespace, &alias.ty) 864 + fn resolve_inline_type(&self, namespace: &str, inline_type: &InlineType) -> Result<(), ValidationErrors> { 865 + self.resolve_type(namespace, &inline_type.ty) 866 + } 867 + 868 + fn resolve_def_type(&self, namespace: &str, def_type: &DefType) -> Result<(), ValidationErrors> { 869 + self.resolve_type(namespace, &def_type.ty) 738 870 } 739 871 740 872 fn resolve_query(&self, namespace: &str, query: &Query) -> Result<(), ValidationErrors> { ··· 849 981 } else { 850 982 Err(errors) 851 983 } 984 + } 985 + Type::Parenthesized { inner, .. } => { 986 + self.resolve_type(namespace, inner) 852 987 } 853 988 Type::Constrained { base, .. } => { 854 989 self.resolve_type(namespace, base)
+20 -6
mlf-validation/src/lib.rs
··· 97 97 Type::Union { types, .. } => { 98 98 self.validate_union(value, types, path, errors); 99 99 } 100 + Type::Parenthesized { inner, .. } => { 101 + self.validate_against_type(value, inner, path, errors); 102 + } 100 103 Type::Reference { path: ref_path, .. } => { 101 104 // Try to resolve reference 102 105 if let Some(resolved_type) = self.resolve_reference(ref_path) { ··· 112 115 } 113 116 114 117 fn resolve_reference(&self, path: &Path) -> Option<Type> { 115 - // Simple resolution: look for aliases with matching name 118 + // Simple resolution: look for inline/def types with matching name 116 119 if path.segments.len() == 1 { 117 120 let name = &path.segments[0].name; 118 121 for item in &self.lexicon.items { 119 - if let Item::Alias(alias) = item { 120 - if alias.name.name == *name { 121 - return Some(alias.ty.clone()); 122 + match item { 123 + Item::InlineType(i) if i.name.name == *name => { 124 + return Some(i.ty.clone()); 122 125 } 126 + Item::DefType(d) if d.name.name == *name => { 127 + return Some(d.ty.clone()); 128 + } 129 + _ => {} 123 130 } 124 131 } 125 132 } ··· 300 307 } 301 308 Constraint::Enum { values, .. } => { 302 309 if let Some(s) = value.as_str() { 303 - if !values.contains(&s.to_string()) { 310 + let enum_strings: Vec<String> = values.iter().map(|v| match v { 311 + mlf_lang::ast::ValueRef::Literal(lit) => lit.clone(), 312 + mlf_lang::ast::ValueRef::Reference(path) => path.to_string(), 313 + }).collect(); 314 + if !enum_strings.contains(&s.to_string()) { 304 315 errors.push(ValidationError { 305 316 path: path.to_string(), 306 - message: format!("Value '{}' not in enum: {:?}", s, values), 317 + message: format!("Value '{}' not in enum: {:?}", s, enum_strings), 307 318 }); 308 319 } 309 320 } ··· 344 355 } 345 356 Constraint::Default { .. } => { 346 357 // Default values are used when field is missing, not for validation 358 + } 359 + Constraint::Const { .. } => { 360 + // Const values are enforced at compile time, not runtime validation 347 361 } 348 362 } 349 363 }
+34 -1
mlf-wasm/src/lib.rs
··· 106 106 } 107 107 }; 108 108 109 + // Create workspace with prelude for type resolution 110 + let mut workspace = match mlf_lang::Workspace::with_prelude() { 111 + Ok(ws) => ws, 112 + Err(e) => { 113 + let result = GenerateResult { 114 + success: false, 115 + lexicon: None, 116 + error: Some(format!("Failed to load prelude: {:?}", e)), 117 + }; 118 + return serde_wasm_bindgen::to_value(&result).unwrap(); 119 + } 120 + }; 121 + 122 + // Add the module to workspace for resolution 123 + if let Err(e) = workspace.add_module(namespace.to_string(), lexicon.clone()) { 124 + let result = GenerateResult { 125 + success: false, 126 + lexicon: None, 127 + error: Some(format!("Failed to add module: {:?}", e)), 128 + }; 129 + return serde_wasm_bindgen::to_value(&result).unwrap(); 130 + } 131 + 132 + // Resolve types (expands inline types from prelude) 133 + if let Err(e) = workspace.resolve() { 134 + let result = GenerateResult { 135 + success: false, 136 + lexicon: None, 137 + error: Some(format!("Type resolution error: {:?}", e)), 138 + }; 139 + return serde_wasm_bindgen::to_value(&result).unwrap(); 140 + } 141 + 109 142 // Generate JSON lexicon 110 - let json_lexicon = mlf_codegen::generate_lexicon(namespace, &lexicon); 143 + let json_lexicon = mlf_codegen::generate_lexicon(namespace, &lexicon, &workspace); 111 144 112 145 match serde_json::to_string_pretty(&json_lexicon) { 113 146 Ok(json_str) => {
+11 -11
resources/prelude.mlf
··· 1 - alias AtIdentifier = string constrained { 1 + inline type AtIdentifier = string constrained { 2 2 format: "at-identifier", 3 3 }; 4 4 5 - alias AtUri = string constrained { 5 + inline type AtUri = string constrained { 6 6 format: "at-uri", 7 7 }; 8 8 9 - alias Cid = string constrained { 9 + inline type Cid = string constrained { 10 10 format: "cid", 11 11 }; 12 12 13 - alias Datetime = string constrained { 13 + inline type Datetime = string constrained { 14 14 format: "datetime", 15 15 }; 16 16 17 - alias Did = string constrained { 17 + inline type Did = string constrained { 18 18 format: "did", 19 19 }; 20 20 21 - alias Handle = string constrained { 21 + inline type Handle = string constrained { 22 22 format: "handle", 23 23 }; 24 24 25 - alias Nsid = string constrained { 25 + inline type Nsid = string constrained { 26 26 format: "nsid", 27 27 }; 28 28 29 - alias Tid = string constrained { 29 + inline type Tid = string constrained { 30 30 format: "tid", 31 31 }; 32 32 33 - alias RecordKey = string constrained { 33 + inline type RecordKey = string constrained { 34 34 format: "record-key", 35 35 }; 36 36 37 - alias Uri = string constrained { 37 + inline type Uri = string constrained { 38 38 format: "uri", 39 39 }; 40 40 41 - alias Language = string constrained { 41 + inline type Language = string constrained { 42 42 format: "language", 43 43 };
+17 -16
tree-sitter-mlf/grammar.js
··· 20 20 source_file: $ => repeat($.item), 21 21 22 22 item: $ => choice( 23 - $.namespace_declaration, 24 23 $.use_statement, 25 24 $.record_definition, 26 - $.alias_definition, 25 + $.inline_type_definition, 26 + $.def_type_definition, 27 27 $.token_definition, 28 28 $.query_definition, 29 29 $.procedure_definition, ··· 34 34 doc_comment: $ => token(seq('///', /.*/)), 35 35 comment: $ => token(seq('//', /.*/)), 36 36 37 - // Namespace 38 - namespace_declaration: $ => seq( 39 - 'namespace', 40 - field('name', $.namespace_identifier), 41 - ';' 42 - ), 43 - 44 - namespace_identifier: $ => /[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*/, 45 - 46 37 // Use statements 47 38 use_statement: $ => seq( 48 39 'use', ··· 54 45 record_definition: $ => seq( 55 46 'record', 56 47 field('name', $.identifier), 57 - field('body', $.record_body), 58 - ';' 48 + field('body', $.record_body) 59 49 ), 60 50 61 51 record_body: $ => seq( ··· 73 63 ',' 74 64 ), 75 65 76 - // Alias definition 77 - alias_definition: $ => seq( 78 - 'alias', 66 + // Inline type definition 67 + inline_type_definition: $ => seq( 68 + 'inline', 69 + 'type', 70 + field('name', $.identifier), 71 + '=', 72 + field('type', $.type), 73 + ';' 74 + ), 75 + 76 + // Def type definition 77 + def_type_definition: $ => seq( 78 + 'def', 79 + 'type', 79 80 field('name', $.identifier), 80 81 '=', 81 82 field('type', $.type),
+48 -3
tree-sitter-mlf/src/grammar.json
··· 25 25 }, 26 26 { 27 27 "type": "SYMBOL", 28 - "name": "alias_definition" 28 + "name": "inline_type_definition" 29 + }, 30 + { 31 + "type": "SYMBOL", 32 + "name": "def_type_definition" 29 33 }, 30 34 { 31 35 "type": "SYMBOL", ··· 225 229 } 226 230 ] 227 231 }, 228 - "alias_definition": { 232 + "inline_type_definition": { 233 + "type": "SEQ", 234 + "members": [ 235 + { 236 + "type": "STRING", 237 + "value": "inline" 238 + }, 239 + { 240 + "type": "STRING", 241 + "value": "type" 242 + }, 243 + { 244 + "type": "FIELD", 245 + "name": "name", 246 + "content": { 247 + "type": "SYMBOL", 248 + "name": "identifier" 249 + } 250 + }, 251 + { 252 + "type": "STRING", 253 + "value": "=" 254 + }, 255 + { 256 + "type": "FIELD", 257 + "name": "type", 258 + "content": { 259 + "type": "SYMBOL", 260 + "name": "type" 261 + } 262 + }, 263 + { 264 + "type": "STRING", 265 + "value": ";" 266 + } 267 + ] 268 + }, 269 + "def_type_definition": { 229 270 "type": "SEQ", 230 271 "members": [ 231 272 { 232 273 "type": "STRING", 233 - "value": "alias" 274 + "value": "def" 275 + }, 276 + { 277 + "type": "STRING", 278 + "value": "type" 234 279 }, 235 280 { 236 281 "type": "FIELD",
+71 -33
tree-sitter-mlf/src/node-types.json
··· 1 1 [ 2 2 { 3 - "type": "alias_definition", 4 - "named": true, 5 - "fields": { 6 - "name": { 7 - "multiple": false, 8 - "required": true, 9 - "types": [ 10 - { 11 - "type": "identifier", 12 - "named": true 13 - } 14 - ] 15 - }, 16 - "type": { 17 - "multiple": false, 18 - "required": true, 19 - "types": [ 20 - { 21 - "type": "type", 22 - "named": true 23 - } 24 - ] 25 - } 26 - } 27 - }, 28 - { 29 3 "type": "array_literal", 30 4 "named": true, 31 5 "fields": {}, ··· 156 130 } 157 131 }, 158 132 { 133 + "type": "def_type_definition", 134 + "named": true, 135 + "fields": { 136 + "name": { 137 + "multiple": false, 138 + "required": true, 139 + "types": [ 140 + { 141 + "type": "identifier", 142 + "named": true 143 + } 144 + ] 145 + }, 146 + "type": { 147 + "multiple": false, 148 + "required": true, 149 + "types": [ 150 + { 151 + "type": "type", 152 + "named": true 153 + } 154 + ] 155 + } 156 + } 157 + }, 158 + { 159 159 "type": "error_definition", 160 160 "named": true, 161 161 "fields": { ··· 223 223 "fields": {} 224 224 }, 225 225 { 226 + "type": "inline_type_definition", 227 + "named": true, 228 + "fields": { 229 + "name": { 230 + "multiple": false, 231 + "required": true, 232 + "types": [ 233 + { 234 + "type": "identifier", 235 + "named": true 236 + } 237 + ] 238 + }, 239 + "type": { 240 + "multiple": false, 241 + "required": true, 242 + "types": [ 243 + { 244 + "type": "type", 245 + "named": true 246 + } 247 + ] 248 + } 249 + } 250 + }, 251 + { 226 252 "type": "item", 227 253 "named": true, 228 254 "fields": {}, ··· 231 257 "required": true, 232 258 "types": [ 233 259 { 234 - "type": "alias_definition", 260 + "type": "def_type_definition", 261 + "named": true 262 + }, 263 + { 264 + "type": "inline_type_definition", 235 265 "named": true 236 266 }, 237 267 { ··· 701 731 "named": false 702 732 }, 703 733 { 704 - "type": "alias", 705 - "named": false 706 - }, 707 - { 708 734 "type": "blob", 709 735 "named": false 710 736 }, ··· 725 751 "named": false 726 752 }, 727 753 { 754 + "type": "def", 755 + "named": false 756 + }, 757 + { 728 758 "type": "doc_comment", 729 759 "named": true 730 760 }, ··· 733 763 "named": false 734 764 }, 735 765 { 766 + "type": "inline", 767 + "named": false 768 + }, 769 + { 736 770 "type": "integer", 737 771 "named": false 738 772 }, ··· 770 804 }, 771 805 { 772 806 "type": "string", 773 - "named": true 807 + "named": false 774 808 }, 775 809 { 776 810 "type": "string", 777 - "named": false 811 + "named": true 778 812 }, 779 813 { 780 814 "type": "subscription", ··· 790 824 }, 791 825 { 792 826 "type": "true", 827 + "named": false 828 + }, 829 + { 830 + "type": "type", 793 831 "named": false 794 832 }, 795 833 {
+173 -130
website/content/docs/syntax.md
··· 15 15 ``` 16 16 17 17 ### File Naming Convention 18 - Files should follow the lexicon NSID: 18 + The file path determines the lexicon NSID. Files should follow the lexicon NSID structure: 19 19 - `com.example.forum.thread.mlf` → Lexicon NSID: `com.example.forum.thread` 20 20 - `com.example.user.profile.mlf` → Lexicon NSID: `com.example.user.profile` 21 + 22 + The lexicon NSID is derived solely from the filename, not from any internal declarations. 21 23 22 24 ## Basic Structure 23 25 24 26 Every MLF file can contain: 25 27 26 - - Namespace declarations 27 28 - Use statements (imports) 28 - - Type definitions (record, alias, token, query, procedure, subscription) 29 + - Type definitions (record, inline type, def type, token, query, procedure, subscription) 30 + 31 + ## Syntax Rules 32 + 33 + ### Semicolons 34 + 35 + - **Records** do NOT have semicolons after the closing brace `}` 36 + - All other definitions require semicolons: 37 + - `use` statements end with `;` 38 + - `token` definitions end with `;` 39 + - `inline type` definitions end with `;` 40 + - `def type` definitions end with `;` 41 + - `query` definitions end with `;` 42 + - `procedure` definitions end with `;` 43 + - `subscription` definitions end with `;` 44 + 45 + ### Commas 46 + 47 + Commas are **required** between items, with **trailing commas allowed**: 48 + 49 + **Record fields:** 50 + ```mlf 51 + record example { 52 + field1: string, 53 + field2: integer, // trailing comma allowed 54 + } 55 + ``` 56 + 57 + **Constraints:** 58 + ```mlf 59 + title: string constrained { 60 + maxLength: 200, 61 + minLength: 1, // trailing comma allowed 62 + } 63 + ``` 64 + 65 + **Error definitions:** 66 + ```mlf 67 + query getThread(): thread | error { 68 + NotFound, 69 + BadRequest, // trailing comma allowed 70 + } 71 + ``` 29 72 30 73 ## Primitive Types 31 74 ··· 63 106 record thread { 64 107 /// Thread title 65 108 title: string constrained { 66 - maxLength: 200, 67 - minLength: 1, 68 - }, 109 + maxLength: 200 110 + minLength: 1 111 + } 69 112 /// Thread body 70 - body?: string, // Optional field 113 + body?: string // Optional field 71 114 /// Thread creation timestamp 72 - createdAt: Datetime, 115 + createdAt: Datetime 116 + } 117 + ``` 118 + 119 + ## Type Definitions 120 + 121 + MLF supports two kinds of type definitions: 122 + 123 + ### Inline Types 124 + 125 + Expanded at the point of use, never appear in generated lexicon defs: 126 + 127 + ```mlf 128 + inline type AtIdentifier = string constrained { 129 + format "at-identifier" 73 130 }; 74 131 ``` 75 132 76 - ## Aliases 133 + ### Def Types 77 134 78 - Type aliases define reusable object shapes: 135 + Become named definitions in the lexicon's defs block: 79 136 80 137 ```mlf 81 - alias replyRef = { 82 - root: AtUri, 83 - parent: AtUri, 138 + def type replyRef = { 139 + root: AtUri 140 + parent: AtUri 84 141 }; 85 142 86 143 record thread { 87 - reply?: replyRef, 88 - }; 144 + reply?: replyRef 145 + } 89 146 ``` 90 147 91 - If used in multiple places, they will be hoisted to a def. If only used once, they will be inlined. 148 + Use `inline type` for type aliases that should be expanded inline (like primitive type wrappers). Use `def type` for types that should be referenced by name in the generated lexicon. 92 149 93 150 ## Tokens 94 151 ··· 103 160 104 161 record issue { 105 162 state: string constrained { 106 - knownValues: [open, closed], 107 - default: "open", 108 - }, 109 - }; 163 + knownValues: [open, closed] 164 + default: "open" 165 + } 166 + } 110 167 ``` 111 168 112 169 Tokens must have doc comments describing their purpose. ··· 117 174 118 175 ```mlf 119 176 title: string constrained { 120 - maxLength: 200, 121 - minLength: 1, 122 - }; 177 + maxLength: 200 178 + minLength: 1 179 + } 123 180 124 181 age: integer constrained { 125 - minimum: 0, 126 - maximum: 150, 127 - }; 182 + minimum: 0 183 + maximum: 150 184 + } 128 185 129 186 status: string constrained { 130 - enum: ["draft", "published", "archived"], 131 - }; 187 + enum: ["draft", "published", "archived"] 188 + } 132 189 ``` 133 190 134 191 ### String Constraints ··· 136 193 - `maxLength` / `minLength` - Length in bytes 137 194 - `maxGraphemes` / `minGraphemes` - Length in grapheme clusters 138 195 - `format` - Format validation (datetime, uri, did, handle, etc.) 139 - - `enum` - Allowed values (closed set) 140 - - `knownValues` - Known values (extensible set, can reference tokens) 196 + - `enum` - Allowed values (closed set) - accepts string literals or token references 197 + - `knownValues` - Known values (extensible set) - accepts string literals or token references 141 198 - `default` - Default value 142 199 200 + **enum, knownValues, and default** can use either literals or references: 201 + ```mlf 202 + // String literals 203 + status: string constrained { 204 + knownValues: ["open", "closed", "pending"] 205 + default: "open" 206 + } 207 + 208 + // References to named items (tokens, aliases, records, etc.) 209 + token open; 210 + token closed; 211 + 212 + status: string constrained { 213 + knownValues: [open, closed] // References tokens defined above 214 + default: open // References the token 215 + } 216 + ``` 217 + 143 218 ### Integer Constraints 144 219 145 220 - `minimum` / `maximum` - Min/max values ··· 150 225 151 226 ```mlf 152 227 tags: string[] constrained { 153 - minLength: 1, 154 - maxLength: 10, 228 + minLength: 1 229 + maxLength: 10 155 230 } 156 231 ``` 157 232 ··· 159 234 160 235 ```mlf 161 236 avatar: blob constrained { 162 - accept: ["image/png", "image/jpeg"], 163 - maxSize: 1000000, // bytes 237 + accept: ["image/png", "image/jpeg"] 238 + maxSize: 1000000 // bytes 164 239 } 165 240 ``` 166 241 ··· 168 243 169 244 ```mlf 170 245 field: boolean constrained { 171 - default: false, 246 + default: false 172 247 } 173 248 ``` 174 249 ··· 177 252 Constraints can only make types **more restrictive**, never less restrictive: 178 253 179 254 ```mlf 180 - alias shortString = string constrained { 181 - maxLength: 100, 255 + def type shortString = string constrained { 256 + maxLength: 100 182 257 }; 183 258 184 259 record post { 185 260 // Valid: 50 is more restrictive than 100 186 261 title: shortString constrained { 187 - maxLength: 50, 188 - }, 189 - }; 262 + maxLength: 50 263 + } 264 + } 190 265 ``` 191 266 192 267 **Refinement rules:** ··· 231 306 232 307 ```mlf 233 308 metadata: { 234 - version: integer, 235 - timestamp: Datetime, 309 + version: integer 310 + timestamp: Datetime 236 311 } 237 312 ``` 238 313 ··· 244 319 /// Get a user profile 245 320 query getProfile( 246 321 /// The actor's DID or handle 247 - actor: AtIdentifier, 322 + actor: AtIdentifier 248 323 ): profile; 249 324 ``` 250 325 ··· 252 327 253 328 ```mlf 254 329 query getThread( 255 - uri: AtUri, 330 + uri: AtUri 256 331 ): thread | error { 257 332 /// Thread not found 258 - NotFound, 333 + NotFound 259 334 /// Invalid request 260 - BadRequest, 335 + BadRequest 261 336 }; 262 337 ``` 263 338 ··· 268 343 ```mlf 269 344 /// Create a new thread 270 345 procedure createThread( 271 - title: string, 272 - body: string, 346 + title: string 347 + body: string 273 348 ): { 274 - uri: AtUri, 275 - cid: Cid, 349 + uri: AtUri 350 + cid: Cid 276 351 } | error { 277 352 /// Title too long 278 - TitleTooLong, 353 + TitleTooLong 279 354 }; 280 355 ``` 281 356 ··· 287 362 /// Subscribe to repository events 288 363 subscription subscribeRepos( 289 364 /// Optional cursor for resuming 290 - cursor?: integer, 365 + cursor?: integer 291 366 ): commit | identity | handle; 292 367 ``` 293 368 294 - Message types must be defined as aliases or records: 369 + Message types must be defined as def types or records: 295 370 296 371 ```mlf 297 372 /// Commit message 298 - alias commit = { 299 - seq: integer, 300 - repo: Did, 301 - commit: Cid, 302 - time: Datetime, 373 + def type commit = { 374 + seq: integer 375 + repo: Did 376 + commit: Cid 377 + time: Datetime 303 378 }; 304 379 305 380 /// Identity message 306 - alias identity = { 307 - did: Did, 308 - handle: Handle, 381 + def type identity = { 382 + did: Did 383 + handle: Handle 309 384 }; 310 385 ``` 311 386 ··· 319 394 /// A forum thread 320 395 record thread { 321 396 /// Thread title 322 - title: string, 323 - }; 397 + title: string 398 + } 324 399 ``` 325 400 326 401 ### Regular Comments ··· 330 405 ```mlf 331 406 // This is a regular comment 332 407 record example { 333 - field: string, // inline comment 334 - }; 408 + field: string // inline comment 409 + } 335 410 ``` 336 411 337 412 ## Annotations ··· 342 417 ```mlf 343 418 @deprecated 344 419 record oldRecord { 345 - field: string, 420 + field: string 346 421 } 347 422 ``` 348 423 ··· 351 426 @since(1, 2, 0) 352 427 @doc("https://example.com/docs") 353 428 record example { 354 - field: string, 429 + field: string 355 430 } 356 431 ``` 357 432 ··· 361 436 @table(name: "threads", indexes: "did,createdAt") 362 437 record thread { 363 438 @indexed 364 - did: Did, 439 + did: Did 365 440 366 441 @sensitive(pii: true) 367 - title: string, 442 + title: string 368 443 } 369 444 ``` 370 445 371 - Annotations can be placed on records, aliases, tokens, queries, procedures, subscriptions, and fields. 446 + Annotations can be placed on records, inline types, def types, tokens, queries, procedures, subscriptions, and fields. 372 447 373 448 ## Imports 374 449 ··· 397 472 use com.example.user.profile; 398 473 399 474 record thread { 400 - author: profile, // Instead of com.example.user.profile 401 - } 402 - ``` 403 - 404 - ## Namespaces 405 - 406 - Organize related definitions: 407 - 408 - ```mlf 409 - namespace com.example.forum.thread; 410 - 411 - record thread { 412 - title: string, 413 - }; 414 - ``` 415 - 416 - Or use nested namespaces: 417 - 418 - ```mlf 419 - namespace .forum { 420 - record thread { 421 - title: string, 422 - } 423 - 424 - query getThread( 425 - uri: AtUri, 426 - ): thread; 427 - } 428 - 429 - namespace .user { 430 - record profile { 431 - displayName: string, 432 - } 475 + author: profile // Instead of com.example.user.profile 433 476 } 434 477 ``` 435 478 ··· 440 483 ```mlf 441 484 // Local reference (same file) 442 485 record thread { 443 - author: author, // References 'alias author' in same file 486 + author: author // References 'def type author' in same file 444 487 } 445 488 446 489 // Cross-file reference 447 490 record thread { 448 - profile: com.example.user.profile, // References com/example/user/profile.mlf 491 + profile: com.example.user.profile // References com/example/user/profile.mlf 449 492 } 450 493 ``` 451 494 452 - **Note:** All references use dotted notation. The `#` character is NOT used for references. 495 + All references use dotted notation. 453 496 454 497 ## Optional Fields 455 498 ··· 457 500 458 501 ```mlf 459 502 record thread { 460 - title: string, // Required 461 - body?: string, // Optional 462 - tags?: string[], // Optional array 503 + title: string // Required 504 + body?: string // Optional 505 + tags?: string[] // Optional array 463 506 } 464 507 ``` 465 508 ··· 468 511 Use backticks to escape reserved keywords when you need to use them as identifiers: 469 512 470 513 ```mlf 471 - alias `record` = { 472 - `record`: com.atproto.repo.strongRef, 473 - `error`: string, 514 + def type `record` = { 515 + `record`: com.atproto.repo.strongRef 516 + `error`: string 474 517 }; 475 518 ``` 476 519 ··· 509 552 record thread { 510 553 /// Thread title 511 554 title: string constrained { 512 - minGraphemes: 1, 513 - maxGraphemes: 200, 514 - }, 555 + minGraphemes: 1 556 + maxGraphemes: 200 557 + } 515 558 /// Thread body (markdown) 516 559 body?: string constrained { 517 - maxGraphemes: 10000, 518 - }, 560 + maxGraphemes: 10000 561 + } 519 562 /// Thread state 520 563 state: string constrained { 521 - knownValues: [open, closed], 522 - default: "open", 523 - }, 564 + knownValues: [open, closed] 565 + default: "open" 566 + } 524 567 /// Author profile 525 - author: profile, 568 + author: profile 526 569 /// Creation timestamp 527 - createdAt: Datetime, 528 - }; 570 + createdAt: Datetime 571 + } 529 572 530 573 /// Get a thread by URI 531 574 query getThread( 532 575 /// Thread AT-URI 533 - uri: AtUri, 576 + uri: AtUri 534 577 ): thread | error { 535 578 /// Thread not found 536 - NotFound, 579 + NotFound 537 580 }; 538 581 539 582 /// Create a new thread 540 583 procedure createThread( 541 - title: string, 542 - body?: string, 584 + title: string 585 + body?: string 543 586 ): { 544 - uri: AtUri, 545 - cid: Cid, 587 + uri: AtUri 588 + cid: Cid 546 589 } | error { 547 590 /// Title too long 548 - TitleTooLong, 591 + TitleTooLong 549 592 }; 550 593 ```
+115 -15
website/sass/style.scss
··· 381 381 padding: 0.75rem 1rem; 382 382 background: var(--bg-alt); 383 383 border-bottom: 1px solid var(--border); 384 - height: 3rem; 384 + min-height: 3rem; 385 + flex-wrap: wrap; 386 + gap: 0.5rem; 385 387 } 386 388 387 389 .panel-header h3 { ··· 390 392 margin: 0; 391 393 } 392 394 395 + .file-path-container { 396 + display: flex; 397 + align-items: center; 398 + flex: 1; 399 + } 400 + 401 + .file-path-container input { 402 + flex: 1; 403 + padding: 0.375rem 0.75rem; 404 + background: var(--bg); 405 + border: 1px solid var(--border); 406 + border-radius: 0.25rem; 407 + color: var(--text); 408 + font-size: 0.875rem; 409 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 410 + min-width: 0; 411 + } 412 + 413 + .file-path-container input:focus { 414 + outline: none; 415 + border-color: var(--accent); 416 + } 417 + 393 418 .tabs { 394 419 display: flex; 395 420 gap: 0.5rem; ··· 443 468 color: #718096; 444 469 } 445 470 446 - .shiki-editor-container { 471 + .editor-container-with-lines { 447 472 width: 100%; 448 473 flex: 1; 449 474 min-height: 0; 450 - overflow: auto; 451 475 background: var(--code-bg); 476 + display: flex; 477 + flex-direction: row; 478 + } 479 + 480 + .editor-wrapper { 481 + position: relative; 482 + flex: 1; 483 + min-height: 0; 484 + overflow: hidden; 452 485 } 453 486 454 - .shiki-editor { 455 - min-height: 100%; 487 + .highlight-backdrop { 488 + position: absolute; 489 + top: 0; 490 + left: 0; 491 + right: 0; 492 + bottom: 0; 493 + padding: 1rem; 494 + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Menlo', monospace; 495 + font-size: 0.875rem; 496 + line-height: 1.6; 497 + white-space: pre; 498 + overflow: hidden; 499 + pointer-events: none; 500 + z-index: 1; 501 + } 502 + 503 + .highlight-backdrop span { 504 + font-family: inherit; 505 + font-size: inherit; 506 + line-height: inherit; 507 + } 508 + 509 + .mlf-textarea { 510 + position: absolute; 511 + top: 0; 512 + left: 0; 513 + right: 0; 514 + bottom: 0; 515 + width: 100%; 516 + height: 100%; 456 517 padding: 1rem; 457 518 border: none; 458 - font-family: 'Atkinson Hyperlegible Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Menlo', monospace; 519 + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Menlo', monospace; 459 520 font-size: 0.875rem; 460 521 line-height: 1.6; 461 - color: var(--code-text); 522 + resize: none; 523 + background: transparent; 524 + color: transparent; 462 525 white-space: pre; 463 526 tab-size: 4; 464 - caret-color: var(--text); 527 + overflow: auto; 528 + z-index: 2; 529 + caret-color: #fff; 530 + -webkit-text-fill-color: transparent; 465 531 } 466 532 467 - .shiki-editor:focus { 533 + .mlf-textarea:focus { 468 534 outline: none; 469 535 } 470 536 471 - .shiki-editor span { 472 - font-family: inherit; 473 - font-size: inherit; 474 - line-height: inherit; 537 + .mlf-textarea::selection { 538 + background: rgba(255, 255, 255, 0.2); 475 539 } 476 540 477 - .shiki-output-container { 541 + .shiki-output-outer-container { 478 542 width: 100%; 479 543 flex: 1; 480 544 min-height: 0; 481 - overflow: auto; 482 545 background: var(--code-bg); 546 + display: flex; 547 + flex-direction: row; 548 + } 549 + 550 + .shiki-output-container { 551 + min-height: 100%; 483 552 padding: 1rem; 484 553 font-family: 'Atkinson Hyperlegible Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Menlo', monospace; 485 554 font-size: 0.875rem; ··· 836 905 color: var(--accent); 837 906 text-decoration: none; 838 907 } 908 + 909 + /* Line numbers for code editor */ 910 + .line-numbers { 911 + flex-shrink: 0; 912 + min-width: 3rem; 913 + padding: 1rem 0.5rem 1rem 1rem; 914 + text-align: right; 915 + user-select: none; 916 + color: var(--text-muted); 917 + background: var(--code-bg); 918 + border-right: 1px solid var(--border); 919 + overflow: hidden; 920 + } 921 + 922 + .line-number { 923 + font-family: 'Atkinson Hyperlegible Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Menlo', monospace; 924 + font-size: 0.875rem; 925 + line-height: 1.6; 926 + } 927 + 928 + .editor-wrapper { 929 + flex: 1; 930 + overflow: auto; 931 + min-width: 0; 932 + } 933 + 934 + .output-wrapper { 935 + flex: 1; 936 + overflow: auto; 937 + min-width: 0; 938 + }
+161 -43
website/static/js/app.js
··· 35 35 patterns: [ 36 36 { 37 37 name: 'keyword.control.mlf', 38 - match: '\\b(namespace|use|record|alias|token|query|procedure|subscription|throws|constrained)\\b' 38 + match: '\\b(use|record|inline|def|type|token|query|procedure|subscription|throws|constrained)\\b' 39 39 }, 40 40 { 41 41 name: 'keyword.other.mlf', 42 - match: '\\b(main)\\b' 42 + match: '\\b(main|defs)\\b' 43 43 } 44 44 ] 45 45 }, ··· 144 144 const textarea = document.getElementById('mlf-editor'); 145 145 const initialCode = textarea.value; 146 146 147 - // Create editor container 147 + // Create editor container with line numbers and highlighting 148 148 editorContainer = document.createElement('div'); 149 - editorContainer.className = 'shiki-editor-container'; 149 + editorContainer.className = 'editor-container-with-lines'; 150 150 151 - const editor = document.createElement('div'); 152 - editor.className = 'shiki-editor'; 153 - editor.contentEditable = 'true'; 154 - editor.spellcheck = false; 155 - editor.id = 'shiki-editor'; 151 + // Create line numbers 152 + const lineNumbers = document.createElement('div'); 153 + lineNumbers.className = 'line-numbers'; 154 + lineNumbers.id = 'line-numbers'; 155 + 156 + // Create wrapper for textarea and highlight layer 157 + const editorWrapper = document.createElement('div'); 158 + editorWrapper.className = 'editor-wrapper'; 159 + 160 + // Create highlight backdrop 161 + const highlightBackdrop = document.createElement('div'); 162 + highlightBackdrop.className = 'highlight-backdrop'; 163 + highlightBackdrop.id = 'highlight-backdrop'; 164 + 165 + // Style the textarea 166 + textarea.className = 'mlf-textarea'; 167 + 168 + // Insert container before textarea, then build the structure 169 + const parent = textarea.parentNode; 170 + parent.insertBefore(editorContainer, textarea); 156 171 157 - editorContainer.appendChild(editor); 172 + editorWrapper.appendChild(highlightBackdrop); 173 + editorWrapper.appendChild(textarea); 158 174 159 - // Replace textarea with editor 160 - textarea.style.display = 'none'; 161 - textarea.parentNode.insertBefore(editorContainer, textarea); 175 + editorContainer.appendChild(lineNumbers); 176 + editorContainer.appendChild(editorWrapper); 162 177 163 - // Set initial content 178 + // Set initial line numbers and highlighting 179 + updateLineNumbers(initialCode); 164 180 updateHighlighting(initialCode); 165 181 166 - // Convert JSON output textarea to highlighted div 182 + // Convert JSON output textarea to highlighted div with line numbers 167 183 const jsonTextarea = document.getElementById('lexicon-result'); 184 + 185 + const jsonOuterContainer = document.createElement('div'); 186 + jsonOuterContainer.className = 'shiki-output-outer-container'; 187 + jsonOuterContainer.id = 'lexicon-result-outer-container'; 188 + 189 + const jsonLineNumbers = document.createElement('div'); 190 + jsonLineNumbers.className = 'line-numbers'; 191 + jsonLineNumbers.id = 'json-line-numbers'; 192 + 193 + const jsonWrapper = document.createElement('div'); 194 + jsonWrapper.className = 'output-wrapper'; 195 + 168 196 const jsonContainer = document.createElement('div'); 169 197 jsonContainer.className = 'shiki-output-container'; 170 198 jsonContainer.id = 'lexicon-result-container'; 171 199 200 + jsonWrapper.appendChild(jsonContainer); 201 + jsonOuterContainer.appendChild(jsonLineNumbers); 202 + jsonOuterContainer.appendChild(jsonWrapper); 203 + 172 204 jsonTextarea.style.display = 'none'; 173 - jsonTextarea.parentNode.insertBefore(jsonContainer, jsonTextarea); 205 + jsonTextarea.parentNode.insertBefore(jsonOuterContainer, jsonTextarea); 174 206 } 175 207 176 208 function updateHighlighting(code) { 177 - if (!highlighter || !editorContainer) return; 209 + if (!highlighter) return; 178 210 179 - const editor = editorContainer.querySelector('.shiki-editor'); 180 - if (!editor) return; 211 + const backdrop = document.getElementById('highlight-backdrop'); 212 + if (!backdrop) return; 181 213 182 - // Store cursor position 183 - const cursorOffset = getCaretPosition(editor); 184 - 185 - // Update highlighted content 214 + // Update highlighted content in backdrop 186 215 const html = highlighter.codeToHtml(code, { 187 216 lang: 'mlf', 188 217 theme: 'dracula' ··· 194 223 const codeElement = temp.querySelector('code'); 195 224 196 225 if (codeElement) { 197 - editor.innerHTML = codeElement.innerHTML; 226 + backdrop.innerHTML = codeElement.innerHTML; 198 227 } else { 199 - editor.textContent = code; 228 + backdrop.textContent = code; 200 229 } 230 + } 201 231 202 - // Restore cursor position 203 - if (document.activeElement === editor) { 204 - setCaretPosition(editor, cursorOffset); 205 - } 232 + function updateLineNumbers(code) { 233 + const lineNumbers = document.getElementById('line-numbers'); 234 + if (!lineNumbers) return; 235 + 236 + const lines = code.split('\n').length; 237 + const lineNumbersHtml = Array.from({ length: lines }, (_, i) => 238 + `<div class="line-number">${i + 1}</div>` 239 + ).join(''); 240 + 241 + lineNumbers.innerHTML = lineNumbersHtml; 206 242 } 207 243 208 244 function getCaretPosition(element) { ··· 256 292 } 257 293 258 294 function getEditorContent() { 259 - const editor = editorContainer?.querySelector('.shiki-editor'); 260 - return editor ? editor.textContent : ''; 295 + const textarea = document.getElementById('mlf-editor'); 296 + return textarea ? textarea.value : ''; 261 297 } 262 298 263 299 function updateJsonOutput(jsonString) { ··· 284 320 } else { 285 321 container.textContent = formatted; 286 322 } 323 + 324 + // Update line numbers for JSON output 325 + updateJsonLineNumbers(formatted); 287 326 } catch (e) { 288 327 container.textContent = jsonString; 328 + updateJsonLineNumbers(jsonString); 289 329 } 290 330 } 291 331 332 + function updateJsonLineNumbers(code) { 333 + const lineNumbers = document.getElementById('json-line-numbers'); 334 + if (!lineNumbers) return; 335 + 336 + const lines = code.split('\n').length; 337 + const lineNumbersHtml = Array.from({ length: lines }, (_, i) => 338 + `<div class="line-number">${i + 1}</div>` 339 + ).join(''); 340 + 341 + lineNumbers.innerHTML = lineNumbersHtml; 342 + } 343 + 292 344 function hidePlayground() { 293 345 const playground = document.querySelector('.playground-container'); 294 346 if (playground) { ··· 307 359 }); 308 360 309 361 // Editor input with debounce 310 - const editor = editorContainer?.querySelector('.shiki-editor'); 311 - if (editor) { 312 - let timeout; 313 - editor.addEventListener('input', () => { 314 - clearTimeout(timeout); 315 - timeout = setTimeout(() => { 316 - const code = getEditorContent(); 317 - updateHighlighting(code); 362 + const textarea = document.getElementById('mlf-editor'); 363 + if (textarea) { 364 + let checkTimeout; 365 + 366 + textarea.addEventListener('input', () => { 367 + const code = textarea.value; 368 + 369 + // Update line numbers and highlighting immediately 370 + updateLineNumbers(code); 371 + updateHighlighting(code); 372 + 373 + // Clear previous timeout 374 + clearTimeout(checkTimeout); 375 + 376 + // Debounce validation/check only 377 + checkTimeout = setTimeout(() => { 318 378 handleCheck(); 319 379 }, 500); 320 380 }); 381 + 382 + // Synchronize scroll between textarea, line numbers, and backdrop 383 + const lineNumbers = document.getElementById('line-numbers'); 384 + const backdrop = document.getElementById('highlight-backdrop'); 385 + if (lineNumbers && backdrop) { 386 + textarea.addEventListener('scroll', () => { 387 + lineNumbers.scrollTop = textarea.scrollTop; 388 + // Use transform to scroll the backdrop in sync 389 + backdrop.style.transform = `translate(-${textarea.scrollLeft}px, -${textarea.scrollTop}px)`; 390 + }); 391 + } 392 + } 393 + 394 + // Synchronize scroll between JSON output and line numbers 395 + const jsonWrapper = document.querySelector('.output-wrapper'); 396 + const jsonLineNumbers = document.getElementById('json-line-numbers'); 397 + if (jsonWrapper && jsonLineNumbers) { 398 + jsonWrapper.addEventListener('scroll', () => { 399 + jsonLineNumbers.scrollTop = jsonWrapper.scrollTop; 400 + }); 321 401 } 322 402 323 403 // Auto-validate on record input change (debounced) ··· 329 409 timeout = setTimeout(handleValidate, 500); 330 410 }); 331 411 } 412 + 413 + // File path input change triggers re-generation 414 + const filePathInput = document.getElementById('file-path'); 415 + if (filePathInput) { 416 + let timeout; 417 + filePathInput.addEventListener('input', () => { 418 + clearTimeout(timeout); 419 + timeout = setTimeout(handleCheck, 500); 420 + }); 421 + } 332 422 } 333 423 334 424 function switchTab(tabName) { ··· 343 433 }); 344 434 } 345 435 436 + function extractNamespaceFromPath(filePath) { 437 + // Remove leading/trailing slashes 438 + filePath = filePath.trim().replace(/^\/+|\/+$/g, ''); 439 + 440 + // Validate it ends with .mlf 441 + if (!filePath.endsWith('.mlf')) { 442 + return null; 443 + } 444 + 445 + // Remove the .mlf extension 446 + const withoutExt = filePath.slice(0, -4); 447 + 448 + // Replace slashes with dots to get the namespace 449 + const namespace = withoutExt.replace(/\//g, '.'); 450 + 451 + // Validate namespace format (should be valid NSID segments) 452 + // Basic validation: only alphanumeric, dots, and hyphens 453 + if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(namespace)) { 454 + return null; 455 + } 456 + 457 + return namespace; 458 + } 459 + 346 460 function handleCheck() { 347 461 if (!wasm) { 348 462 return; 349 463 } 350 464 351 465 const source = getEditorContent(); 466 + const filePath = document.getElementById('file-path').value; 467 + 468 + // Extract namespace from file path 469 + const namespace = extractNamespaceFromPath(filePath); 470 + if (!namespace) { 471 + showError('Invalid file path. Must be a valid path ending in .mlf (e.g., com/example/app/thread.mlf)'); 472 + return; 473 + } 352 474 353 475 try { 354 476 // Check the MLF source ··· 356 478 357 479 if (checkResult.success) { 358 480 hideError(); 359 - 360 - // Generate lexicon - extract namespace from source or use default 361 - const namespaceMatch = source.match(/namespace\s+([\w.]+)/); 362 - const namespace = namespaceMatch ? namespaceMatch[1] : 'com.example.post'; 363 481 364 482 const generateResult = wasm.generate_lexicon(source, namespace); 365 483
+2 -2
website/syntaxes/mlf.sublime-syntax
··· 29 29 pop: true 30 30 31 31 keywords: 32 - - match: '\b(namespace|use|record|alias|token|query|procedure|subscription|throws|constrained)\b' 32 + - match: '\b(namespace|use|record|inline|def|type|token|query|procedure|subscription|throws|constrained)\b' 33 33 scope: keyword.control.mlf 34 - - match: '\b(main)\b' 34 + - match: '\b(main|defs)\b' 35 35 scope: keyword.other.mlf 36 36 37 37 types:
+5 -4
website/templates/playground.html
··· 5 5 <div class="playground-container"> 6 6 <div class="editor-panel"> 7 7 <div class="panel-header"> 8 - <h3>MLF Source</h3> 8 + <div class="file-path-container"> 9 + <input type="text" id="file-path" value="com/example/forum/thread.mlf" spellcheck="false" /> 10 + </div> 9 11 </div> 10 12 <textarea id="mlf-editor" spellcheck="false">/// A forum thread 11 13 record thread { ··· 15 17 minLength: 1, 16 18 }, 17 19 /// Thread body 18 - body: string constrained { 20 + body?: string constrained { 19 21 maxLength: 10000, 20 22 }, 21 23 /// Thread creation timestamp 22 24 createdAt: Datetime, 23 - };</textarea> 25 + }</textarea> 24 26 </div> 25 27 26 28 <div class="output-panel"> 27 29 <div class="panel-header"> 28 - <h3>Output</h3> 29 30 <div class="tabs"> 30 31 <button class="tab active" data-tab="lexicon">Lexicon</button> 31 32 <button class="tab" data-tab="validate">Validate</button>