diff --git a/mlf-lang/src/ast.rs b/mlf-lang/src/ast.rs index 88812a3..beffc62 100644 --- a/mlf-lang/src/ast.rs +++ b/mlf-lang/src/ast.rs @@ -62,10 +62,14 @@ pub struct DocComment { pub span: Span, } -/// Annotation (e.g., @deprecated, @since(1, 0, 0)) +/// Annotation (e.g., @deprecated, @rust:deprecated, @rust,typescript:deprecated) #[derive(Debug, Clone, PartialEq)] pub struct Annotation { + /// Generator selectors (empty for bare annotations visible to all generators) + pub selectors: Vec, + /// Annotation name pub name: Ident, + /// Annotation arguments pub args: Vec, pub span: Span, } diff --git a/mlf-lang/src/parser.rs b/mlf-lang/src/parser.rs index ab0134c..24b4cec 100644 --- a/mlf-lang/src/parser.rs +++ b/mlf-lang/src/parser.rs @@ -230,7 +230,29 @@ impl Parser { fn parse_annotation(&mut self) -> Result { let start = self.expect(LexToken::At)?; - let name = self.parse_ident()?; + let first_ident = self.parse_ident()?; + + // Check if this is a generator selector or bare annotation + let (selectors, name) = if matches!(self.current().token, LexToken::Comma) || matches!(self.current().token, LexToken::Colon) { + // Generator selector syntax: @rust:deprecated or @rust,typescript:deprecated + let mut selectors = alloc::vec![first_ident]; + + // Parse comma-separated selectors + while matches!(self.current().token, LexToken::Comma) { + self.advance(); // consume comma + selectors.push(self.parse_ident()?); + } + + // Now expect and consume the colon + self.expect(LexToken::Colon)?; + + // Parse the annotation name + let name = self.parse_ident()?; + (selectors, name) + } else { + // Bare annotation: @deprecated + (alloc::vec![], first_ident) + }; let mut args = Vec::new(); @@ -257,6 +279,7 @@ impl Parser { }; Ok(Annotation { + selectors, name, args, span: Span::new(start.start, end), @@ -1465,6 +1488,137 @@ mod tests { } } + #[test] + fn test_parse_annotation_bare() { + let input = "@deprecated\nquery foo();"; + let result = parse_lexicon(input); + assert!(result.is_ok()); + let lexicon = result.unwrap(); + assert_eq!(lexicon.items.len(), 1); + match &lexicon.items[0] { + Item::Query(q) => { + assert_eq!(q.annotations.len(), 1); + assert_eq!(q.annotations[0].selectors.len(), 0); + assert_eq!(q.annotations[0].name.name, "deprecated"); + } + _ => panic!("Expected query"), + } + } + + #[test] + fn test_parse_annotation_single_selector() { + let input = "@rust:deprecated\nquery bar();"; + let result = parse_lexicon(input); + assert!(result.is_ok()); + let lexicon = result.unwrap(); + assert_eq!(lexicon.items.len(), 1); + match &lexicon.items[0] { + Item::Query(q) => { + assert_eq!(q.annotations.len(), 1); + assert_eq!(q.annotations[0].selectors.len(), 1); + assert_eq!(q.annotations[0].selectors[0].name, "rust"); + assert_eq!(q.annotations[0].name.name, "deprecated"); + } + _ => panic!("Expected query"), + } + } + + #[test] + fn test_parse_annotation_multiple_selectors() { + let input = "@rust,typescript:deprecated\nquery baz();"; + let result = parse_lexicon(input); + assert!(result.is_ok()); + let lexicon = result.unwrap(); + assert_eq!(lexicon.items.len(), 1); + match &lexicon.items[0] { + Item::Query(q) => { + assert_eq!(q.annotations.len(), 1); + assert_eq!(q.annotations[0].selectors.len(), 2); + assert_eq!(q.annotations[0].selectors[0].name, "rust"); + assert_eq!(q.annotations[0].selectors[1].name, "typescript"); + assert_eq!(q.annotations[0].name.name, "deprecated"); + } + _ => panic!("Expected query"), + } + } + + #[test] + fn test_parse_annotation_multiple_separate() { + let input = "@rust:deprecated\n@typescript:deprecated\nquery qux();"; + let result = parse_lexicon(input); + assert!(result.is_ok()); + let lexicon = result.unwrap(); + assert_eq!(lexicon.items.len(), 1); + match &lexicon.items[0] { + Item::Query(q) => { + assert_eq!(q.annotations.len(), 2); + assert_eq!(q.annotations[0].selectors[0].name, "rust"); + assert_eq!(q.annotations[0].name.name, "deprecated"); + assert_eq!(q.annotations[1].selectors[0].name, "typescript"); + assert_eq!(q.annotations[1].name.name, "deprecated"); + } + _ => panic!("Expected query"), + } + } + + #[test] + fn test_parse_annotation_bare_with_args() { + let input = "@foo(1)\n@bar(\"blah\", true)\n@baz(thing: 1, thang: 2)\nquery test();"; + let result = parse_lexicon(input); + assert!(result.is_ok()); + let lexicon = result.unwrap(); + assert_eq!(lexicon.items.len(), 1); + match &lexicon.items[0] { + Item::Query(q) => { + assert_eq!(q.annotations.len(), 3); + // @foo(1) + assert_eq!(q.annotations[0].selectors.len(), 0); + assert_eq!(q.annotations[0].name.name, "foo"); + assert_eq!(q.annotations[0].args.len(), 1); + // @bar("blah", true) + assert_eq!(q.annotations[1].selectors.len(), 0); + assert_eq!(q.annotations[1].name.name, "bar"); + assert_eq!(q.annotations[1].args.len(), 2); + // @baz(thing: 1, thang: 2) + assert_eq!(q.annotations[2].selectors.len(), 0); + assert_eq!(q.annotations[2].name.name, "baz"); + assert_eq!(q.annotations[2].args.len(), 2); + } + _ => panic!("Expected query"), + } + } + + #[test] + fn test_parse_annotation_selectors_with_args() { + let input = "@rust:derive(\"Debug, Clone\")\n@typescript:export(name: \"CustomName\")\n@rust,typescript:since(1, 2, 0)\nquery test();"; + let result = parse_lexicon(input); + assert!(result.is_ok()); + let lexicon = result.unwrap(); + assert_eq!(lexicon.items.len(), 1); + match &lexicon.items[0] { + Item::Query(q) => { + assert_eq!(q.annotations.len(), 3); + // @rust:derive("Debug, Clone") + assert_eq!(q.annotations[0].selectors.len(), 1); + assert_eq!(q.annotations[0].selectors[0].name, "rust"); + assert_eq!(q.annotations[0].name.name, "derive"); + assert_eq!(q.annotations[0].args.len(), 1); + // @typescript:export(name: "CustomName") + assert_eq!(q.annotations[1].selectors.len(), 1); + assert_eq!(q.annotations[1].selectors[0].name, "typescript"); + assert_eq!(q.annotations[1].name.name, "export"); + assert_eq!(q.annotations[1].args.len(), 1); + // @rust,typescript:since(1, 2, 0) + assert_eq!(q.annotations[2].selectors.len(), 2); + assert_eq!(q.annotations[2].selectors[0].name, "rust"); + assert_eq!(q.annotations[2].selectors[1].name, "typescript"); + assert_eq!(q.annotations[2].name.name, "since"); + assert_eq!(q.annotations[2].args.len(), 3); + } + _ => panic!("Expected query"), + } + } + #[test] fn test_parse_optional_field() { let input = r#"record user { diff --git a/website/content/docs/cli/07-fetch.md b/website/content/docs/cli/07-fetch.md index a54c293..e2aef7f 100644 --- a/website/content/docs/cli/07-fetch.md +++ b/website/content/docs/cli/07-fetch.md @@ -105,13 +105,7 @@ optimize_transitive_fetches = false ## How It Works -The fetch command follows the ATProto lexicon discovery protocol: - -1. **DNS Lookup** - Queries `_lexicon.` TXT record -2. **DID Resolution** - Resolves the DID to a PDS endpoint -3. **Fetch Records** - Queries `com.atproto.repo.listRecords` for lexicon schemas -4. **Save & Convert** - Saves JSON and converts to MLF format -5. **Update Lockfile** - Records NSIDs, DIDs, checksums, and dependencies +The fetch command uses ATProto's lexicon discovery protocol to locate and download lexicons via DNS lookups and DID resolution, then converts them to MLF format and updates the lockfile. ## Examples @@ -208,179 +202,14 @@ This: 2. Adds `"com.example.forum.*"` to the dependencies array in `mlf.toml` 3. Creates `mlf.toml` if it doesn't exist -## Storage Structure - -Fetched lexicons are stored in `.mlf/lexicons/`: - -``` -.mlf/ -├── .gitignore # Auto-generated -└── lexicons/ - ├── json/ # Original JSON lexicons - │ ├── com/ - │ │ └── example/ - │ │ ├── forum/ - │ │ │ ├── post.json - │ │ │ └── thread.json - │ │ └── social/ - │ │ └── profile.json - │ └── app/ - │ └── bsky/ - │ └── feed/ - │ └── post.json - └── mlf/ # Converted MLF format - ├── com/ - │ └── example/ - │ ├── forum/ - │ │ ├── post.mlf - │ │ └── thread.mlf - │ └── social/ - │ └── profile.mlf - └── app/ - └── bsky/ - └── feed/ - └── post.mlf -``` - -**Note:** The lockfile (`mlf-lock.toml`) lives at the project root, sibling to `mlf.toml`. - -## DNS Resolution - -For an NSID like `com.example.forum.post`: - -1. Extract authority: `com.example` (first 2 segments) -2. Reverse for DNS: `example.com` -3. Query TXT record: `_lexicon.example.com` -4. Parse `did=did:web:...` or `did=did:plc:...` - -**Example DNS record:** -``` -_lexicon.example.com. 300 IN TXT "did=did:web:example.com" -``` - -## DID Resolution - -### did:web - -For `did:web:example.com`, the PDS is `https://example.com` - -### did:plc - -For `did:plc:abc123...`, query `https://plc.directory/did:plc:abc123...` to get the PDS endpoint from the DID document. - -## Fetched Lexicons in Your Code - -Once fetched, lexicons in `.mlf/lexicons/mlf/` are automatically available for: - -### Type References - -```mlf -use com.example.forum.post; - -record reply { - post!: com.example.forum.post, - text!: string, -} -``` - -### Code Generation - -```bash -mlf generate code -g typescript -i my-lexicon.mlf -o src/types/ -``` - -The generator can resolve references to fetched lexicons. - -### Validation - -```bash -mlf check my-lexicon.mlf -``` - -The check command loads fetched lexicons for type resolution. - -## Working Without mlf.toml - -If you don't have an `mlf.toml`, the fetch command will offer to create one: - -```bash -$ mlf fetch com.example.forum.* -No mlf.toml found in current or parent directories. -Would you like to create one in the current directory? (y/n) -y -Created mlf.toml in /path/to/current/dir -... -``` - -## Error Handling - -### DNS Errors -``` -✗ DNS lookup failed: No TXT record found for _lexicon.example.com -``` - -**Causes:** -- Domain doesn't have a lexicon TXT record -- DNS propagation delay -- Network issues - -### DID Resolution Errors - -``` -✗ Failed to resolve DID: No PDS endpoint found in DID document -``` - -**Causes:** -- Invalid DID format -- PLC directory unreachable -- DID document missing PDS service - -### No Records Found - -``` -✗ No lexicons matched pattern: com.example.forum.* -``` - -**Causes:** -- No lexicons exist matching the pattern -- Wrong NSID (typo) -- PDS doesn't support lexicon publishing - -### Invalid NSID Format - -``` -✗ NSID must have at least 2 segments or use wildcard: com -``` - -**Solution:** -- Use a specific NSID: `com.example.forum.post` -- Or use a wildcard: `com.example.forum.*` - -### Checksum Mismatch (--locked mode) - -``` -✗ Checksum mismatch for place.stream.facet: expected sha256:abc123, got sha256:def456 -``` - -**Causes:** -- Lexicon was updated on the server -- Lock file is out of date - -**Solution:** -```bash -mlf fetch --update # Update lockfile with new checksums -``` ## Best Practices 1. **Commit lockfile** - Always commit `mlf-lock.toml` to version control 2. **Use --locked in CI** - Ensures reproducible builds in CI/CD pipelines -3. **Fetch before work** - Always fetch dependencies before coding -4. **Use --save** - Keep `mlf.toml` up to date with dependencies -5. **Don't commit `.mlf/`** - Let each developer fetch independently -6. **Check DNS** - Verify TXT records before fetching -7. **Update explicitly** - Use `mlf fetch --update` when you want latest versions +3. **Don't commit `.mlf/`** - Let each developer fetch independently +4. **Update explicitly** - Use `mlf fetch --update` when you want latest versions ## Comparison with npm/cargo @@ -396,36 +225,3 @@ The fetch command follows patterns from popular package managers: | Lock file | `package-lock.json` | `Cargo.lock` | `mlf-lock.toml` | | Cache | `node_modules/` | `~/.cargo/` | `.mlf/` | -## Troubleshooting - -### Network Issues - -```bash -# Check DNS resolution -dig TXT _lexicon.example.com - -# Test DID resolution -curl https://plc.directory/did:plc:abc123 -``` - -### Invalid NSID Format - -Make sure you're using the correct format: -- ✓ `com.example.forum.post` (specific lexicon) -- ✓ `com.example.forum.*` (wildcard) -- ✓ `app.bsky.feed.*` (real-world wildcard) -- ✗ `com` (must have at least 2 segments) - -### Permission Errors - -Ensure you have write permissions for the project directory to create `.mlf/` and `mlf-lock.toml`. - -### Conflicting Flags - -``` -✗ Cannot use --update and --locked together -``` - -Choose one mode: -- Use `--update` to get latest versions -- Use `--locked` for strict reproducible builds diff --git a/website/content/docs/language-guide/11-annotations.md b/website/content/docs/language-guide/11-annotations.md index c7a1d15..8d60c6d 100644 --- a/website/content/docs/language-guide/11-annotations.md +++ b/website/content/docs/language-guide/11-annotations.md @@ -37,7 +37,7 @@ Arguments can be: ```mlf @validate(min: 0, max: 100, strict: true) -@codegen(language: "rust", derive: "Debug, Clone") +@cache(ttl: 3600, strategy: "lru") record example { field: integer, } @@ -72,72 +72,59 @@ record profile { } ``` -## MLF Annotations vs Generator Annotations +## Annotation Semantics -MLF distinguishes between two categories: +Annotations in MLF are interpreted by whatever consumes them - whether that's the MLF compiler itself or external code generators. -### 1. MLF Annotations +### Bare Annotations -Built into the MLF language and affect compilation/validation. These are **bare annotations** without any namespace prefix: - -**`@main`** - Marks an item as the main definition when there's ambiguity: +**Bare annotations** (without generator selectors) are visible to **all generators** and each can interpret them as needed: ```mlf -// File: com/example/thread.mlf -@main -record thread { - title!: string, -} - -// This def shares the same name but is not main -def type thread = { - id!: string, - viewCount!: integer, -} +// Visible to all generators - each interprets as appropriate +@deprecated +query foo(); ``` -See [Important Info](/docs/language-guide/important-info/#the-main-definition) for more details on the `@main` annotation. +MLF itself recognizes the **`@main` annotation** for resolving naming conflicts. See [Important Info](/docs/language-guide/important-info/#the-main-definition) for details. -### 2. Generator Annotations +### Generator Selectors -Used by code generators and external tools. These have no effect on MLF compilation and **must** be namespaced with the generator name: +To target **specific generators**, use the generator selector syntax with a colon: ```mlf -@rust:derive("Debug, Clone, Serialize") -@typescript:export -@go:tag(json: "custom_name") -record example { - field: string, -} +// Only for rust generator +@rust:deprecated +query bar(); + +// Only for typescript generator +@typescript:deprecated +query baz(); ``` -**Generator namespacing rules:** -- All generator annotations must have a namespace prefix (e.g., `@rust:foo`) -- Use `@all:annotation` to apply an annotation to all generators -- Bare annotations (without `:`) are reserved for MLF itself +### Multiple Generator Selectors -**Common generator namespaces:** -- `@rust:*` - Rust code generator annotations -- `@typescript:*` - TypeScript code generator annotations -- `@go:*` - Go code generator annotations -- `@python:*` - Python code generator annotations -- `@all:*` - Applies to all generators +You can apply an annotation to multiple specific generators using comma-separated selectors: -## Custom Annotations +```mlf +// For both rust AND typescript +@rust,typescript:deprecated +query qux(); +``` -You can define your own annotations for custom tooling: +Alternatively, you can write separate annotations: ```mlf -@myapp:cache(ttl: 3600) -@myapp:permission("read:public") -query getProfile(actor!: Did): profile; - -@myapp:audit_log -@myapp:rate_limit(requests: 100, window: 60) -procedure updateProfile(data!: profile): unit; +// Equivalent to above +@rust:deprecated +@typescript:deprecated +query qux(); ``` -The interpretation is entirely up to your tooling. +**Common generator selectors:** +- `@rust:*` - Rust code generator annotations +- `@typescript:*` - TypeScript code generator annotations +- `@go:*` - Go code generator annotations ## Annotation Processing @@ -153,11 +140,12 @@ Each tool decides which annotations to support and how to interpret them. ## Best Practices -1. **Always namespace generator annotations** - Use `@generator:name` for all generator-specific annotations -2. **Use `@all:` for cross-generator annotations** - When an annotation should apply to all generators -3. **Document custom annotations** - Keep a registry of annotations your project uses -4. **Be consistent** - Use the same annotation patterns across your codebase -5. **Don't overuse** - Annotations should augment, not replace, good design +1. **Use bare annotations for universal concepts** - Use `@deprecated` without selectors when you want all generators to see it +2. **Use generator selectors for specific tooling** - Use `@rust:derive` or `@typescript:export` when targeting one generator +3. **Group with comma selectors when appropriate** - Use `@rust,typescript:deprecated` to apply the same annotation to multiple generators +4. **Document custom annotations** - Keep a registry of annotations your project uses +5. **Be consistent** - Use the same annotation patterns across your codebase +6. **Don't overuse** - Annotations should augment, not replace, good design ## What's Next?