Skip to main content

rmux_core/
target_find.rs

1//! tmux-style unresolved target lookup over live in-memory session state.
2//!
3//! This module intentionally sits above the exact protocol target structs in
4//! `rmux-proto`: it accepts raw command target text and resolves it to the
5//! existing `Target`, `WindowTarget`, and `PaneTarget` output representation.
6//! Frozen source anchors: `/opt/rmux/reference/tmux` at commit
7//! `31d77e29b6c9fbb07d032018da78db3a8a38d979`, especially
8//! `cmd-find.c:247`, `cmd-find.c:347`, `cmd-find.c:563`, and
9//! `cmd-find.c:922`.
10
11use rmux_proto::{RmuxError, SessionName, Target};
12
13use crate::SessionStore;
14
15#[path = "target_find/current.rs"]
16mod current;
17#[path = "target_find/matching.rs"]
18mod matching;
19#[path = "target_find/metadata.rs"]
20mod metadata;
21#[path = "target_find/pane_navigation.rs"]
22mod pane_navigation;
23#[path = "target_find/session.rs"]
24mod session;
25#[path = "target_find/syntax.rs"]
26mod syntax;
27#[path = "target_find/types.rs"]
28mod types;
29
30use matching::{
31    cyclic_pane_offset, cyclic_window_offset, find_pane_id_in_session, offset_index, parse_offset,
32    parse_required_prefixed_id, special_window_index, target_for_type, target_not_found,
33    unique_match, unique_window_name_match, MatchMode, ResolvedParts,
34};
35pub use metadata::command_target_metadata;
36use pane_navigation::{directional_pane, pane_description};
37use syntax::TargetParts;
38pub use types::{
39    CommandTargetMetadata, CommandTargetSpec, MissingCurrentTargetFallback, TargetFindContext,
40    TargetFindFlags, TargetFindType, UnresolvedTarget,
41};
42
43impl SessionStore {
44    /// Resolves an unresolved command target to the existing typed target representation.
45    pub fn resolve_unresolved_target(
46        &self,
47        target: &UnresolvedTarget,
48        find_type: TargetFindType,
49        flags: TargetFindFlags,
50        context: &TargetFindContext,
51    ) -> Result<Target, RmuxError> {
52        let Some(raw) = target.as_deref() else {
53            return self.current_target_for_type(find_type, flags, context);
54        };
55        if raw.is_empty() {
56            return self.current_target_for_type(find_type, flags, context);
57        }
58
59        if raw == "." {
60            return self.current_target_for_type(find_type, flags, context);
61        }
62        if matches!(raw, "{active}" | "{current}") {
63            return self.current_target_for_type(find_type, flags, context);
64        }
65        if matches!(raw, "=" | "{mouse}") {
66            return self.mouse_target_for_type(find_type, context);
67        }
68        if matches!(raw, "~" | "{marked}") {
69            return self.marked_target_for_type(find_type, context);
70        }
71
72        let mut parts = TargetParts::parse(raw, find_type);
73        let mut flags = flags;
74        parts.apply_exact_prefixes(&mut flags);
75        if flags.contains(TargetFindFlags::WINDOW_INDEX)
76            && parts.window_only
77            && parts.window == Some("")
78            && parts.pane.is_none()
79        {
80            return self.resolve_empty_window_index_target(
81                raw,
82                parts.session,
83                find_type,
84                flags,
85                context,
86            );
87        }
88        parts.drop_empty();
89        parts.map_tokens();
90        if flags.contains(TargetFindFlags::WINDOW_INDEX) && parts.pane.is_some() {
91            return Err(RmuxError::invalid_target(raw, "can't specify pane here"));
92        }
93
94        self.resolve_parts(raw, parts, find_type, flags, context)
95    }
96
97    fn resolve_parts(
98        &self,
99        raw: &str,
100        parts: TargetParts<'_>,
101        find_type: TargetFindType,
102        flags: TargetFindFlags,
103        context: &TargetFindContext,
104    ) -> Result<Target, RmuxError> {
105        if let Some(session) = parts.session {
106            let session_name = self.resolve_session_part(session, flags)?;
107            if parts.window.is_none() && parts.pane.is_none() {
108                return self.target_for_session_current(find_type, session_name);
109            }
110            if let Some(window) = parts.window {
111                let window = self.resolve_window_in_session(raw, &session_name, window, flags)?;
112                if let Some(pane) = parts.pane {
113                    let pane = self.resolve_pane_in_window(
114                        raw,
115                        &window.session_name,
116                        window.window_index,
117                        pane,
118                        context,
119                    )?;
120                    return Ok(target_for_type(find_type, pane));
121                }
122                return self.target_for_window_current(find_type, window);
123            }
124
125            let pane = self.resolve_pane_in_session(
126                raw,
127                &session_name,
128                parts.pane.expect("pane branch requires pane"),
129                context,
130            )?;
131            return Ok(target_for_type(find_type, pane));
132        }
133
134        if let Some(window) = parts.window {
135            let window =
136                self.resolve_window_from_context(raw, window, parts.window_only, flags, context)?;
137            if let Some(pane) = parts.pane {
138                let pane = self.resolve_pane_in_window(
139                    raw,
140                    &window.session_name,
141                    window.window_index,
142                    pane,
143                    context,
144                )?;
145                return Ok(target_for_type(find_type, pane));
146            }
147            return self.target_for_window_current(find_type, window);
148        }
149
150        if let Some(pane) = parts.pane {
151            let pane =
152                self.resolve_pane_from_context(raw, pane, parts.pane_only, flags, context)?;
153            return Ok(target_for_type(find_type, pane));
154        }
155
156        self.current_target_for_type(find_type, flags, context)
157    }
158
159    fn resolve_empty_window_index_target(
160        &self,
161        raw: &str,
162        session_part: Option<&str>,
163        find_type: TargetFindType,
164        flags: TargetFindFlags,
165        context: &TargetFindContext,
166    ) -> Result<Target, RmuxError> {
167        let session_name = match session_part.filter(|value| !value.is_empty()) {
168            Some(session_part) => self.resolve_session_part(session_part, flags)?,
169            None => self
170                .current_parts(context)
171                .map(|parts| parts.session_name)
172                .or_else(|_| self.default_session_name(flags))?,
173        };
174        let session = self
175            .session(&session_name)
176            .ok_or_else(|| target_not_found(raw, "session"))?;
177        let window_index = session.lowest_available_window_index_at_or_above(0)?;
178        Ok(target_for_type(
179            find_type,
180            ResolvedParts::window(session_name, window_index),
181        ))
182    }
183
184    fn resolve_window_from_context(
185        &self,
186        raw: &str,
187        value: &str,
188        only_window: bool,
189        flags: TargetFindFlags,
190        context: &TargetFindContext,
191    ) -> Result<ResolvedParts, RmuxError> {
192        if value.starts_with('@') {
193            return self.resolve_window_id(raw, value, context);
194        }
195
196        let current = match self.current_parts(context) {
197            Ok(current) => current,
198            Err(error) if !only_window => {
199                if let Ok(window) = self.resolve_window_globally(raw, value, flags) {
200                    return Ok(window);
201                }
202                return self
203                    .resolve_session_part(value, flags)
204                    .and_then(|session_name| self.parts_for_session_current(&session_name))
205                    .map_err(|_| error);
206            }
207            Err(_) if only_window => {
208                return self.resolve_window_globally(raw, value, flags);
209            }
210            Err(error) => return Err(error),
211        };
212        match self.resolve_window_in_session(raw, &current.session_name, value, flags) {
213            Ok(window) => Ok(window),
214            Err(error) if !only_window => match self.resolve_session_part(value, flags) {
215                Ok(session_name) => self.parts_for_session_current(&session_name),
216                Err(_) => Err(error),
217            },
218            Err(error) => Err(error),
219        }
220    }
221
222    fn resolve_window_globally(
223        &self,
224        raw: &str,
225        value: &str,
226        flags: TargetFindFlags,
227    ) -> Result<ResolvedParts, RmuxError> {
228        let mut exact = Vec::new();
229        for (session_name, _) in self.iter() {
230            if let Ok(window) = self.resolve_window_in_session(raw, session_name, value, flags) {
231                exact.push(window);
232            }
233        }
234        exact.sort_by(|left, right| {
235            left.session_name
236                .as_str()
237                .cmp(right.session_name.as_str())
238                .then(left.window_index.cmp(&right.window_index))
239        });
240        if let Some(window) = unique_match(exact, raw, "window")? {
241            return Ok(window);
242        }
243
244        Err(target_not_found(value, "window"))
245    }
246
247    fn resolve_window_in_session(
248        &self,
249        raw: &str,
250        session_name: &SessionName,
251        value: &str,
252        flags: TargetFindFlags,
253    ) -> Result<ResolvedParts, RmuxError> {
254        let session = self
255            .session(session_name)
256            .ok_or_else(|| target_not_found(session_name.as_str(), "session"))?;
257
258        if value.starts_with('@') {
259            if value == "@" {
260                return Err(target_not_found(value, "window"));
261            }
262            let window_id = crate::WindowId::new(parse_required_prefixed_id(value, '@')?);
263            let Some(window_index) = session
264                .windows()
265                .iter()
266                .find_map(|(index, window)| (window.id() == window_id).then_some(*index))
267            else {
268                return Err(target_not_found(value, "window"));
269            };
270            return self.parts_for_window(session_name, window_index);
271        }
272
273        if flags.contains(TargetFindFlags::WINDOW_INDEX) {
274            if let Some(window_index) = resolve_absolute_window_index_slot(value)? {
275                return Ok(ResolvedParts::window(session_name.clone(), window_index));
276            }
277        }
278
279        if !flags.contains(TargetFindFlags::EXACT_WINDOW) {
280            if let Some(offset) = parse_offset(value)? {
281                let window_index = if flags.contains(TargetFindFlags::WINDOW_INDEX) {
282                    offset_index(session.active_window_index(), offset)
283                        .ok_or_else(|| target_not_found(value, "window"))?
284                } else {
285                    cyclic_window_offset(session, offset)
286                        .ok_or_else(|| target_not_found(value, "window"))?
287                };
288                return Ok(ResolvedParts::window(session_name.clone(), window_index));
289            }
290
291            if let Some(window_index) = special_window_index(session, value) {
292                return self.parts_for_window(session_name, window_index);
293            }
294        }
295
296        if !value.starts_with(['+', '-']) {
297            if let Ok(window_index) = value.parse::<u32>() {
298                if session.window_at(window_index).is_some() {
299                    return self.parts_for_window(session_name, window_index);
300                }
301                if flags.contains(TargetFindFlags::WINDOW_INDEX) {
302                    return Ok(ResolvedParts::window(session_name.clone(), window_index));
303                }
304            }
305        }
306
307        if let Some(window_index) = unique_window_name_match(session, value, MatchMode::Exact)? {
308            return self.parts_for_window(session_name, window_index);
309        }
310
311        if flags.contains(TargetFindFlags::EXACT_WINDOW) {
312            return Err(target_not_found(value, "window"));
313        }
314
315        if let Some(window_index) = unique_window_name_match(session, value, MatchMode::Prefix)? {
316            return self.parts_for_window(session_name, window_index);
317        }
318        if let Some(window_index) = unique_window_name_match(session, value, MatchMode::Pattern)? {
319            return self.parts_for_window(session_name, window_index);
320        }
321
322        Err(RmuxError::invalid_target(
323            raw,
324            format!("can't find window: {value}"),
325        ))
326    }
327
328    fn resolve_window_id(
329        &self,
330        raw: &str,
331        value: &str,
332        context: &TargetFindContext,
333    ) -> Result<ResolvedParts, RmuxError> {
334        if value == "@" {
335            return Err(target_not_found(value, "window"));
336        }
337        let window_id = crate::WindowId::new(parse_required_prefixed_id(value, '@')?);
338        if let Ok(current) = self.current_parts(context) {
339            if let Some(window_index) = self.session(&current.session_name).and_then(|session| {
340                session
341                    .windows()
342                    .iter()
343                    .find_map(|(index, window)| (window.id() == window_id).then_some(*index))
344            }) {
345                return Ok(ResolvedParts::window(current.session_name, window_index));
346            }
347        }
348
349        let mut matches = self
350            .iter()
351            .flat_map(|(session_name, session)| {
352                session
353                    .windows()
354                    .iter()
355                    .filter(move |(_, window)| window.id() == window_id)
356                    .map(move |(window_index, _)| {
357                        ResolvedParts::window(session_name.clone(), *window_index)
358                    })
359            })
360            .collect::<Vec<_>>();
361        matches.sort_by(|left, right| {
362            left.session_name
363                .as_str()
364                .cmp(right.session_name.as_str())
365                .then(left.window_index.cmp(&right.window_index))
366        });
367        unique_match(matches, raw, "window")?.ok_or_else(|| target_not_found(value, "window"))
368    }
369
370    fn resolve_pane_from_context(
371        &self,
372        raw: &str,
373        value: &str,
374        only_pane: bool,
375        flags: TargetFindFlags,
376        context: &TargetFindContext,
377    ) -> Result<ResolvedParts, RmuxError> {
378        if value.starts_with('%') {
379            return self.resolve_pane_id(raw, value, context);
380        }
381
382        let current = match self.current_parts(context) {
383            Ok(current) => current,
384            Err(error) if !only_pane => {
385                return self
386                    .resolve_window_from_context(raw, value, false, flags, context)
387                    .and_then(|window| {
388                        self.target_for_window_current(TargetFindType::Pane, window)
389                            .and_then(|target| {
390                                if let Target::Pane(target) = target {
391                                    Ok(ResolvedParts::pane(
392                                        target.session_name().clone(),
393                                        target.window_index(),
394                                        target.pane_index(),
395                                    ))
396                                } else {
397                                    Err(RmuxError::Server(
398                                        "pane fallback produced non-pane target".to_owned(),
399                                    ))
400                                }
401                            })
402                    })
403                    .map_err(|_| error);
404            }
405            Err(error) => return Err(error),
406        };
407        match self.resolve_pane_in_window(
408            raw,
409            &current.session_name,
410            current.window_index,
411            value,
412            context,
413        ) {
414            Ok(pane) => Ok(pane),
415            Err(error) if !only_pane => {
416                match self.resolve_window_from_context(raw, value, false, flags, context) {
417                    Ok(window) => self
418                        .target_for_window_current(TargetFindType::Pane, window)
419                        .and_then(|target| {
420                            if let Target::Pane(target) = target {
421                                Ok(ResolvedParts::pane(
422                                    target.session_name().clone(),
423                                    target.window_index(),
424                                    target.pane_index(),
425                                ))
426                            } else {
427                                Err(RmuxError::Server(
428                                    "pane fallback produced non-pane target".to_owned(),
429                                ))
430                            }
431                        }),
432                    Err(_) => Err(error),
433                }
434            }
435            Err(error) => Err(error),
436        }
437    }
438
439    fn resolve_pane_in_session(
440        &self,
441        raw: &str,
442        session_name: &SessionName,
443        value: &str,
444        context: &TargetFindContext,
445    ) -> Result<ResolvedParts, RmuxError> {
446        if value.starts_with('%') {
447            let pane_id = crate::PaneId::new(parse_required_prefixed_id(value, '%')?);
448            let session = self
449                .session(session_name)
450                .ok_or_else(|| target_not_found(session_name.as_str(), "session"))?;
451            let Some((window_index, pane_index)) = find_pane_id_in_session(session, pane_id) else {
452                return Err(target_not_found(value, "pane"));
453            };
454            return Ok(ResolvedParts::pane(
455                session_name.clone(),
456                window_index,
457                pane_index,
458            ));
459        }
460
461        let window_index = self
462            .session(session_name)
463            .ok_or_else(|| target_not_found(session_name.as_str(), "session"))?
464            .active_window_index();
465        self.resolve_pane_in_window(raw, session_name, window_index, value, context)
466    }
467
468    fn resolve_pane_in_window(
469        &self,
470        raw: &str,
471        session_name: &SessionName,
472        window_index: u32,
473        value: &str,
474        context: &TargetFindContext,
475    ) -> Result<ResolvedParts, RmuxError> {
476        let session = self
477            .session(session_name)
478            .ok_or_else(|| target_not_found(session_name.as_str(), "session"))?;
479        let window = session
480            .window_at(window_index)
481            .ok_or_else(|| target_not_found(&format!("{session_name}:{window_index}"), "window"))?;
482
483        if value.starts_with('%') {
484            let pane_id = crate::PaneId::new(parse_required_prefixed_id(value, '%')?);
485            let Some(pane) = window.panes().iter().find(|pane| pane.id() == pane_id) else {
486                return Err(target_not_found(value, "pane"));
487            };
488            return Ok(ResolvedParts::pane(
489                session_name.clone(),
490                window_index,
491                pane.index(),
492            ));
493        }
494
495        if value == "!" {
496            let pane_index = window
497                .last_pane_index()
498                .ok_or_else(|| target_not_found(value, "pane"))?;
499            return Ok(ResolvedParts::pane(
500                session_name.clone(),
501                window_index,
502                pane_index,
503            ));
504        }
505
506        if let Some(pane_index) = directional_pane(window, value) {
507            return Ok(ResolvedParts::pane(
508                session_name.clone(),
509                window_index,
510                pane_index,
511            ));
512        }
513
514        if let Some(offset) = parse_offset(value)? {
515            let pane_index = cyclic_pane_offset(window, offset)
516                .ok_or_else(|| target_not_found(value, "pane"))?;
517            return Ok(ResolvedParts::pane(
518                session_name.clone(),
519                window_index,
520                pane_index,
521            ));
522        }
523
524        if let Ok(visible_pane_index) = value.parse::<u32>() {
525            let pane_base_index = context.pane_base_index(session_name, window_index);
526            if let Some(pane_index) = visible_pane_index.checked_sub(pane_base_index) {
527                if window.pane(pane_index).is_some() {
528                    return Ok(ResolvedParts::pane(
529                        session_name.clone(),
530                        window_index,
531                        pane_index,
532                    ));
533                }
534            }
535            if pane_base_index == 0 && window.pane(visible_pane_index).is_some() {
536                return Ok(ResolvedParts::pane(
537                    session_name.clone(),
538                    window_index,
539                    visible_pane_index,
540                ));
541            }
542        }
543
544        if let Some(pane_index) = pane_description(window, value) {
545            return Ok(ResolvedParts::pane(
546                session_name.clone(),
547                window_index,
548                pane_index,
549            ));
550        }
551
552        Err(RmuxError::invalid_target(
553            raw,
554            format!("can't find pane: {value}"),
555        ))
556    }
557
558    fn resolve_pane_id(
559        &self,
560        raw: &str,
561        value: &str,
562        context: &TargetFindContext,
563    ) -> Result<ResolvedParts, RmuxError> {
564        let pane_id = crate::PaneId::new(parse_required_prefixed_id(value, '%')?);
565        if let Ok(current) = self.current_parts(context) {
566            if let Some(pane) = self
567                .session(&current.session_name)
568                .and_then(|session| session.window_at(current.window_index))
569                .and_then(|window| window.pane(current.pane_index))
570            {
571                if pane.id() == pane_id {
572                    return Ok(current);
573                }
574            }
575        }
576
577        let mut matches = self
578            .iter()
579            .flat_map(|(session_name, session)| {
580                session
581                    .windows()
582                    .iter()
583                    .flat_map(move |(window_index, window)| {
584                        window
585                            .panes()
586                            .iter()
587                            .filter(move |pane| pane.id() == pane_id)
588                            .map(move |pane| {
589                                ResolvedParts::pane(
590                                    session_name.clone(),
591                                    *window_index,
592                                    pane.index(),
593                                )
594                            })
595                    })
596            })
597            .collect::<Vec<_>>();
598        matches.sort_by(|left, right| {
599            left.session_name
600                .as_str()
601                .cmp(right.session_name.as_str())
602                .then(left.window_index.cmp(&right.window_index))
603                .then(left.pane_index.cmp(&right.pane_index))
604        });
605        unique_match(matches, raw, "pane")?.ok_or_else(|| target_not_found(value, "pane"))
606    }
607}
608
609fn resolve_absolute_window_index_slot(value: &str) -> Result<Option<u32>, RmuxError> {
610    if value.is_empty() || value.starts_with(['+', '-']) || matches!(value, "!" | "^" | "$") {
611        return Ok(None);
612    }
613    value.parse::<u32>().map(Some).map_err(|_| {
614        RmuxError::invalid_target(value, "window index must fit in an unsigned integer")
615    })
616}
617
618#[cfg(test)]
619#[path = "target_find/session_recency_tests.rs"]
620mod session_recency_tests;
621#[cfg(test)]
622#[path = "target_find/tests.rs"]
623mod tests;