1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Optional checks for file types other than Rust source
//!
//! Handles python tool version managment via a virtual environment in
//! `build/venv`.
//!
//! # Functional outline
//!
//! 1. Run tidy with an extra option: `--extra-checks=py,shell`,
//!    `--extra-checks=py:lint`, or similar. Optionally provide specific
//!    configuration after a double dash (`--extra-checks=py -- foo.py`)
//! 2. Build configuration based on args/environment:
//!    - Formatters by default are in check only mode
//!    - If in CI (TIDY_PRINT_DIFF=1 is set), check and print the diff
//!    - If `--bless` is provided, formatters may run
//!    - Pass any additional config after the `--`. If no files are specified,
//!      use a default.
//! 3. Print the output of the given command. If it fails and `TIDY_PRINT_DIFF`
//!    is set, rerun the tool to print a suggestion diff (for e.g. CI)

use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;

/// Minimum python revision is 3.7 for ruff
const MIN_PY_REV: (u32, u32) = (3, 7);
const MIN_PY_REV_STR: &str = "≥3.7";

/// Path to find the python executable within a virtual environment
#[cfg(target_os = "windows")]
const REL_PY_PATH: &[&str] = &["Scripts", "python3.exe"];
#[cfg(not(target_os = "windows"))]
const REL_PY_PATH: &[&str] = &["bin", "python3"];

const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];
const BLACK_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "black.toml"];
/// Location within build directory
const RUFF_CACH_PATH: &[&str] = &["cache", "ruff_cache"];
const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];

pub fn check(
    root_path: &Path,
    outdir: &Path,
    bless: bool,
    extra_checks: Option<&str>,
    pos_args: &[String],
    bad: &mut bool,
) {
    if let Err(e) = check_impl(root_path, outdir, bless, extra_checks, pos_args) {
        tidy_error!(bad, "{e}");
    }
}

fn check_impl(
    root_path: &Path,
    outdir: &Path,
    bless: bool,
    extra_checks: Option<&str>,
    pos_args: &[String],
) -> Result<(), Error> {
    let show_diff = std::env::var("TIDY_PRINT_DIFF")
        .map_or(false, |v| v.eq_ignore_ascii_case("true") || v == "1");

    // Split comma-separated args up
    let lint_args = match extra_checks {
        Some(s) => s.strip_prefix("--extra-checks=").unwrap().split(',').collect(),
        None => vec![],
    };

    let python_all = lint_args.contains(&"py");
    let python_lint = lint_args.contains(&"py:lint") || python_all;
    let python_fmt = lint_args.contains(&"py:fmt") || python_all;
    let shell_all = lint_args.contains(&"shell");
    let shell_lint = lint_args.contains(&"shell:lint") || shell_all;

    let mut py_path = None;

    let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args
        .into_iter()
        .map(OsStr::new)
        .partition(|arg| arg.to_str().is_some_and(|s| s.starts_with("-")));

    if python_lint || python_fmt {
        let venv_path = outdir.join("venv");
        let mut reqs_path = root_path.to_owned();
        reqs_path.extend(PIP_REQ_PATH);
        py_path = Some(get_or_create_venv(&venv_path, &reqs_path)?);
    }

    if python_lint {
        eprintln!("linting python files");
        let mut cfg_args_ruff = cfg_args.clone();
        let mut file_args_ruff = file_args.clone();

        let mut cfg_path = root_path.to_owned();
        cfg_path.extend(RUFF_CONFIG_PATH);
        let mut cache_dir = outdir.to_owned();
        cache_dir.extend(RUFF_CACH_PATH);

        cfg_args_ruff.extend([
            "--config".as_ref(),
            cfg_path.as_os_str(),
            "--cache-dir".as_ref(),
            cache_dir.as_os_str(),
        ]);

        if file_args_ruff.is_empty() {
            file_args_ruff.push(root_path.as_os_str());
        }

        let mut args = merge_args(&cfg_args_ruff, &file_args_ruff);
        let res = py_runner(py_path.as_ref().unwrap(), "ruff", &args);

        if res.is_err() && show_diff {
            eprintln!("\npython linting failed! Printing diff suggestions:");

            args.insert(0, "--diff".as_ref());
            let _ = py_runner(py_path.as_ref().unwrap(), "ruff", &args);
        }
        // Rethrow error
        let _ = res?;
    }

    if python_fmt {
        let mut cfg_args_black = cfg_args.clone();
        let mut file_args_black = file_args.clone();

        if bless {
            eprintln!("formatting python files");
        } else {
            eprintln!("checking python file formatting");
            cfg_args_black.push("--check".as_ref());
        }

        let mut cfg_path = root_path.to_owned();
        cfg_path.extend(BLACK_CONFIG_PATH);

        cfg_args_black.extend(["--config".as_ref(), cfg_path.as_os_str()]);

        if file_args_black.is_empty() {
            file_args_black.push(root_path.as_os_str());
        }

        let mut args = merge_args(&cfg_args_black, &file_args_black);
        let res = py_runner(py_path.as_ref().unwrap(), "black", &args);

        if res.is_err() && show_diff {
            eprintln!("\npython formatting does not match! Printing diff:");

            args.insert(0, "--diff".as_ref());
            let _ = py_runner(py_path.as_ref().unwrap(), "black", &args);
        }
        // Rethrow error
        let _ = res?;
    }

    if shell_lint {
        eprintln!("linting shell files");

        let mut file_args_shc = file_args.clone();
        let files;
        if file_args_shc.is_empty() {
            files = find_with_extension(root_path, "sh")?;
            file_args_shc.extend(files.iter().map(|p| p.as_os_str()));
        }

        shellcheck_runner(&merge_args(&cfg_args, &file_args_shc))?;
    }

    Ok(())
}

