use std::collections::HashSet;
use super::{EnvHint, EnvHintSource};
const SHELL_ENV_BLACKLIST: &[&str] = &[
"PATH", "HOME", "USER", "SHELL", "PWD", "LANG", "TERM", "TMPDIR", "PAGER",
];
const PATTERN2_WINDOW: usize = 4;
pub(super) fn parse_env_hints_bash_style(raw: &str) -> Vec<EnvHint> {
let stripped = strip_clap_env_annotations(raw);
let lines: Vec<&str> = stripped.lines().collect();
let flag_line_indices: Vec<usize> = lines
.iter()
.enumerate()
.filter_map(|(i, l)| is_flag_line(l).then_some(i))
.collect();
let env_section_range = find_env_section(&lines);
let mut line_source: Vec<Option<EnvHintSource>> = vec![None; lines.len()];
for &idx in &flag_line_indices {
let lo = idx.saturating_sub(PATTERN2_WINDOW);
let hi = (idx + PATTERN2_WINDOW + 1).min(lines.len());
for slot in line_source[lo..hi].iter_mut() {
*slot = Some(EnvHintSource::Proximity);
}
}
if let Some((lo, hi)) = env_section_range {
for slot in line_source[lo..hi].iter_mut() {
*slot = Some(EnvHintSource::EnvSection);
}
}
let mut hints = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for (i, line) in lines.iter().enumerate() {
let Some(source) = line_source[i] else {
continue;
};
for token in extract_env_tokens(line) {
if SHELL_ENV_BLACKLIST.contains(&token.as_str()) {
continue;
}
if seen.insert(token.clone()) {
hints.push(EnvHint { var: token, source });
}
}
}
hints
}
fn strip_clap_env_annotations(raw: &str) -> String {
const TAG: &str = "[env:";
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(pos) = rest.find(TAG) {
out.push_str(&rest[..pos]);
let after = &rest[pos..];
let close = after.find(']').map(|i| i + 1).unwrap_or(after.len());
for ch in after[..close].chars() {
out.push(if ch == '\n' { '\n' } else { ' ' });
}
rest = &after[close..];
}
out.push_str(rest);
out
}
fn is_flag_line(line: &str) -> bool {
if !line.starts_with(' ') {
return false;
}
let trimmed = line.trim_start();
trimmed.starts_with('-') && !trimmed.starts_with("---")
}
fn is_section_header_line(line: &str) -> bool {
!line.is_empty()
&& !line.starts_with(' ')
&& line.trim().ends_with(':')
&& line.chars().any(|c| c.is_ascii_uppercase())
}
fn find_env_section(lines: &[&str]) -> Option<(usize, usize)> {
let start = lines.iter().position(|l| {
let t = l.trim();
matches!(
t,
"ENVIRONMENT"
| "ENVIRONMENT:"
| "ENVIRONMENT VARIABLES"
| "ENVIRONMENT VARIABLES:"
| "ENV VARS"
| "ENV VARS:"
| "Environment:"
)
})?;
let end = lines[start + 1..]
.iter()
.position(|l| is_section_header_line(l))
.map(|offset| start + 1 + offset)
.unwrap_or(lines.len());
Some((start, end))
}
fn extract_env_tokens(line: &str) -> Vec<String> {
if is_section_header_line(line) {
return Vec::new();
}
let mut out = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut start = i;
let mut had_dollar = false;
if bytes[i] == b'$' {
had_dollar = true;
start = i + 1;
if start < bytes.len() && bytes[start] == b'{' {
start += 1;
}
}
if start < bytes.len() && (bytes[start].is_ascii_uppercase() || bytes[start] == b'_') {
let mut end = start + 1;
while end < bytes.len()
&& (bytes[end].is_ascii_uppercase()
|| bytes[end].is_ascii_digit()
|| bytes[end] == b'_')
{
end += 1;
}
let candidate = &line[start..end];
let left_ok = start == 0 || !bytes[start - 1].is_ascii_lowercase();
let right_ok = end >= bytes.len() || !bytes[end].is_ascii_lowercase();
let in_placeholder_bracket = !had_dollar
&& matches!(
start.checked_sub(1).map(|p| bytes[p]),
Some(b'[') | Some(b'<')
)
&& matches!(bytes.get(end).copied(), Some(b']') | Some(b'>'));
let is_tool_scoped = had_dollar || candidate.contains('_');
if left_ok
&& right_ok
&& !in_placeholder_bracket
&& is_tool_scoped
&& candidate.len() >= 3
{
out.push(candidate.to_string());
}
i = end;
} else {
i += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::super::parse_env_hints;
use super::*;
const GH_HELP: &str = "\
Work seamlessly with GitHub from the command line.
USAGE
gh <command> <subcommand> [flags]
OPTIONS
--help Show help for command.
ENVIRONMENT VARIABLES
GH_TOKEN, GITHUB_TOKEN: authentication credentials.
GH_REPO: specifies default repo for commands.
";
const RIPGREP_PROSE: &str = "\
USAGE: rg [OPTIONS] PATTERN
OPTIONS:
--config <FILE>
Use config file. If set to an empty string, RIPGREP_CONFIG_PATH
is read from $RIPGREP_CONFIG_PATH. Respects RIPGREP_COLOR env
variable when rendering output.
";
#[test]
fn pattern2_captures_gh_environment_section() {
let hints = parse_env_hints(GH_HELP);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
assert!(names.contains(&"GH_TOKEN"), "got {names:?}");
assert!(names.contains(&"GITHUB_TOKEN"), "got {names:?}");
assert!(names.contains(&"GH_REPO"), "got {names:?}");
}
#[test]
fn pattern2_captures_ripgrep_prose_near_flag() {
let hints = parse_env_hints(RIPGREP_PROSE);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
assert!(names.contains(&"RIPGREP_CONFIG_PATH"), "got {names:?}");
assert!(names.contains(&"RIPGREP_COLOR"), "got {names:?}");
}
#[test]
fn pattern2_blacklist_rejects_shell_env() {
let src = "\
USAGE: foo
OPTIONS:
--bin Runs the binary from $PATH. Use $HOME to override.
--less Pipes output through $PAGER when stdout is a TTY.
";
let hints = parse_env_hints(src);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
assert!(!names.contains(&"PATH"));
assert!(!names.contains(&"HOME"));
assert!(
!names.contains(&"PAGER"),
"$PAGER must stay in SHELL_ENV_BLACKLIST — it's ambient shell, \
not a flag binding (overlaps with p6-no-pager signalling)",
);
}
#[test]
fn pattern2_ignores_tokens_outside_flag_window() {
let src = "\
MyTool - does stuff.
See also: MYVAR is described in the CONFIG manual, page 42.
Completely unrelated paragraph with no flags in sight. MYVAR again.
Another paragraph.
Another paragraph.
Another paragraph.
Another paragraph.
";
let hints = parse_env_hints(src);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
assert!(
!names.contains(&"MYVAR"),
"MYVAR outside flag window should be ignored, got {names:?}",
);
}
#[test]
fn pattern2_dedupes_against_pattern1() {
let src = "\
USAGE: tool [OPTIONS]
OPTIONS:
--foo <VAL> Configures foo. See $MY_FOO for details. [env: MY_FOO]
";
let hints = parse_env_hints(src);
let my_foo_count = hints.iter().filter(|h| h.var == "MY_FOO").count();
assert_eq!(
my_foo_count, 1,
"expected MY_FOO deduped across patterns, got {hints:?}",
);
}
#[test]
fn pattern2_ignores_section_header_lines() {
let src = "\
USAGE: tool
OPTIONS:
--flag Does something.
DOCKER_CONFIG:
/etc/tool/docker.conf
";
let hints = parse_env_hints(src);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
assert!(
!names.contains(&"DOCKER_CONFIG"),
"section-header line must not contribute env hints, got {names:?}",
);
}
#[test]
fn pattern2_rejects_mixed_case_and_placeholders_near_flag() {
let src = "\
USAGE: tool [OPTIONS] [FILES]
OPTIONS:
--tpl <TEMPLATE> Uses CamelCase naming. Read $Path then MACROname.
";
let hints = parse_env_hints(src);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
for rejected in ["Path", "FILES", "TEMPLATE", "CamelCase", "MACROname"] {
assert!(
!names.contains(&rejected),
"{rejected} must be rejected by Pattern 2, got {names:?}",
);
}
}
#[test]
fn pattern2_rejects_bracketed_env_vars_in_prose() {
let src = "\
USAGE: tool
ENVIRONMENT:
Uses [GH_TOKEN] when set.
Also respects <GITHUB_TOKEN>.
";
let hints = parse_env_hints(src);
let names: Vec<&str> = hints.iter().map(|h| h.var.as_str()).collect();
assert!(
!names.contains(&"GH_TOKEN"),
"bracketed [GH_TOKEN] must stay rejected; see \
extract_env_tokens doc comment (recall gap noted)",
);
assert!(
!names.contains(&"GITHUB_TOKEN"),
"angle-bracket <GITHUB_TOKEN> must stay rejected",
);
}
#[test]
fn pattern2_tags_source_for_each_hint() {
let hints = parse_env_hints(GH_HELP);
let gh_token = hints
.iter()
.find(|h| h.var == "GH_TOKEN")
.expect("GH_TOKEN in hints");
assert_eq!(gh_token.source, EnvHintSource::EnvSection);
let hints = parse_env_hints(RIPGREP_PROSE);
let rg_color = hints
.iter()
.find(|h| h.var == "RIPGREP_COLOR")
.expect("RIPGREP_COLOR in hints");
assert_eq!(rg_color.source, EnvHintSource::Proximity);
}
}