Skip to main content

jpx_core/
query_library.rs

1//! Named Query Library parser for `.jpx` files.
2//!
3//! This module provides parsing support for query library files that contain
4//! multiple named, reusable JMESPath expressions. The format is inspired by
5//! SQLDelight/HugSQL patterns.
6//!
7//! # File Format
8//!
9//! ```text
10//! -- :name top-keywords
11//! -- :desc Extract top keywords from text field
12//! tokens(@) | remove_stopwords(@) | stems(@) | frequencies(@)
13//!
14//! -- :name clean-html
15//! -- :desc Strip HTML tags and normalize whitespace
16//! regex_replace(@, `"<[^>]+>"`, `" "`) | collapse_whitespace(@)
17//! ```
18//!
19//! ## Directives
20//!
21//! - `-- :name <name>` - Starts a new query (required)
22//! - `-- :desc <description>` - Adds a description to the current query (optional)
23//! - `-- ` - Other comment lines are ignored
24//!
25//! Everything between `-- :name` directives becomes the query expression.
26//! Multi-line expressions are supported.
27//!
28//! # Example
29//!
30//! ```rust
31//! use jpx_core::query_library::QueryLibrary;
32//!
33//! let content = r#"
34//! -- :name greet
35//! -- :desc Simple greeting
36//! `"hello"`
37//!
38//! -- :name count
39//! length(@)
40//! "#;
41//!
42//! let library = QueryLibrary::parse(content).unwrap();
43//!
44//! assert_eq!(library.names(), vec!["greet", "count"]);
45//!
46//! let greet = library.get("greet").unwrap();
47//! assert_eq!(greet.name, "greet");
48//! assert_eq!(greet.description, Some("Simple greeting".to_string()));
49//! assert_eq!(greet.expression, r#"`"hello"`"#);
50//!
51//! let count = library.get("count").unwrap();
52//! assert_eq!(count.expression, "length(@)");
53//! ```
54//!
55//! # Detection
56//!
57//! Use [`is_query_library`] to check if content looks like a query library:
58//!
59//! ```rust
60//! use jpx_core::query_library::is_query_library;
61//!
62//! assert!(is_query_library("-- :name foo\nlength(@)"));
63//! assert!(!is_query_library("length(@)"));
64//! ```
65
66use std::fmt;
67
68/// Error type for query library parsing.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ParseError {
71    /// Error message
72    pub message: String,
73    /// Line number where the error occurred (1-indexed)
74    pub line: Option<usize>,
75}
76
77impl fmt::Display for ParseError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self.line {
80            Some(line) => write!(f, "{} at line {}", self.message, line),
81            None => write!(f, "{}", self.message),
82        }
83    }
84}
85
86impl std::error::Error for ParseError {}
87
88impl ParseError {
89    /// Create a new parse error with a message.
90    pub fn new(message: impl Into<String>) -> Self {
91        Self {
92            message: message.into(),
93            line: None,
94        }
95    }
96
97    /// Create a new parse error with a message and line number.
98    pub fn with_line(message: impl Into<String>, line: usize) -> Self {
99        Self {
100            message: message.into(),
101            line: Some(line),
102        }
103    }
104}
105
106/// A named query with optional description.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct NamedQuery {
109    /// Query name (used for lookup)
110    pub name: String,
111    /// Optional description
112    pub description: Option<String>,
113    /// The JMESPath expression
114    pub expression: String,
115    /// Line number where the query starts (1-indexed, for error messages)
116    pub line_number: usize,
117}
118
119/// A collection of named queries parsed from a `.jpx` file.
120#[derive(Debug, Clone, Default)]
121pub struct QueryLibrary {
122    queries: Vec<NamedQuery>,
123}
124
125impl QueryLibrary {
126    /// Create an empty query library.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Parse a query library from file content.
132    ///
133    /// # Format
134    ///
135    /// - `-- :name <name>` starts a new query
136    /// - `-- :desc <description>` adds a description to the current query
137    /// - `-- ` other comment lines are ignored
138    /// - Non-comment lines are appended to the current query's expression
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if:
143    /// - A query has an empty name
144    /// - A query has no expression
145    /// - Duplicate query names are found
146    /// - No queries are found in the content
147    ///
148    /// # Example
149    ///
150    /// ```rust
151    /// use jpx_core::query_library::QueryLibrary;
152    ///
153    /// let content = r#"
154    /// -- :name count
155    /// length(@)
156    /// "#;
157    ///
158    /// let library = QueryLibrary::parse(content).unwrap();
159    /// assert_eq!(library.len(), 1);
160    /// ```
161    pub fn parse(content: &str) -> Result<Self, ParseError> {
162        let mut queries = Vec::new();
163        let mut current_name: Option<String> = None;
164        let mut current_desc: Option<String> = None;
165        let mut current_expr = String::new();
166        let mut current_line_number = 0usize;
167
168        for (line_num, line) in content.lines().enumerate() {
169            let line_number = line_num + 1; // 1-indexed for error messages
170            let trimmed = line.trim();
171
172            if let Some(rest) = trimmed.strip_prefix("-- :name ").or_else(|| {
173                // Handle "-- :name" without trailing space (empty name case)
174                if trimmed == "-- :name" {
175                    Some("")
176                } else {
177                    None
178                }
179            }) {
180                // Save previous query if exists
181                if let Some(name) = current_name.take() {
182                    let expr = current_expr.trim().to_string();
183                    if expr.is_empty() {
184                        return Err(ParseError::with_line(
185                            format!("Query '{}' has no expression", name),
186                            current_line_number,
187                        ));
188                    }
189                    queries.push(NamedQuery {
190                        name,
191                        description: current_desc.take(),
192                        expression: expr,
193                        line_number: current_line_number,
194                    });
195                    current_expr.clear();
196                }
197
198                // Start new query
199                let name = rest.trim().to_string();
200                if name.is_empty() {
201                    return Err(ParseError::with_line("Empty query name", line_number));
202                }
203
204                // Check for duplicates
205                if queries.iter().any(|q| q.name == name) {
206                    return Err(ParseError::with_line(
207                        format!("Duplicate query name '{}'", name),
208                        line_number,
209                    ));
210                }
211
212                current_name = Some(name);
213                current_line_number = line_number;
214            } else if let Some(rest) = trimmed.strip_prefix("-- :desc ") {
215                // Add description to current query
216                if current_name.is_some() {
217                    current_desc = Some(rest.trim().to_string());
218                }
219            } else if trimmed.starts_with("-- ") || trimmed == "--" {
220                // Skip other comments
221            } else if !trimmed.is_empty() {
222                // Append to current expression
223                if current_name.is_some() {
224                    if !current_expr.is_empty() {
225                        current_expr.push('\n');
226                    }
227                    current_expr.push_str(line);
228                }
229            }
230        }
231
232        // Save final query
233        if let Some(name) = current_name {
234            let expr = current_expr.trim().to_string();
235            if expr.is_empty() {
236                return Err(ParseError::with_line(
237                    format!("Query '{}' has no expression", name),
238                    current_line_number,
239                ));
240            }
241            queries.push(NamedQuery {
242                name,
243                description: current_desc,
244                expression: expr,
245                line_number: current_line_number,
246            });
247        }
248
249        if queries.is_empty() {
250            return Err(ParseError::new(
251                "No queries found. Use '-- :name <query-name>' to define queries.",
252            ));
253        }
254
255        Ok(QueryLibrary { queries })
256    }
257
258    /// Get a query by name.
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// use jpx_core::query_library::QueryLibrary;
264    ///
265    /// let lib = QueryLibrary::parse("-- :name test\nlength(@)").unwrap();
266    /// let query = lib.get("test").unwrap();
267    /// assert_eq!(query.expression, "length(@)");
268    /// ```
269    pub fn get(&self, name: &str) -> Option<&NamedQuery> {
270        self.queries.iter().find(|q| q.name == name)
271    }
272
273    /// Get all queries.
274    ///
275    /// # Example
276    ///
277    /// ```rust
278    /// use jpx_core::query_library::QueryLibrary;
279    ///
280    /// let lib = QueryLibrary::parse("-- :name a\n`1`\n-- :name b\n`2`").unwrap();
281    /// assert_eq!(lib.list().len(), 2);
282    /// ```
283    pub fn list(&self) -> &[NamedQuery] {
284        &self.queries
285    }
286
287    /// Get query names.
288    ///
289    /// # Example
290    ///
291    /// ```rust
292    /// use jpx_core::query_library::QueryLibrary;
293    ///
294    /// let lib = QueryLibrary::parse("-- :name foo\n`1`\n-- :name bar\n`2`").unwrap();
295    /// assert_eq!(lib.names(), vec!["foo", "bar"]);
296    /// ```
297    pub fn names(&self) -> Vec<&str> {
298        self.queries.iter().map(|q| q.name.as_str()).collect()
299    }
300
301    /// Get the number of queries in the library.
302    pub fn len(&self) -> usize {
303        self.queries.len()
304    }
305
306    /// Check if the library is empty.
307    pub fn is_empty(&self) -> bool {
308        self.queries.is_empty()
309    }
310
311    /// Iterate over queries.
312    pub fn iter(&self) -> impl Iterator<Item = &NamedQuery> {
313        self.queries.iter()
314    }
315}
316
317impl<'a> IntoIterator for &'a QueryLibrary {
318    type Item = &'a NamedQuery;
319    type IntoIter = std::slice::Iter<'a, NamedQuery>;
320
321    fn into_iter(self) -> Self::IntoIter {
322        self.queries.iter()
323    }
324}
325
326/// Check if content looks like a query library (starts with `-- :name`).
327///
328/// This function checks if the first non-empty line starts with `-- :name `,
329/// indicating a query library format.
330///
331/// # Example
332///
333/// ```rust
334/// use jpx_core::query_library::is_query_library;
335///
336/// assert!(is_query_library("-- :name foo\nlength(@)"));
337/// assert!(is_query_library("  -- :name foo\nlength(@)"));
338/// assert!(is_query_library("\n-- :name foo\nlength(@)"));
339/// assert!(!is_query_library("length(@)"));
340/// assert!(!is_query_library("-- comment\nlength(@)"));
341/// ```
342pub fn is_query_library(content: &str) -> bool {
343    content
344        .lines()
345        .find(|line| !line.trim().is_empty())
346        .map(|line| line.trim().starts_with("-- :name "))
347        .unwrap_or(false)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_parse_simple_library() {
356        let content = r#"
357-- :name greet
358-- :desc Simple greeting
359`"hello"`
360
361-- :name count
362length(@)
363"#;
364        let lib = QueryLibrary::parse(content).unwrap();
365        assert_eq!(lib.len(), 2);
366
367        let greet = lib.get("greet").unwrap();
368        assert_eq!(greet.name, "greet");
369        assert_eq!(greet.description, Some("Simple greeting".to_string()));
370        assert_eq!(greet.expression, "`\"hello\"`");
371
372        let count = lib.get("count").unwrap();
373        assert_eq!(count.name, "count");
374        assert_eq!(count.description, None);
375        assert_eq!(count.expression, "length(@)");
376    }
377
378    #[test]
379    fn test_parse_multiline_expression() {
380        let content = r#"
381-- :name complex
382-- :desc Multi-line query
383{
384  total: length(@),
385  first: @[0]
386}
387"#;
388        let lib = QueryLibrary::parse(content).unwrap();
389        let query = lib.get("complex").unwrap();
390        assert!(query.expression.contains("total: length(@)"));
391        assert!(query.expression.contains("first: @[0]"));
392    }
393
394    #[test]
395    fn test_parse_empty_name_error() {
396        let content = "-- :name \nlength(@)";
397        let result = QueryLibrary::parse(content);
398        assert!(result.is_err());
399        let err = result.unwrap_err();
400        assert!(err.message.contains("Empty query name"));
401        assert_eq!(err.line, Some(1));
402    }
403
404    #[test]
405    fn test_parse_duplicate_name_error() {
406        let content = r#"
407-- :name foo
408length(@)
409
410-- :name foo
411keys(@)
412"#;
413        let result = QueryLibrary::parse(content);
414        assert!(result.is_err());
415        assert!(result.unwrap_err().message.contains("Duplicate query name"));
416    }
417
418    #[test]
419    fn test_parse_no_expression_error() {
420        let content = "-- :name empty\n-- :name another\nlength(@)";
421        let result = QueryLibrary::parse(content);
422        assert!(result.is_err());
423        assert!(result.unwrap_err().message.contains("has no expression"));
424    }
425
426    #[test]
427    fn test_parse_no_queries_error() {
428        let content = "-- just a comment\nlength(@)";
429        let result = QueryLibrary::parse(content);
430        assert!(result.is_err());
431        assert!(result.unwrap_err().message.contains("No queries found"));
432    }
433
434    #[test]
435    fn test_is_query_library() {
436        assert!(is_query_library("-- :name foo\nlength(@)"));
437        assert!(is_query_library("  -- :name foo\nlength(@)"));
438        assert!(is_query_library("\n-- :name foo\nlength(@)"));
439        assert!(!is_query_library("length(@)"));
440        assert!(!is_query_library("-- comment\nlength(@)"));
441    }
442
443    #[test]
444    fn test_comments_ignored() {
445        let content = r#"
446-- :name test
447-- :desc Description
448-- This is a regular comment
449-- Another comment
450length(@)
451-- Trailing comment
452"#;
453        let lib = QueryLibrary::parse(content).unwrap();
454        let query = lib.get("test").unwrap();
455        assert_eq!(query.expression, "length(@)");
456    }
457
458    #[test]
459    fn test_iter() {
460        let content = "-- :name a\n`1`\n-- :name b\n`2`";
461        let lib = QueryLibrary::parse(content).unwrap();
462        let names: Vec<_> = lib.iter().map(|q| &q.name).collect();
463        assert_eq!(names, vec!["a", "b"]);
464    }
465
466    #[test]
467    fn test_into_iter() {
468        let content = "-- :name x\n`1`";
469        let lib = QueryLibrary::parse(content).unwrap();
470        for query in &lib {
471            assert_eq!(query.name, "x");
472        }
473    }
474}