diff --git a/mlf-lsp/src/context.rs b/mlf-lsp/src/context.rs index d5f111b..deece8f 100644 --- a/mlf-lsp/src/context.rs +++ b/mlf-lsp/src/context.rs @@ -10,12 +10,45 @@ pub enum CompletionContext { TypePosition, /// Inside constrained { } block ConstraintBlock, + /// Typing an annotation (after @) + Annotation, + /// Typing annotation selectors (after @rust,) + AnnotationSelector, /// Unknown context Unknown, } /// Detect the completion context based on the text before the cursor pub fn detect_context(text_before_cursor: &str) -> CompletionContext { + // Check if we're typing an annotation + if let Some(last_line) = text_before_cursor.lines().last() { + let last_line_trimmed = last_line.trim_start(); + + // Check for annotation selector context: @rust, or @rust,typescript, + if let Some(at_pos) = last_line_trimmed.rfind('@') { + let after_at = &last_line_trimmed[at_pos + 1..]; + + // If there's a colon, we're past the selectors + if after_at.contains(':') { + // Check if we're completing the annotation name + let parts: Vec<&str> = after_at.split(':').collect(); + if parts.len() >= 2 && !parts[1].contains('(') { + return CompletionContext::Annotation; + } + } else if after_at.ends_with(',') || (after_at.contains(',') && !after_at.contains(':')) { + // We're completing another selector: @rust, or @rust,typescript, + return CompletionContext::AnnotationSelector; + } else if !after_at.is_empty() && !after_at.contains(':') && !after_at.contains('(') { + // We're completing the first part (could be selector or annotation name) + // Default to annotation for simplicity + return CompletionContext::Annotation; + } else if after_at.is_empty() { + // Just typed @ - suggest annotations + return CompletionContext::Annotation; + } + } + } + // Check if we're in a use statement (check before trimming!) if let Some(last_line) = text_before_cursor.lines().last() { let last_line = last_line.trim_start(); // Only trim left side @@ -127,4 +160,23 @@ mod tests { assert_eq!(detect_context(""), CompletionContext::TopLevel); assert_eq!(detect_context("rec"), CompletionContext::TopLevel); } + + #[test] + fn test_annotation() { + assert_eq!(detect_context("@"), CompletionContext::Annotation); + assert_eq!(detect_context("@dep"), CompletionContext::Annotation); + assert_eq!(detect_context("@deprecated"), CompletionContext::Annotation); + } + + #[test] + fn test_annotation_selector() { + assert_eq!(detect_context("@rust,"), CompletionContext::AnnotationSelector); + assert_eq!(detect_context("@rust,typescript,"), CompletionContext::AnnotationSelector); + } + + #[test] + fn test_annotation_after_selector() { + assert_eq!(detect_context("@rust:"), CompletionContext::Annotation); + assert_eq!(detect_context("@rust,typescript:"), CompletionContext::Annotation); + } } diff --git a/mlf-lsp/src/server.rs b/mlf-lsp/src/server.rs index ba88d46..eadbc2e 100644 --- a/mlf-lsp/src/server.rs +++ b/mlf-lsp/src/server.rs @@ -792,6 +792,8 @@ impl LanguageServer for MlfLanguageServer { ".".to_string(), ":".to_string(), " ".to_string(), + "@".to_string(), + ",".to_string(), ]), ..Default::default() }), @@ -896,6 +898,113 @@ impl LanguageServer for MlfLanguageServer { if let Some(lexicon) = &doc_state.lexicon { // Convert position to offset if let Some(offset) = position_to_offset(&doc_state.text, position) { + // Check if we're hovering over an annotation + for item in &lexicon.items { + let annotations_to_check = match item { + Item::Record(r) => Some(&r.annotations), + Item::InlineType(i) => Some(&i.annotations), + Item::DefType(d) => Some(&d.annotations), + Item::Token(t) => Some(&t.annotations), + Item::Query(q) => Some(&q.annotations), + Item::Procedure(p) => Some(&p.annotations), + Item::Subscription(s) => Some(&s.annotations), + _ => None, + }; + + if let Some(annotations) = annotations_to_check { + for annotation in annotations { + if annotation.span.start <= offset && offset <= annotation.span.end { + // Build hover content for the annotation + let mut contents = vec![]; + + // Format annotation name with selectors if present + let annotation_display = if annotation.selectors.is_empty() { + format!("@{}", annotation.name.name) + } else { + let selector_names: Vec<_> = annotation.selectors.iter() + .map(|s| s.name.as_str()) + .collect(); + format!("@{}:{}", selector_names.join(","), annotation.name.name) + }; + + contents.push(MarkedString::LanguageString(LanguageString { + language: "mlf".to_string(), + value: annotation_display.clone(), + })); + + // Add description based on annotation name + let description = match annotation.name.name.as_str() { + "deprecated" => "Marks this definition as deprecated", + "main" => "Designates this as the main definition for conflict resolution", + "key" => "Specifies the record key type (e.g., 'tid', 'literal:self')", + "encoding" => "Specifies MIME type encoding for XRPC (e.g., 'application/json', 'application/cbor')", + "since" => "Indicates the version when this was added", + "doc" => "Provides a documentation URL", + "validate" => "Specifies validation rules", + "cache" => "Defines caching strategy", + "indexed" => "Marks this field as indexed", + "sensitive" => "Marks this field as containing sensitive data (e.g., PII)", + _ => "Custom annotation", + }; + + contents.push(MarkedString::String(description.to_string())); + + // Add information about selectors if present + if !annotation.selectors.is_empty() { + let selector_info = format!( + "This annotation applies to: {}", + annotation.selectors.iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(", ") + ); + contents.push(MarkedString::String(selector_info)); + } else { + contents.push(MarkedString::String( + "This annotation is visible to all generators".to_string() + )); + } + + return Ok(Some(Hover { + contents: HoverContents::Array(contents), + range: None, + })); + } + } + } + + // Also check field annotations + match item { + Item::Record(r) => { + for field in &r.fields { + for annotation in &field.annotations { + if annotation.span.start <= offset && offset <= annotation.span.end { + let annotation_display = if annotation.selectors.is_empty() { + format!("@{}", annotation.name.name) + } else { + let selector_names: Vec<_> = annotation.selectors.iter() + .map(|s| s.name.as_str()) + .collect(); + format!("@{}:{}", selector_names.join(","), annotation.name.name) + }; + + return Ok(Some(Hover { + contents: HoverContents::Scalar( + MarkedString::LanguageString(LanguageString { + language: "mlf".to_string(), + value: format!("Field annotation: {}", annotation_display), + }) + ), + range: None, + })); + } + } + } + } + _ => {} + } + } + // Find the item at this position if let Some(item) = find_item_at_offset(lexicon, offset) { let name = get_item_name(item); @@ -1262,6 +1371,51 @@ impl LanguageServer for MlfLanguageServer { tracing::debug!("Total completions: {}", completions.len()); } + MlfCompletionContext::Annotation => { + // Suggest common annotation names + let annotations = vec![ + ("deprecated", "Mark as deprecated"), + ("main", "Main definition for conflict resolution"), + ("key", "Specify record key type (e.g., @key(\"literal:self\"))"), + ("encoding", "Specify MIME type encoding (e.g., @encoding(\"application/cbor\"))"), + ("since", "Version when added (e.g., @since(1, 2, 0))"), + ("doc", "Documentation URL (e.g., @doc(\"https://example.com\"))"), + ("validate", "Validation rules (e.g., @validate(min: 0, max: 100))"), + ("cache", "Caching strategy (e.g., @cache(ttl: 3600))"), + ("indexed", "Mark field as indexed"), + ("sensitive", "Mark field as containing sensitive data"), + ]; + + for (label, detail) in annotations { + completions.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(detail.to_string()), + ..Default::default() + }); + } + } + + MlfCompletionContext::AnnotationSelector => { + // Suggest generator selector names + let generators = vec![ + ("rust", "Rust code generator"), + ("typescript", "TypeScript code generator"), + ("go", "Go code generator"), + ("python", "Python code generator"), + ("java", "Java code generator"), + ]; + + for (label, detail) in generators { + completions.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::MODULE), + detail: Some(detail.to_string()), + ..Default::default() + }); + } + } + MlfCompletionContext::TopLevel => { // Suggest keywords for top-level declarations let keywords = vec![ diff --git a/tree-sitter-mlf/grammar.js b/tree-sitter-mlf/grammar.js index e9d16d0..ba6ae60 100644 --- a/tree-sitter-mlf/grammar.js +++ b/tree-sitter-mlf/grammar.js @@ -39,6 +39,47 @@ module.exports = grammar({ doc_comment: $ => token(seq('///', /.*/)), comment: $ => token(seq('//', /.*/)), + // Annotations + annotation: $ => seq( + '@', + optional(field('selectors', $.annotation_selectors)), + field('name', $.identifier), + optional(field('args', $.annotation_args)) + ), + + annotation_selectors: $ => seq( + $.identifier, + repeat(seq(',', $.identifier)), + ':' + ), + + annotation_args: $ => seq( + '(', + optional(seq( + $.annotation_arg, + repeat(seq(',', $.annotation_arg)), + optional(',') + )), + ')' + ), + + annotation_arg: $ => choice( + // Named argument: name: value + seq( + field('name', $.identifier), + ':', + field('value', $.annotation_value) + ), + // Positional argument: value + field('value', $.annotation_value) + ), + + annotation_value: $ => choice( + $.string, + $.number, + $.boolean + ), + // Use statements use_statement: $ => seq( 'use', @@ -68,6 +109,7 @@ module.exports = grammar({ // Record definition record_definition: $ => seq( + repeat($.annotation), 'record', field('name', $.identifier), field('body', $.record_body) @@ -81,6 +123,7 @@ module.exports = grammar({ field: $ => seq( optional($.doc_comment), + repeat($.annotation), field('name', $.identifier), optional('!'), ':', @@ -90,6 +133,7 @@ module.exports = grammar({ // Inline type definition inline_type_definition: $ => seq( + repeat($.annotation), 'inline', 'type', field('name', $.identifier), @@ -100,6 +144,7 @@ module.exports = grammar({ // Def type definition def_type_definition: $ => seq( + repeat($.annotation), 'def', 'type', field('name', $.identifier), @@ -110,6 +155,7 @@ module.exports = grammar({ // Token definition token_definition: $ => seq( + repeat($.annotation), 'token', field('name', $.identifier), ';' @@ -117,6 +163,7 @@ module.exports = grammar({ // Query definition query_definition: $ => seq( + repeat($.annotation), 'query', field('name', $.identifier), field('params', $.parameter_list), @@ -127,6 +174,7 @@ module.exports = grammar({ // Procedure definition procedure_definition: $ => seq( + repeat($.annotation), 'procedure', field('name', $.identifier), field('params', $.parameter_list), @@ -137,6 +185,7 @@ module.exports = grammar({ // Subscription definition subscription_definition: $ => seq( + repeat($.annotation), 'subscription', field('name', $.identifier), field('params', $.parameter_list), @@ -156,6 +205,7 @@ module.exports = grammar({ ), parameter: $ => seq( + repeat($.annotation), field('name', $.identifier), optional('!'), ':', diff --git a/tree-sitter-mlf/queries/highlights.scm b/tree-sitter-mlf/queries/highlights.scm index ead1ec2..06d23d9 100644 --- a/tree-sitter-mlf/queries/highlights.scm +++ b/tree-sitter-mlf/queries/highlights.scm @@ -87,6 +87,18 @@ (doc_comment) @comment.documentation (comment) @comment +; Annotations +"@" @punctuation.special + +(annotation + name: (identifier) @attribute) + +(annotation_selectors + (identifier) @namespace) + +(annotation_arg + name: (identifier) @property) + ; Operators [ ":" diff --git a/tree-sitter-mlf/src/grammar.json b/tree-sitter-mlf/src/grammar.json index 3189030..969c204 100644 --- a/tree-sitter-mlf/src/grammar.json +++ b/tree-sitter-mlf/src/grammar.json @@ -77,6 +77,197 @@ ] } }, + "annotation": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "@" + }, + { + "type": "CHOICE", + "members": [ + { + "type": "FIELD", + "name": "selectors", + "content": { + "type": "SYMBOL", + "name": "annotation_selectors" + } + }, + { + "type": "BLANK" + } + ] + }, + { + "type": "FIELD", + "name": "name", + "content": { + "type": "SYMBOL", + "name": "identifier" + } + }, + { + "type": "CHOICE", + "members": [ + { + "type": "FIELD", + "name": "args", + "content": { + "type": "SYMBOL", + "name": "annotation_args" + } + }, + { + "type": "BLANK" + } + ] + } + ] + }, + "annotation_selectors": { + "type": "SEQ", + "members": [ + { + "type": "SYMBOL", + "name": "identifier" + }, + { + "type": "REPEAT", + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "," + }, + { + "type": "SYMBOL", + "name": "identifier" + } + ] + } + }, + { + "type": "STRING", + "value": ":" + } + ] + }, + "annotation_args": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "(" + }, + { + "type": "CHOICE", + "members": [ + { + "type": "SEQ", + "members": [ + { + "type": "SYMBOL", + "name": "annotation_arg" + }, + { + "type": "REPEAT", + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "," + }, + { + "type": "SYMBOL", + "name": "annotation_arg" + } + ] + } + }, + { + "type": "CHOICE", + "members": [ + { + "type": "STRING", + "value": "," + }, + { + "type": "BLANK" + } + ] + } + ] + }, + { + "type": "BLANK" + } + ] + }, + { + "type": "STRING", + "value": ")" + } + ] + }, + "annotation_arg": { + "type": "CHOICE", + "members": [ + { + "type": "SEQ", + "members": [ + { + "type": "FIELD", + "name": "name", + "content": { + "type": "SYMBOL", + "name": "identifier" + } + }, + { + "type": "STRING", + "value": ":" + }, + { + "type": "FIELD", + "name": "value", + "content": { + "type": "SYMBOL", + "name": "annotation_value" + } + } + ] + }, + { + "type": "FIELD", + "name": "value", + "content": { + "type": "SYMBOL", + "name": "annotation_value" + } + } + ] + }, + "annotation_value": { + "type": "CHOICE", + "members": [ + { + "type": "SYMBOL", + "name": "string" + }, + { + "type": "SYMBOL", + "name": "number" + }, + { + "type": "SYMBOL", + "name": "boolean" + } + ] + }, "use_statement": { "type": "SEQ", "members": [ @@ -266,6 +457,13 @@ "record_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "record" @@ -323,6 +521,13 @@ } ] }, + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "FIELD", "name": "name", @@ -364,6 +569,13 @@ "inline_type_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "inline" @@ -401,6 +613,13 @@ "def_type_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "def" @@ -438,6 +657,13 @@ "token_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "token" @@ -459,6 +685,13 @@ "query_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "query" @@ -500,6 +733,13 @@ "procedure_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "procedure" @@ -541,6 +781,13 @@ "subscription_definition": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "STRING", "value": "subscription" @@ -640,6 +887,13 @@ "parameter": { "type": "SEQ", "members": [ + { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "annotation" + } + }, { "type": "FIELD", "name": "name", diff --git a/tree-sitter-mlf/src/node-types.json b/tree-sitter-mlf/src/node-types.json index 57b0fa3..ff85253 100644 --- a/tree-sitter-mlf/src/node-types.json +++ b/tree-sitter-mlf/src/node-types.json @@ -1,4 +1,119 @@ [ + { + "type": "annotation", + "named": true, + "fields": { + "args": { + "multiple": false, + "required": false, + "types": [ + { + "type": "annotation_args", + "named": true + } + ] + }, + "name": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + }, + "selectors": { + "multiple": false, + "required": false, + "types": [ + { + "type": "annotation_selectors", + "named": true + } + ] + } + } + }, + { + "type": "annotation_arg", + "named": true, + "fields": { + "name": { + "multiple": false, + "required": false, + "types": [ + { + "type": "identifier", + "named": true + } + ] + }, + "value": { + "multiple": false, + "required": true, + "types": [ + { + "type": "annotation_value", + "named": true + } + ] + } + } + }, + { + "type": "annotation_args", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation_arg", + "named": true + } + ] + } + }, + { + "type": "annotation_selectors", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "annotation_value", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "boolean", + "named": true + }, + { + "type": "number", + "named": true + }, + { + "type": "string", + "named": true + } + ] + } + }, { "type": "array_literal", "named": true, @@ -153,6 +268,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -207,9 +332,13 @@ } }, "children": { - "multiple": false, + "multiple": true, "required": false, "types": [ + { + "type": "annotation", + "named": true + }, { "type": "doc_comment", "named": true @@ -291,6 +420,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -406,6 +545,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -462,6 +611,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -498,6 +657,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -539,6 +708,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -624,6 +803,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -640,6 +829,16 @@ } ] } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "annotation", + "named": true + } + ] } }, { @@ -763,6 +962,10 @@ "type": "=", "named": false }, + { + "type": "@", + "named": false + }, { "type": "[", "named": false @@ -849,11 +1052,11 @@ }, { "type": "string", - "named": true + "named": false }, { "type": "string", - "named": false + "named": true }, { "type": "subscription", diff --git a/website/syntaxes/mlf.sublime-syntax b/website/syntaxes/mlf.sublime-syntax index b41c2ec..1476061 100644 --- a/website/syntaxes/mlf.sublime-syntax +++ b/website/syntaxes/mlf.sublime-syntax @@ -8,6 +8,7 @@ scope: source.mlf contexts: main: - include: comments + - include: annotations - include: keywords - include: types - include: strings @@ -28,6 +29,59 @@ contexts: - match: $ pop: true + annotations: + # Annotation with selectors and args: @rust,typescript:deprecated(true) + - match: '@([a-zA-Z_][a-zA-Z0-9_]*(?:,[a-zA-Z_][a-zA-Z0-9_]*)*):([a-zA-Z_][a-zA-Z0-9_]*)' + scope: meta.annotation.mlf + captures: + 1: entity.name.namespace.mlf + 2: entity.name.function.annotation.mlf + push: + - match: '\(' + scope: punctuation.section.arguments.begin.mlf + set: + - meta_scope: meta.annotation.arguments.mlf + - match: '\)' + scope: punctuation.section.arguments.end.mlf + pop: true + - match: '([a-zA-Z_][a-zA-Z0-9_]*)\s*(:)' + captures: + 1: variable.parameter.mlf + 2: punctuation.separator.mlf + - include: strings + - include: numbers + - match: '\b(true|false)\b' + scope: constant.language.mlf + - match: ',' + scope: punctuation.separator.mlf + - match: '(?=\S)' + pop: true + # Bare annotation with args: @deprecated(true) + - match: '@([a-zA-Z_][a-zA-Z0-9_]*)' + scope: meta.annotation.mlf + captures: + 1: entity.name.function.annotation.mlf + push: + - match: '\(' + scope: punctuation.section.arguments.begin.mlf + set: + - meta_scope: meta.annotation.arguments.mlf + - match: '\)' + scope: punctuation.section.arguments.end.mlf + pop: true + - match: '([a-zA-Z_][a-zA-Z0-9_]*)\s*(:)' + captures: + 1: variable.parameter.mlf + 2: punctuation.separator.mlf + - include: strings + - include: numbers + - match: '\b(true|false)\b' + scope: constant.language.mlf + - match: ',' + scope: punctuation.separator.mlf + - match: '(?=\S)' + pop: true + keywords: - match: '\b(namespace|use|as|record|inline|def|type|token|query|procedure|subscription|throws|constrained|error)\b' scope: keyword.control.mlf