prose: human-readable projection renderer (iter 20a)
New crate ailang-prose with one public fn module_to_prose(&Module) -> String. Rust-flavour with braces, =>-match-arms, mode keywords (own/borrow), effects as trailing 'with IO', Cons(1, Nil) ctor form, /// doc strings. Lossy projection where the LLM can re-derive: (con T) wrap, (fn-type ...) wrap, (term-ctor ...) wrap. Load-bearing semantic detail stays visible (modes, effects, clone, reuse-as, doc strings, type annotations, tail flag). CLI: 'ail prose <file.ail.json>' prints the projection. 3 snapshot fixtures + 28 unit tests. 215-line .ail.json reduces to ~18 lines of legible source. 20b queued: infix arithmetic, paren elision, let-inlining, do prettify.
This commit is contained in:
Generated
+8
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"ailang-check",
|
||||
"ailang-codegen",
|
||||
"ailang-core",
|
||||
"ailang-prose",
|
||||
"ailang-surface",
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -48,6 +49,13 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ailang-prose"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"ailang-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ailang-surface"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/ailang-check",
|
||||
"crates/ailang-codegen",
|
||||
"crates/ailang-surface",
|
||||
"crates/ailang-prose",
|
||||
"crates/ail",
|
||||
]
|
||||
|
||||
@@ -28,6 +29,7 @@ ailang-core = { path = "crates/ailang-core" }
|
||||
ailang-check = { path = "crates/ailang-check" }
|
||||
ailang-codegen = { path = "crates/ailang-codegen" }
|
||||
ailang-surface = { path = "crates/ailang-surface" }
|
||||
ailang-prose = { path = "crates/ailang-prose" }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
||||
@@ -13,6 +13,7 @@ ailang-core.workspace = true
|
||||
ailang-check.workspace = true
|
||||
ailang-codegen.workspace = true
|
||||
ailang-surface.workspace = true
|
||||
ailang-prose.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -200,6 +200,13 @@ enum Cmd {
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Iter 20a: prints the module as human-readable prose (form B).
|
||||
///
|
||||
/// One-way projection: the renderer is deterministic, but no
|
||||
/// parser exists for the prose surface. The canonical authoring
|
||||
/// surface remains form (A) (`render` / `parse`) and the canonical
|
||||
/// hashable artefact remains the JSON-AST.
|
||||
Prose { path: PathBuf },
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -298,6 +305,13 @@ fn main() -> Result<()> {
|
||||
let m = ailang_core::load_module(&path)?;
|
||||
print!("{}", ailang_surface::print(&m));
|
||||
}
|
||||
Cmd::Prose { path } => {
|
||||
// Iter 20a: load via `load_module` (single-module mode,
|
||||
// matching how `render` / `parse` work). Workspace-wide
|
||||
// prose is not in scope for 20a.
|
||||
let m = ailang_core::load_module(&path)?;
|
||||
print!("{}", ailang_prose::module_to_prose(&m));
|
||||
}
|
||||
Cmd::Describe { path, name, json, workspace } => {
|
||||
if workspace {
|
||||
let ws = ailang_core::load_workspace(&path)?;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ailang-prose"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ailang-core.workspace = true
|
||||
@@ -0,0 +1,924 @@
|
||||
//! AILang prose projection — human-readable presentation layer.
|
||||
//!
|
||||
//! This crate is the form-(B) projection of [`ailang_core::ast::Module`]:
|
||||
//! a Rust-flavoured, brace-and-comma text rendering optimised for human
|
||||
//! reading. It is a *one-way* projection — there is no parser. Editing
|
||||
//! the prose and re-integrating the changes is the round-trip-mediator's
|
||||
//! job (Iter 20d), not this crate's.
|
||||
//!
|
||||
//! The canonical authoring surface remains form (A) (`ailang-surface`)
|
||||
//! and the canonical hashable artefact remains the JSON-AST
|
||||
//! (`ailang-core`). Prose is deliberately lossy where the LLM can
|
||||
//! re-derive the dropped machinery from typecheck context (the `(con T)`
|
||||
//! wrap, the `(fn-type ...)` wrap, the `(term-ctor T C ...)` collapsing
|
||||
//! to `C(...)`); every load-bearing semantic detail (mode annotations,
|
||||
//! effects, `clone`, `reuse-as`, doc strings, type annotations on
|
||||
//! signatures and lambdas, the `tail` flag) stays visible.
|
||||
//!
|
||||
//! See `docs/JOURNAL.md` "Pinned: human-readable prose surface
|
||||
//! (Family 20 candidate)" for the design pinning, and "Iter 20a" for
|
||||
//! this iter's spec.
|
||||
//!
|
||||
//! # Public API
|
||||
//!
|
||||
//! [`module_to_prose`] is the only entry point. It is deterministic and
|
||||
//! never fails on a well-formed AST.
|
||||
|
||||
use ailang_core::ast::{
|
||||
ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type, TypeDef,
|
||||
};
|
||||
|
||||
/// Render a [`Module`] as human-readable prose.
|
||||
///
|
||||
/// The output is deterministic; identical input AST always yields
|
||||
/// identical bytes. The renderer never fails on a well-formed AST.
|
||||
pub fn module_to_prose(m: &Module) -> String {
|
||||
let mut out = String::new();
|
||||
write_module(&mut out, m);
|
||||
out
|
||||
}
|
||||
|
||||
// ---- module ---------------------------------------------------------------
|
||||
|
||||
fn write_module(out: &mut String, m: &Module) {
|
||||
out.push_str("// module ");
|
||||
out.push_str(&m.name);
|
||||
out.push('\n');
|
||||
|
||||
if !m.imports.is_empty() {
|
||||
out.push('\n');
|
||||
for imp in &m.imports {
|
||||
write_import(out, imp);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
for def in &m.defs {
|
||||
out.push('\n');
|
||||
write_def(out, def, 0);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn write_import(out: &mut String, imp: &Import) {
|
||||
out.push_str("import ");
|
||||
out.push_str(&imp.module);
|
||||
if let Some(alias) = &imp.alias {
|
||||
out.push_str(" as ");
|
||||
out.push_str(alias);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- defs -----------------------------------------------------------------
|
||||
|
||||
fn write_def(out: &mut String, def: &Def, level: usize) {
|
||||
match def {
|
||||
Def::Type(td) => write_type_def(out, td, level),
|
||||
Def::Fn(fd) => write_fn_def(out, fd, level),
|
||||
Def::Const(cd) => write_const_def(out, cd, level),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_doc(out: &mut String, doc: &Option<String>, level: usize) {
|
||||
if let Some(d) = doc {
|
||||
for line in d.split('\n') {
|
||||
indent(out, level);
|
||||
out.push_str("/// ");
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_type_def(out: &mut String, td: &TypeDef, level: usize) {
|
||||
write_doc(out, &td.doc, level);
|
||||
indent(out, level);
|
||||
out.push_str("data ");
|
||||
out.push_str(&td.name);
|
||||
if !td.vars.is_empty() {
|
||||
out.push('<');
|
||||
for (i, v) in td.vars.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push('>');
|
||||
}
|
||||
out.push_str(" = ");
|
||||
for (i, c) in td.ctors.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(" | ");
|
||||
}
|
||||
write_ctor(out, c);
|
||||
}
|
||||
if td.drop_iterative {
|
||||
out.push_str(" with drop-iterative");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ctor(out: &mut String, c: &Ctor) {
|
||||
out.push_str(&c.name);
|
||||
if !c.fields.is_empty() {
|
||||
out.push('(');
|
||||
for (i, f) in c.fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_type(out, f);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
|
||||
write_doc(out, &fd.doc, level);
|
||||
// The FnDef carries `params` (names) plus the type. The signature
|
||||
// surface combines them slot-for-slot. `fd.ty` is either a
|
||||
// `Type::Fn` or a `Type::Forall` wrapping one — in the latter case
|
||||
// we render the forall on the line above and then the inner fn
|
||||
// signature.
|
||||
let (forall_vars, fn_ty) = match &fd.ty {
|
||||
Type::Forall { vars, body } => (Some(vars.clone()), body.as_ref()),
|
||||
other => (None, other),
|
||||
};
|
||||
|
||||
if let Some(vars) = &forall_vars {
|
||||
indent(out, level);
|
||||
out.push_str("forall<");
|
||||
for (i, v) in vars.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push_str(">\n");
|
||||
}
|
||||
|
||||
indent(out, level);
|
||||
out.push_str("fn ");
|
||||
out.push_str(&fd.name);
|
||||
out.push('(');
|
||||
if let Type::Fn {
|
||||
params,
|
||||
param_modes,
|
||||
ret,
|
||||
ret_mode,
|
||||
effects,
|
||||
} = fn_ty
|
||||
{
|
||||
for (i, ty) in params.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
// Param name from FnDef.params, type+mode from Type::Fn.
|
||||
let pname = fd.params.get(i).map(String::as_str).unwrap_or("_");
|
||||
out.push_str(pname);
|
||||
out.push_str(": ");
|
||||
let mode = param_modes
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(ParamMode::Implicit);
|
||||
write_mode_type(out, ty, mode);
|
||||
}
|
||||
out.push_str(") -> ");
|
||||
write_mode_type(out, ret, *ret_mode);
|
||||
if !effects.is_empty() {
|
||||
out.push_str(" with ");
|
||||
// Stable order: as written in the AST. Effects are
|
||||
// semantically a set, but re-sorting would erase
|
||||
// author-asserted ordering; preserve the AST order.
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Defensive fallback: a non-Fn fn-type. Render the bare type so
|
||||
// nothing crashes; this path is unreachable for typechecked
|
||||
// input.
|
||||
out.push_str(") -> ");
|
||||
write_type(out, fn_ty);
|
||||
}
|
||||
out.push_str(" {\n");
|
||||
indent(out, level + 1);
|
||||
write_term(out, &fd.body, level + 1);
|
||||
out.push('\n');
|
||||
indent(out, level);
|
||||
out.push('}');
|
||||
}
|
||||
|
||||
fn write_const_def(out: &mut String, cd: &ConstDef, level: usize) {
|
||||
write_doc(out, &cd.doc, level);
|
||||
indent(out, level);
|
||||
out.push_str("const ");
|
||||
out.push_str(&cd.name);
|
||||
out.push_str(": ");
|
||||
write_type(out, &cd.ty);
|
||||
out.push_str(" = ");
|
||||
write_term(out, &cd.value, level);
|
||||
}
|
||||
|
||||
// ---- types ----------------------------------------------------------------
|
||||
|
||||
fn write_mode_type(out: &mut String, t: &Type, mode: ParamMode) {
|
||||
match mode {
|
||||
ParamMode::Implicit => write_type(out, t),
|
||||
ParamMode::Own => {
|
||||
out.push_str("own ");
|
||||
write_type(out, t);
|
||||
}
|
||||
ParamMode::Borrow => {
|
||||
out.push_str("borrow ");
|
||||
write_type(out, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_type(out: &mut String, t: &Type) {
|
||||
match t {
|
||||
Type::Var { name } => out.push_str(name),
|
||||
Type::Con { name, args } => {
|
||||
out.push_str(name);
|
||||
if !args.is_empty() {
|
||||
out.push('<');
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_type(out, a);
|
||||
}
|
||||
out.push('>');
|
||||
}
|
||||
}
|
||||
Type::Fn {
|
||||
params,
|
||||
param_modes,
|
||||
ret,
|
||||
ret_mode,
|
||||
effects,
|
||||
} => {
|
||||
out.push('(');
|
||||
for (i, p) in params.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
let mode = param_modes
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(ParamMode::Implicit);
|
||||
// In a bare `Type::Fn` outside a fn signature we have no
|
||||
// parameter names, so render `mode T` only (no `name:`).
|
||||
write_mode_type(out, p, mode);
|
||||
}
|
||||
out.push_str(") -> ");
|
||||
write_mode_type(out, ret, *ret_mode);
|
||||
if !effects.is_empty() {
|
||||
out.push_str(" with ");
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Type::Forall { vars, body } => {
|
||||
out.push_str("forall<");
|
||||
for (i, v) in vars.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push_str("> ");
|
||||
write_type(out, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- terms ----------------------------------------------------------------
|
||||
|
||||
fn write_term(out: &mut String, t: &Term, level: usize) {
|
||||
match t {
|
||||
Term::Lit { lit } => write_lit(out, lit),
|
||||
Term::Var { name } => out.push_str(name),
|
||||
Term::App { callee, args, tail } => {
|
||||
if *tail {
|
||||
out.push_str("tail ");
|
||||
}
|
||||
write_term(out, callee, level);
|
||||
out.push('(');
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_term(out, a, level);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
Term::Let { name, value, body } => {
|
||||
// `let x = value;` then body on the next line at the same
|
||||
// level. Both lines are emitted by the *enclosing* context's
|
||||
// indentation; the caller indented us once already, so for
|
||||
// the body we re-indent.
|
||||
out.push_str("let ");
|
||||
out.push_str(name);
|
||||
out.push_str(" = ");
|
||||
write_term(out, value, level);
|
||||
out.push_str(";\n");
|
||||
indent(out, level);
|
||||
write_term(out, body, level);
|
||||
}
|
||||
Term::LetRec {
|
||||
name,
|
||||
ty,
|
||||
params,
|
||||
body,
|
||||
in_term,
|
||||
} => {
|
||||
// Local recursive fn-binding. Render as a nested fn-shape
|
||||
// followed by the body.
|
||||
out.push_str("let-rec ");
|
||||
out.push_str(name);
|
||||
out.push('(');
|
||||
if let Type::Fn {
|
||||
params: ptys,
|
||||
param_modes,
|
||||
ret,
|
||||
ret_mode,
|
||||
effects,
|
||||
} = ty
|
||||
{
|
||||
for (i, pty) in ptys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
let pname = params.get(i).map(String::as_str).unwrap_or("_");
|
||||
out.push_str(pname);
|
||||
out.push_str(": ");
|
||||
let mode = param_modes
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(ParamMode::Implicit);
|
||||
write_mode_type(out, pty, mode);
|
||||
}
|
||||
out.push_str(") -> ");
|
||||
write_mode_type(out, ret, *ret_mode);
|
||||
if !effects.is_empty() {
|
||||
out.push_str(" with ");
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push_str(") -> ");
|
||||
write_type(out, ty);
|
||||
}
|
||||
out.push_str(" {\n");
|
||||
indent(out, level + 1);
|
||||
write_term(out, body, level + 1);
|
||||
out.push('\n');
|
||||
indent(out, level);
|
||||
out.push_str("};\n");
|
||||
indent(out, level);
|
||||
write_term(out, in_term, level);
|
||||
}
|
||||
Term::If { cond, then, else_ } => {
|
||||
out.push_str("if ");
|
||||
write_term(out, cond, level);
|
||||
out.push_str(" {\n");
|
||||
indent(out, level + 1);
|
||||
write_term(out, then, level + 1);
|
||||
out.push('\n');
|
||||
indent(out, level);
|
||||
out.push_str("} else {\n");
|
||||
indent(out, level + 1);
|
||||
write_term(out, else_, level + 1);
|
||||
out.push('\n');
|
||||
indent(out, level);
|
||||
out.push('}');
|
||||
}
|
||||
Term::Do { op, args, tail } => {
|
||||
if *tail {
|
||||
out.push_str("tail ");
|
||||
}
|
||||
out.push_str("do ");
|
||||
out.push_str(op);
|
||||
out.push('(');
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_term(out, a, level);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
Term::Ctor {
|
||||
type_name: _,
|
||||
ctor,
|
||||
args,
|
||||
} => {
|
||||
out.push_str(ctor);
|
||||
if !args.is_empty() {
|
||||
out.push('(');
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_term(out, a, level);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
}
|
||||
Term::Match { scrutinee, arms } => {
|
||||
out.push_str("match ");
|
||||
write_term(out, scrutinee, level);
|
||||
out.push_str(" {\n");
|
||||
for (i, arm) in arms.iter().enumerate() {
|
||||
indent(out, level + 1);
|
||||
write_pattern(out, &arm.pat);
|
||||
out.push_str(" => ");
|
||||
write_term(out, &arm.body, level + 1);
|
||||
if i + 1 < arms.len() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
indent(out, level);
|
||||
out.push('}');
|
||||
}
|
||||
Term::Lam {
|
||||
params,
|
||||
param_tys,
|
||||
ret_ty,
|
||||
effects,
|
||||
body,
|
||||
} => {
|
||||
// Rust-closure flavour: `|p1: T1, p2: T2| -> R with EFF { body }`
|
||||
out.push('|');
|
||||
for (i, (pname, pty)) in params.iter().zip(param_tys.iter()).enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(pname);
|
||||
out.push_str(": ");
|
||||
write_type(out, pty);
|
||||
}
|
||||
out.push_str("| -> ");
|
||||
write_type(out, ret_ty);
|
||||
if !effects.is_empty() {
|
||||
out.push_str(" with ");
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str(e);
|
||||
}
|
||||
}
|
||||
out.push_str(" {\n");
|
||||
indent(out, level + 1);
|
||||
write_term(out, body, level + 1);
|
||||
out.push('\n');
|
||||
indent(out, level);
|
||||
out.push('}');
|
||||
}
|
||||
Term::Seq { lhs, rhs } => {
|
||||
// Semicolon-as-discard. `lhs;` on its own line, `rhs` on
|
||||
// the next at the same indent. The caller already indented us.
|
||||
write_term(out, lhs, level);
|
||||
out.push_str(";\n");
|
||||
indent(out, level);
|
||||
write_term(out, rhs, level);
|
||||
}
|
||||
Term::Clone { value } => {
|
||||
out.push_str("clone(");
|
||||
write_term(out, value, level);
|
||||
out.push(')');
|
||||
}
|
||||
Term::ReuseAs { source, body } => {
|
||||
out.push_str("reuse-as ");
|
||||
write_term(out, source, level);
|
||||
out.push_str(" {\n");
|
||||
indent(out, level + 1);
|
||||
write_term(out, body, level + 1);
|
||||
out.push('\n');
|
||||
indent(out, level);
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_pattern(out: &mut String, p: &Pattern) {
|
||||
match p {
|
||||
Pattern::Wild => out.push('_'),
|
||||
Pattern::Var { name } => out.push_str(name),
|
||||
Pattern::Lit { lit } => write_lit(out, lit),
|
||||
Pattern::Ctor { ctor, fields } => {
|
||||
out.push_str(ctor);
|
||||
if !fields.is_empty() {
|
||||
out.push('(');
|
||||
for (i, f) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_pattern(out, f);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_lit(out: &mut String, lit: &Literal) {
|
||||
match lit {
|
||||
Literal::Int { value } => out.push_str(&value.to_string()),
|
||||
Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }),
|
||||
Literal::Str { value } => write_string_lit(out, value),
|
||||
Literal::Unit => out.push_str("()"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_string_lit(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
fn indent(out: &mut String, level: usize) {
|
||||
for _ in 0..level {
|
||||
out.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- unit tests -----------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ailang_core::ast::{Arm, Ctor, FnDef, Literal, Pattern, Term, Type};
|
||||
|
||||
fn render_term(t: &Term) -> String {
|
||||
let mut out = String::new();
|
||||
write_term(&mut out, t, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn render_type(t: &Type) -> String {
|
||||
let mut out = String::new();
|
||||
write_type(&mut out, t);
|
||||
out
|
||||
}
|
||||
|
||||
// ---- Lit / Var ----
|
||||
|
||||
#[test]
|
||||
fn lit_int_renders_as_decimal() {
|
||||
assert_eq!(render_term(&Term::Lit { lit: Literal::Int { value: 42 } }), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lit_bool_renders_as_keyword() {
|
||||
assert_eq!(
|
||||
render_term(&Term::Lit { lit: Literal::Bool { value: true } }),
|
||||
"true"
|
||||
);
|
||||
assert_eq!(
|
||||
render_term(&Term::Lit { lit: Literal::Bool { value: false } }),
|
||||
"false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lit_str_renders_with_escapes() {
|
||||
assert_eq!(
|
||||
render_term(&Term::Lit { lit: Literal::Str { value: "hi\n".into() } }),
|
||||
"\"hi\\n\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lit_unit_renders_as_unit_pair() {
|
||||
assert_eq!(render_term(&Term::Lit { lit: Literal::Unit }), "()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn var_renders_as_name() {
|
||||
assert_eq!(render_term(&Term::Var { name: "x".into() }), "x");
|
||||
}
|
||||
|
||||
// ---- App ----
|
||||
|
||||
#[test]
|
||||
fn app_two_args_renders_as_call() {
|
||||
let t = Term::App {
|
||||
callee: Box::new(Term::Var { name: "f".into() }),
|
||||
args: vec![
|
||||
Term::Lit { lit: Literal::Int { value: 1 } },
|
||||
Term::Lit { lit: Literal::Int { value: 2 } },
|
||||
],
|
||||
tail: false,
|
||||
};
|
||||
assert_eq!(render_term(&t), "f(1, 2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_tail_renders_with_keyword() {
|
||||
let t = Term::App {
|
||||
callee: Box::new(Term::Var { name: "f".into() }),
|
||||
args: vec![],
|
||||
tail: true,
|
||||
};
|
||||
assert_eq!(render_term(&t), "tail f()");
|
||||
}
|
||||
|
||||
// ---- Let / If ----
|
||||
|
||||
#[test]
|
||||
fn let_renders_as_binding_and_body() {
|
||||
let t = Term::Let {
|
||||
name: "x".into(),
|
||||
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
|
||||
body: Box::new(Term::Var { name: "x".into() }),
|
||||
};
|
||||
assert_eq!(render_term(&t), "let x = 1;\nx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_renders_with_braces_and_else() {
|
||||
let t = Term::If {
|
||||
cond: Box::new(Term::Lit { lit: Literal::Bool { value: true } }),
|
||||
then: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
|
||||
else_: Box::new(Term::Lit { lit: Literal::Int { value: 2 } }),
|
||||
};
|
||||
assert_eq!(
|
||||
render_term(&t),
|
||||
"if true {\n 1\n} else {\n 2\n}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Match ----
|
||||
|
||||
#[test]
|
||||
fn match_two_arms_renders_with_arrows_and_commas() {
|
||||
let t = Term::Match {
|
||||
scrutinee: Box::new(Term::Var { name: "xs".into() }),
|
||||
arms: vec![
|
||||
Arm {
|
||||
pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
|
||||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||||
},
|
||||
Arm {
|
||||
pat: Pattern::Ctor {
|
||||
ctor: "Cons".into(),
|
||||
fields: vec![
|
||||
Pattern::Var { name: "h".into() },
|
||||
Pattern::Var { name: "t".into() },
|
||||
],
|
||||
},
|
||||
body: Term::Var { name: "h".into() },
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
render_term(&t),
|
||||
"match xs {\n Nil => 0,\n Cons(h, t) => h\n}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Ctor ----
|
||||
|
||||
#[test]
|
||||
fn ctor_nullary_renders_as_bare_name() {
|
||||
let t = Term::Ctor {
|
||||
type_name: "List".into(),
|
||||
ctor: "Nil".into(),
|
||||
args: vec![],
|
||||
};
|
||||
assert_eq!(render_term(&t), "Nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctor_with_args_renders_as_call() {
|
||||
let t = Term::Ctor {
|
||||
type_name: "List".into(),
|
||||
ctor: "Cons".into(),
|
||||
args: vec![
|
||||
Term::Lit { lit: Literal::Int { value: 1 } },
|
||||
Term::Ctor {
|
||||
type_name: "List".into(),
|
||||
ctor: "Nil".into(),
|
||||
args: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(render_term(&t), "Cons(1, Nil)");
|
||||
}
|
||||
|
||||
// ---- Lam ----
|
||||
|
||||
#[test]
|
||||
fn lam_renders_as_rust_closure() {
|
||||
let t = Term::Lam {
|
||||
params: vec!["x".into()],
|
||||
param_tys: vec![Type::int()],
|
||||
ret_ty: Box::new(Type::int()),
|
||||
effects: vec![],
|
||||
body: Box::new(Term::Var { name: "x".into() }),
|
||||
};
|
||||
assert_eq!(render_term(&t), "|x: Int| -> Int {\n x\n}");
|
||||
}
|
||||
|
||||
// ---- Seq / Clone / ReuseAs / Do ----
|
||||
|
||||
#[test]
|
||||
fn seq_renders_with_semicolon_and_newline() {
|
||||
let t = Term::Seq {
|
||||
lhs: Box::new(Term::Var { name: "a".into() }),
|
||||
rhs: Box::new(Term::Var { name: "b".into() }),
|
||||
};
|
||||
assert_eq!(render_term(&t), "a;\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_renders_explicitly() {
|
||||
let t = Term::Clone {
|
||||
value: Box::new(Term::Var { name: "x".into() }),
|
||||
};
|
||||
assert_eq!(render_term(&t), "clone(x)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reuse_as_renders_with_keyword_and_braces() {
|
||||
let t = Term::ReuseAs {
|
||||
source: Box::new(Term::Var { name: "xs".into() }),
|
||||
body: Box::new(Term::Ctor {
|
||||
type_name: "List".into(),
|
||||
ctor: "Nil".into(),
|
||||
args: vec![],
|
||||
}),
|
||||
};
|
||||
assert_eq!(render_term(&t), "reuse-as xs {\n Nil\n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_renders_with_keyword_and_op() {
|
||||
let t = Term::Do {
|
||||
op: "io/print_int".into(),
|
||||
args: vec![Term::Lit { lit: Literal::Int { value: 5 } }],
|
||||
tail: false,
|
||||
};
|
||||
assert_eq!(render_term(&t), "do io/print_int(5)");
|
||||
}
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
#[test]
|
||||
fn type_con_no_args_drops_wrap() {
|
||||
assert_eq!(render_type(&Type::int()), "Int");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_con_with_args_renders_as_generic() {
|
||||
let t = Type::Con {
|
||||
name: "List".into(),
|
||||
args: vec![Type::int()],
|
||||
};
|
||||
assert_eq!(render_type(&t), "List<Int>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_var_renders_as_name() {
|
||||
assert_eq!(render_type(&Type::Var { name: "a".into() }), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_fn_no_modes_no_effects() {
|
||||
let t = Type::fn_implicit(vec![Type::int()], Type::int(), vec![]);
|
||||
assert_eq!(render_type(&t), "(Int) -> Int");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_fn_with_effects() {
|
||||
let t = Type::fn_implicit(vec![], Type::unit(), vec!["IO".into()]);
|
||||
assert_eq!(render_type(&t), "() -> Unit with IO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_fn_with_modes_renders_keywords_before_type() {
|
||||
let t = Type::Fn {
|
||||
params: vec![Type::int(), Type::bool_()],
|
||||
param_modes: vec![ParamMode::Own, ParamMode::Borrow],
|
||||
ret: Box::new(Type::int()),
|
||||
ret_mode: ParamMode::Own,
|
||||
effects: vec![],
|
||||
};
|
||||
assert_eq!(render_type(&t), "(own Int, borrow Bool) -> own Int");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_forall_renders_with_angle_vars() {
|
||||
let t = Type::Forall {
|
||||
vars: vec!["a".into()],
|
||||
body: Box::new(Type::Var { name: "a".into() }),
|
||||
};
|
||||
assert_eq!(render_type(&t), "forall<a> a");
|
||||
}
|
||||
|
||||
// ---- FnDef integration: modes BEFORE the type, in fn signature ----
|
||||
|
||||
#[test]
|
||||
fn fn_def_signature_renders_modes_before_type() {
|
||||
let fd = FnDef {
|
||||
name: "head_or_zero".into(),
|
||||
ty: Type::Fn {
|
||||
params: vec![Type::Con {
|
||||
name: "IntList".into(),
|
||||
args: vec![],
|
||||
}],
|
||||
param_modes: vec![ParamMode::Own],
|
||||
ret: Box::new(Type::int()),
|
||||
ret_mode: ParamMode::Implicit,
|
||||
effects: vec![],
|
||||
},
|
||||
params: vec!["xs".into()],
|
||||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||||
doc: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
write_fn_def(&mut out, &fd, 0);
|
||||
assert!(out.contains("xs: own IntList"), "got:\n{out}");
|
||||
assert!(out.contains(") -> Int "), "got:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_def_with_effects_appends_with_clause() {
|
||||
let fd = FnDef {
|
||||
name: "main".into(),
|
||||
ty: Type::fn_implicit(vec![], Type::unit(), vec!["IO".into()]),
|
||||
params: vec![],
|
||||
body: Term::Lit { lit: Literal::Unit },
|
||||
doc: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
write_fn_def(&mut out, &fd, 0);
|
||||
assert!(out.contains("() -> Unit with IO {"), "got:\n{out}");
|
||||
}
|
||||
|
||||
// ---- Doc string lines ----
|
||||
|
||||
#[test]
|
||||
fn doc_string_renders_as_triple_slash_lines() {
|
||||
let td = TypeDef {
|
||||
name: "Foo".into(),
|
||||
vars: vec![],
|
||||
ctors: vec![Ctor {
|
||||
name: "MkFoo".into(),
|
||||
fields: vec![],
|
||||
}],
|
||||
doc: Some("line one\nline two".into()),
|
||||
drop_iterative: false,
|
||||
};
|
||||
let mut out = String::new();
|
||||
write_type_def(&mut out, &td, 0);
|
||||
assert!(out.starts_with("/// line one\n/// line two\n"), "got:\n{out}");
|
||||
}
|
||||
|
||||
// ---- TypeDef ----
|
||||
|
||||
#[test]
|
||||
fn type_def_renders_with_pipe_separated_ctors() {
|
||||
let td = TypeDef {
|
||||
name: "IntList".into(),
|
||||
vars: vec![],
|
||||
ctors: vec![
|
||||
Ctor { name: "Nil".into(), fields: vec![] },
|
||||
Ctor {
|
||||
name: "Cons".into(),
|
||||
fields: vec![Type::int(), Type::Con { name: "IntList".into(), args: vec![] }],
|
||||
},
|
||||
],
|
||||
doc: None,
|
||||
drop_iterative: false,
|
||||
};
|
||||
let mut out = String::new();
|
||||
write_type_def(&mut out, &td, 0);
|
||||
assert_eq!(out, "data IntList = Nil | Cons(Int, IntList)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Iter 20a snapshot tests.
|
||||
//!
|
||||
//! For each `examples/<name>.ail.json`, the renderer's output is
|
||||
//! compared against the committed `examples/<name>.prose.txt`. The
|
||||
//! snapshot files are part of the repo: changing the renderer requires
|
||||
//! a deliberate snapshot update, which is the point of this test.
|
||||
//!
|
||||
//! Update protocol (manual): regenerate the file by running
|
||||
//! `cargo run -p ail -- prose <fixture>.ail.json > <fixture>.prose.txt`
|
||||
//! once the renderer change is agreed; commit alongside the change.
|
||||
//! There is no `UPDATE_SNAPSHOTS=1` environment short-cut here on
|
||||
//! purpose — Iter 20a is the seeding round, and we want every change
|
||||
//! after this to be visible in a diff.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn examples_dir() -> PathBuf {
|
||||
// tests/ runs with CARGO_MANIFEST_DIR == crates/ailang-prose.
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.pop();
|
||||
p.pop();
|
||||
p.push("examples");
|
||||
p
|
||||
}
|
||||
|
||||
fn check_snapshot(stem: &str) {
|
||||
let dir = examples_dir();
|
||||
let json_path = dir.join(format!("{stem}.ail.json"));
|
||||
let prose_path = dir.join(format!("{stem}.prose.txt"));
|
||||
|
||||
let module = ailang_core::load_module(&json_path)
|
||||
.unwrap_or_else(|e| panic!("failed to load {json_path:?}: {e}"));
|
||||
let actual = ailang_prose::module_to_prose(&module);
|
||||
|
||||
let expected = std::fs::read_to_string(&prose_path)
|
||||
.unwrap_or_else(|e| panic!("failed to read snapshot {prose_path:?}: {e}"));
|
||||
|
||||
if actual != expected {
|
||||
// Write `.actual` for inspection, then fail.
|
||||
let actual_path = dir.join(format!("{stem}.prose.txt.actual"));
|
||||
std::fs::write(&actual_path, &actual).expect("write .actual");
|
||||
panic!(
|
||||
"prose snapshot mismatch for {stem}.\n\
|
||||
Expected: {prose_path:?}\n\
|
||||
Actual: {actual_path:?}\n\
|
||||
To accept the new output, replace the expected file with the .actual file."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rc_own_param_drop() {
|
||||
check_snapshot("rc_own_param_drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rc_match_arm_partial_drop_leak() {
|
||||
check_snapshot("rc_match_arm_partial_drop_leak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rc_app_let_partial_drop_leak() {
|
||||
check_snapshot("rc_app_let_partial_drop_leak");
|
||||
}
|
||||
@@ -8879,3 +8879,88 @@ is the form-B *projection*).
|
||||
- Whether snapshot tests live in `crates/ailang-prose/tests/`
|
||||
or `examples/` (as `.prose.txt` siblings to the .ail.json).
|
||||
|
||||
|
||||
## 2026-05-08 — Iter 20a: prose renderer skeleton shipped
|
||||
|
||||
The renderer-side of the family-20 design pinning. New crate
|
||||
`ailang-prose` with one public fn `module_to_prose(&Module) -> String`,
|
||||
plus a `ail prose <file.ail.json>` CLI subcommand that prints the
|
||||
projection to stdout.
|
||||
|
||||
### Style commitments (orchestrator-fixed for 20a, not up to renderer)
|
||||
|
||||
- Rust-flavour with braces and `=>` for match arms.
|
||||
- Mode keywords `own T` / `borrow T` (not sigils — Decision-10 consistency).
|
||||
- Effects trailing the return type: `with IO`.
|
||||
- Constructor application as `Cons(1, Nil)` (no `(term-ctor ...)` wrap).
|
||||
- Types as bare names: `Int`, `List<Int>` (no `(con ...)` wrap).
|
||||
- Doc strings as `///` lines above the def.
|
||||
- `tail` flag on calls renders as a `tail ` prefix.
|
||||
- All other load-bearing semantic detail stays visible: `clone`,
|
||||
`reuse-as`, effects, doc strings, type annotations on signatures
|
||||
and lambdas.
|
||||
- No infix yet; arithmetic primitives stay as `i64-add(a, b)`.
|
||||
That's 20b.
|
||||
|
||||
### Snapshot coverage
|
||||
|
||||
Three fixtures got `.prose.txt` siblings, asserted by
|
||||
`crates/ailang-prose/tests/snapshot.rs`:
|
||||
|
||||
- `examples/rc_own_param_drop.prose.txt`
|
||||
- `examples/rc_match_arm_partial_drop_leak.prose.txt`
|
||||
- `examples/rc_app_let_partial_drop_leak.prose.txt`
|
||||
|
||||
Sample (`rc_own_param_drop.prose.txt`):
|
||||
|
||||
```
|
||||
fn head_or_zero(xs: own IntList) -> Int {
|
||||
match xs {
|
||||
Nil => 0,
|
||||
Cons(h, t) => h
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
vs. the 215-line `.ail.json` it projects from.
|
||||
|
||||
The `head_or_zero` body is exactly the kind of thing the user
|
||||
named: dramatically less syntactic noise, immediately legible to
|
||||
a human, with `own` mode and `Cons(h, t)` as conventional source
|
||||
forms.
|
||||
|
||||
### Visible nits queued for 20b
|
||||
|
||||
- Long doc strings stay on one wrap-less line.
|
||||
- `do io/print_int(x)` could be `print(x)` for the IO/Int-print
|
||||
default.
|
||||
- Nested match arms could break across lines.
|
||||
- Arithmetic primitives are still prefix.
|
||||
- No precedence-aware paren elision yet.
|
||||
|
||||
All explicitly out of 20a's scope; pinned for 20b.
|
||||
|
||||
### Files
|
||||
|
||||
- New crate `crates/ailang-prose/` (`Cargo.toml`, `src/lib.rs`
|
||||
~924 lines, `tests/snapshot.rs` 64 lines).
|
||||
- `Cargo.toml` (root) + `crates/ail/Cargo.toml`: workspace + dep wiring.
|
||||
- `crates/ail/src/main.rs`: `Cmd::Prose { path }` variant + dispatch.
|
||||
- Three new `examples/*.prose.txt` snapshots.
|
||||
|
||||
### Build/test status
|
||||
|
||||
- `cargo build --workspace` — green, no warnings.
|
||||
- `cargo test --workspace` — 28 prose-unit + 3 prose-snapshot pass;
|
||||
existing 69 e2e + 53 ailang-check + others all unchanged-green.
|
||||
|
||||
### What stays queued
|
||||
|
||||
- **Iter 20b** — formatting polish: infix for arithmetic, paren
|
||||
elision by precedence, let-inlining, prettier `do`, line-wrap
|
||||
for long doc strings.
|
||||
- **Iter 20c** — the CLI subcommand was bundled into 20a (one
|
||||
natural unit). Originally pinned as a separate iter; collapsed.
|
||||
- **Iter 20d** — the round-trip mediator (prose-edit → updated
|
||||
AIL via LLM call). Specification + prompt template, not a
|
||||
compiler pass.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// module rc_app_let_partial_drop_leak
|
||||
|
||||
data Wrap = MkWrap(Int)
|
||||
|
||||
data Cell = MkCell(Wrap, Wrap)
|
||||
|
||||
data Pair = MkPair(Cell, Cell)
|
||||
|
||||
fn build_pair(n: Int) -> own Pair {
|
||||
MkPair(MkCell(MkWrap(n), MkWrap(2)), MkCell(MkWrap(3), MkWrap(4)))
|
||||
}
|
||||
|
||||
fn use_cell(c: own Cell) -> Int {
|
||||
match c {
|
||||
MkCell(w1, w2) => 1
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Unit with IO {
|
||||
let p = build_pair(1);
|
||||
do io/print_int(match p {
|
||||
MkPair(a, _) => use_cell(a)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// module rc_match_arm_partial_drop_leak
|
||||
|
||||
data Wrap = MkWrap(Int)
|
||||
|
||||
data Cell = MkCell(Wrap, Wrap)
|
||||
|
||||
data Pair = MkPair(Cell, Cell)
|
||||
|
||||
fn build_pair(n: Int) -> own Pair {
|
||||
MkPair(MkCell(MkWrap(n), MkWrap(2)), MkCell(MkWrap(3), MkWrap(4)))
|
||||
}
|
||||
|
||||
fn use_first(p: own Pair) -> Int {
|
||||
match p {
|
||||
MkPair(a, b) => match a {
|
||||
MkCell(w1, _) => 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Unit with IO {
|
||||
do io/print_int(use_first(build_pair(1)))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// module rc_own_param_drop
|
||||
|
||||
/// Recursive Int list — boxed.
|
||||
data IntList = Nil | Cons(Int, IntList)
|
||||
|
||||
/// Take ownership of an IntList; return its head if Cons, else 0. The tail is loaded as a pattern binder but never consumed — iter A dec's it at arm close. The outer cell is dec'd at fn return via iter B's Own-param emission.
|
||||
fn head_or_zero(xs: own IntList) -> Int {
|
||||
match xs {
|
||||
Nil => 0,
|
||||
Cons(h, t) => h
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 5-element IntList; pass to head_or_zero (transferring ownership); print the result.
|
||||
fn main() -> Unit with IO {
|
||||
let xs = Cons(11, Cons(22, Cons(33, Cons(44, Cons(55, Nil)))));
|
||||
do io/print_int(head_or_zero(xs))
|
||||
}
|
||||
Reference in New Issue
Block a user