diff --git a/Cargo.lock b/Cargo.lock index bb73ad4a..67561bf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,9 +57,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "proc-macro2" @@ -174,6 +174,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tree-sitter" version = "0.26.13" @@ -201,6 +221,20 @@ dependencies = [ "cc", "tree-sitter", "tree-sitter-language", + "tree-sitter-tags", +] + +[[package]] +name = "tree-sitter-tags" +version = "0.26.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f822ba86032a0e7907c6a24efd7bc15f57566e1f523f2a6841c61686c1f5b8" +dependencies = [ + "memchr", + "regex", + "streaming-iterator", + "thiserror", + "tree-sitter", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5f159f87..d0f1f0ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ cc = "1.1" [dev-dependencies] tree-sitter = "0.26.13" +tree-sitter-tags = "0.26.13" diff --git a/bindings/rust/lib.rs b/bindings/rust/lib.rs index 776346ee..04d3f8a3 100644 --- a/bindings/rust/lib.rs +++ b/bindings/rust/lib.rs @@ -57,4 +57,90 @@ mod tests { .set_language(&super::LANGUAGE.into()) .expect("Error loading Ruby parser"); } + + /// Returns the doc comment the tags query attaches to each method definition, + /// keyed by method name. + fn method_docs(source: &str) -> std::collections::HashMap> { + let config = tree_sitter_tags::TagsConfiguration::new( + super::LANGUAGE.into(), + super::TAGS_QUERY, + super::LOCALS_QUERY, + ) + .expect("Error loading tags query"); + let mut context = tree_sitter_tags::TagsContext::new(); + let (tags, _) = context + .generate_tags(&config, source.as_bytes(), None) + .expect("Error generating tags"); + + tags.filter_map(Result::ok) + .filter(|tag| tag.is_definition) + .filter(|tag| config.syntax_type_name(tag.syntax_type_id) == "method") + .map(|tag| { + ( + source[tag.name_range.clone()].to_string(), + tag.docs.clone(), + ) + }) + .collect() + } + + /// A Sorbet `sig` block sits between a method's doc comment and its definition. + /// The tags query has to look past it, without losing definitions that follow an + /// unrelated block call and without attaching comments separated by a blank line. + #[test] + fn test_tags_attach_doc_comments_across_sorbet_sig() { + let docs = method_docs( + r#" +# Converts to a string. +sig { params(a: Integer).returns(String) } +def sigged(a); end + +# Returns nothing. +sig do + void +end +def sigged_do; end + +# Builds an instance. +sig { returns(Calc) } +def self.singleton; end + +# Documents the block, not the method. + +sig { void } +def spaced; end + +# Documents the block, not the method. +memoize { :cached } +def after_non_sig_block; end + +# Plain documented method. +def plain; end +"#, + ); + + assert_eq!( + docs.get("sigged"), + Some(&Some("Converts to a string.".to_string())) + ); + assert_eq!( + docs.get("sigged_do"), + Some(&Some("Returns nothing.".to_string())) + ); + assert_eq!( + docs.get("singleton"), + Some(&Some("Builds an instance.".to_string())) + ); + assert_eq!( + docs.get("plain"), + Some(&Some("Plain documented method.".to_string())) + ); + + // A blank line still separates a comment from the definition it precedes. + assert_eq!(docs.get("spaced"), Some(&None)); + + // Only `sig` is skipped, so this comment stays with the block call, but the + // definition itself must still be tagged. + assert_eq!(docs.get("after_non_sig_block"), Some(&None)); + } } diff --git a/queries/tags.scm b/queries/tags.scm index 47ba1eb5..8167de09 100644 --- a/queries/tags.scm +++ b/queries/tags.scm @@ -1,5 +1,31 @@ ; Method definitions +; A Sorbet `sig` block sits between a method's doc comment and its definition, so +; the comment is not adjacent to the definition and the general pattern below +; cannot attach it. Anchoring adjacency on the `sig` call instead recovers the +; doc comment. This must precede the general pattern: when several patterns match +; the same node, tree-sitter-tags keeps the one from the earliest pattern. +( + (comment)* @doc + . + (call + method: (identifier) @reference.call + block: [ + (block) + (do_block) + ]) + . + [ + (method + name: (_) @name) @definition.method + (singleton_method + name: (_) @name) @definition.method + ] + (#eq? @reference.call "sig") + (#strip! @doc "^#\\s*") + (#select-adjacent! @doc @reference.call) +) + ( (comment)* @doc .