diff --git a/library/compiler-builtins/libm/.github/workflows/main.yml b/library/compiler-builtins/libm/.github/workflows/main.yml index 83875f3682c6..0f5becf7305b 100644 --- a/library/compiler-builtins/libm/.github/workflows/main.yml +++ b/library/compiler-builtins/libm/.github/workflows/main.yml @@ -96,6 +96,10 @@ jobs: run: ./ci/download-musl.sh shell: bash + - name: Verify API list + if: matrix.os == 'ubuntu-24.04' + run: python3 etc/update-api-list.py --check + # Non-linux tests just use our raw script - name: Run locally if: matrix.os != 'ubuntu-24.04' || contains(matrix.target, 'wasm') diff --git a/library/compiler-builtins/libm/crates/libm-test/build.rs b/library/compiler-builtins/libm/crates/libm-test/build.rs index f2cd298baf21..134fb11ceeb6 100644 --- a/library/compiler-builtins/libm/crates/libm-test/build.rs +++ b/library/compiler-builtins/libm/crates/libm-test/build.rs @@ -1,42 +1,8 @@ -use std::fmt::Write; -use std::fs; - #[path = "../../configure.rs"] mod configure; use configure::Config; fn main() { let cfg = Config::from_env(); - - list_all_tests(&cfg); - configure::emit_test_config(&cfg); } - -/// Create a list of all source files in an array. This can be used for making sure that -/// all functions are tested or otherwise covered in some way. -// FIXME: it would probably be better to use rustdoc JSON output to get public functions. -fn list_all_tests(cfg: &Config) { - let math_src = cfg.manifest_dir.join("../../src/math"); - - let mut files = fs::read_dir(math_src) - .unwrap() - .map(|f| f.unwrap().path()) - .filter(|entry| entry.is_file()) - .map(|f| f.file_stem().unwrap().to_str().unwrap().to_owned()) - .collect::>(); - files.sort(); - - let mut s = "pub const ALL_FUNCTIONS: &[&str] = &[".to_owned(); - for f in files { - if f == "mod" { - // skip mod.rs - continue; - } - write!(s, "\"{f}\",").unwrap(); - } - write!(s, "];").unwrap(); - - let outfile = cfg.out_dir.join("all_files.rs"); - fs::write(outfile, s).unwrap(); -} diff --git a/library/compiler-builtins/libm/crates/libm-test/src/lib.rs b/library/compiler-builtins/libm/crates/libm-test/src/lib.rs index eb457b0ae7bb..fdba0357f7f0 100644 --- a/library/compiler-builtins/libm/crates/libm-test/src/lib.rs +++ b/library/compiler-builtins/libm/crates/libm-test/src/lib.rs @@ -23,9 +23,6 @@ pub use test_traits::{CheckOutput, GenerateInput, Hex, TupleCall}; /// propagate. pub type TestResult = Result; -// List of all files present in libm's source -include!(concat!(env!("OUT_DIR"), "/all_files.rs")); - /// True if `EMULATED` is set and nonempty. Used to determine how many iterations to run. pub const fn emulated() -> bool { match option_env!("EMULATED") { @@ -34,3 +31,12 @@ pub const fn emulated() -> bool { Some(_) => true, } } + +/// True if `CI` is set and nonempty. +pub const fn ci() -> bool { + match option_env!("CI") { + Some(s) if s.is_empty() => false, + None => false, + Some(_) => true, + } +} diff --git a/library/compiler-builtins/libm/crates/libm-test/tests/check_coverage.rs b/library/compiler-builtins/libm/crates/libm-test/tests/check_coverage.rs index b7988660e662..9f85d64244bf 100644 --- a/library/compiler-builtins/libm/crates/libm-test/tests/check_coverage.rs +++ b/library/compiler-builtins/libm/crates/libm-test/tests/check_coverage.rs @@ -1,54 +1,60 @@ //! Ensure that `for_each_function!` isn't missing any symbols. -/// Files in `src/` that do not export a testable symbol. -const ALLOWED_SKIPS: &[&str] = &[ - // Not a generic test function - "fenv", - // Nonpublic functions - "expo2", - "k_cos", - "k_cosf", - "k_expo2", - "k_expo2f", - "k_sin", - "k_sinf", - "k_tan", - "k_tanf", - "rem_pio2", - "rem_pio2_large", - "rem_pio2f", -]; +use std::collections::HashSet; +use std::env; +use std::path::Path; +use std::process::Command; macro_rules! callback { ( fn_name: $name:ident, - extra: [$push_to:ident], + extra: [$set:ident], ) => { - $push_to.push(stringify!($name)); + let name = stringify!($name); + let new = $set.insert(name); + assert!(new, "duplicate function `{name}` in `ALL_OPERATIONS`"); }; } #[test] fn test_for_each_function_all_included() { - let mut included = Vec::new(); - let mut missing = Vec::new(); + let all_functions: HashSet<_> = include_str!("../../../etc/function-list.txt") + .lines() + .filter(|line| !line.starts_with("#")) + .collect(); + + let mut tested = HashSet::new(); libm_macros::for_each_function! { callback: callback, - extra: [included], + extra: [tested], }; - for f in libm_test::ALL_FUNCTIONS { - if !included.contains(f) && !ALLOWED_SKIPS.contains(f) { - missing.push(f) - } - } - - if !missing.is_empty() { + let untested = all_functions.difference(&tested); + if untested.clone().next().is_some() { panic!( - "missing tests for the following: {missing:#?} \ + "missing tests for the following: {untested:#?} \ \nmake sure any new functions are entered in \ - `ALL_FUNCTIONS` (in `libm-macros`)." + `ALL_OPERATIONS` (in `libm-macros`)." ); } + assert_eq!(all_functions, tested); +} + +#[test] +fn ensure_list_updated() { + if libm_test::ci() { + // Most CI tests run in Docker where we don't have Python or Rustdoc, so it's easiest + // to just run the python file directly when it is available. + eprintln!("skipping test; CI runs the python file directly"); + return; + } + + let res = Command::new("python3") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../etc/update-api-list.py")) + .arg("--check") + .status() + .unwrap(); + + assert!(res.success(), "May need to run `./etc/update-api-list.py`"); } diff --git a/library/compiler-builtins/libm/etc/function-list.txt b/library/compiler-builtins/libm/etc/function-list.txt new file mode 100644 index 000000000000..51f5b221c26f --- /dev/null +++ b/library/compiler-builtins/libm/etc/function-list.txt @@ -0,0 +1,115 @@ +# autogenerated by update-api-list.py +acos +acosf +acosh +acoshf +asin +asinf +asinh +asinhf +atan +atan2 +atan2f +atanf +atanh +atanhf +cbrt +cbrtf +ceil +ceilf +copysign +copysignf +cos +cosf +cosh +coshf +erf +erfc +erfcf +erff +exp +exp10 +exp10f +exp2 +exp2f +expf +expm1 +expm1f +fabs +fabsf +fdim +fdimf +floor +floorf +fma +fmaf +fmax +fmaxf +fmin +fminf +fmod +fmodf +frexp +frexpf +hypot +hypotf +ilogb +ilogbf +j0 +j0f +j1 +j1f +jn +jnf +ldexp +ldexpf +lgamma +lgamma_r +lgammaf +lgammaf_r +log +log10 +log10f +log1p +log1pf +log2 +log2f +logf +modf +modff +nextafter +nextafterf +pow +powf +remainder +remainderf +remquo +remquof +rint +rintf +round +roundf +scalbn +scalbnf +sin +sincos +sincosf +sinf +sinh +sinhf +sqrt +sqrtf +tan +tanf +tanh +tanhf +tgamma +tgammaf +trunc +truncf +y0 +y0f +y1 +y1f +yn +ynf diff --git a/library/compiler-builtins/libm/etc/update-api-list.py b/library/compiler-builtins/libm/etc/update-api-list.py new file mode 100755 index 000000000000..7284a628c6a6 --- /dev/null +++ b/library/compiler-builtins/libm/etc/update-api-list.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Create a text file listing all public API. This can be used to ensure that all +functions are covered by our macros. +""" + +import json +import subprocess as sp +import sys +import difflib +from pathlib import Path +from typing import Any + +ETC_DIR = Path(__file__).parent + + +def get_rustdoc_json() -> dict[Any, Any]: + """Get rustdoc's JSON output for the `libm` crate.""" + + librs_path = ETC_DIR.joinpath("../src/lib.rs") + j = sp.check_output( + [ + "rustdoc", + librs_path, + "--edition=2021", + "--output-format=json", + "-Zunstable-options", + "-o-", + ], + text=True, + ) + j = json.loads(j) + return j + + +def list_public_functions() -> list[str]: + """Get a list of public functions from rustdoc JSON output. + + Note that this only finds functions that are reexported in `lib.rs`, this will + need to be adjusted if we need to account for functions that are defined there. + """ + names = [] + index: dict[str, dict[str, Any]] = get_rustdoc_json()["index"] + for item in index.values(): + # Find public items + if item["visibility"] != "public": + continue + + # Find only reexports + if "use" not in item["inner"].keys(): + continue + + # Locate the item that is reexported + id = item["inner"]["use"]["id"] + srcitem = index.get(str(id)) + + # External crate + if srcitem is None: + continue + + # Skip if not a function + if "function" not in srcitem["inner"].keys(): + continue + + names.append(srcitem["name"]) + + names.sort() + return names + + +def diff_and_exit(actual: str, expected: str): + """If the two strings are different, print a diff between them and then exit + with an error. + """ + if actual == expected: + print("output matches expected; success") + return + + a = [f"{line}\n" for line in actual.splitlines()] + b = [f"{line}\n" for line in expected.splitlines()] + + diff = difflib.unified_diff(a, b, "actual", "expected") + sys.stdout.writelines(diff) + print("mismatched function list") + exit(1) + + +def main(): + """By default overwrite the file. If `--check` is passed, print a diff instead and + error if the files are different. + """ + match sys.argv: + case [_]: + check = False + case [_, "--check"]: + check = True + case _: + print("unrecognized arguments") + exit(1) + + names = list_public_functions() + output = "# autogenerated by update-api-list.py\n" + for name in names: + output += f"{name}\n" + + out_file = ETC_DIR.joinpath("function-list.txt") + + if check: + with open(out_file, "r") as f: + current = f.read() + diff_and_exit(current, output) + else: + with open(out_file, "w") as f: + f.write(output) + + +if __name__ == "__main__": + main()