Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-search/src/lib.rs

//! **facett-search** — the ordning query-language parser + a search-bar facet.
//!
//! One grammar, used everywhere (search, the command line, saved views, boards,
//! automation conditions). This crate owns the **parser** (the valuable, pure,
//! well-tested core) and a thin egui **search bar** that parses-as-you-type and
//! shows the parsed clauses as chips.
//!
//! Grammar (a YouTrack-flavored subset; see manual ch.10):
//! ```text
//!   attr:value            equals
//!   attr:v1,v2            OR within a field (comma)
//!   -attr:value           exclude
//!   attr:lo..hi           range
//!   has:x   has:-x        existence
//!   #shortcut             a saved shorthand (#me, #overdue, #unresolved)
//!   sort:field            ordering (sort:field-desc for descending)
//!   bare words            full-text terms
//! ```
//!
//! The parser is `no-egui` logic; the facet is a thin wrapper. Destined for
//! facett (implements the `Facet` shape).

use serde::{Deserialize, Serialize};

/// A parsed query.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Query {
    pub clauses: Vec<Clause>,
    /// Bare full-text terms.
    pub text: Vec<String>,
    pub sort: Option<Sort>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Clause {
    /// `attr:value` / `attr:a,b` / `-attr:value`
    Eq { attr: String, values: Vec<String>, negate: bool },
    /// `attr:lo..hi`
    Range { attr: String, lo: String, hi: String },
    /// `has:x` / `has:-x`
    Has { attr: String, negate: bool },
    /// `#shortcut`
    Shortcut { name: String },
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sort {
    pub field: String,
    pub desc: bool,
}

/// Parse a query string into a [`Query`]. Never fails — unrecognized tokens
/// degrade to full-text terms, so a half-typed query is still usable.
pub fn parse(input: &str) -> Query {
    let mut q = Query::default();
    for tok in tokenize(input) {
        // #shortcut
        if let Some(name) = tok.strip_prefix('#') && !name.is_empty() {
            q.clauses.push(Clause::Shortcut { name: name.to_string() });
            continue;
        }
        // -attr:value (negated)
        let (negate, body) = match tok.strip_prefix('-') {
            Some(rest) if rest.contains(':') => (true, rest),
            _ => (false, tok.as_str()),
        };
        if let Some((attr, value)) = body.split_once(':') {
            let attr = attr.trim().to_lowercase();
            let value = value.trim();
            if attr == "sort" {
                let (field, desc) = match value.strip_suffix("-desc") {
                    Some(f) => (f.to_string(), true),
                    None => match value.strip_suffix(" desc") {
                        Some(f) => (f.trim().to_string(), true),
                        None => (value.to_string(), false),
                    },
                };
                q.sort = Some(Sort { field, desc });
                continue;
            }
            if attr == "has" {
                let (neg2, name) = match value.strip_prefix('-') {
                    Some(n) => (true, n),
                    None => (false, value),
                };
                q.clauses.push(Clause::Has { attr: name.to_string(), negate: negate ^ neg2 });
                continue;
            }
            if let Some((lo, hi)) = value.split_once("..") {
                q.clauses.push(Clause::Range {
                    attr,
                    lo: lo.trim().to_string(),
                    hi: hi.trim().to_string(),
                });
                continue;
            }
            let values: Vec<String> = value
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();
            if values.is_empty() {
                continue;
            }
            q.clauses.push(Clause::Eq { attr, values, negate });
            continue;
        }
        // bare word → full-text
        if !tok.is_empty() {
            q.text.push(tok);
        }
    }
    q
}

/// Split into tokens, honoring `"quoted values"` and `{brace values}` as single
/// tokens (so `status:{In Progress}` and `comment:"väntar på svar"` work).
fn tokenize(input: &str) -> Vec<String> {
    let mut toks = Vec::new();
    let mut cur = String::new();
    let mut quote: Option<char> = None;
    for ch in input.chars() {
        match quote {
            Some(q) => {
                let closes = (q == '"' && ch == '"') || (q == '{' && ch == '}');
                if closes {
                    quote = None;
                } else {
                    cur.push(ch);
                }
            }
            None => match ch {
                '"' => quote = Some('"'),
                '{' => quote = Some('{'),
                c if c.is_whitespace() => {
                    if !cur.is_empty() {
                        toks.push(std::mem::take(&mut cur));
                    }
                }
                c => cur.push(c),
            },
        }
    }
    if !cur.is_empty() {
        toks.push(cur);
    }
    toks
}

impl Query {
    /// A short human description of the parsed query (for chips / debugging).
    pub fn describe(&self) -> String {
        let mut parts: Vec<String> = Vec::new();
        for c in &self.clauses {
            parts.push(match c {
                Clause::Eq { attr, values, negate } => {
                    format!("{}{}: {}", if *negate { "≠" } else { "" }, attr, values.join(" OR "))
                }
                Clause::Range { attr, lo, hi } => format!("{attr}: {lo}..{hi}"),
                Clause::Has { attr, negate } => format!("{}has:{attr}", if *negate { "no " } else { "" }),
                Clause::Shortcut { name } => format!("#{name}"),
            });
        }
        if !self.text.is_empty() {
            parts.push(format!("text:\"{}\"", self.text.join(" ")));
        }
        if let Some(s) = &self.sort {
            parts.push(format!("sort:{}{}", s.field, if s.desc { " ↓" } else { " ↑" }));
        }
        parts.join("  ·  ")
    }
}

// ── The egui search-bar facet ────────────────────────────────────────────────

/// A search-bar facet: a text input that parses as you type and shows the parsed
/// clauses. `take_submit()` yields the [`Query`] when the user presses Enter.
pub struct SearchBar {
    title: String,
    input: String,
    parsed: Query,
    submitted: Option<Query>,
}

impl Default for SearchBar {
    fn default() -> Self {
        Self::new("Search")
    }
}

impl SearchBar {
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), input: String::new(), parsed: Query::default(), submitted: None }
    }

    /// Pre-fill the bar (e.g. loading a saved view).
    pub fn set_text(&mut self, text: impl Into<String>) {
        self.input = text.into();
        self.parsed = parse(&self.input);
    }

    pub fn query(&self) -> &Query {
        &self.parsed
    }

    /// The query submitted (Enter pressed) since the last call, if any.
    pub fn take_submit(&mut self) -> Option<Query> {
        self.submitted.take()
    }

    /// Submit the current input **headlessly** — the robot counterpart of
    /// pressing Enter in the bar, so `take_submit()` sees the same payload.
    pub fn submit(&mut self) {
        self.submitted = Some(self.parsed.clone());
    }

    /// The **shared demo fixture** — a bar pre-filled with a query exercising
    /// every clause kind the parser branches on (a field filter, a free-text
    /// term, a negation and a sort). Consumed by the robot and matrix tests
    /// (L5: one fixture, not one per test file).
    pub fn local() -> Self {
        let mut s = Self::new("Search");
        s.set_text("status:open assignee:me -tag:spam bygglov sort:due");
        s
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "input": self.input,
            "parsed": self.parsed,
            "describe": self.parsed.describe(),
        })
    }

    pub fn ui(&mut self, ui: &mut egui::Ui) {
        let resp = ui.add(
            egui::TextEdit::singleline(&mut self.input)
                .hint_text("query — e.g. status:open assignee:me sort:due")
                .desired_width(f32::INFINITY),
        );
        if resp.changed() {
            self.parsed = parse(&self.input);
        }
        if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
            self.parsed = parse(&self.input);
            self.submitted = Some(self.parsed.clone());
        }
        if !self.parsed.clauses.is_empty() || self.parsed.sort.is_some() || !self.parsed.text.is_empty() {
            ui.label(egui::RichText::new(self.parsed.describe()).small().color(egui::Color32::GRAY));
        }
    }
}

