Skip to main content

strop_engine/editor/remote_completion/
mod.rs

1//! RW2 remote host/path completion (0036 "Completion and homes").
2//!
3//! Ex Tab on a file-bearing command's `ssh://` operand completes from
4//! two honest sources only: local host data (`strop_remote::hosts`,
5//! read on a worker — never `ssh -G`, never `Match exec`) and an
6//! already-live connection's directory listing via
7//! `RemoteClient::list_connected`, which must never authenticate or
8//! open a connection. Without a live connection the user gets cached
9//! candidates or an explicit connect instruction — completion never
10//! surprises anyone with an authentication prompt.
11//!
12//! Ownership is the second half: a request captures the exact prompt
13//! moment (focus, document, revision, full input text and cursor) and
14//! every delivery re-checks it, so a result that lands after an edit,
15//! Esc, focus change or close can neither rewrite the input line nor
16//! open a picker. Cycling reuses the one prompt grammar
17//! (`PendingEvent::CompleteEx`); there is no second prompt surface.
18//! Applied candidates are canonical `RemoteFile` URIs, so native
19//! filename bytes (spaces, non-UTF-8) survive as percent escapes.
20
21use std::collections::VecDeque;
22use std::path::PathBuf;
23use std::sync::mpsc::{self, Receiver, Sender};
24
25use strop_core::id::{BufferRevision, DocumentId};
26use strop_core::worker::{self, CancelReason, Completion, FailureKind, Outcome, Ticket};
27use strop_remote::{HostCandidate, HostSources, RemoteClient, RemoteEntryKind};
28use strop_workspace::RemoteFile;
29
30use super::document::DocumentSource;
31use super::pending::{PendingEvent, PromptContext};
32use super::Editor;
33
34#[cfg(test)]
35mod tests;
36
37/// Bounded fallback cache: successful live listings remembered so a
38/// later offline Tab still completes from the last observed truth.
39const CACHE_DIRS: usize = 32;
40
41/// Commands whose final argument is a remote URI, and how many numeric
42/// arguments may precede it (`(min, max)`). `w`/`wq` refuse remote
43/// targets outright (execution refuses them too), so they are absent
44/// here and answered with an honest message instead.
45fn remote_operand_shape(command: &str) -> Option<(usize, usize)> {
46    match command {
47        "e" | "e!" | "view" | "sp" | "split" | "vs" | "vsplit" | "browse" | "follow" => {
48            Some((0, 0))
49        }
50        // `:tail [BYTES] URI` — the byte count is optional.
51        "tail" => Some((0, 1)),
52        // `:range START BYTES URI` — both numbers required.
53        "range" => Some((2, 2)),
54        _ => None,
55    }
56}
57
58/// Which local question a completion asked. Pure serde data — the
59/// replayable half of the exchange; worker inputs (sources, history,
60/// the client) are live values and never serialize.
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub enum RemoteCompletionQuery {
63    /// Complete the endpoint token typed after `ssh://` (no `/` yet).
64    Hosts { partial: String },
65    /// Complete the final path segment of `ssh://<authority>/dir/seg…`
66    /// against a live read-only connection. `directory` is URI text
67    /// with a leading `/` (the canonical listing target is derived and
68    /// validated through `RemoteFile::parse`).
69    Path {
70        authority: String,
71        directory: String,
72        segment: String,
73    },
74}
75
76impl RemoteCompletionQuery {
77    fn label(&self) -> &'static str {
78        match self {
79            Self::Hosts { .. } => "hosts",
80            Self::Path { .. } => "path",
81        }
82    }
83}
84
85/// One completion answer item: the canonical URI text that replaces
86/// the typed `ssh://…` token. Directories carry a trailing `/` so the
87/// next Tab descends into them; files are complete URIs.
88#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub struct RemoteCandidate {
90    pub uri: String,
91    pub directory: bool,
92}
93
94/// Where candidates came from — surfaced so cache is never mistaken
95/// for live state.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub enum CandidateSource {
98    Config,
99    Connection,
100    Cache,
101}
102
103/// The typed moment a request owns; every delivery re-checks it.
104#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105pub struct RemoteCompletionKey {
106    pub focus: u64,
107    pub document: DocumentId,
108    pub revision: BufferRevision,
109    /// Full prompt text (sigil included) at request time.
110    pub text: String,
111    pub cursor: usize,
112    pub query: RemoteCompletionQuery,
113}
114
115/// The worker's terminal answer. Failures travel as
116/// `Outcome::Failed`; this carries only successes.
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118pub enum RemoteCompletionResult {
119    Candidates {
120        items: Vec<RemoteCandidate>,
121        source: CandidateSource,
122        /// Enumeration diagnostics (bounded) shown when nothing matched.
123        notes: Vec<String>,
124        /// Canonical URI of the listed directory, for the fallback
125        /// cache. None for host completions.
126        listed_directory: Option<String>,
127    },
128    /// Path completion found no live connection. Connecting is an
129    /// explicit user action (open/browse); completion must not take it.
130    ConnectRequired { endpoint: String },
131}
132
133/// One worker delivery, ticket-stamped like every other service.
134pub type RemoteCompletionEvent = Completion<RemoteCompletionKey, RemoteCompletionResult>;
135
136/// Landed candidates plus the moment they were applied to — the cycle
137/// state between Tabs.
138#[derive(Debug, Clone)]
139struct ReadyCompletion {
140    /// Prompt body before the `ssh://` token (e.g. `e ` or `tail 64k `).
141    prefix_body: String,
142    /// Full prompt text after our last apply; a mismatch means the
143    /// user typed since, and Tab starts a fresh request instead.
144    applied: String,
145    candidates: Vec<RemoteCandidate>,
146    index: usize,
147}
148
149/// The editor's remote-completion slot: one in-flight request, one
150/// landed cycle, one bounded fallback cache, one delivery channel.
151#[derive(Debug)]
152pub(crate) struct RemoteCompletionState {
153    /// Worker deliveries; the TUI forwards this onto the app channel
154    /// (Main wires `AppEvent::RemoteCompletion`), headless drains it.
155    pub tx: Sender<RemoteCompletionEvent>,
156    pub rx: Option<Receiver<RemoteCompletionEvent>>,
157    pub(crate) pending: Option<Ticket<RemoteCompletionKey>>,
158    ready: Option<ReadyCompletion>,
159    cache: VecDeque<(String, Vec<RemoteCandidate>)>,
160}
161
162impl Default for RemoteCompletionState {
163    fn default() -> Self {
164        let (tx, rx) = mpsc::channel();
165        Self {
166            tx,
167            rx: Some(rx),
168            pending: None,
169            ready: None,
170            cache: VecDeque::new(),
171        }
172    }
173}
174
175impl RemoteCompletionState {
176    fn cached(&self, canonical_dir: &str) -> Option<Vec<RemoteCandidate>> {
177        self.cache
178            .iter()
179            .rev()
180            .find(|(key, _)| key == canonical_dir)
181            .map(|(_, items)| items.clone())
182    }
183
184    fn store_cache(&mut self, canonical_dir: String, items: Vec<RemoteCandidate>) {
185        if items.is_empty() {
186            return;
187        }
188        self.cache.retain(|(key, _)| key != &canonical_dir);
189        self.cache.push_back((canonical_dir, items));
190        while self.cache.len() > CACHE_DIRS {
191            self.cache.pop_front();
192        }
193    }
194
195    #[cfg(test)]
196    fn ticket(&self) -> Option<Ticket<RemoteCompletionKey>> {
197        self.pending.clone()
198    }
199}
200
201impl Editor {
202    /// Tab on the ex line's remote operand: cycle landed candidates or
203    /// start a request. Returns true when the line was a remote
204    /// completion (even when the answer is a refusal message), so the
205    /// caller's command-name cycling never touches a remote line.
206    pub(crate) fn remote_completion_tab(&mut self) -> bool {
207        let Some((text, cursor)) = self
208            .pending
209            .prompt()
210            .map(|prompt| (prompt.text().to_owned(), prompt.cursor()))
211        else {
212            return false;
213        };
214        let Some(body) = text.strip_prefix(':') else {
215            return false;
216        };
217        let Some((cmd, rest)) = body.split_once(' ') else {
218            return false;
219        };
220        let tokens: Vec<&str> = rest.split(' ').filter(|token| !token.is_empty()).collect();
221        let Some(operand) = tokens.last().copied() else {
222            return false;
223        };
224        if !operand.starts_with("ssh://") {
225            // A URI stranded before the final token means a raw space
226            // was typed into it; complete only well-formed lines.
227            if tokens.iter().any(|token| token.starts_with("ssh://")) {
228                self.message = "remote URI cannot contain a raw space (type %20)".into();
229                return true;
230            }
231            return false;
232        }
233        if matches!(cmd, "w" | "w!" | "wq" | "wq!") {
234            self.message = "remote save-as completion is unsupported".into();
235            return true;
236        }
237        let Some((min_args, max_args)) = remote_operand_shape(cmd) else {
238            return false;
239        };
240        let leading = tokens.len() - 1;
241        if leading < min_args || leading > max_args {
242            self.message = match cmd {
243                "range" => ":range needs START BYTES before the URI".into(),
244                "tail" => ":tail takes at most one byte count before the URI".into(),
245                _ => format!(":{cmd} takes no argument before the URI"),
246            };
247            return true;
248        }
249        if cursor != text.len() {
250            self.message = "completion needs the cursor at the end of the line".into();
251            return true;
252        }
253        if let Some(ready) = self.remote_completion.ready.as_ref() {
254            if self.pending.text() == ready.applied && ready.candidates.len() > 1 {
255                let next = (ready.index + 1) % ready.candidates.len();
256                let uri = ready.candidates[next].uri.clone();
257                let prefix = ready.prefix_body.clone();
258                self.apply_completion(&prefix, &uri);
259                if let Some(ready) = self.remote_completion.ready.as_mut() {
260                    ready.index = next;
261                    ready.applied = self.pending.text().to_owned();
262                }
263                return true;
264            }
265            // A sole candidate descends into directories or re-queries.
266        }
267        let typed = operand.strip_prefix("ssh://").unwrap_or_default();
268        self.start_remote_completion(typed);
269        true
270    }
271
272    /// Classify the typed operand and launch the owned worker request.
273    fn start_remote_completion(&mut self, typed: &str) {
274        let query = match classify_remote_operand(typed) {
275            Ok(query) => query,
276            Err(message) => {
277                self.message = message;
278                return;
279            }
280        };
281        let Some((text, cursor, document, revision)) =
282            self.pending
283                .prompt()
284                .and_then(|prompt| match prompt.context() {
285                    PromptContext::Ex(origin) => Some((
286                        prompt.text().to_owned(),
287                        prompt.cursor(),
288                        origin.pane.doc,
289                        origin.revision,
290                    )),
291                    _ => None,
292                })
293        else {
294            return;
295        };
296        // A path query needs the canonical listing target up front:
297        // admission happens once, through the address grammar. The
298        // canonical directory URI is also the fallback-cache key.
299        let (dir_file, fallback) = match &query {
300            RemoteCompletionQuery::Path {
301                authority,
302                directory,
303                ..
304            } => match RemoteFile::parse(&format!("ssh://{authority}{directory}")) {
305                Ok(file) => {
306                    let fallback = self.remote_completion.cached(&file.to_string());
307                    (Some(file), fallback)
308                }
309                Err(error) => {
310                    self.message = format!("invalid remote address: {error}");
311                    return;
312                }
313            },
314            RemoteCompletionQuery::Hosts { .. } => (None, None),
315        };
316        // A new request replaces any in-flight one; the old worker's
317        // late delivery is rejected by ticket mismatch.
318        if let Some(old) = self.remote_completion.pending.take() {
319            if let Some(handle) = self.worker_handles.remove(&old.request) {
320                handle.cancel(CancelReason::Superseded);
321            }
322        }
323        self.remote_completion.ready = None;
324        let key = RemoteCompletionKey {
325            focus: self.focus_epoch,
326            document,
327            revision,
328            text,
329            cursor,
330            query: query.clone(),
331        };
332        let request = match self.worker_ids.allocate() {
333            Ok(request) => request,
334            Err(error) => {
335                self.message = error.message;
336                return;
337            }
338        };
339        let ticket = Ticket {
340            request,
341            key: key.clone(),
342        };
343        self.remote_completion.pending = Some(ticket.clone());
344        self.message = "completing…".into();
345        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
346            serde_json::json!({
347                "service":"remote-completion","request":request.get(),
348                "query":query.label(),
349            })
350        });
351        match self.tape.request("remote.completion", &ticket) {
352            Ok(false) => return,
353            Ok(true) => {}
354            Err(error) => {
355                self.handle_remote_completion(Completion {
356                    ticket,
357                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
358                });
359                return;
360            }
361        }
362        let sources = completion_host_sources();
363        let history = self.remote_history();
364        let client = self.remote_client();
365        let tx = self.remote_completion.tx.clone();
366        let handle = worker::spawn(
367            "strop-remote-complete",
368            move |outcome| {
369                let _ = tx.send(Completion { ticket, outcome });
370            },
371            move |cancel| {
372                run_completion(query, dir_file, fallback, sources, history, client, cancel)
373            },
374        );
375        self.worker_handles.insert(request, handle);
376    }
377
378    /// One worker delivery: only the owning ticket may touch the
379    /// model, and only a still-fresh prompt moment may be rewritten.
380    pub(crate) fn handle_remote_completion(&mut self, event: RemoteCompletionEvent) {
381        if self.remote_completion.pending.as_ref() != Some(&event.ticket) {
382            strop_trace::record_with(
383                strop_trace::EventKind::JobRejected,
384                || serde_json::json!({"service":"remote-completion","reason":"superseded"}),
385            );
386            return;
387        }
388        let ticket = event.ticket;
389        self.remote_completion.pending = None;
390        self.worker_handles.remove(&ticket.request);
391        if !self.completion_prompt_fresh(&ticket.key) {
392            strop_trace::record_with(
393                strop_trace::EventKind::JobRejected,
394                || serde_json::json!({"service":"remote-completion","reason":"stale prompt"}),
395            );
396            return;
397        }
398        match event.outcome {
399            Outcome::Success(RemoteCompletionResult::Candidates {
400                items,
401                source,
402                notes,
403                listed_directory,
404            }) => {
405                if let Some(directory) = &listed_directory {
406                    self.remote_completion
407                        .store_cache(directory.clone(), items.clone());
408                }
409                if items.is_empty() {
410                    self.message = match notes.first() {
411                        Some(note) => format!("no remote matches: {note}"),
412                        None => "no remote matches".into(),
413                    };
414                    return;
415                }
416                let prefix_body = completion_prefix_body(&ticket.key);
417                self.apply_completion(&prefix_body, &items[0].uri);
418                self.remote_completion.ready = Some(ReadyCompletion {
419                    prefix_body,
420                    applied: self.pending.text().to_owned(),
421                    candidates: items.clone(),
422                    index: 0,
423                });
424                self.message = candidates_message(&items, source);
425            }
426            Outcome::Success(RemoteCompletionResult::ConnectRequired { endpoint }) => {
427                self.message = format!(
428                    "no live connection to {endpoint}; completion never connects \
429                     — open or browse the remote first"
430                );
431            }
432            Outcome::Failed { failure, .. } => self.message = failure.message,
433            Outcome::Cancelled(_) => {}
434        }
435    }
436
437    /// The request's prompt moment still describes the editor: same
438    /// ex prompt, focus, document, revision, input text and cursor.
439    fn completion_prompt_fresh(&self, key: &RemoteCompletionKey) -> bool {
440        let Some(prompt) = self.pending.prompt() else {
441            return false;
442        };
443        matches!(prompt.context(), PromptContext::Ex(_))
444            && !self.docs.is_empty()
445            && self.current() == key.document
446            && self.focus_epoch == key.focus
447            && self.buf().revision() == key.revision
448            && prompt.text() == key.text
449            && prompt.cursor() == key.cursor
450    }
451
452    /// Apply one candidate through the one prompt grammar.
453    fn apply_completion(&mut self, prefix_body: &str, uri: &str) {
454        self.feed_pending_event(PendingEvent::CompleteEx(format!("{prefix_body}{uri}")));
455    }
456
457    /// Host candidates from endpoints this editor already opened.
458    fn remote_history(&self) -> Vec<HostCandidate> {
459        self.docs
460            .iter()
461            .filter_map(|(_, document)| match &document.source {
462                DocumentSource::Remote(file) => {
463                    let endpoint = file.file.endpoint();
464                    Some(HostCandidate::new(
465                        endpoint.host().to_owned(),
466                        endpoint.user().map(str::to_owned),
467                        endpoint.port(),
468                        strop_remote::CandidateOrigin::History,
469                    ))
470                }
471                _ => None,
472            })
473            .collect()
474    }
475}
476
477/// The prompt body before the remote operand (`e `, `tail 64k `),
478/// rebuilt from the request's captured text. The operand is the last
479/// token, so the last `ssh://` is its start.
480fn completion_prefix_body(key: &RemoteCompletionKey) -> String {
481    let body = key.text.strip_prefix(':').unwrap_or(&key.text);
482    match body.rfind("ssh://") {
483        Some(at) => body[..at].to_owned(),
484        None => body.to_owned(),
485    }
486}
487
488/// Sort, show and label the candidate list for the message line.
489fn candidates_message(items: &[RemoteCandidate], source: CandidateSource) -> String {
490    let mut text = items
491        .iter()
492        .take(6)
493        .map(display_segment)
494        .collect::<Vec<_>>()
495        .join("  ");
496    if items.len() > 6 {
497        text.push_str(&format!("  (+{})", items.len() - 6));
498    }
499    match source {
500        CandidateSource::Cache => text.push_str("  (cached)"),
501        CandidateSource::Connection => text.push_str("  (live)"),
502        CandidateSource::Config => {}
503    }
504    if text.len() > 160 {
505        text.truncate(160);
506    }
507    text
508}
509
510/// Safe display of one candidate: the final URI segment (directories
511/// keep their trailing `/`). Escaped form — never a lossy decode of
512/// native bytes.
513fn display_segment(candidate: &RemoteCandidate) -> &str {
514    let uri = &candidate.uri;
515    let cut = match uri.rfind('/') {
516        Some(at) if at + 1 == uri.len() => uri[..at].rfind('/').map_or(at, |prev| prev + 1),
517        Some(at) => at + 1,
518        None => return uri.strip_prefix("ssh://").unwrap_or(uri),
519    };
520    &uri[cut..]
521}
522
523/// Split the typed operand into a query, or an honest refusal. `~`
524/// entries are unresolved home queries (RemoteLocation's domain): they
525/// need a negotiated connection, so completion refuses instead of
526/// guessing a home.
527fn classify_remote_operand(typed: &str) -> Result<RemoteCompletionQuery, String> {
528    let refuse_home =
529        || "cannot complete `~` paths: open the remote file so its home resolves first".to_string();
530    if typed.starts_with('~') {
531        return Err(refuse_home());
532    }
533    let Some((authority, path)) = typed.split_once('/') else {
534        return Ok(RemoteCompletionQuery::Hosts {
535            partial: typed.to_owned(),
536        });
537    };
538    if authority.is_empty() {
539        return Err("ssh:// needs a host before the path".to_string());
540    }
541    if path.split('/').next() == Some("~") {
542        return Err(refuse_home());
543    }
544    let (directory, segment) = match path.rsplit_once('/') {
545        Some((before, last)) => (format!("/{before}"), last.to_owned()),
546        None => ("/".to_owned(), path.to_owned()),
547    };
548    Ok(RemoteCompletionQuery::Path {
549        authority: authority.to_owned(),
550        directory,
551        segment,
552    })
553}
554
555/// Standard local host-data locations for this process's home. Only
556/// path names are resolved here — reading happens on the worker.
557fn completion_host_sources() -> HostSources {
558    let home = std::env::var_os("HOME").map(PathBuf::from);
559    HostSources::discover(home.as_deref())
560}
561
562/// The worker side of one completion request. Host completion reads
563/// local data; path completion uses `list_connected` — never a new
564/// connection — and falls back to the caller's cached listing when the
565/// endpoint is not connected.
566fn run_completion(
567    query: RemoteCompletionQuery,
568    directory: Option<RemoteFile>,
569    fallback: Option<Vec<RemoteCandidate>>,
570    sources: HostSources,
571    history: Vec<HostCandidate>,
572    client: RemoteClient,
573    cancel: worker::CancelToken,
574) -> Outcome<RemoteCompletionResult> {
575    if cancel.is_cancelled() {
576        return Outcome::Cancelled(CancelReason::OwnerClosed);
577    }
578    match query {
579        RemoteCompletionQuery::Hosts { partial } => {
580            let enumeration = strop_remote::enumerate_hosts(&sources, &history);
581            let items = enumeration
582                .complete(&partial)
583                .into_iter()
584                .map(|token| RemoteCandidate {
585                    uri: format!("ssh://{token}"),
586                    directory: false,
587                })
588                .collect();
589            Outcome::Success(RemoteCompletionResult::Candidates {
590                items,
591                source: CandidateSource::Config,
592                notes: enumeration.notes().to_vec(),
593                listed_directory: None,
594            })
595        }
596        RemoteCompletionQuery::Path { segment, .. } => {
597            let Some(dir) = directory else {
598                return Outcome::failed(
599                    FailureKind::Protocol,
600                    "path completion without a listing target",
601                );
602            };
603            let prefix = lenient_percent_decode(&segment);
604            match client.list_connected(&dir, &cancel) {
605                Ok(entries) => {
606                    let mut items: Vec<RemoteCandidate> = entries
607                        .into_iter()
608                        .filter_map(|entry| {
609                            // `.`/`..` have no file_name; browsing owns
610                            // parent navigation, completion owns names.
611                            let name = entry.file.path().file_name()?;
612                            if !name.as_encoded_bytes().starts_with(&prefix) {
613                                return None;
614                            }
615                            let directory = matches!(entry.kind, RemoteEntryKind::Directory);
616                            let mut uri = entry.file.to_string();
617                            if directory && !uri.ends_with('/') {
618                                uri.push('/');
619                            }
620                            Some(RemoteCandidate { uri, directory })
621                        })
622                        .collect();
623                    items.sort_by(|a, b| {
624                        b.directory
625                            .cmp(&a.directory)
626                            .then_with(|| a.uri.cmp(&b.uri))
627                    });
628                    let listed = dir.to_string();
629                    Outcome::Success(RemoteCompletionResult::Candidates {
630                        items,
631                        source: CandidateSource::Connection,
632                        notes: Vec::new(),
633                        listed_directory: Some(listed),
634                    })
635                }
636                Err(_not_connected) => {
637                    if cancel.is_cancelled() {
638                        return Outcome::Cancelled(CancelReason::OwnerClosed);
639                    }
640                    if let Some(cached) = fallback {
641                        return Outcome::Success(RemoteCompletionResult::Candidates {
642                            items: cached,
643                            source: CandidateSource::Cache,
644                            notes: Vec::new(),
645                            listed_directory: None,
646                        });
647                    }
648                    Outcome::Success(RemoteCompletionResult::ConnectRequired {
649                        endpoint: endpoint_display(&dir),
650                    })
651                }
652            }
653        }
654    }
655}
656
657/// The authority region of a canonical URI, for the connect
658/// instruction.
659fn endpoint_display(file: &RemoteFile) -> String {
660    let uri = file.to_string();
661    let rest = uri.strip_prefix("ssh://").unwrap_or(&uri);
662    let end = rest.find('/').unwrap_or(rest.len());
663    format!("ssh://{}", &rest[..end])
664}
665
666/// Decode a typed segment for native prefix matching. Malformed or
667/// half-typed escapes stay literal: this filters names, it never
668/// admits one — the applied candidate is always a canonical URI.
669fn lenient_percent_decode(text: &str) -> Vec<u8> {
670    fn hex_value(byte: u8) -> u8 {
671        match byte {
672            b'0'..=b'9' => byte - b'0',
673            b'a'..=b'f' => byte - b'a' + 10,
674            _ => byte - b'A' + 10,
675        }
676    }
677    let bytes = text.as_bytes();
678    let mut out = Vec::with_capacity(bytes.len());
679    let mut at = 0;
680    while at < bytes.len() {
681        if bytes[at] == b'%' {
682            let high = bytes.get(at + 1).copied().filter(|b| b.is_ascii_hexdigit());
683            let low = bytes.get(at + 2).copied().filter(|b| b.is_ascii_hexdigit());
684            if let (Some(high), Some(low)) = (high, low) {
685                out.push((hex_value(high) << 4) | hex_value(low));
686                at += 3;
687                continue;
688            }
689        }
690        out.push(bytes[at]);
691        at += 1;
692    }
693    out
694}