use ryo_pattern::{BodyMatch, Relations};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(
title = "RyoQL Query",
description = "AI agent-friendly structured code query"
)]
pub struct Query {
pub kind: QueryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#match: Option<MatchAttrs>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inner: Vec<Query>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queries: Vec<Query>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<BodyMatch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relations: Option<Relations>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolve: Option<ResolveConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Scope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<ViewMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum QueryKind {
Any,
Function,
Struct,
Enum,
Trait,
Impl,
Mod,
Const,
Static,
TypeAlias,
ReturnType,
Parameter,
Field,
Variant,
Or,
And,
Pattern,
Literal,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct MatchAttrs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<NameMatcher>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore_case: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore_word_separate: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vis: Option<Visibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_async: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_unsafe: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub receiver: Option<ReceiverKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generics: Option<GenericsMatch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_empty: Option<RecoveryStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lit_type: Option<LiteralType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<NameMatcher>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum LiteralType {
String,
ByteStr,
Char,
Byte,
Int,
Float,
Bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum NameMatcher {
Exact(String),
Detailed(NameMatcherDetailed),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NameMatcherDetailed {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub starts_with: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ends_with: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glob: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore_case: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore_word_separate: Option<bool>,
}
impl NameMatcher {
pub fn matches(&self, name: &str) -> bool {
match self {
NameMatcher::Exact(pattern) => name == pattern,
NameMatcher::Detailed(d) => d.matches(name),
}
}
}
impl NameMatcherDetailed {
pub fn matches(&self, name: &str) -> bool {
let ignore_case = self.ignore_case.unwrap_or(false);
let ignore_word_separate = self.ignore_word_separate.unwrap_or(false);
if ignore_word_separate {
let name_words = normalize_to_words(name);
if let Some(ref contains) = self.contains {
let pattern_words = normalize_to_words(contains);
if !contains_words(&name_words, &pattern_words) {
return false;
}
}
if let Some(ref starts) = self.starts_with {
let pattern_words = normalize_to_words(starts);
if !starts_with_words(&name_words, &pattern_words) {
return false;
}
}
if let Some(ref ends) = self.ends_with {
let pattern_words = normalize_to_words(ends);
if !ends_with_words(&name_words, &pattern_words) {
return false;
}
}
if let Some(ref pattern) = self.glob {
let pattern_words = normalize_pattern_to_words(pattern);
if !match_word_pattern(&pattern_words, &name_words) {
return false;
}
}
if let Some(ref pattern) = self.regex {
if let Ok(re) = regex::Regex::new(pattern) {
if !re.is_match(name) {
return false;
}
}
}
} else if ignore_case {
let name_lower = name.to_ascii_lowercase();
if let Some(ref contains) = self.contains {
if !name_lower.contains(&contains.to_ascii_lowercase()) {
return false;
}
}
if let Some(ref starts) = self.starts_with {
if !name_lower.starts_with(&starts.to_ascii_lowercase()) {
return false;
}
}
if let Some(ref ends) = self.ends_with {
if !name_lower.ends_with(&ends.to_ascii_lowercase()) {
return false;
}
}
if let Some(ref pattern) = self.regex {
if let Ok(re) = regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
{
if !re.is_match(name) {
return false;
}
}
}
if let Some(ref pattern) = self.glob {
if let Ok(glob_pattern) = glob::Pattern::new(&pattern.to_ascii_lowercase()) {
if !glob_pattern.matches(&name_lower) {
return false;
}
}
}
} else {
if let Some(ref contains) = self.contains {
if !name.contains(contains) {
return false;
}
}
if let Some(ref starts) = self.starts_with {
if !name.starts_with(starts) {
return false;
}
}
if let Some(ref ends) = self.ends_with {
if !name.ends_with(ends) {
return false;
}
}
if let Some(ref pattern) = self.regex {
if let Ok(re) = regex::Regex::new(pattern) {
if !re.is_match(name) {
return false;
}
}
}
if let Some(ref pattern) = self.glob {
if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
if !glob_pattern.matches(name) {
return false;
}
}
}
}
true
}
}
fn normalize_to_words(s: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current_word = String::new();
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
for i in 0..len {
let c = chars[i];
if c == '_' {
if !current_word.is_empty() {
words.push(current_word.to_ascii_lowercase());
current_word.clear();
}
} else if c.is_ascii_uppercase() {
let prev_lower = i > 0 && chars[i - 1].is_ascii_lowercase();
let next_lower = i + 1 < len && chars[i + 1].is_ascii_lowercase();
if (prev_lower || (i > 0 && !current_word.is_empty() && next_lower))
&& !current_word.is_empty()
{
words.push(current_word.to_ascii_lowercase());
current_word.clear();
}
current_word.push(c);
} else {
current_word.push(c);
}
}
if !current_word.is_empty() {
words.push(current_word.to_ascii_lowercase());
}
words
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PatternWord {
Literal(String),
AnyWords,
AnyChar,
}
fn normalize_pattern_to_words(pattern: &str) -> Vec<PatternWord> {
let mut result = Vec::new();
let mut current = String::new();
let mut in_wildcard_seq = false;
for c in pattern.chars() {
match c {
'*' => {
if !current.is_empty() {
result.extend(
normalize_to_words(¤t)
.into_iter()
.map(PatternWord::Literal),
);
current.clear();
}
if !in_wildcard_seq {
result.push(PatternWord::AnyWords);
in_wildcard_seq = true;
}
}
'?' => {
if !current.is_empty() {
result.extend(
normalize_to_words(¤t)
.into_iter()
.map(PatternWord::Literal),
);
current.clear();
}
result.push(PatternWord::AnyChar);
in_wildcard_seq = false;
}
'_' => {
if !current.is_empty() {
result.extend(
normalize_to_words(¤t)
.into_iter()
.map(PatternWord::Literal),
);
current.clear();
}
in_wildcard_seq = false;
}
_ => {
current.push(c);
in_wildcard_seq = false;
}
}
}
if !current.is_empty() {
result.extend(
normalize_to_words(¤t)
.into_iter()
.map(PatternWord::Literal),
);
}
result
}
fn match_word_pattern(pattern: &[PatternWord], target: &[String]) -> bool {
match_word_pattern_recursive(pattern, target, 0, 0)
}
fn match_word_pattern_recursive(
pattern: &[PatternWord],
target: &[String],
pi: usize,
ti: usize,
) -> bool {
if pi == pattern.len() && ti == target.len() {
return true;
}
if pi == pattern.len() {
return false;
}
match &pattern[pi] {
PatternWord::AnyWords => {
for skip in 0..=(target.len() - ti) {
if match_word_pattern_recursive(pattern, target, pi + 1, ti + skip) {
return true;
}
}
false
}
PatternWord::Literal(word) => {
if ti < target.len() && target[ti] == *word {
match_word_pattern_recursive(pattern, target, pi + 1, ti + 1)
} else {
false
}
}
PatternWord::AnyChar => {
if ti < target.len() {
match_word_pattern_recursive(pattern, target, pi + 1, ti + 1)
} else {
false
}
}
}
}
fn contains_words(haystack: &[String], needle: &[String]) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > haystack.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
fn starts_with_words(haystack: &[String], prefix: &[String]) -> bool {
if prefix.len() > haystack.len() {
return false;
}
haystack[..prefix.len()] == *prefix
}
fn ends_with_words(haystack: &[String], suffix: &[String]) -> bool {
if suffix.len() > haystack.len() {
return false;
}
haystack[haystack.len() - suffix.len()..] == *suffix
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ReceiverKind {
None,
Ref,
MutRef,
Owned,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Visibility {
Public,
Private,
Crate,
Super,
Restricted(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct GenericsMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bounds: Option<Vec<NameMatcher>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifetimes: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct RecoveryStrategy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fuzzy: Option<FuzzyConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub split_words: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enumerate_scope: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FuzzyConfig {
pub max_distance: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ResolveConfig {
pub kind: ResolveKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ResolveKind {
References,
Definition,
Callers,
Callees,
Uses,
UsedBy,
Implementations,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct Scope {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub enum ViewMode {
#[default]
Snippet,
Precise,
Count,
Def,
Full,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QueryResponse {
pub status: QueryStatus,
pub results: Vec<MatchResult>,
pub suggestions: Vec<Suggestion>,
pub metadata: QueryMetadata,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum QueryStatus {
Found,
NotFound,
Partial,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MatchResult {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
pub path: String,
pub node_kind: String,
pub name: String,
#[serde(flatten)]
pub view: MatchView,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "view_mode", rename_all = "snake_case")]
pub enum MatchView {
Snippet {
text: String,
},
Precise,
Def {
module_path: String,
definition: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
doc: Option<String>,
},
Full {
module_path: String,
definition: String,
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
doc: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Suggestion {
pub kind: SuggestionKind,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance: Option<u32>,
pub confidence: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum SuggestionKind {
Typo,
Similar,
InScope,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QueryMetadata {
pub elapsed_ms: u32,
pub total_matches: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolve_status: Option<ResolveStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ResolveStatus {
Complete,
TimedOut,
RateLimited,
}
pub fn query_json_schema() -> schemars::Schema {
schemars::schema_for!(Query)
}
pub fn response_json_schema() -> schemars::Schema {
schemars::schema_for!(QueryResponse)
}
pub fn query_json_schema_string() -> String {
serde_json::to_string_pretty(&query_json_schema()).unwrap_or_default()
}
pub fn response_json_schema_string() -> String {
serde_json::to_string_pretty(&response_json_schema()).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_query() {
let yaml = r#"
kind: Function
match:
name: "process"
vis: Public
is_async: true
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Function);
let m = query.r#match.unwrap();
assert!(matches!(m.name, Some(NameMatcher::Exact(ref s)) if s == "process"));
assert_eq!(m.vis, Some(Visibility::Public));
assert_eq!(m.is_async, Some(true));
}
#[test]
fn test_parse_nested_query() {
let yaml = r#"
kind: Function
match:
name: { starts_with: "process_" }
inner:
- kind: ReturnType
match:
name: "Result"
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Function);
assert_eq!(query.inner.len(), 1);
assert_eq!(query.inner[0].kind, QueryKind::ReturnType);
}
#[test]
fn test_parse_or_query() {
let yaml = r#"
kind: Or
queries:
- kind: Struct
match:
name: { contains: "Error" }
- kind: Enum
match:
name: { contains: "Error" }
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Or);
assert_eq!(query.queries.len(), 2);
}
#[test]
fn test_name_matcher_exact() {
let matcher = NameMatcher::Exact("process".to_string());
assert!(matcher.matches("process"));
assert!(!matcher.matches("process_event"));
}
#[test]
fn test_name_matcher_contains() {
let matcher = NameMatcher::Detailed(NameMatcherDetailed {
contains: Some("process".to_string()),
starts_with: None,
ends_with: None,
regex: None,
glob: None,
ignore_case: None,
ignore_word_separate: None,
});
assert!(matcher.matches("process"));
assert!(matcher.matches("process_event"));
assert!(matcher.matches("do_process"));
assert!(!matcher.matches("handle"));
}
#[test]
fn test_name_matcher_glob() {
let matcher = NameMatcher::Detailed(NameMatcherDetailed {
contains: None,
starts_with: None,
ends_with: None,
regex: None,
glob: Some("*Config".to_string()),
ignore_case: None,
ignore_word_separate: None,
});
assert!(matcher.matches("AppConfig"));
assert!(matcher.matches("Config"));
assert!(!matcher.matches("ConfigManager"));
}
#[test]
fn test_name_matcher_ignore_case() {
let matcher = NameMatcher::Detailed(NameMatcherDetailed {
contains: Some("config".to_string()),
starts_with: None,
ends_with: None,
regex: None,
glob: None,
ignore_case: Some(true),
ignore_word_separate: None,
});
assert!(matcher.matches("AppConfig"));
assert!(matcher.matches("APPCONFIG"));
assert!(matcher.matches("config"));
}
#[test]
fn test_name_matcher_ignore_word_separate() {
let matcher = NameMatcher::Detailed(NameMatcherDetailed {
contains: None,
starts_with: Some("get_user".to_string()),
ends_with: None,
regex: None,
glob: None,
ignore_case: None,
ignore_word_separate: Some(true),
});
assert!(matcher.matches("get_user_name"));
assert!(matcher.matches("getUserName"));
assert!(matcher.matches("GetUserName"));
assert!(!matcher.matches("fetch_user_name"));
}
#[test]
fn test_parse_literal_query() {
let yaml = r#"
kind: Literal
match:
pattern: "*error*"
lit_type: String
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Literal);
let m = query.r#match.unwrap();
assert_eq!(m.pattern, Some("*error*".to_string()));
assert_eq!(m.lit_type, Some(LiteralType::String));
}
#[test]
fn test_parse_literal_query_int() {
let yaml = r#"
kind: Literal
match:
pattern: "0x*"
lit_type: Int
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Literal);
let m = query.r#match.unwrap();
assert_eq!(m.lit_type, Some(LiteralType::Int));
}
#[test]
fn test_literal_type_all_variants() {
let types = [
("String", LiteralType::String),
("ByteStr", LiteralType::ByteStr),
("Char", LiteralType::Char),
("Byte", LiteralType::Byte),
("Int", LiteralType::Int),
("Float", LiteralType::Float),
("Bool", LiteralType::Bool),
];
for (s, expected) in types {
let json = format!(r#""{s}""#);
let parsed: LiteralType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, expected, "Failed for {s}");
}
}
#[test]
fn test_parse_query_with_body() {
let yaml = r#"
kind: Function
match:
name: "process"
body:
contains:
- node: MethodCall
capture: "call"
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Function);
let body = query.body.unwrap();
let contains = body.contains.unwrap();
assert_eq!(contains.len(), 1);
assert_eq!(contains[0].node, ryo_pattern::NodeKind::MethodCall);
assert_eq!(contains[0].capture, Some("call".to_string()));
}
#[test]
fn test_deny_unknown_fields_rejects_top_level_is_async() {
let json = r#"{"kind":"Function","is_async":true}"#;
let result: Result<Query, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"top-level is_async must be rejected by deny_unknown_fields"
);
}
#[test]
fn test_is_async_in_match_accepted() {
let json = r#"{"kind":"Function","match":{"is_async":true}}"#;
let query: Query = serde_json::from_str(json).unwrap();
assert_eq!(query.r#match.unwrap().is_async, Some(true));
}
#[test]
fn test_parse_query_with_relations() {
use ryo_pattern::RelationKind;
let yaml = r#"
kind: Function
match:
name: "handler"
relations:
any:
- kind: Calls
target: {}
transitive: true
max_depth: 3
none:
- kind: TypeReferences
target: {}
"#;
let query: Query = serde_yaml::from_str(yaml).unwrap();
assert_eq!(query.kind, QueryKind::Function);
let relations = query.relations.unwrap();
let any = relations.any.unwrap();
assert_eq!(any.len(), 1);
assert_eq!(any[0].kind, RelationKind::Calls);
assert!(any[0].transitive);
assert_eq!(any[0].max_depth, Some(3));
let none = relations.none.unwrap();
assert_eq!(none.len(), 1);
assert_eq!(none[0].kind, RelationKind::TypeReferences);
}
}