/// Helper to create `cfg1 cfg2 -- file1 file2` output
fn merge_args<'a>(cfg_args: &[&'a OsStr], file_args: &[&'a OsStr]) -> Vec<&'a OsStr> {
    let mut args = cfg_args.to_owned();
    args.push("--".as_ref());
    args.extend(file_args);
    args
}

/// Run a python command with given arguments. `py_path` should be a virtualenv.
fn py_runner(py_path: &Path, bin: &'static str, args: &[&OsStr]) -> Result<(), Error> {
    let status = Command::new(py_path).arg("-m").arg(bin).args(args).status()?;
    if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }
}

/// Create a virtuaenv at a given path if it doesn't already exist, or validate
/// the install if it does. Returns the path to that venv's python executable.
fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
    let mut should_create = true;
    let dst_reqs_path = venv_path.join("requirements.txt");
    let mut py_path = venv_path.to_owned();
    py_path.extend(REL_PY_PATH);

    if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
        if req == fs::read_to_string(src_reqs_path)? {
            // found existing environment
            should_create = false;
        } else {
            eprintln!("requirements.txt file mismatch, recreating environment");
        }
    }

    if should_create {
        eprintln!("removing old virtual environment");
        if venv_path.is_dir() {
            fs::remove_dir_all(venv_path).unwrap_or_else(|_| {
                panic!("failed to remove directory at {}", venv_path.display())
            });
        }
        create_venv_at_path(venv_path)?;
        install_requirements(&py_path, src_reqs_path, &dst_reqs_path)?;
    }

    verify_py_version(&py_path)?;
    Ok(py_path)
}

/// Attempt to create a virtualenv at this path. Cycles through all expected
/// valid python versions to find one that is installed.
fn create_venv_at_path(path: &Path) -> Result<(), Error> {
    /// Preferred python versions in order. Newest to oldest then current
    /// development versions
    const TRY_PY: &[&str] = &[
        "python3.11",
        "python3.10",
        "python3.9",
        "python3.8",
        "python3.7",
        "python3",
        "python",
        "python3.12",
        "python3.13",
    ];

    let mut sys_py = None;
    let mut found = Vec::new();

    for py in TRY_PY {
        match verify_py_version(Path::new(py)) {
            Ok(_) => {
                sys_py = Some(*py);
                break;
            }
            // Skip not found errors
            Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
            // Skip insufficient version errors
            Err(Error::Version { installed, .. }) => found.push(installed),
            // just log and skip unrecognized errors
            Err(e) => eprintln!("note: error running '{py}': {e}"),
        }
    }

    let Some(sys_py) = sys_py else {
        let ret = if found.is_empty() {
            Error::MissingReq("python3", "python file checks", None)
        } else {
            found.sort();
            found.dedup();
            Error::Version {
                program: "python3",
                required: MIN_PY_REV_STR,
                installed: found.join(", "),
            }
        };
        return Err(ret);
    };

    eprintln!("creating virtual environment at '{}' using '{sys_py}'", path.display());
    let out = Command::new(sys_py).args(["-m", "virtualenv"]).arg(path).output().unwrap();

    if out.status.success() {
        return Ok(());
    }
    let err = if String::from_utf8_lossy(&out.stderr).contains("No module named virtualenv") {
        Error::Generic(format!(
            "virtualenv not found: you may need to install it \
                               (`python3 -m pip install venv`)"
        ))
    } else {
        Error::Generic(format!("failed to create venv at '{}' using {sys_py}", path.display()))
    };
    Err(err)
}

