apsis-book/scripts/extract_api.py

244 lines
8.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Extract the public interface of apsis-rs into content/api.json.
The service presents the public interface of the `apsis-rs` workspace as it
is written in the source, so the catalog is generated from the source rather
than maintained by hand. This script reads the crate directories of the
layers that `apsis-web` enables, collects the crate level documentation and
every public item with its doc comment and signature, and writes the result
to `content/api.json`. The file is embedded in the binary by `build.rs`.
Run it from the `apsis-web` directory when the `apsis-rs` sources change:
scripts/extract_api.sh
It requires Python 3.8 or later. It is a development tool: the Rust build
does not run it and does not depend on Python.
"""
import json
import os
import re
import sys
# Crates of the layers that the enabled `apsis-rs` features pull in, in
# dependency order. The order is the one the workspace layer rule defines.
LAYERS = [
("foundation", ["apsis-core", "apsis-units", "apsis-math", "apsis-macros"]),
("physics", ["apsis-time", "apsis-leap", "apsis-coord", "apsis-geodesy", "apsis-frames"]),
("orbit", [
"apsis-tle",
"apsis-sgp4",
"apsis-kepler",
"apsis-ephemeris",
"apsis-maneuver",
"apsis-orbit",
]),
]
# Directories of the workspace, relative to the workspace root, keyed by
# crate name.
CRATE_DIRS = {
"apsis-core": "crates/foundation/apsis-core",
"apsis-units": "crates/foundation/apsis-units",
"apsis-math": "crates/foundation/apsis-math",
"apsis-macros": "crates/foundation/apsis-macros",
"apsis-time": "crates/physics/apsis-time",
"apsis-leap": "crates/physics/apsis-leap",
"apsis-coord": "crates/physics/apsis-coord",
"apsis-geodesy": "crates/physics/apsis-geodesy",
"apsis-frames": "crates/physics/apsis-frames",
"apsis-tle": "crates/orbit/apsis-tle",
"apsis-sgp4": "crates/orbit/apsis-sgp4",
"apsis-kepler": "crates/orbit/apsis-kepler",
"apsis-ephemeris": "crates/orbit/apsis-ephemeris",
"apsis-maneuver": "crates/orbit/apsis-maneuver",
"apsis-orbit": "crates/orbit/apsis-orbit",
}
ITEM = re.compile(
r"^(?P<indent>\s*)pub (?:(?P<qualifier>const|unsafe) )?"
r"(?P<kind>fn|struct|enum|trait|const|type|union) "
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
)
DOC = re.compile(r"^\s*/// ?(.*)$")
MODULE_DOC = re.compile(r"^\s*//! ?(.*)$")
def module_doc(lines):
"""Return the first paragraph of the leading //! block."""
paragraph = []
for line in lines:
match = MODULE_DOC.match(line)
if match:
paragraph.append(match.group(1))
elif paragraph:
break
elif line.strip() and not line.startswith("//!"):
break
text = " ".join(part.strip() for part in paragraph if part.strip())
text = re.sub(r"\s+", " ", text).strip()
# Drop a trailing colon that introduces a list, which reads poorly when
# the paragraph is shown on its own.
return text.rstrip(":")
def doc_comment(buffer):
"""Return the doc comment at the end of `buffer` as one string."""
collected = []
for line in reversed(buffer):
match = DOC.match(line)
if match:
collected.append(match.group(1))
continue
stripped = line.strip()
if stripped.startswith("#[") or stripped.startswith("#!["):
continue
break
collected.reverse()
return "\n".join(collected).strip()
def is_test_attribute(line):
"""Return true for the attribute that introduces the test module."""
return line.strip() in ("#[cfg(test)]", "#[cfg(test)] #[cfg(test)]")
def collect_file(path):
"""Return the module documentation and public items of one source file."""
with open(path, "r", encoding="utf-8") as handle:
lines = handle.read().split("\n")
items = []
buffer = []
index = 0
# Depth of the test module: zero means not inside one.
test_depth = 0
brace_depth = 0
while index < len(lines):
line = lines[index]
if test_depth:
brace_depth += line.count("{") - line.count("}")
if brace_depth <= 0:
test_depth = 0
brace_depth = 0
index += 1
continue
if is_test_attribute(line) and index + 1 < len(lines) and "mod tests" in lines[index + 1]:
test_depth = 1
brace_depth = 0
index += 2
continue
match = ITEM.match(line)
if match:
doc = doc_comment(buffer)
signature_lines = [line.strip()]
# A declaration continues until its parentheses balance and the
# line ends with `{`, `;`, or a where clause that is closed.
depth = line.count("(") - line.count(")")
cursor = index
while not signature_lines[-1].rstrip().endswith(("{", ";")) or depth > 0:
cursor += 1
if cursor >= len(lines):
break
continuation = lines[cursor].strip()
signature_lines.append(continuation)
depth += continuation.count("(") - continuation.count(")")
signature = " ".join(signature_lines)
signature = re.sub(r"\s+", " ", signature).strip()
kind = match.group("kind")
if kind == "union":
kind = "struct"
items.append(
{
"kind": kind,
"name": match.group("name"),
"signature": signature,
"doc": doc,
"associated": bool(match.group("indent")),
}
)
buffer = []
index = cursor + 1
continue
buffer.append(line)
if len(buffer) > 200:
buffer = buffer[-200:]
index += 1
return module_doc(lines), items
def collect_crate(workspace, name, directory):
"""Return the catalog entry of one crate."""
root = os.path.join(workspace, directory, "src")
summary = ""
items = []
lib = os.path.join(root, "lib.rs")
if os.path.exists(lib):
summary, _ = collect_file(lib)
for current, _, files in os.walk(root):
for file_name in sorted(files):
if not file_name.endswith(".rs"):
continue
path = os.path.join(current, file_name)
_, file_items = collect_file(path)
items.extend(file_items)
# The facade crate re-exports items that are documented on the crates
# below it, so a name that occurs more than once keeps its first
# occurrence, which is the definition.
seen = set()
unique = []
for item in items:
key = (item["kind"], item["name"], item["signature"])
if key in seen:
continue
seen.add(key)
unique.append(item)
return {"name": name, "layer": None, "summary": summary, "items": unique}
def main():
here = os.path.dirname(os.path.abspath(__file__))
web_dir = os.path.dirname(here)
workspace = os.environ.get(
"APSIS_RS_WORKSPACE",
os.path.normpath(os.path.join(web_dir, "..", "apsis-rs")),
)
if not os.path.isdir(workspace):
sys.stderr.write("apsis-rs workspace not found at %s\n" % workspace)
return 1
crates = []
sources = []
for layer, names in LAYERS:
for name in names:
directory = CRATE_DIRS[name]
entry = collect_crate(workspace, name, directory)
entry["layer"] = layer
crates.append(entry)
sources.append(os.path.join(directory, "src"))
document = {
"generated_from": sources,
"crates": crates,
}
output = os.path.join(web_dir, "content", "api.json")
os.makedirs(os.path.dirname(output), exist_ok=True)
with open(output, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=1, sort_keys=False)
handle.write("\n")
total = sum(len(crate["items"]) for crate in crates)
print("wrote %s: %d crates, %d items" % (output, len(crates), total))
return 0
if __name__ == "__main__":
sys.exit(main())