docs: bilingual comments for the CI workflow, manifests and scripts

Extends the checker to the module level `//!` docs, the tests, the
examples, and the non-Rust prose comments in the workflow, the manifests
and the shell and Python scripts.
This commit is contained in:
ApsisRS contributors 2026-10-02 01:40:39 +08:00
parent be28a301af
commit ff329e5a8d
7 changed files with 257 additions and 32 deletions

View File

@ -20,6 +20,8 @@ jobs:
- name: Check
# --all-features matters: the facade crate enables nothing by default,
# so without it almost all of its code, and every example, is skipped.
# `--all-features` 很重要:门面 crate 默认不启用任何特性,没有它
# 几乎其全部代码以及每个示例都会被跳过。
run: cargo check --workspace --all-targets --all-features
- name: Clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
@ -30,6 +32,9 @@ jobs:
# are also meant to be read and run by hand. `cargo test` does not run
# them, so without this step a change that breaks one layer's
# assumptions about another would pass CI silently.
# 跨层检查是示例而非测试,因为它们也意在供人阅读并手动运行。
# `cargo test` 不会运行它们,故没有这一步,破坏某层对另一层假设的
# 改动会无声地通过 CI。
run: |
cargo run -p apsis-rs --features network --example integrate_network
cargo run -p apsis-rs --features ops --example integrate_ops
@ -38,6 +43,8 @@ jobs:
# The benchmarks are not run in CI, because their timings are only
# meaningful on a quiet machine, but they must still compile so that
# they do not rot.
# 基准在 CI 中不运行,因为其计时只在安静的机器上有意义,但它们
# 仍必须能编译,以免腐坏。
run: |
cargo build -p apsis-rs --features full --example bench
cargo build -p apsis-rs --features full --example bench_scale
@ -45,6 +52,7 @@ jobs:
- name: Documentation
# Catches a doc link that no longer resolves, which is how a doc
# comment drifts away from the code it describes.
# 捕获不再解析的文档链接,文档注释正是这样与其所述代码渐行渐远。
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --all-features --no-deps
@ -52,12 +60,17 @@ jobs:
# README.md states that a crate may only depend on layers to its
# left. Nothing else in the build enforces that, so a reverse
# dependency would otherwise pass CI silently.
# README.md 规定一个 crate 只能依赖其左侧的层。构建中没有别的
# 东西强制这一点,故反向依赖否则会无声地通过 CI。
run: bash scripts/check-layers.sh
- name: Verify dependency freedom
run: |
# Every package in the graph must be a workspace crate. `--prefix
# none` prints bare `name version` lines, one per package, which the
# box drawing prefix of the default format would otherwise hide.
# 依赖图中的每个包都必须是工作区 crate。`--prefix none` 打印
# 裸的 `name version` 行,每包一行;否则默认格式的制表前缀会把
# 它隐藏。
external=$(cargo tree --workspace --all-features \
--edges normal,build,dev --prefix none 2>/dev/null \
| sed -E 's/ \(\*\)$//; s/ \([^)]*\)$//' \

View File

@ -3,25 +3,30 @@
# The workspace resolves without any external dependency so that the
# numerical kernels stay self-contained and auditable. Every member crate
# is unpublished.
#
# ApsisRS Rust 工作区根。
#
# 工作区在没有任何外部依赖的情况下解析,使各数值内核保持自包含且可
# 审计。每个成员 crate 都不发布。
[workspace]
resolver = "2"
members = [
# foundation
# foundation / 基础层
"crates/foundation/apsis-core",
"crates/foundation/apsis-units",
"crates/foundation/apsis-math",
"crates/foundation/apsis-macros",
# physics
# physics / 物理层
"crates/physics/apsis-time",
"crates/physics/apsis-leap",
"crates/physics/apsis-coord",
"crates/physics/apsis-geodesy",
"crates/physics/apsis-frames",
# orbit
# orbit / 轨道层
"crates/orbit/apsis-tle",
"crates/orbit/apsis-sgp4",
"crates/orbit/apsis-kepler",
@ -29,7 +34,7 @@ members = [
"crates/orbit/apsis-maneuver",
"crates/orbit/apsis-orbit",
# rf
# rf / 射频层
"crates/rf/apsis-rf",
"crates/rf/apsis-antenna",
"crates/rf/apsis-doppler",
@ -38,7 +43,7 @@ members = [
"crates/rf/apsis-interference",
"crates/rf/apsis-link",
# network
# network / 网络层
"crates/network/apsis-constellation",
"crates/network/apsis-coverage",
"crates/network/apsis-access",
@ -48,21 +53,21 @@ members = [
"crates/network/apsis-handover",
"crates/network/apsis-mesh",
# ops
# ops / 运行层
"crates/ops/apsis-resource",
"crates/ops/apsis-task",
"crates/ops/apsis-scheduler",
"crates/ops/apsis-conflict",
"crates/ops/apsis-schedule",
# sim
# sim / 仿真层
"crates/sim/apsis-sim-engine",
"crates/sim/apsis-scenario",
"crates/sim/apsis-event",
"crates/sim/apsis-output",
"crates/sim/apsis-sim",
# facade
# facade / 门面层
"crates/apsis-rs",
]
@ -81,6 +86,8 @@ missing_docs = "deny"
# Numeric literals here follow the grouping of the quantity they express,
# such as the digits of a reference value, rather than a fixed width. The
# style lint would force a regrouping that obscures the source of a value.
# 此处的数值字面量按其所表达量的分组来分隔,例如某参考值的各位数字,
# 而非固定宽度。风格 lint 会强制重新分组,从而模糊某值的来源。
inconsistent_digit_grouping = "allow"
[workspace.dependencies]

View File

@ -13,6 +13,8 @@ path = "src/lib.rs"
# Examples that produce validation input. Each needs the layers it reads
# from, so it is built only when the matching feature is enabled.
# 产生验证输入的示例。每个都需要它所读取的层,故只在匹配的特性启用时
# 才被构建。
[[example]]
name = "dump_geodesy"
@ -37,6 +39,7 @@ workspace = true
default = []
# Enabling a layer also enables the layers it is allowed to depend on.
# 启用某一层也会启用它所允许依赖的各层。
foundation = [
"dep:apsis-core",
"dep:apsis-units",
@ -97,6 +100,8 @@ ops = [
# The simulation layer aggregates every other layer, so it enables `ops`.
# Enable `ops` instead of `full` when the simulation layer is not needed.
# 仿真层聚合其余每一层,故它启用 `ops`。不需要仿真层时请启用 `ops`
# 而非 `full`。
sim = [
"ops",
"dep:apsis-sim-engine",
@ -108,6 +113,7 @@ sim = [
# Every layer, including `sim`. Enabling `sim` pulls in `ops` and, through
# it, every lower layer.
# 每一层,包括 `sim`。启用 `sim` 会引入 `ops`,并经它引入每一个更低的层。
full = ["sim"]
[dependencies]

View File

@ -12,6 +12,17 @@ skipping.
Usage, from the repository root:
python3 scripts/bilingual.py crates/orbit/apsis-kepler/src/elements.rs
对文件施加一张「英文到双语注释」的替换表。
对仓库的清扫是一长串编辑,每一次都把一段英文注释块改写为英文段后接中文
段。要把每一对写两遍(作为两次独立替换)容易出错,故此辅助工具接收一张
替换对列表,施加后重写文件,并报告有多少未匹配。未匹配的替换几乎总是
英文文本的重新折行,值得一看而非跳过。
用法(在仓库根下):
python3 scripts/bilingual.py crates/orbit/apsis-kepler/src/elements.rs
"""
from __future__ import annotations
@ -21,7 +32,10 @@ from pathlib import Path
def apply(path: Path, pairs: list[tuple[str, str]]) -> tuple[int, list[str]]:
"""Replaces each pair and returns how many applied with the misses."""
"""Replaces each pair and returns how many applied with the misses.
替换每一对,并返回已施加的数量与未匹配者。
"""
text = path.read_text(encoding="utf-8")
missed: list[str] = []
applied = 0

View File

@ -6,56 +6,117 @@ English paragraph first and the Chinese one after a blank doc line. This
script reports the blocks that have no Chinese yet, so the sweep can be
driven and verified without reading every file.
It covers the Rust sources, including the module level `//!` docs, the
integration tests, and the examples, and also the non-Rust files that carry
prose comments: the CI workflow, the manifests, the shell and Python
scripts, and the Python docstrings.
It also reports the two mistakes that are easy to make while adding a
language: a code string that was translated by accident, and a comment that
mentions a term the glossary keeps in English where a Chinese name exists.
检查每一处源码注释是否有中文对应。
仓库正在被改为双语:每个注释块先写英文段,再在一条空白文档行之后写中文
段。本脚本报告尚无中文的块,使这次清扫无需逐文件阅读即可被驱动与核验。
它覆盖 Rust 源码,包括模块级 `//!` 文档、集成测试与示例,也覆盖携带散文
注释的非 Rust 文件:CI 工作流、各清单、shell 与 Python 脚本,以及 Python
文档字符串。
它还会报告添加一种语言时容易犯的两种错误:被误译的代码字符串,以及提到
了词汇表在中文名存在处仍保留英文术语的注释。
Usage:
python3 scripts/check-bilingual.py # every crate
python3 scripts/check-bilingual.py # the whole repository
python3 scripts/check-bilingual.py crates/orbit
python3 scripts/check-bilingual.py --quiet # only the summary line
用法:
python3 scripts/check-bilingual.py # 整个仓库
python3 scripts/check-bilingual.py crates/orbit
python3 scripts/check-bilingual.py --quiet # 只打印汇总行
"""
from __future__ import annotations
import argparse
import ast
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# A comment line: a doc line, or a plain comment that is not a doc marker.
# A Rust comment line: a doc line, a module doc line, or a plain comment.
# Rust 注释行:文档行、模块文档行,或普通注释。
DOC = re.compile(r"^\s*///")
MOD = re.compile(r"^\s*//!")
LINE = re.compile(r"^\s*//[^/!]")
# A hash comment line, used by the non-Rust files. The shebang is excluded
# because it is an interpreter directive rather than prose.
# 非 Rust 文件所用的井号注释行。shebang 被排除,因为它是解释器指令而非散文。
HASH = re.compile(r"^\s*#")
SHEBANG = re.compile(r"^#!")
# CJK, used to decide whether a block already carries the Chinese text.
# CJK,用于判断某个块是否已携带中文。
CJK = re.compile(r"[\u4e00-\u9fff]")
# The replacement character, which shows up when a byte sequence is mangled.
MOJIBAKE = "<EFBFBD>"
# 替换字符,当字节序列被损坏时出现。
MOJIBAKE = "\ufffd"
# An English function word sitting between Chinese characters, which means a
# half translated sentence. The glossary keeps words like `Walker` and
# `SGP4` in English, so a lowercase word between CJK is a mistake.
# 夹在汉字之间的英文虚词,意味着一个半翻译的句子。词汇表把 `Walker` 与
# `SGP4` 之类的词保留为英文,故 CJK 之间的小写单词是错误。
MIXED = re.compile(r"[\u4e00-\u9fff][a-z]{3,}[\u4e00-\u9fff]")
# A comment that is only a formula or a bare symbol carries no prose to
# translate, so demanding a second language on it would be noise. Such a
# comment is left as it stands, which is the whole of it.
FORMULA = re.compile("^`?[A-Za-z0-9_^*+\\-/() =,;.\u3002\uff0c]*`?[\.\u3002]?$")
# 只含公式或纯符号的注释没有可翻译的散文,故要求它带第二语言只会是噪音。
# 这样的注释保持原样,而它就是其全部。
FORMULA = re.compile(
r"^`?[A-Za-z0-9_^*+\-/() =,;.\u3002\uff0c]*`?[.\u3002]?$"
)
# Three letters in a row is a word, not a symbol, so a line carrying one is
# prose and needs translating even if it is mostly numbers.
# 连续三个字母便是单词而非符号,故带它的行是散文,即便大部分是数字也需翻译。
PROSE = re.compile(r"[A-Za-z]{3}")
# Directories that hold generated or third party files, which carry no
# comment that this repository owns.
# 存放生成文件或第三方文件的目录,其中没有本仓库拥有的注释。
SKIP = ("target", ".pixi", "node_modules", ".git")
# File suffixes that carry hash comments rather than the Rust slashes.
# 携带井号注释而非 Rust 斜杠注释的文件后缀。
HASH_SUFFIXES = (".yml", ".yaml", ".toml", ".sh", ".py")
def strip_marker(line: str) -> str:
"""Returns the body of a comment line with its marker removed.
返回去除注释标记后的注释行正文。
"""
return re.sub(r"^\s*(///|//!|//|#)\s?", "", line).strip()
def is_formula_only(block: list[str]) -> bool:
"""True when every line of the block is code, a formula, or blank."""
"""True when every line of the block is code, a formula, or blank.
当块中每一行都是代码、公式或空白时返回真。
"""
saw_content = False
for line in block:
body = re.sub(r"^\s*(///|//[^/!])\s?", "", line).strip()
body = strip_marker(line)
if not body:
continue
saw_content = True
@ -63,21 +124,58 @@ def is_formula_only(block: list[str]) -> bool:
return False
# A single backticked token is a symbol, whatever it spells. Prose
# needs a verb, and `cos` or `eta` has none.
# 单个反引号包裹的记号是符号,不论它拼出什么。散文需要动词,而
# `cos` 或 `eta` 没有。
if len(re.findall(r"[A-Za-z]{2,}", body)) > 1:
return False
return saw_content
def blocks(path: Path) -> list[tuple[int, list[str]]]:
"""Returns the comment blocks of a file with the line each starts on."""
def rust_blocks(path: Path) -> list[tuple[int, list[str]]]:
"""Returns the comment blocks of a Rust file with the line each starts
on. Each of the three markers runs within its own block, so a `//!`
module header is not merged into an adjacent `///` declaration.
返回 Rust 文件的注释块及其各自的起始行。三种标记各自在自己的块内
连续,故 `//!` 模块头部不会与相邻的 `///` 声明合并。
"""
lines = path.read_text(encoding="utf-8").splitlines()
found: list[tuple[int, list[str]]] = []
index = 0
while index < len(lines):
if DOC.match(lines[index]) or LINE.match(lines[index]):
for marker in (DOC, MOD, LINE):
if marker.match(lines[index]):
start = index
block: list[str] = []
while index < len(lines) and marker.match(lines[index]):
block.append(lines[index])
index += 1
found.append((start + 1, block))
break
else:
index += 1
return found
def hash_blocks(path: Path) -> list[tuple[int, list[str]]]:
"""Returns the hash comment blocks of a non-Rust file with the line each
starts on. A shebang is skipped because it opens the file.
返回非 Rust 文件的井号注释块及其各自的起始行。shebang 被跳过,
因为它位于文件开头。
"""
lines = path.read_text(encoding="utf-8").splitlines()
found: list[tuple[int, list[str]]] = []
index = 0
while index < len(lines):
if HASH.match(lines[index]) and not SHEBANG.match(lines[index]):
start = index
block: list[str] = []
while index < len(lines) and (DOC.match(lines[index]) or LINE.match(lines[index])):
while (
index < len(lines)
and HASH.match(lines[index])
and not SHEBANG.match(lines[index])
):
block.append(lines[index])
index += 1
found.append((start + 1, block))
@ -86,19 +184,80 @@ def blocks(path: Path) -> list[tuple[int, list[str]]]:
return found
def docstring_blocks(path: Path) -> list[tuple[int, list[str]]]:
"""Returns the docstrings of a Python file with the line each starts on.
A docstring is the Python form of a doc comment, so it is swept too.
返回 Python 文件的文档字符串及其各自的起始行。文档字符串是 Python
形式的文档注释,故也一并清扫。
"""
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, ValueError):
return []
found: list[tuple[int, list[str]]] = []
holders = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
for node in ast.walk(tree):
if not isinstance(node, holders) or not node.body:
continue
first = node.body[0]
if not isinstance(first, ast.Expr):
continue
value = first.value
if isinstance(value, ast.Constant) and isinstance(value.value, str):
found.append((first.lineno, value.value.splitlines()))
return found
def collect(path: Path) -> list[tuple[int, list[str]]]:
"""Returns every comment or docstring block of one file.
返回单个文件的每一个注释或文档字符串块。
"""
if path.suffix == ".rs":
return rust_blocks(path)
if path.suffix in HASH_SUFFIXES:
found = hash_blocks(path)
if path.suffix == ".py":
found.extend(docstring_blocks(path))
return found
return []
def candidates(base: Path) -> list[Path]:
"""Returns the files under a root that carry comments this repository
owns. A file passed directly is always included.
返回某个根目录下携带本仓库拥有注释的文件。直接传入的文件总是包含
在内。
"""
if base.is_file():
return [base]
files: list[Path] = []
for path in sorted(base.rglob("*")):
if not path.is_file():
continue
if any(part in SKIP for part in path.parts):
continue
if path.suffix == ".rs" or path.suffix in HASH_SUFFIXES:
files.append(path)
return files
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="*", default=[])
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args(argv)
roots = [Path(p).resolve() for p in args.paths] or [ROOT / "crates"]
roots = [Path(p).resolve() for p in args.paths] or [ROOT]
files: list[Path] = []
seen: set[Path] = set()
for base in roots:
if base.is_file():
files.append(base)
else:
files.extend(sorted(base.rglob("src/**/*.rs")))
for path in candidates(base):
if path not in seen:
seen.add(path)
files.append(path)
total = 0
translated = 0
@ -107,26 +266,23 @@ def main(argv: list[str]) -> int:
mixed: list[str] = []
for path in files:
if "examples" in path.parts:
continue
for line, block in blocks(path):
for line, block in collect(path):
text = "\n".join(block)
if MOJIBAKE in text:
mojibake.append(f"{path.relative_to(ROOT)}:{line}")
found = MIXED.search(text)
if found:
mixed.append(
f"{path.relative_to(ROOT)}:{line} {found.group(0)!r}"
)
mixed.append(f"{path.relative_to(ROOT)}:{line} {found.group(0)!r}")
if is_formula_only(block):
# Nothing to translate; the symbol is the whole comment.
# 无可翻译;该符号就是整条注释。
continue
total += 1
if CJK.search(text):
translated += 1
else:
first = block[0].strip().lstrip("/!").strip()
gaps.append(f"{path.relative_to(ROOT)}:{line} {first[:64]}")
first = strip_marker(block[0])[:64]
gaps.append(f"{path.relative_to(ROOT)}:{line} {first}")
if not args.quiet:
for gap in gaps:

View File

@ -20,21 +20,46 @@
# scripts/check-layers.sh
#
# Exit status is zero when the rule holds and one otherwise.
#
# 检查 README.md 所述的分层依赖规则。
#
# 工作区组织为按依赖排序的各层:
#
# foundation -> physics -> orbit -> rf -> network -> ops -> sim
#
# 一个 crate 可以依赖其左侧层中的任何 crate,但绝不依赖其右侧的。本脚本
# 读取每个工作区清单,判定每个依赖属于哪一层,并在任何依赖指向右侧时
# 失败。
#
# `apsis-core` 与 `apsis-math` 作为全局前提被豁免:它们位于
# `foundation`,每一层都可使用它们,而仅向左的规则本已允许,故它们无需
# 特殊处理。
#
# 用法:
#
# scripts/check-layers.sh
#
# 规则成立时退出状态为零,否则为一。
set -euo pipefail
# The layer order, left to right. A dependency to a layer with a lower index
# is allowed; to a higher index is a reverse dependency and fails.
# 层的次序,从左到右。依赖到下标更低的层是允许的;到更高的下标即反向
# 依赖,会失败。
LAYERS=(foundation physics orbit rf network ops sim)
# The root of the workspace, taken from this script's location so that the
# check works from any working directory.
# 工作区的根,取自本脚本的位置,使检查可在任何工作目录下工作。
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
# Returns the index of a layer name, or an empty string when the name is not
# a layer. A crate outside these directories is not part of the layer scheme
# and is skipped rather than reported.
# 返回某层名的下标;该名不是层时返回空串。位于这些目录之外的 crate 不属于
# 分层方案,会被跳过而非报告。
layer_index() {
local name="$1"
local index=0
@ -55,6 +80,7 @@ for manifest in $(find crates -name Cargo.toml | sort); do
directory="$(dirname "$manifest")"
# The layer directory is the second path component, as in
# `crates/<layer>/<crate>/Cargo.toml`.
# 层目录是路径的第二段,如 `crates/<layer>/<crate>/Cargo.toml`。
layer="$(echo "$directory" | cut -d/ -f2)"
source_index="$(layer_index "$layer")"
[ -z "$source_index" ] && continue
@ -62,6 +88,8 @@ for manifest in $(find crates -name Cargo.toml | sort); do
# Every `apsis-...` name in the manifest is a dependency or a package
# name. The package name is the crate itself, which resolves to the same
# layer and is skipped by the index comparison below.
# 清单中每个 `apsis-...` 名称要么是依赖,要么是包名。包名即该 crate
# 本身,它解析到同一层,会被下面的下标比较跳过。
for dependency in $(grep -oE 'apsis-[a-z-]+' "$manifest" | sort -u); do
dependency_directory="$(find crates -maxdepth 2 -type d -name "$dependency" 2>/dev/null | head -1)"
[ -z "$dependency_directory" ] && continue

View File

@ -10,6 +10,7 @@ python = "3.12.*"
# Reference implementations used only for cross-checking Rust output.
# These packages are not dependencies of any Rust crate.
# 仅用于交叉核对 Rust 输出的参考实现。这些包不是任何 Rust crate 的依赖。
[pypi-dependencies]
numpy = "*"
sgp4 = "*"