// ── The host-facing `Facet` contract ───────────────────────────────────────────
impl facett_core::Facet for SearchBar {
    fn title(&self) -> &str {
        SearchBar::title(self)
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        SearchBar::ui(self, ui)
    }
    fn state_json(&self) -> serde_json::Value {
        SearchBar::state_json(self)
    }
    /// The **robot drive seam**: `{"set_text":"status:open"}` retypes the query
    /// (re-parsing it, exactly as typing does) and `"submit"` is the Enter key.
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        if v == serde_json::json!("submit") {
            return self.submit();
        }
        if let Some(t) = v.get("set_text").and_then(|s| s.as_str()) {
            self.set_text(t);
        }
    }
    fn caps(&self) -> facett_core::FacetCaps {
        facett_core::FacetCaps::NONE.copyable()
    }
    fn copy(&mut self) -> Option<String> {
        Some(self.input.clone())
    }
    fn paste(&mut self, text: &str) -> bool {
        self.set_text(text);
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_basic_clauses() {
        let q = parse("status:open assignee:me priority:high");
        assert_eq!(q.clauses.len(), 3);
        assert_eq!(
            q.clauses[0],
            Clause::Eq { attr: "status".into(), values: vec!["open".into()], negate: false }
        );
    }

    #[test]
    fn comma_is_or_and_minus_is_exclude() {
        let q = parse("status:open,waiting -priority:low");
        assert_eq!(q.clauses[0], Clause::Eq { attr: "status".into(), values: vec!["open".into(), "waiting".into()], negate: false });
        assert_eq!(q.clauses[1], Clause::Eq { attr: "priority".into(), values: vec!["low".into()], negate: true });
    }

    #[test]
    fn ranges_has_shortcuts_sort_text() {
        let q = parse("created:2026-01-01..2026-03-31 has:attachments has:-assignee #overdue sort:due-desc login loop");
        assert!(q.clauses.contains(&Clause::Range { attr: "created".into(), lo: "2026-01-01".into(), hi: "2026-03-31".into() }));
        assert!(q.clauses.contains(&Clause::Has { attr: "attachments".into(), negate: false }));
        assert!(q.clauses.contains(&Clause::Has { attr: "assignee".into(), negate: true }));
        assert!(q.clauses.contains(&Clause::Shortcut { name: "overdue".into() }));
        assert_eq!(q.sort, Some(Sort { field: "due".into(), desc: true }));
        assert_eq!(q.text, vec!["login".to_string(), "loop".to_string()]);
    }

    #[test]
    fn quotes_and_braces_group_values() {
        let q = parse(r#"status:{In Progress} comment:"väntar på svar""#);
        assert_eq!(q.clauses[0], Clause::Eq { attr: "status".into(), values: vec!["In Progress".into()], negate: false });
        assert_eq!(q.clauses[1], Clause::Eq { attr: "comment".into(), values: vec!["väntar på svar".into()], negate: false });
    }

    #[test]
    fn describe_is_stable() {
        let q = parse("status:open,waiting sort:due");
        assert!(q.describe().contains("status: open OR waiting"));
        assert!(q.describe().contains("sort:due"));
    }
}