/// Parse python's version output (`Python x.y.z`) and ensure we have a
/// suitable version.
fn verify_py_version(py_path: &Path) -> Result<(), Error> {
    let out = Command::new(py_path).arg("--version").output()?;
    let outstr = String::from_utf8_lossy(&out.stdout);
    let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();
    let mut vers_comps = vers.split('.');
    let major: u32 = vers_comps.next().unwrap().parse().unwrap();
    let minor: u32 = vers_comps.next().unwrap().parse().unwrap();

    if (major, minor) < MIN_PY_REV {
        Err(Error::Version {
            program: "python",
            required: MIN_PY_REV_STR,
            installed: vers.to_owned(),
        })
    } else {
        Ok(())
    }
}

fn install_requirements(
    py_path: &Path,
    src_reqs_path: &Path,
    dst_reqs_path: &Path,
) -> Result<(), Error> {
    let stat = Command::new(py_path)
        .args(["-m", "pip", "install", "--upgrade", "pip"])
        .status()
        .expect("failed to launch pip");
    if !stat.success() {
        return Err(Error::Generic(format!("pip install failed with status {stat}")));
    }

    let stat = Command::new(py_path)
        .args(["-m", "pip", "install", "--require-hashes", "-r"])
        .arg(src_reqs_path)
        .status()?;
    if !stat.success() {
        return Err(Error::Generic(format!(
            "failed to install requirements at {}",
            src_reqs_path.display()
        )));
    }
    fs::copy(src_reqs_path, dst_reqs_path)?;
    assert_eq!(
        fs::read_to_string(src_reqs_path).unwrap(),
        fs::read_to_string(dst_reqs_path).unwrap()
    );
    Ok(())
}

/// Check that shellcheck is installed then run it at the given path
fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
    match Command::new("shellcheck").arg("--version").status() {
        Ok(_) => (),
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            return Err(Error::MissingReq(
                "shellcheck",
                "shell file checks",
                Some(
                    "see <https://github.com/koalaman/shellcheck#installing> \
                    for installation instructions"
                        .to_owned(),
                ),
            ));
        }
        Err(e) => return Err(e.into()),
    }

    let status = Command::new("shellcheck").args(args).status()?;
    if status.success() { Ok(()) } else { Err(Error::FailedCheck("black")) }
}

/// Check git for tracked files matching an extension
fn find_with_extension(root_path: &Path, extension: &str) -> Result<Vec<PathBuf>, Error> {
    // Untracked files show up for short status and are indicated with a leading `?`
    // -C changes git to be as if run from that directory
    let stat_output =
        Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;

    if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {
        eprintln!("found untracked files, ignoring");
    }

    let mut output = Vec::new();
    let binding = Command::new("git").arg("-C").arg(root_path).args(["ls-files"]).output()?;
    let tracked = String::from_utf8_lossy(&binding.stdout);

    for line in tracked.lines() {
        let line = line.trim();
        let path = Path::new(line);

        if path.extension() == Some(OsStr::new(extension)) {
            output.push(path.to_owned());
        }
    }

    Ok(output)
}

#[derive(Debug)]
enum Error {
    Io(io::Error),
    /// a is required to run b. c is extra info
    MissingReq(&'static str, &'static str, Option<String>),
    /// Tool x failed the check
    FailedCheck(&'static str),
    /// Any message, just print it
    Generic(String),
    /// Installed but wrong version
    Version {
        program: &'static str,
        required: &'static str,
        installed: String,
    },
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingReq(a, b, ex) => {
                write!(
                    f,
                    "{a} is required to run {b} but it could not be located. Is it installed?"
                )?;
                if let Some(s) = ex {
                    write!(f, "\n{s}")?;
                };
                Ok(())
            }
            Self::Version { program, required, installed } => write!(
                f,
                "insufficient version of '{program}' to run external tools: \
                {required} required but found {installed}",
            ),
            Self::Generic(s) => f.write_str(s),
            Self::Io(e) => write!(f, "IO error: {e}"),
            Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),
        }
    }
}

impl From<io::Error> for Error {
    fn from(value: io::Error) -> Self {
        Self::Io(value)
    }
}