use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub message: String,
pub line: Option<usize>,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.line {
Some(line) => write!(f, "{} at line {}", self.message, line),
None => write!(f, "{}", self.message),
}
}
}
impl std::error::Error for ParseError {}
impl ParseError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
line: None,
}
}
pub fn with_line(message: impl Into<String>, line: usize) -> Self {
Self {
message: message.into(),
line: Some(line),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamedQuery {
pub name: String,
pub description: Option<String>,
pub expression: String,
pub line_number: usize,
}
#[derive(Debug, Clone, Default)]
pub struct QueryLibrary {
queries: Vec<NamedQuery>,
}
impl QueryLibrary {
pub fn new() -> Self {
Self::default()
}
pub fn parse(content: &str) -> Result<Self, ParseError> {
let mut queries = Vec::new();
let mut current_name: Option<String> = None;
let mut current_desc: Option<String> = None;
let mut current_expr = String::new();
let mut current_line_number = 0usize;
for (line_num, line) in content.lines().enumerate() {
let line_number = line_num + 1; let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("-- :name ").or_else(|| {
if trimmed == "-- :name" {
Some("")
} else {
None
}
}) {
if let Some(name) = current_name.take() {
let expr = current_expr.trim().to_string();
if expr.is_empty() {
return Err(ParseError::with_line(
format!("Query '{}' has no expression", name),
current_line_number,
));
}
queries.push(NamedQuery {
name,
description: current_desc.take(),
expression: expr,
line_number: current_line_number,
});
current_expr.clear();
}
let name = rest.trim().to_string();
if name.is_empty() {
return Err(ParseError::with_line("Empty query name", line_number));
}
if queries.iter().any(|q| q.name == name) {
return Err(ParseError::with_line(
format!("Duplicate query name '{}'", name),
line_number,
));
}
current_name = Some(name);
current_line_number = line_number;
} else if let Some(rest) = trimmed.strip_prefix("-- :desc ") {
if current_name.is_some() {
current_desc = Some(rest.trim().to_string());
}
} else if trimmed.starts_with("-- ") || trimmed == "--" {
} else if !trimmed.is_empty() {
if current_name.is_some() {
if !current_expr.is_empty() {
current_expr.push('\n');
}
current_expr.push_str(line);
}
}
}
if let Some(name) = current_name {
let expr = current_expr.trim().to_string();
if expr.is_empty() {
return Err(ParseError::with_line(
format!("Query '{}' has no expression", name),
current_line_number,
));
}
queries.push(NamedQuery {
name,
description: current_desc,
expression: expr,
line_number: current_line_number,
});
}
if queries.is_empty() {
return Err(ParseError::new(
"No queries found. Use '-- :name <query-name>' to define queries.",
));
}
Ok(QueryLibrary { queries })
}
pub fn get(&self, name: &str) -> Option<&NamedQuery> {
self.queries.iter().find(|q| q.name == name)
}
pub fn list(&self) -> &[NamedQuery] {
&self.queries
}
pub fn names(&self) -> Vec<&str> {
self.queries.iter().map(|q| q.name.as_str()).collect()
}
pub fn len(&self) -> usize {
self.queries.len()
}
pub fn is_empty(&self) -> bool {
self.queries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &NamedQuery> {
self.queries.iter()
}
}
impl<'a> IntoIterator for &'a QueryLibrary {
type Item = &'a NamedQuery;
type IntoIter = std::slice::Iter<'a, NamedQuery>;
fn into_iter(self) -> Self::IntoIter {
self.queries.iter()
}
}
pub fn is_query_library(content: &str) -> bool {
content
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().starts_with("-- :name "))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_library() {
let content = r#"
-- :name greet
-- :desc Simple greeting
`"hello"`
-- :name count
length(@)
"#;
let lib = QueryLibrary::parse(content).unwrap();
assert_eq!(lib.len(), 2);
let greet = lib.get("greet").unwrap();
assert_eq!(greet.name, "greet");
assert_eq!(greet.description, Some("Simple greeting".to_string()));
assert_eq!(greet.expression, "`\"hello\"`");
let count = lib.get("count").unwrap();
assert_eq!(count.name, "count");
assert_eq!(count.description, None);
assert_eq!(count.expression, "length(@)");
}
#[test]
fn test_parse_multiline_expression() {
let content = r#"
-- :name complex
-- :desc Multi-line query
{
total: length(@),
first: @[0]
}
"#;
let lib = QueryLibrary::parse(content).unwrap();
let query = lib.get("complex").unwrap();
assert!(query.expression.contains("total: length(@)"));
assert!(query.expression.contains("first: @[0]"));
}
#[test]
fn test_parse_empty_name_error() {
let content = "-- :name \nlength(@)";
let result = QueryLibrary::parse(content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("Empty query name"));
assert_eq!(err.line, Some(1));
}
#[test]
fn test_parse_duplicate_name_error() {
let content = r#"
-- :name foo
length(@)
-- :name foo
keys(@)
"#;
let result = QueryLibrary::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("Duplicate query name"));
}
#[test]
fn test_parse_no_expression_error() {
let content = "-- :name empty\n-- :name another\nlength(@)";
let result = QueryLibrary::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("has no expression"));
}
#[test]
fn test_parse_no_queries_error() {
let content = "-- just a comment\nlength(@)";
let result = QueryLibrary::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("No queries found"));
}
#[test]
fn test_is_query_library() {
assert!(is_query_library("-- :name foo\nlength(@)"));
assert!(is_query_library(" -- :name foo\nlength(@)"));
assert!(is_query_library("\n-- :name foo\nlength(@)"));
assert!(!is_query_library("length(@)"));
assert!(!is_query_library("-- comment\nlength(@)"));
}
#[test]
fn test_comments_ignored() {
let content = r#"
-- :name test
-- :desc Description
-- This is a regular comment
-- Another comment
length(@)
-- Trailing comment
"#;
let lib = QueryLibrary::parse(content).unwrap();
let query = lib.get("test").unwrap();
assert_eq!(query.expression, "length(@)");
}
#[test]
fn test_iter() {
let content = "-- :name a\n`1`\n-- :name b\n`2`";
let lib = QueryLibrary::parse(content).unwrap();
let names: Vec<_> = lib.iter().map(|q| &q.name).collect();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn test_into_iter() {
let content = "-- :name x\n`1`";
let lib = QueryLibrary::parse(content).unwrap();
for query in &lib {
assert_eq!(query.name, "x");
}
}
}