From ff329e5a8dc2ad62f933938fe43a10b430bb5a38 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Fri, 2 Oct 2026 01:40:39 +0800 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 13 +++ Cargo.toml | 23 +++-- crates/apsis-rs/Cargo.toml | 6 ++ scripts/bilingual.py | 16 ++- scripts/check-bilingual.py | 202 ++++++++++++++++++++++++++++++++----- scripts/check-layers.sh | 28 +++++ validation/pixi.toml | 1 + 7 files changed, 257 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c2b240..0651bf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/ \([^)]*\)$//' \ diff --git a/Cargo.toml b/Cargo.toml index 7804892..a66ff92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/crates/apsis-rs/Cargo.toml b/crates/apsis-rs/Cargo.toml index 296b695..6f16304 100644 --- a/crates/apsis-rs/Cargo.toml +++ b/crates/apsis-rs/Cargo.toml @@ -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] diff --git a/scripts/bilingual.py b/scripts/bilingual.py index ec25da5..c5e7513 100644 --- a/scripts/bilingual.py +++ b/scripts/bilingual.py @@ -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 diff --git a/scripts/check-bilingual.py b/scripts/check-bilingual.py index 76bda46..6c72b5e 100755 --- a/scripts/check-bilingual.py +++ b/scripts/check-bilingual.py @@ -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 = "�" +# 替换字符,当字节序列被损坏时出现。 +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: diff --git a/scripts/check-layers.sh b/scripts/check-layers.sh index da75e65..b665ae2 100755 --- a/scripts/check-layers.sh +++ b/scripts/check-layers.sh @@ -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///Cargo.toml`. + # 层目录是路径的第二段,如 `crates///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 diff --git a/validation/pixi.toml b/validation/pixi.toml index e718682..619b835 100644 --- a/validation/pixi.toml +++ b/validation/pixi.toml @@ -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 = "*"