Skip to main content

sley_ref_filter/
atoms.rs

1//! for-each-ref atom families: identity name/email/date atoms, oid atoms,
2//! color escapes, and the typed-atom renderer.
3
4use super::context::{ForEachRefFormatContext, ForEachRefSignatureVerification};
5use super::{
6    ForEachRefAtom, ForEachRefAtomIdentityPart, ForEachRefAtomIdentityRole, ForEachRefEmailMode,
7    ForEachRefNameFormat, ForEachRefNameSource, ForEachRefStripDirection,
8    for_each_ref_abbrev_oid, for_each_ref_identity_date, for_each_ref_identity_email,
9    parse_for_each_ref_abbrev_width, parse_for_each_ref_hex_color,
10    write_for_each_ref_identity, write_for_each_ref_identity_date_mode,
11    write_for_each_ref_identity_date_raw, write_for_each_ref_identity_email_mode,
12    write_for_each_ref_identity_name,
13};
14use sley_core::{DateMode, GitError, Result};
15use std::io::Write;
16
17pub fn write_for_each_ref_signature(
18    stdout: &mut impl Write,
19    verification: &dyn ForEachRefSignatureVerification,
20    option: &str,
21) -> Result<()> {
22    match option.strip_prefix(':').unwrap_or("") {
23        // The bare atom prints gpg's human-readable verification output.
24        "" => stdout.write_all(verification.bare_output())?,
25        // grade: 'G'/'U'/'B'/'E'/'N' — git downgrades a good-but-untrusted
26        // signature to 'U', which pretty_code already encodes.
27        "grade" => stdout.write_all(&[verification.grade_byte()])?,
28        "key" => stdout.write_all(verification.key().as_bytes())?,
29        "signer" => stdout.write_all(verification.signer().as_bytes())?,
30        "fingerprint" => stdout.write_all(verification.fingerprint().as_bytes())?,
31        "primarykeyfingerprint" => {
32            stdout.write_all(verification.primary_fingerprint().as_bytes())?
33        }
34        "trustlevel" => stdout.write_all(verification.trust().as_bytes())?,
35        _ => {}
36    }
37    Ok(())
38}
39
40pub fn for_each_ref_typed_refname<'a>(
41    context: &'a ForEachRefFormatContext<'_>,
42    source: ForEachRefNameSource,
43) -> &'a str {
44    match source {
45        ForEachRefNameSource::Ref => context.refname,
46        ForEachRefNameSource::Upstream => context
47            .upstream
48            .as_ref()
49            .map(|upstream| upstream.refname.as_str())
50            .unwrap_or(""),
51        ForEachRefNameSource::Push => context
52            .push
53            .as_ref()
54            .and_then(|push| push.refname.as_deref())
55            .unwrap_or(""),
56    }
57}
58
59pub fn for_each_ref_typed_identity<'a>(
60    context: &'a ForEachRefFormatContext<'_>,
61    peeled: bool,
62    role: ForEachRefAtomIdentityRole,
63) -> Option<&'a [u8]> {
64    if peeled {
65        let peeled = context.peeled_object.as_ref();
66        return match role {
67            ForEachRefAtomIdentityRole::Author => {
68                peeled.and_then(|peeled| peeled.author.as_deref())
69            }
70            ForEachRefAtomIdentityRole::Committer => {
71                peeled.and_then(|peeled| peeled.committer.as_deref())
72            }
73            ForEachRefAtomIdentityRole::Tagger => None,
74            ForEachRefAtomIdentityRole::Creator => {
75                peeled.and_then(|peeled| peeled.creator.as_deref())
76            }
77        };
78    }
79
80    let contents = context.contents.as_ref();
81    match role {
82        ForEachRefAtomIdentityRole::Author => {
83            contents.and_then(|contents| contents.author.as_deref())
84        }
85        ForEachRefAtomIdentityRole::Committer => {
86            contents.and_then(|contents| contents.committer.as_deref())
87        }
88        ForEachRefAtomIdentityRole::Tagger => {
89            contents.and_then(|contents| contents.tagger.as_deref())
90        }
91        ForEachRefAtomIdentityRole::Creator => {
92            contents.and_then(|contents| contents.creator.as_deref())
93        }
94    }
95}
96
97pub fn write_for_each_ref_typed_atom(
98    stdout: &mut impl Write,
99    atom: &ForEachRefAtom,
100    context: &ForEachRefFormatContext<'_>,
101) -> Result<()> {
102    match atom {
103        ForEachRefAtom::Raw(_) => unreachable!("raw atoms are handled by the compatibility path"),
104        ForEachRefAtom::Color(value) => {
105            let color = for_each_ref_color_escape(value)?;
106            if context.color {
107                stdout.write_all(color.as_bytes())?;
108            }
109        }
110        ForEachRefAtom::RefName { source, format } => {
111            let refname = for_each_ref_typed_refname(context, *source);
112            match format {
113                ForEachRefNameFormat::Full => stdout.write_all(refname.as_bytes())?,
114                ForEachRefNameFormat::Short => {
115                    stdout.write_all(context.shorten_ref(refname).as_bytes())?
116                }
117                ForEachRefNameFormat::Strip(strip) => {
118                    let refname = match strip.direction {
119                        ForEachRefStripDirection::Left => {
120                            super::for_each_ref_lstrip_name(refname, strip.count)
121                        }
122                        ForEachRefStripDirection::Right => {
123                            super::for_each_ref_rstrip_name(refname, strip.count)
124                        }
125                    };
126                    stdout.write_all(refname.as_bytes())?;
127                }
128            }
129        }
130        ForEachRefAtom::ObjectName { peeled, abbrev } => {
131            let oid = if *peeled {
132                context.peeled_object.as_ref().map(|peeled| &peeled.oid)
133            } else {
134                Some(context.oid)
135            };
136            if let Some(oid) = oid {
137                match abbrev {
138                    None => write!(stdout, "{oid}")?,
139                    Some(0) => stdout.write_all(
140                        for_each_ref_abbrev_oid(
141                            oid,
142                            context.objectname_abbrev,
143                            context.objectname_candidates,
144                        )
145                        .as_bytes(),
146                    )?,
147                    Some(width) => stdout.write_all(
148                        for_each_ref_abbrev_oid(oid, Some(*width), context.objectname_candidates)
149                            .as_bytes(),
150                    )?,
151                }
152            }
153        }
154        ForEachRefAtom::Identity { peeled, role, part } => {
155            let identity = for_each_ref_typed_identity(context, *peeled, *role);
156            match part {
157                ForEachRefAtomIdentityPart::Full => write_for_each_ref_identity(stdout, identity)?,
158                ForEachRefAtomIdentityPart::Name => {
159                    write_for_each_ref_identity_name(stdout, identity)?
160                }
161                ForEachRefAtomIdentityPart::Email(mode) => {
162                    write_for_each_ref_identity_email_mode(stdout, identity, *mode)?
163                }
164                ForEachRefAtomIdentityPart::Date(mode) => {
165                    write_for_each_ref_identity_date_mode(stdout, identity, mode)?
166                }
167                ForEachRefAtomIdentityPart::DateRaw => {
168                    write_for_each_ref_identity_date_raw(stdout, identity)?
169                }
170            }
171        }
172        ForEachRefAtom::ContentsLines { peeled, count } => {
173            let message = if *peeled {
174                context
175                    .peeled_object
176                    .as_ref()
177                    .and_then(|peeled| peeled.message.as_deref())
178            } else {
179                context
180                    .contents
181                    .as_ref()
182                    .map(|contents| contents.message.as_ref())
183            };
184            if let Some(message) = message {
185                super::write_for_each_ref_contents_lines(stdout, message, *count)?;
186            }
187        }
188    }
189    Ok(())
190}
191
192/// The set of `%(...email)` options, mirroring git's `email_option` bitset
193/// (ref-filter.c `EO_TRIM`/`EO_LOCALPART`/`EO_MAILMAP`).
194#[derive(Clone, Copy, Default)]
195pub struct ForEachRefEmailOptions {
196    trim: bool,
197    localpart: bool,
198    mailmap: bool,
199}
200
201impl ForEachRefEmailOptions {
202    pub fn mode(&self) -> ForEachRefEmailMode {
203        if self.localpart {
204            ForEachRefEmailMode::LocalPart
205        } else if self.trim {
206            ForEachRefEmailMode::Trim
207        } else {
208            ForEachRefEmailMode::Bracketed
209        }
210    }
211
212    pub fn wants_mailmap(&self) -> bool {
213        self.mailmap
214    }
215}
216
217/// Parse the option string after `%(authoremail:...)` exactly as git's
218/// `person_email_atom_parser` does. Options are comma-separated and may repeat;
219/// each must be an exact `trim`/`localpart`/`mailmap` token between commas.
220/// On an unrecognized token, returns `Err(bad_arg)` where `bad_arg` is the
221/// unconsumed remainder at the point of failure (git reports this verbatim).
222pub fn setup_for_each_ref_email_options(
223    arg: &str,
224) -> std::result::Result<ForEachRefEmailOptions, String> {
225    let mut options = ForEachRefEmailOptions::default();
226    let mut rest = arg;
227    loop {
228        // git's email_atom_option_parser advances past a matched prefix; the
229        // `bad_arg` it later reports is the *remaining* string AFTER that
230        // consume (so `mailmaptrim` reports `trim`, not `mailmaptrim`).
231        let matched = if let Some(tail) = rest.strip_prefix("trim") {
232            options.trim = true;
233            Some(tail)
234        } else if let Some(tail) = rest.strip_prefix("localpart") {
235            options.localpart = true;
236            Some(tail)
237        } else if let Some(tail) = rest.strip_prefix("mailmap") {
238            options.mailmap = true;
239            Some(tail)
240        } else {
241            None
242        };
243        let Some(tail) = matched else {
244            // No prefix consumed: the bad argument is the whole remainder.
245            return Err(rest.to_string());
246        };
247        rest = tail;
248        let bad_arg = rest;
249        if rest.is_empty() {
250            break;
251        }
252        if let Some(tail) = rest.strip_prefix(',') {
253            rest = tail;
254        } else {
255            return Err(bad_arg.to_string());
256        }
257    }
258    Ok(options)
259}
260
261/// If `placeholder` is an email atom (`(\*?)(author|committer|tagger)email`
262/// with optional `:opts`), render it. Returns `Some(Ok(()))` when handled,
263/// `Some(Err(_))` on a bad-option error (already reported to stderr), and
264/// `None` when the placeholder is not an email atom.
265pub fn for_each_ref_try_email_atom(
266    stdout: &mut impl Write,
267    placeholder: &str,
268    context: &ForEachRefFormatContext<'_>,
269) -> Option<Result<()>> {
270    let (atom, arg) = match placeholder.split_once(':') {
271        Some((atom, arg)) => (atom, Some(arg)),
272        None => (placeholder, None),
273    };
274    let (peeled, role) = match atom {
275        "authoremail" => (false, ForEachRefAtomIdentityRole::Author),
276        "committeremail" => (false, ForEachRefAtomIdentityRole::Committer),
277        "taggeremail" => (false, ForEachRefAtomIdentityRole::Tagger),
278        "*authoremail" => (true, ForEachRefAtomIdentityRole::Author),
279        "*committeremail" => (true, ForEachRefAtomIdentityRole::Committer),
280        "*taggeremail" => (true, ForEachRefAtomIdentityRole::Tagger),
281        _ => return None,
282    };
283    let options = match arg {
284        Some(arg) => match setup_for_each_ref_email_options(arg) {
285            Ok(options) => options,
286            Err(bad_arg) => {
287                let name = atom.strip_prefix('*').unwrap_or(atom);
288                eprintln!("fatal: unrecognized %({name}) argument: {bad_arg}");
289                return Some(Err(GitError::Exit(128)));
290            }
291        },
292        None => ForEachRefEmailOptions::default(),
293    };
294    Some(for_each_ref_write_email(
295        stdout, context, peeled, role, options,
296    ))
297}
298
299pub fn for_each_ref_write_email(
300    stdout: &mut impl Write,
301    context: &ForEachRefFormatContext<'_>,
302    peeled: bool,
303    role: ForEachRefAtomIdentityRole,
304    options: ForEachRefEmailOptions,
305) -> Result<()> {
306    let Some(identity) = for_each_ref_typed_identity(context, peeled, role) else {
307        return Ok(());
308    };
309    let mode = options.mode();
310    if options.wants_mailmap() {
311        let (_, email) = context.mailmap.rewrite_identity(identity);
312        // Reassemble a synthetic identity so the shared email extractor applies
313        // trim/localpart over the rewritten address.
314        let mut synthetic = Vec::with_capacity(email.len() + 2);
315        synthetic.push(b'<');
316        synthetic.extend_from_slice(&email);
317        synthetic.push(b'>');
318        if let Some(value) = for_each_ref_identity_email(&synthetic, mode) {
319            stdout.write_all(value)?;
320        }
321    } else if let Some(value) = for_each_ref_identity_email(identity, mode) {
322        stdout.write_all(value)?;
323    }
324    Ok(())
325}
326
327/// The raw message bytes for the ref's own object (`peeled == false`) or the
328/// peeled tag target (`peeled == true`), if available.
329pub fn for_each_ref_message<'a>(
330    context: &'a ForEachRefFormatContext<'_>,
331    peeled: bool,
332) -> Option<&'a [u8]> {
333    if peeled {
334        context
335            .peeled_object
336            .as_ref()
337            .and_then(|peeled| peeled.message.as_deref())
338    } else {
339        context.contents.as_ref().map(|contents| &*contents.message)
340    }
341}
342
343/// If `placeholder` is a date atom (`(\*?)(author|committer|tagger|creator)date`
344/// with an optional `:spec`), render it through the full date grammar. Returns
345/// `Some(Err(_))` (after reporting to stderr) on an invalid specifier.
346pub fn for_each_ref_try_date_atom(
347    stdout: &mut impl Write,
348    placeholder: &str,
349    context: &ForEachRefFormatContext<'_>,
350) -> Option<Result<()>> {
351    let (atom, arg) = match placeholder.split_once(':') {
352        Some((atom, arg)) => (atom, Some(arg)),
353        None => (placeholder, None),
354    };
355    let (peeled, role) = match atom {
356        "authordate" => (false, ForEachRefAtomIdentityRole::Author),
357        "committerdate" => (false, ForEachRefAtomIdentityRole::Committer),
358        "taggerdate" => (false, ForEachRefAtomIdentityRole::Tagger),
359        "creatordate" => (false, ForEachRefAtomIdentityRole::Creator),
360        "*authordate" => (true, ForEachRefAtomIdentityRole::Author),
361        "*committerdate" => (true, ForEachRefAtomIdentityRole::Committer),
362        "*taggerdate" => (true, ForEachRefAtomIdentityRole::Tagger),
363        "*creatordate" => (true, ForEachRefAtomIdentityRole::Creator),
364        _ => return None,
365    };
366    let Some(mode) = DateMode::parse_atom_modifier(arg) else {
367        let name = atom.strip_prefix('*').unwrap_or(atom);
368        eprintln!(
369            "fatal: unrecognized %({name}) argument: {}",
370            arg.unwrap_or("")
371        );
372        return Some(Err(GitError::Exit(128)));
373    };
374    Some((|| -> Result<()> {
375        if let Some(identity) = for_each_ref_typed_identity(context, peeled, role)
376            && let Some(value) = for_each_ref_identity_date(identity, &mode)
377        {
378            stdout.write_all(value.as_bytes())?;
379        }
380        Ok(())
381    })())
382}
383
384/// For an oid atom like `tree:short` / `parent:short=7`, return the option
385/// argument (`short` or `short=7`) when `placeholder` is exactly `atom:<arg>`.
386pub fn for_each_ref_oid_atom_arg<'a>(placeholder: &'a str, atom: &str) -> Option<&'a str> {
387    let rest = placeholder.strip_prefix(atom)?;
388    rest.strip_prefix(':')
389}
390
391/// Parse the `short`/`short=N` argument of an oid atom into an abbreviation
392/// width, mirroring git's `oid_atom_parser` validation. A bare `short` resolves
393/// to the repository's `DEFAULT_ABBREV` (git's `O_SHORT` case), supplied by the
394/// caller via `default_abbrev`; `short=N` overrides it.
395pub fn for_each_ref_oid_atom_width(
396    arg: &str,
397    atom: &str,
398    default_abbrev: Option<usize>,
399) -> Result<Option<usize>> {
400    if arg == "short" {
401        Ok(default_abbrev)
402    } else if let Some(value) = arg.strip_prefix("short=") {
403        Ok(Some(parse_for_each_ref_abbrev_width(value).map_err(
404            |_| {
405                eprintln!("fatal: positive value expected '{value}' in %({atom})");
406                GitError::Exit(128)
407            },
408        )?))
409    } else {
410        eprintln!("fatal: unrecognized %({atom}) argument: {arg}");
411        Err(GitError::Exit(128))
412    }
413}
414
415/// If `placeholder` is a name atom (`(\*?)(author|committer|tagger)name` with an
416/// optional `:mailmap`/`:` argument), render it. Mirrors git's
417/// `person_name_atom_parser`: the only accepted argument is `mailmap`.
418pub fn for_each_ref_try_name_atom(
419    stdout: &mut impl Write,
420    placeholder: &str,
421    context: &ForEachRefFormatContext<'_>,
422) -> Option<Result<()>> {
423    let (atom, arg) = match placeholder.split_once(':') {
424        Some((atom, arg)) => (atom, Some(arg)),
425        None => (placeholder, None),
426    };
427    let (peeled, role) = match atom {
428        "authorname" => (false, ForEachRefAtomIdentityRole::Author),
429        "committername" => (false, ForEachRefAtomIdentityRole::Committer),
430        "taggername" => (false, ForEachRefAtomIdentityRole::Tagger),
431        "*authorname" => (true, ForEachRefAtomIdentityRole::Author),
432        "*committername" => (true, ForEachRefAtomIdentityRole::Committer),
433        "*taggername" => (true, ForEachRefAtomIdentityRole::Tagger),
434        _ => return None,
435    };
436    let mailmap = match arg {
437        None => false,
438        Some("mailmap") => true,
439        Some(bad_arg) => {
440            let name = atom.strip_prefix('*').unwrap_or(atom);
441            eprintln!("fatal: unrecognized %({name}) argument: {bad_arg}");
442            return Some(Err(GitError::Exit(128)));
443        }
444    };
445    Some((|| -> Result<()> {
446        let Some(identity) = for_each_ref_typed_identity(context, peeled, role) else {
447            return Ok(());
448        };
449        if mailmap {
450            let (name, _) = context.mailmap.rewrite_identity(identity);
451            stdout.write_all(&name)?;
452        } else {
453            write_for_each_ref_identity_name(stdout, Some(identity))?;
454        }
455        Ok(())
456    })())
457}
458
459pub fn for_each_ref_color_escape(value: &str) -> Result<String> {
460    let tokens = value.split_whitespace().collect::<Vec<_>>();
461    if tokens.is_empty() {
462        return Err(GitError::Command("empty for-each-ref color".into()));
463    }
464    if tokens.len() == 1
465        && let Some((red, green, blue)) = parse_for_each_ref_hex_color(tokens[0])
466    {
467        return Ok(format!("\x1b[38;2;{red};{green};{blue}m"));
468    }
469    let mut attributes = Vec::new();
470    let mut foreground = None;
471    let mut background = None;
472    for token in tokens.iter().copied() {
473        match token {
474            "reset" => return Ok("\x1b[m".to_string()),
475            "normal" if tokens.len() == 1 || (foreground.is_some() && background.is_none()) => {}
476            "bold" => attributes.push("1".to_string()),
477            "dim" => attributes.push("2".to_string()),
478            "italic" => attributes.push("3".to_string()),
479            "ul" => attributes.push("4".to_string()),
480            "blink" => attributes.push("5".to_string()),
481            "reverse" => attributes.push("7".to_string()),
482            "strike" => attributes.push("9".to_string()),
483            "nobold" | "nodim" => attributes.push("22".to_string()),
484            "noitalic" => attributes.push("23".to_string()),
485            "noul" => attributes.push("24".to_string()),
486            "noblink" => attributes.push("25".to_string()),
487            "noreverse" => attributes.push("27".to_string()),
488            "nostrike" => attributes.push("29".to_string()),
489            "black" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 30)?,
490            "red" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 31)?,
491            "green" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 32)?,
492            "yellow" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 33)?,
493            "blue" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 34)?,
494            "magenta" => {
495                for_each_ref_push_color_code(value, &mut foreground, &mut background, 35)?
496            }
497            "cyan" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 36)?,
498            "white" => for_each_ref_push_color_code(value, &mut foreground, &mut background, 37)?,
499            "brightblack" => {
500                for_each_ref_push_color_code(value, &mut foreground, &mut background, 90)?
501            }
502            "brightred" => {
503                for_each_ref_push_color_code(value, &mut foreground, &mut background, 91)?
504            }
505            "brightgreen" => {
506                for_each_ref_push_color_code(value, &mut foreground, &mut background, 92)?
507            }
508            "brightyellow" => {
509                for_each_ref_push_color_code(value, &mut foreground, &mut background, 93)?
510            }
511            "brightblue" => {
512                for_each_ref_push_color_code(value, &mut foreground, &mut background, 94)?
513            }
514            "brightmagenta" => {
515                for_each_ref_push_color_code(value, &mut foreground, &mut background, 95)?
516            }
517            "brightcyan" => {
518                for_each_ref_push_color_code(value, &mut foreground, &mut background, 96)?
519            }
520            "brightwhite" => {
521                for_each_ref_push_color_code(value, &mut foreground, &mut background, 97)?
522            }
523            _ => {
524                return Err(GitError::Command(format!(
525                    "unsupported for-each-ref color {value}"
526                )));
527            }
528        }
529    }
530    let mut codes = attributes;
531    if let Some(foreground) = foreground {
532        codes.push(foreground.to_string());
533    }
534    if let Some(background) = background {
535        codes.push(background.to_string());
536    }
537    if codes.is_empty() {
538        return Ok(String::new());
539    }
540    Ok(format!("\x1b[{}m", codes.join(";")))
541}
542
543pub fn for_each_ref_push_color_code(
544    value: &str,
545    foreground: &mut Option<u16>,
546    background: &mut Option<u16>,
547    code: u16,
548) -> Result<()> {
549    if foreground.is_none() {
550        *foreground = Some(code);
551    } else if background.is_none() {
552        *background = Some(code + 10);
553    } else {
554        return Err(GitError::Command(format!(
555            "unsupported for-each-ref color {value}"
556        )));
557    }
558    Ok(())
559}