Skip to main content

linuxutils_misc/
look.rs

1use linuxutils_common::man::ManContent;
2
3pub const MAN: ManContent = ManContent::empty();
4
5use clap::Parser;
6use std::{
7    fs,
8    io::{self, Write},
9    process::ExitCode,
10};
11
12const DEFAULT_DICT: &str = "/usr/share/dict/words";
13const ALT_DICT: &str = "/usr/share/dict/web2";
14
15#[derive(Parser)]
16#[command(
17    name = "look",
18    version,
19    about = "Display lines beginning with a given string"
20)]
21pub struct Args {
22    /// Use the alternative dictionary file
23    #[arg(short = 'a', long = "alternative")]
24    alternative: bool,
25
26    /// Use normal dictionary character set and order (only blanks and
27    /// alphanumeric characters are compared)
28    #[arg(short = 'd', long = "alphanum")]
29    alphanum: bool,
30
31    /// Ignore the case of alphabetic characters
32    #[arg(short = 'f', long = "ignore-case")]
33    ignore_case: bool,
34
35    /// String termination character
36    #[arg(short = 't', long = "terminate")]
37    terminate: Option<char>,
38
39    /// String to search for
40    pub string: String,
41
42    /// File to search (default: /usr/share/dict/words)
43    pub file: Option<String>,
44}
45
46pub fn run(args: Args) -> ExitCode {
47    let using_dict = args.file.is_none();
48    // When using the default dictionary, -d and -f are on by default.
49    let alphanum = args.alphanum || using_dict;
50    let ignore_case = args.ignore_case || using_dict;
51
52    let path = if let Some(ref f) = args.file {
53        f.clone()
54    } else if args.alternative {
55        ALT_DICT.to_string()
56    } else if let Ok(w) = std::env::var("WORDLIST") {
57        w
58    } else {
59        DEFAULT_DICT.to_string()
60    };
61
62    let content = match fs::read_to_string(&path) {
63        Ok(c) => c,
64        Err(e) => {
65            eprintln!("look: {path}: {e}");
66            return ExitCode::from(2);
67        }
68    };
69
70    let search =
71        prepare_key(&args.string, args.terminate, alphanum, ignore_case);
72
73    let lines: Vec<&str> = content.lines().collect();
74
75    // Binary search to find the first matching line.
76    let start = lines.partition_point(|line| {
77        let key = line_key(line, search.len(), alphanum, ignore_case);
78        key < search
79    });
80
81    let stdout = io::stdout();
82    let mut out = io::BufWriter::new(stdout.lock());
83    let mut found = false;
84
85    for line in &lines[start..] {
86        let key = line_key(line, search.len(), alphanum, ignore_case);
87        if key != search {
88            break;
89        }
90        found = true;
91        if let Err(e) = writeln!(out, "{line}") {
92            eprintln!("look: {e}");
93            return ExitCode::from(2);
94        }
95    }
96
97    if found {
98        ExitCode::SUCCESS
99    } else {
100        ExitCode::from(1)
101    }
102}
103
104/// Prepare the search key: apply terminator, then normalize.
105fn prepare_key(
106    s: &str,
107    terminate: Option<char>,
108    alphanum: bool,
109    ignore_case: bool,
110) -> String {
111    let s = match terminate {
112        Some(t) => match s.find(t) {
113            Some(pos) => &s[..pos + t.len_utf8()],
114            None => s,
115        },
116        None => s,
117    };
118    normalize(s, alphanum, ignore_case)
119}
120
121/// Extract a comparable key from the beginning of a line.
122/// The key length is bounded by the search key length (in normalized chars).
123fn line_key(
124    line: &str,
125    key_len: usize,
126    alphanum: bool,
127    ignore_case: bool,
128) -> String {
129    if alphanum {
130        let mut result = String::with_capacity(key_len);
131        for ch in line.chars() {
132            if result.len() >= key_len {
133                break;
134            }
135            if ch.is_alphanumeric() || ch == ' ' || ch == '\t' {
136                if ignore_case {
137                    for c in ch.to_lowercase() {
138                        result.push(c);
139                    }
140                } else {
141                    result.push(ch);
142                }
143            }
144        }
145        result
146    } else {
147        let prefix: String = line.chars().take(key_len).collect();
148        if ignore_case {
149            prefix.to_lowercase()
150        } else {
151            prefix
152        }
153    }
154}
155
156fn normalize(s: &str, alphanum: bool, ignore_case: bool) -> String {
157    let filtered: String = if alphanum {
158        s.chars()
159            .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '\t')
160            .collect()
161    } else {
162        s.to_string()
163    };
164    if ignore_case {
165        filtered.to_lowercase()
166    } else {
167        filtered
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::io::Write;
175    use tempfile::NamedTempFile;
176
177    fn make_file(content: &str) -> NamedTempFile {
178        let mut f = NamedTempFile::new().unwrap();
179        f.write_all(content.as_bytes()).unwrap();
180        f.flush().unwrap();
181        f
182    }
183
184    fn run_look(
185        string: &str,
186        content: &str,
187        alphanum: bool,
188        ignore_case: bool,
189        terminate: Option<char>,
190    ) -> (ExitCode, String) {
191        let file = make_file(content);
192        let args = Args {
193            alternative: false,
194            alphanum,
195            ignore_case,
196            terminate,
197            string: string.to_string(),
198            file: Some(file.path().to_string_lossy().to_string()),
199        };
200
201        // Capture stdout by running the core logic directly.
202        let search =
203            prepare_key(&args.string, args.terminate, alphanum, ignore_case);
204        let content = fs::read_to_string(file.path()).unwrap();
205        let lines: Vec<&str> = content.lines().collect();
206        let start = lines.partition_point(|line| {
207            let key = line_key(line, search.len(), alphanum, ignore_case);
208            key < search
209        });
210
211        let mut output = Vec::new();
212        let mut found = false;
213        for line in &lines[start..] {
214            let key = line_key(line, search.len(), alphanum, ignore_case);
215            if key != search {
216                break;
217            }
218            found = true;
219            writeln!(output, "{line}").unwrap();
220        }
221
222        let code = if found {
223            ExitCode::SUCCESS
224        } else {
225            ExitCode::from(1)
226        };
227        (code, String::from_utf8(output).unwrap())
228    }
229
230    #[test]
231    fn basic_prefix_match() {
232        let (_, out) =
233            run_look("b", "apple\nbanana\nberry\ncherry", false, false, None);
234        assert_eq!(out, "banana\nberry\n");
235    }
236
237    #[test]
238    fn no_match_returns_1() {
239        let (code, out) = run_look("zzz", "apple\nbanana", false, false, None);
240        assert_eq!(out, "");
241        assert_eq!(code, ExitCode::from(1));
242    }
243
244    #[test]
245    fn case_insensitive() {
246        let (_, out) = run_look("APPLE", "apple\nbanana", false, true, None);
247        assert_eq!(out, "apple\n");
248    }
249
250    #[test]
251    fn alphanum_mode() {
252        let (_, out) =
253            run_look("ban", "ban-ana\nbanana\nberry", true, false, None);
254        assert_eq!(out, "ban-ana\nbanana\n");
255    }
256
257    #[test]
258    fn terminate_char() {
259        let (_, out) = run_look(
260            "apple pie",
261            "apple\napple pie\nbanana",
262            false,
263            false,
264            Some(' '),
265        );
266        // -t ' ' means only compare up to and including the first space: "apple "
267        // "apple" is shorter than the key so it doesn't match.
268        assert_eq!(out, "apple pie\n");
269    }
270}