Use rustdoc output to create a list of public API

Rather than collecting a list of file names in `libm-test/build.rs`,
just use a script to parse rustdoc's JSON output.
This commit is contained in:
Trevor Gross 2024-12-31 22:56:36 +00:00
parent 489524413b
commit ed72c4ec69
6 changed files with 283 additions and 69 deletions

View file

@ -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')

View file

@ -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::<Vec<_>>();
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();
}

View file

@ -23,9 +23,6 @@ pub use test_traits::{CheckOutput, GenerateInput, Hex, TupleCall};
/// propagate.
pub type TestResult<T = (), E = anyhow::Error> = Result<T, E>;
// 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,
}
}

View file

@ -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`");
}

View file

@ -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

View file

@ -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()