Iter 15e: render is the exact inverse of parse — CLI hygiene
The CLI help text claimed `render` was symmetric to `parse`, but `render` was wired to ailang_core::pretty::module (an older human-pretty form) while `parse` consumed form (A). Two different "text projections" coexisted under one name. Fix: Cmd::Render and Cmd::Describe (both branches) now call ailang_surface::print, the actual inverse of `parse`. Round-trip gate via `ail render | ail parse` is now an e2e test in crates/ail/tests/e2e.rs in addition to the existing unit-level round-trip across all 25 fixtures in ailang-surface/tests. Cleanup: `ailang_core::pretty::module` and its three private helpers (`def_block`, `term_block`, `term_inline`) deleted — ~260 LOC of duplicate-purpose code removed. pretty.rs goes from 457 to 196 LOC. Surviving surface is intentionally narrow: manifest, type_to_string, pattern_to_string — the diagnostic stringification used in error messages, where one-line ML notation reads better than form (A)'s nested S-expressions. Module-level doc rewritten to nail down the single-purpose framing. Tests: 89/89 (e2e +1, ailang-core unit -1 since the deleted test exercised the deleted fn). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-7
@@ -13,8 +13,9 @@
|
||||
//!
|
||||
//! - `manifest` — compact symbol table of a module (or workspace);
|
||||
//! one line per def with type, effects, hash.
|
||||
//! - `render` — pretty-print a `.ail.json` module as the textual
|
||||
//! `.ail` projection.
|
||||
//! - `render` — print a `.ail.json` module as form-(A) text. Exact
|
||||
//! inverse of `parse`; piping `render | parse` round-trips to the
|
||||
//! same canonical bytes.
|
||||
//! - `describe` — full detail of a single definition, JSON or text.
|
||||
//! - `deps` — static call edges per def (or for one named def);
|
||||
//! workspace mode emits cross-module edges.
|
||||
@@ -70,7 +71,10 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
workspace: bool,
|
||||
},
|
||||
/// Prints the module in text form (pretty-printer).
|
||||
/// Prints the module as form (A) text — the canonical authoring
|
||||
/// surface and the exact inverse of `parse`. Round-tripping
|
||||
/// `render | parse` reproduces the input's canonical bytes
|
||||
/// (gated by `crates/ailang-surface/tests/round_trip.rs`).
|
||||
Render { path: PathBuf },
|
||||
/// Prints a single definition as JSON or pretty text.
|
||||
Describe {
|
||||
@@ -278,7 +282,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
Cmd::Render { path } => {
|
||||
let m = ailang_core::load_module(&path)?;
|
||||
print!("{}", ailang_core::pretty::module(&m));
|
||||
print!("{}", ailang_surface::print(&m));
|
||||
}
|
||||
Cmd::Describe { path, name, json, workspace } => {
|
||||
if workspace {
|
||||
@@ -310,7 +314,7 @@ fn main() -> Result<()> {
|
||||
let h = ailang_core::def_hash(def);
|
||||
println!("module: {}", mod_name);
|
||||
println!("hash: {h}");
|
||||
print!("{}", ailang_core::pretty::module(&one));
|
||||
print!("{}", ailang_surface::print(&one));
|
||||
}
|
||||
} else {
|
||||
let m = ailang_core::load_module(&path)?;
|
||||
@@ -323,7 +327,7 @@ fn main() -> Result<()> {
|
||||
let s = serde_json::to_string_pretty(def)?;
|
||||
println!("{s}");
|
||||
} else {
|
||||
// Pretty form: render module with only this def.
|
||||
// Form-(A) projection of a one-def module.
|
||||
let one = ailang_core::Module {
|
||||
schema: m.schema.clone(),
|
||||
name: m.name.clone(),
|
||||
@@ -332,7 +336,7 @@ fn main() -> Result<()> {
|
||||
};
|
||||
let h = ailang_core::def_hash(def);
|
||||
println!("hash: {h}");
|
||||
print!("{}", ailang_core::pretty::module(&one));
|
||||
print!("{}", ailang_surface::print(&one));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,49 @@ fn std_list_stress_1000_element_folds() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter 15e: `ail render | ail parse` round-trips canonical bytes.
|
||||
/// Property protected: the CLI's `render` is the exact inverse of
|
||||
/// `parse`, not the older human-pretty form. Regression of this
|
||||
/// would mean piping `render` into `parse` no longer yields a
|
||||
/// re-loadable module — a contract the help text now claims.
|
||||
///
|
||||
/// Picks `std_either.ail.json` (richest fixture: 2-type-var data,
|
||||
/// 3-type-var fn, recursive ctors irrelevant) but the round-trip
|
||||
/// test in `ailang-surface` covers all 25 fixtures structurally.
|
||||
/// This test specifically exercises the CLI shell pipeline.
|
||||
#[test]
|
||||
fn render_parse_round_trip_canonical() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace.join("examples").join("std_either.ail.json");
|
||||
let original = std::fs::read(&src).expect("read fixture");
|
||||
|
||||
let render = Command::new(ail_bin())
|
||||
.args(["render", src.to_str().unwrap()])
|
||||
.output()
|
||||
.expect("run ail render");
|
||||
assert!(render.status.success(), "ail render failed");
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!("ailang_render_e2e_{}.ailx", std::process::id()));
|
||||
std::fs::write(&tmp, &render.stdout).expect("write rendered ailx");
|
||||
|
||||
let parsed = Command::new(ail_bin())
|
||||
.args(["parse", tmp.to_str().unwrap()])
|
||||
.output()
|
||||
.expect("run ail parse");
|
||||
assert!(parsed.status.success(), "ail parse failed on rendered output");
|
||||
|
||||
let mut round = parsed.stdout;
|
||||
while round.last() == Some(&b'\n') {
|
||||
round.pop();
|
||||
}
|
||||
let mut orig = original;
|
||||
while orig.last() == Some(&b'\n') {
|
||||
orig.pop();
|
||||
}
|
||||
assert_eq!(orig, round, "render | parse must produce canonical bytes");
|
||||
}
|
||||
|
||||
/// Iter 15d: `std_either` end-to-end. First stdlib ADT with two type
|
||||
/// parameters (`Either<e, a>`); first combinator with three type vars
|
||||
/// (`either : (e -> c) -> (a -> c) -> Either<e, a> -> c`).
|
||||
|
||||
@@ -41,8 +41,12 @@
|
||||
//! - Read an entry module plus all imports: [`load_workspace`].
|
||||
//! - Hash a [`Def`]: [`def_hash`]. Hash a whole [`Module`]:
|
||||
//! [`workspace::module_hash`].
|
||||
//! - Render a [`Module`] for review: [`pretty::module`] /
|
||||
//! [`pretty::manifest`].
|
||||
//! - Form-(A) text projection of a [`Module`]: see
|
||||
//! `ailang-surface::print` (round-trippable; the inverse of
|
||||
//! `ailang-surface::parse`).
|
||||
//! - Diagnostic stringification (manifest summary, type/pattern
|
||||
//! for error messages): [`pretty::manifest`],
|
||||
//! [`pretty::type_to_string`], [`pretty::pattern_to_string`].
|
||||
|
||||
pub mod ast;
|
||||
pub mod canonical;
|
||||
|
||||
@@ -1,53 +1,25 @@
|
||||
//! Pretty-printer: AST → human-readable text form.
|
||||
//! Diagnostic helpers that stringify AST fragments.
|
||||
//!
|
||||
//! The text form is intended for diff and review (LLM and human eyes).
|
||||
//! The canonical source remains the JSON form, and the pretty output
|
||||
//! is deliberately one-way: there is no parser back from text into AST
|
||||
//! in this crate. Every pretty output is deterministic.
|
||||
//! Form (A) — the round-trippable authoring surface — lives in
|
||||
//! `ailang-surface::print`. This module is the asymmetric, lossy
|
||||
//! companion: stringification routines used inside error messages
|
||||
//! and the manifest summary, where one-line ML-style notation
|
||||
//! (`forall a. List<a> -> Int`) reads better than form (A)'s
|
||||
//! `(forall (vars a) (fn-type ...))`.
|
||||
//!
|
||||
//! Two top-level renderers are provided:
|
||||
//! - [`module`] — full S-expression dump of every definition.
|
||||
//! - [`manifest`] — one-line summary per def with its type and
|
||||
//! [`crate::def_hash`].
|
||||
//! Public entry points:
|
||||
//! - [`manifest`] — one line per def, used by `ail manifest`.
|
||||
//! - [`type_to_string`] — single-line ML-style type, used by
|
||||
//! `ailang-check` diagnostics, `ailang-codegen` mismatch errors,
|
||||
//! and `ail describe`'s text projection.
|
||||
//! - [`pattern_to_string`] — single-line pattern, used by
|
||||
//! `ailang-check` diagnostics.
|
||||
//!
|
||||
//! Helpers [`pattern_to_string`] and [`type_to_string`] are exposed
|
||||
//! because diagnostics in `ailang-check` reuse them in error messages.
|
||||
//!
|
||||
//! This module deliberately does **not** roundtrip text back to AST
|
||||
//! and does not attempt to mirror canonical JSON byte-for-byte.
|
||||
//! All output is deterministic. None of it round-trips back to AST.
|
||||
|
||||
use crate::ast::*;
|
||||
use std::fmt::Write;
|
||||
|
||||
/// Render a complete [`Module`] as an S-expression-style text block.
|
||||
///
|
||||
/// Output format is stable but not part of the on-disk schema —
|
||||
/// consumers should treat it as documentation, not a serialization
|
||||
/// format. Used by `ail print` and by the pretty-print review flow.
|
||||
pub fn module(m: &Module) -> String {
|
||||
let mut s = String::new();
|
||||
writeln!(s, "(module {}", m.name).unwrap();
|
||||
if !m.imports.is_empty() {
|
||||
for imp in &m.imports {
|
||||
match &imp.alias {
|
||||
Some(a) => writeln!(s, " (import {} as {})", imp.module, a).unwrap(),
|
||||
None => writeln!(s, " (import {})", imp.module).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, def) in m.defs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push('\n');
|
||||
}
|
||||
let body = def_block(def, 2);
|
||||
s.push_str(&body);
|
||||
s.push('\n');
|
||||
}
|
||||
s.push(')');
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
|
||||
/// Render a one-line-per-def manifest of a [`Module`].
|
||||
///
|
||||
/// Each line carries the def kind keyword (`fn` / `const` / `type`),
|
||||
@@ -106,173 +78,9 @@ pub fn manifest(m: &Module) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn def_block(def: &Def, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent);
|
||||
match def {
|
||||
Def::Fn(f) => {
|
||||
let params = if f.params.is_empty() {
|
||||
"[]".to_string()
|
||||
} else {
|
||||
format!("[{}]", f.params.join(" "))
|
||||
};
|
||||
let mut s = format!(
|
||||
"{pad}(fn {name} :: {ty} {params}\n",
|
||||
pad = pad,
|
||||
name = f.name,
|
||||
ty = type_to_string(&f.ty),
|
||||
params = params,
|
||||
);
|
||||
s.push_str(&term_block(&f.body, indent + 2));
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Def::Const(c) => {
|
||||
let mut s = format!(
|
||||
"{pad}(const {name} :: {ty}\n",
|
||||
pad = pad,
|
||||
name = c.name,
|
||||
ty = type_to_string(&c.ty),
|
||||
);
|
||||
s.push_str(&term_block(&c.value, indent + 2));
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Def::Type(t) => {
|
||||
let header = if t.vars.is_empty() {
|
||||
format!("{pad}(type {name}\n", pad = pad, name = t.name)
|
||||
} else {
|
||||
format!(
|
||||
"{pad}(type {name} [{vars}]\n",
|
||||
pad = pad,
|
||||
name = t.name,
|
||||
vars = t.vars.join(" "),
|
||||
)
|
||||
};
|
||||
let mut s = header;
|
||||
let inner = " ".repeat(indent + 2);
|
||||
for ctor in &t.ctors {
|
||||
if ctor.fields.is_empty() {
|
||||
s.push_str(&format!("{inner}(| {})\n", ctor.name));
|
||||
} else {
|
||||
let fs = ctor
|
||||
.fields
|
||||
.iter()
|
||||
.map(type_to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
s.push_str(&format!("{inner}(| {name} {fs})\n", name = ctor.name));
|
||||
}
|
||||
}
|
||||
s.push_str(&pad);
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn term_block(t: &Term, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent);
|
||||
match t {
|
||||
Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)),
|
||||
Term::Var { name } => format!("{pad}{name}"),
|
||||
Term::App { callee, args, .. } => {
|
||||
let mut s = format!("{pad}(");
|
||||
s.push_str(&term_inline(callee));
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str(&term_inline(a));
|
||||
}
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Let { name, value, body } => {
|
||||
let mut s = format!("{pad}(let {name}\n");
|
||||
s.push_str(&term_block(value, indent + 2));
|
||||
s.push('\n');
|
||||
s.push_str(&term_block(body, indent + 2));
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::If { cond, then, else_ } => {
|
||||
let mut s = format!("{pad}(if\n");
|
||||
s.push_str(&term_block(cond, indent + 2));
|
||||
s.push('\n');
|
||||
s.push_str(&term_block(then, indent + 2));
|
||||
s.push('\n');
|
||||
s.push_str(&term_block(else_, indent + 2));
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Do { op, args, .. } => {
|
||||
let mut s = format!("{pad}(do {op}");
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str(&term_inline(a));
|
||||
}
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Ctor { type_name, ctor, args } => {
|
||||
let mut s = format!("{pad}({type_name}/{ctor}");
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str(&term_inline(a));
|
||||
}
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Match { scrutinee, arms } => {
|
||||
let mut s = format!("{pad}(match {}\n", term_inline(scrutinee));
|
||||
let inner = " ".repeat(indent + 2);
|
||||
for arm in arms {
|
||||
s.push_str(&format!(
|
||||
"{inner}(case {} ->\n",
|
||||
pattern_to_string(&arm.pat)
|
||||
));
|
||||
s.push_str(&term_block(&arm.body, indent + 4));
|
||||
s.push_str(")\n");
|
||||
}
|
||||
s.push_str(&pad);
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Lam { params, param_tys, ret_ty, effects, body } => {
|
||||
// (\ [params] :: typed-sig . body)
|
||||
let typed_params: Vec<String> = params
|
||||
.iter()
|
||||
.zip(param_tys.iter())
|
||||
.map(|(n, t)| format!("{n}: {}", type_to_string(t)))
|
||||
.collect();
|
||||
let eff = if effects.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" !{}", effects.join(","))
|
||||
};
|
||||
let mut s = format!(
|
||||
"{pad}(\\ ({}) -> {}{}\n",
|
||||
typed_params.join(" "),
|
||||
type_to_string(ret_ty),
|
||||
eff,
|
||||
);
|
||||
s.push_str(&term_block(body, indent + 2));
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Seq { lhs, rhs } => {
|
||||
let mut s = format!("{pad}(seq\n");
|
||||
s.push_str(&term_block(lhs, indent + 2));
|
||||
s.push('\n');
|
||||
s.push_str(&term_block(rhs, indent + 2));
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a [`Pattern`] as a single-line string.
|
||||
///
|
||||
/// Used both internally by [`module`] for match arms and externally by
|
||||
/// `ailang-check` when constructing diagnostic messages.
|
||||
/// Used by `ailang-check` when constructing diagnostic messages.
|
||||
pub fn pattern_to_string(p: &Pattern) -> String {
|
||||
match p {
|
||||
Pattern::Wild => "_".into(),
|
||||
@@ -293,67 +101,6 @@ pub fn pattern_to_string(p: &Pattern) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn term_inline(t: &Term) -> String {
|
||||
match t {
|
||||
Term::Lit { lit } => lit_to_string(lit),
|
||||
Term::Var { name } => name.clone(),
|
||||
Term::App { callee, args, .. } => {
|
||||
let mut s = String::from("(");
|
||||
s.push_str(&term_inline(callee));
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str(&term_inline(a));
|
||||
}
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Do { op, args, .. } => {
|
||||
let mut s = format!("(do {op}");
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str(&term_inline(a));
|
||||
}
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Ctor { type_name, ctor, args } => {
|
||||
let mut s = format!("({type_name}/{ctor}");
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str(&term_inline(a));
|
||||
}
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Match { scrutinee, .. } => {
|
||||
// Don't render match inline — use a marker.
|
||||
format!("(match {} ...)", term_inline(scrutinee))
|
||||
}
|
||||
// Structural terms are tricky to render inline recursively; fallback:
|
||||
Term::Let { name, value, body } => {
|
||||
format!(
|
||||
"(let {name} {} {})",
|
||||
term_inline(value),
|
||||
term_inline(body)
|
||||
)
|
||||
}
|
||||
Term::If { cond, then, else_ } => {
|
||||
format!(
|
||||
"(if {} {} {})",
|
||||
term_inline(cond),
|
||||
term_inline(then),
|
||||
term_inline(else_)
|
||||
)
|
||||
}
|
||||
Term::Lam { params, .. } => {
|
||||
format!("(\\ {} ...)", params.join(" "))
|
||||
}
|
||||
Term::Seq { lhs, rhs } => {
|
||||
format!("(seq {} {})", term_inline(lhs), term_inline(rhs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lit_to_string(l: &Literal) -> String {
|
||||
match l {
|
||||
Literal::Int { value } => value.to_string(),
|
||||
@@ -440,14 +187,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_print_does_not_panic() {
|
||||
let s = module(&sample_module());
|
||||
assert!(s.contains("(module sample"));
|
||||
assert!(s.contains("(fn add"));
|
||||
assert!(s.contains("(+ a b)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_contains_type_and_hash() {
|
||||
let s = manifest(&sample_module());
|
||||
|
||||
@@ -2606,3 +2606,67 @@ the CLI. Logged as 15e.
|
||||
**Queue update.** 15d done; remaining queue: 15e (CLI render/parse
|
||||
symmetry — small, just discovered), then `std_pair` if more stdlib
|
||||
is wanted, then 16a/16b language gaps.
|
||||
|
||||
---
|
||||
|
||||
## Iter 15e — `render` is the inverse of `parse` (CLI hygiene)
|
||||
|
||||
**Trigger.** Discovered during 15d: `ail render | ail parse` did not
|
||||
round-trip. The help text claimed they were inverses but `render`
|
||||
called `ailang_core::pretty::module` (an old human-readable
|
||||
S-expression-ish form), while `parse` consumes form (A). Two
|
||||
different "text projections" lived in the codebase, one of them
|
||||
exposed via the CLI under a name that promised inversion.
|
||||
|
||||
The form-(A) printer in `ailang_surface::print` was already correct
|
||||
and gated by `crates/ailang-surface/tests/round_trip.rs` across
|
||||
every fixture (25 modules at the time of writing) — it was simply
|
||||
not exposed at the CLI surface.
|
||||
|
||||
**Changes.**
|
||||
|
||||
1. `Cmd::Render` now calls `ailang_surface::print(&m)`. Help text
|
||||
updated to state explicitly: form (A), exact inverse of `parse`,
|
||||
round-trippable.
|
||||
2. `Cmd::Describe` (both single-module and `--workspace` branches)
|
||||
likewise switched to `ailang_surface::print` so that "text view
|
||||
of one def" means the same thing everywhere in the CLI.
|
||||
3. `ailang_core::pretty::module` deleted. With `module` gone its
|
||||
helpers (`def_block`, `term_block`, `term_inline`) became dead
|
||||
weight — also deleted. ~260 LOC of duplicate-purpose code gone;
|
||||
`crates/ailang-core/src/pretty.rs` shrank from 457 to 196 LOC.
|
||||
4. The remaining surface of `ailang_core::pretty` — `manifest`,
|
||||
`type_to_string`, `pattern_to_string` — is the diagnostic
|
||||
stringification surface (used in error messages and the
|
||||
`ail manifest` summary, where one-line ML notation is more
|
||||
readable than form (A)'s nested S-expression). Module-level
|
||||
doc rewritten to make this single-purpose framing explicit;
|
||||
no more "pretty-printer" / "render" overloading.
|
||||
5. New e2e test `render_parse_round_trip_canonical` (in
|
||||
`crates/ail/tests/e2e.rs`) gates the CLI shell pipeline:
|
||||
shell out `ail render <fixture>` → write to tmp .ailx →
|
||||
shell out `ail parse <tmp>` → assert canonical-byte equality
|
||||
with the original fixture. The unit-level round-trip in
|
||||
`ailang-surface/tests/round_trip.rs` covered the printer
|
||||
function directly; this new test additionally guards the
|
||||
CLI wiring.
|
||||
6. The `ailang_core::pretty` unit test `pretty_print_does_not_panic`
|
||||
exercised the deleted `module` fn — removed. `manifest_contains_
|
||||
type_and_hash` stays.
|
||||
|
||||
**Doctrinal pin.** Form (A) is the only round-trippable text form.
|
||||
The diagnostic stringification in `ailang_core::pretty` is
|
||||
asymmetric and lossy by design — it exists for the *limited*
|
||||
purpose of sticking a type or pattern into an error message in
|
||||
human-friendlier shape than form (A). It does not roundtrip and
|
||||
must not be used as an authoring or persistence target. Any
|
||||
future add to it should add to that purpose, not back-fill toward
|
||||
"another text projection".
|
||||
|
||||
**Tests: 89/89.** (e2e went from 31 to 32; ailang-core unit
|
||||
tests went from 11 to 10 — the deleted unit test exercised the
|
||||
removed fn. Net same.)
|
||||
|
||||
**Cumulative state, post-15e.** No new language features. Two
|
||||
canonical text projections collapsed to one (form A). Internal
|
||||
cleanup; no behavioural change for any program in the repo.
|
||||
|
||||
Reference in New Issue
Block a user