Skip to main content

kimun_notes/components/
query_vars.rs

1//! Query variables: `{name}` placeholders the TUI resolves to runtime
2//! values before a query reaches core (see `CONTEXT.md` "Query variable"
3//!). Core's query language never sees these.
4
5use kimun_core::nfs::VaultPath;
6use kimun_core::{expand_bare_note_prefixes, quote_query_term, strip_order_directive};
7
8/// The current-note variable. A bare note operator (`<`, `>`, `=`, their long
9/// forms and `-` exclusion variants) typed in the query panel is sugar for
10/// `<op>{note}`, expanded by core's [`expand_bare_note_prefixes`] before
11/// resolution so the DSL's tokenization is never re-implemented here.
12pub const VAR_NOTE: &str = "{note}";
13
14/// The runtime context a query template resolves against. Today it carries
15/// only the open note, but it is the single place future query variables
16/// (`{date}`, `{selection}`, …) extend: row sources and call sites resolve
17/// through a `QueryContext`, so adding a variable touches this struct and the
18/// resolver below — never the sources. See CONTEXT.md "Query context".
19#[derive(Clone, Default)]
20pub struct QueryContext {
21    /// The note open when the query runs. `{note}` and the bare-operator sugar
22    /// resolve against its clean name. `None`, or a root/empty path, means no
23    /// note is available to resolve against.
24    pub current_note: Option<VaultPath>,
25}
26
27impl QueryContext {
28    /// Context with just the open note — the only field today.
29    pub fn with_note(current_note: Option<VaultPath>) -> Self {
30        Self { current_note }
31    }
32
33    /// True when a note is available to resolve `{note}` against (present and
34    /// not the root/empty path).
35    fn has_note(&self) -> bool {
36        self.current_note
37            .as_ref()
38            .is_some_and(|p| !p.is_root_or_empty())
39    }
40
41    fn note_term(&self) -> String {
42        self.current_note
43            .as_ref()
44            .map(|p| quote_query_term(&p.get_clean_name()))
45            .unwrap_or_default()
46    }
47}
48
49/// True if `template` contains any query variable (including the bare-operator
50/// sugar forms). The query panel uses this to decide whether to re-run on note
51/// navigation.
52pub fn query_has_variables(template: &str) -> bool {
53    expand_bare_note_prefixes(template, VAR_NOTE).contains(VAR_NOTE)
54}
55
56/// True when `template` needs the current note but none is available: it
57/// contains note variables (literal `{note}` or bare-operator sugar) and,
58/// after dropping every variable-bearing token and the order directive,
59/// nothing searchable remains. Mixed queries (`widget <`) keep their concrete
60/// terms and must still run — core simply drops the unresolved bare prefix.
61/// Purely note-dependent queries (`<`, `<{note} or:title`) would reach core
62/// as dropped bare prefixes: a wasted round-trip that returns nothing, so
63/// callers should skip the search (and pick their own fallback).
64pub fn query_is_unresolvable(template: &str, ctx: &QueryContext) -> bool {
65    if ctx.has_note() {
66        return false;
67    }
68    let expanded = expand_bare_note_prefixes(template, VAR_NOTE);
69    expanded.contains(VAR_NOTE)
70        && strip_order_directive(&expanded)
71            .split_whitespace()
72            .all(|token| token.contains(VAR_NOTE))
73}
74
75/// Resolve all query variables in `template` against the open note,
76/// producing a plain query string for `vault.search_notes`. Bare note
77/// operators are first expanded to their `{note}` form, then `{note}`
78/// becomes the note's clean name (matching how `<` targets are
79/// matched), quoted when it contains whitespace so a multi-word name
80/// stays a single query token. When no note is open, `{note}` resolves
81/// to the empty string.
82pub fn resolve_query(template: &str, ctx: &QueryContext) -> String {
83    expand_bare_note_prefixes(template, VAR_NOTE).replace(VAR_NOTE, &ctx.note_term())
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    /// Context with the given note open.
91    fn with(p: &VaultPath) -> QueryContext {
92        QueryContext::with_note(Some(p.clone()))
93    }
94
95    /// Context with no note open.
96    fn none() -> QueryContext {
97        QueryContext::default()
98    }
99
100    #[test]
101    fn detects_variables() {
102        assert!(query_has_variables("<{note}"));
103        assert!(query_has_variables("#todo <{note}"));
104        assert!(!query_has_variables("#todo"));
105    }
106
107    #[test]
108    fn resolves_note_variable() {
109        let p = VaultPath::note_path_from("work/spec.md");
110        assert_eq!(resolve_query("<{note}", &with(&p)), "<spec");
111        assert_eq!(resolve_query("#todo <{note}", &with(&p)), "#todo <spec");
112    }
113
114    #[test]
115    fn resolves_note_with_spaces_quoted() {
116        let p = VaultPath::note_path_from("work/my note.md");
117        // Multi-word name must be quoted so the parser sees one link target,
118        // not `<my` plus a stray `note` term.
119        assert_eq!(resolve_query("<{note}", &with(&p)), "<\"my note\"");
120    }
121
122    #[test]
123    fn resolves_to_empty_without_note() {
124        assert_eq!(resolve_query("<{note}", &none()), "<");
125        assert_eq!(resolve_query("#todo", &none()), "#todo");
126    }
127
128    #[test]
129    fn bare_operators_expand_to_note_variable() {
130        let p = VaultPath::note_path_from("work/spec.md");
131        assert_eq!(resolve_query("<", &with(&p)), "<spec");
132        assert_eq!(resolve_query(">", &with(&p)), ">spec");
133        assert_eq!(resolve_query("=", &with(&p)), "=spec");
134        assert_eq!(resolve_query("#todo <", &with(&p)), "#todo <spec");
135        assert_eq!(resolve_query("< #todo", &with(&p)), "<spec #todo");
136    }
137
138    #[test]
139    fn bare_long_forms_and_exclusions_expand_too() {
140        let p = VaultPath::note_path_from("work/spec.md");
141        assert_eq!(resolve_query("lk:", &with(&p)), "lk:spec");
142        assert_eq!(resolve_query("fwd:", &with(&p)), "fwd:spec");
143        assert_eq!(resolve_query("name:", &with(&p)), "name:spec");
144        assert_eq!(resolve_query("-<", &with(&p)), "-<spec");
145    }
146
147    #[test]
148    fn apostrophe_in_term_does_not_suppress_expansion() {
149        // A mid-token apostrophe is a literal character (matching core's
150        // parser), not a quote opener, so sugar after a contraction still
151        // expands.
152        let p = VaultPath::note_path_from("work/spec.md");
153        assert_eq!(resolve_query("don't <", &with(&p)), "don't <spec");
154        assert_eq!(resolve_query("= don't <", &with(&p)), "=spec don't <spec");
155    }
156
157    #[test]
158    fn operators_with_targets_stay_untouched() {
159        let p = VaultPath::note_path_from("work/spec.md");
160        assert_eq!(resolve_query("<projects", &with(&p)), "<projects");
161        assert_eq!(resolve_query(">projects", &with(&p)), ">projects");
162        assert_eq!(resolve_query("=projects", &with(&p)), "=projects");
163    }
164
165    #[test]
166    fn bare_operator_inside_quotes_stays_untouched() {
167        let p = VaultPath::note_path_from("work/spec.md");
168        assert_eq!(resolve_query("\"a < b\"", &with(&p)), "\"a < b\"");
169        assert_eq!(resolve_query("'a = b'", &with(&p)), "'a = b'");
170    }
171
172    #[test]
173    fn unresolvable_only_when_purely_note_dependent() {
174        let p = VaultPath::note_path_from("work/spec.md");
175        // A real note resolves everything.
176        assert!(!query_is_unresolvable("<", &with(&p)));
177        assert!(!query_is_unresolvable("<{note}", &with(&p)));
178        // No note (or the empty root path): purely note-dependent queries
179        // cannot produce results.
180        assert!(query_is_unresolvable("<", &none()));
181        assert!(query_is_unresolvable("<{note}", &none()));
182        assert!(query_is_unresolvable("< or:title", &none()));
183        assert!(query_is_unresolvable("<", &with(&VaultPath::empty())));
184        // Mixed queries keep concrete terms and must still run.
185        assert!(!query_is_unresolvable("widget <", &none()));
186        assert!(!query_is_unresolvable("#todo <{note}", &none()));
187        // No variables at all: always resolvable.
188        assert!(!query_is_unresolvable("widget", &none()));
189        assert!(!query_is_unresolvable("", &none()));
190    }
191
192    #[test]
193    fn bare_operators_count_as_variables() {
194        assert!(query_has_variables("<"));
195        assert!(query_has_variables(">"));
196        assert!(query_has_variables("="));
197        assert!(query_has_variables("#todo <"));
198        assert!(!query_has_variables("<projects"));
199        assert!(!query_has_variables("\"a < b\""));
200    }
201}