check: mode-strict-because suppression (iter 19b)
Closes the 19a/19a.1/19b arc. Corpus signal from 19a.1 (5/65
fixtures fire over-strict-mode, all deliberate RC codegen-test
fixtures) justified shipping the suppress mechanism end-to-end.
Schema: Suppress { code, because } on FnDef. Pre-19b hashes
bit-identical via skip_serializing_if. Typechecker drops matching
diagnostics; empty 'because' is Error severity; wrong codes are
silent no-ops (open-set registry).
Form-A: (suppress (code "...") (because "...")) clause, parser
+ printer round-trip clean. Form-B: '// @suppress <code>: <reason>'
above the doc string, lossless contract metadata.
5 RC fixtures migrated (.ail.json + .ailx + .prose.txt). Corpus
signal: 5/65 -> 0/65. Lint still fires on any future fn that's
accidentally over-strict without an authored reason.
Test counts: ailang-check 55->61, ailang-core 26->28, ailang-surface
21->26, ailang-prose 49->52, e2e 70 unchanged.
Known debt: .ailx comment headers lost on regen (ail render's
contract excludes comments); parse_suppress_attr accepts
duplicate code/because attrs without diagnose (bounded by canonical
print order).
This commit is contained in:
@@ -14,7 +14,9 @@
|
||||
//! doc-attr ::= "(" "doc" string ")"
|
||||
//!
|
||||
//! fn-def ::= "(" "fn" ident fn-attr* ")"
|
||||
//! fn-attr ::= doc-attr | type-attr | params-attr | body-attr
|
||||
//! fn-attr ::= doc-attr | suppress-attr | type-attr | params-attr | body-attr
|
||||
//! suppress-attr ::= "(" "suppress" "(" "code" string ")"
|
||||
//! "(" "because" string ")" ")"
|
||||
//! type-attr ::= "(" "type" type ")"
|
||||
//! params-attr ::= "(" "params" ident* ")"
|
||||
//! body-attr ::= "(" "body" term ")"
|
||||
@@ -83,8 +85,8 @@
|
||||
//! [`ailang_core::ast::Import::alias`].
|
||||
|
||||
use ailang_core::ast::{
|
||||
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type,
|
||||
TypeDef,
|
||||
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term,
|
||||
Type, TypeDef,
|
||||
};
|
||||
use ailang_core::SCHEMA;
|
||||
use thiserror::Error;
|
||||
@@ -474,9 +476,13 @@ impl<'a> Parser<'a> {
|
||||
let mut ty: Option<Type> = None;
|
||||
let mut params: Option<Vec<String>> = None;
|
||||
let mut body: Option<Term> = None;
|
||||
// Iter 19b: every `(suppress ...)` clause appends one entry. Order
|
||||
// is preserved (matches the on-disk JSON-AST order).
|
||||
let mut suppress: Vec<Suppress> = Vec::new();
|
||||
loop {
|
||||
match self.peek_head_ident() {
|
||||
Some("doc") => doc = Some(self.parse_doc()?),
|
||||
Some("suppress") => suppress.push(self.parse_suppress_attr()?),
|
||||
Some("type") => {
|
||||
let t = self.parse_type_attr()?;
|
||||
ty = Some(t);
|
||||
@@ -494,7 +500,7 @@ impl<'a> Parser<'a> {
|
||||
return Err(ParseError::Production {
|
||||
production: "fn-def",
|
||||
message: format!(
|
||||
"unknown fn attribute `{other}`; expected `doc`, `type`, `params`, or `body`"
|
||||
"unknown fn attribute `{other}`; expected `doc`, `suppress`, `type`, `params`, or `body`"
|
||||
),
|
||||
pos,
|
||||
});
|
||||
@@ -524,9 +530,82 @@ impl<'a> Parser<'a> {
|
||||
params,
|
||||
body,
|
||||
doc,
|
||||
suppress,
|
||||
})
|
||||
}
|
||||
|
||||
/// Iter 19b: parse one `(suppress (code "<c>") (because "<r>"))`
|
||||
/// clause, returning a [`Suppress`] entry. Unknown sub-keywords
|
||||
/// inside the clause are rejected with [`ParseError::Production`].
|
||||
/// The `because` text is allowed to be empty here (the typechecker
|
||||
/// emits `empty-suppress-reason` instead of the parser, so the
|
||||
/// invalid form round-trips through the surface for diagnostic
|
||||
/// purposes).
|
||||
fn parse_suppress_attr(&mut self) -> Result<Suppress, ParseError> {
|
||||
self.expect_lparen("suppress-attr")?;
|
||||
self.expect_keyword("suppress")?;
|
||||
let mut code: Option<String> = None;
|
||||
let mut because: Option<String> = None;
|
||||
loop {
|
||||
match self.peek_head_ident() {
|
||||
Some("code") => {
|
||||
self.expect_lparen("suppress.code")?;
|
||||
self.expect_keyword("code")?;
|
||||
code = Some(self.expect_string("code body")?);
|
||||
self.expect_rparen("suppress.code")?;
|
||||
}
|
||||
Some("because") => {
|
||||
self.expect_lparen("suppress.because")?;
|
||||
self.expect_keyword("because")?;
|
||||
because = Some(self.expect_string("because body")?);
|
||||
self.expect_rparen("suppress.because")?;
|
||||
}
|
||||
Some(other) => {
|
||||
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
|
||||
return Err(ParseError::Production {
|
||||
production: "suppress-attr",
|
||||
message: format!(
|
||||
"unknown suppress sub-attribute `{other}`; expected `code` or `because`"
|
||||
),
|
||||
pos,
|
||||
});
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.expect_rparen("suppress-attr")?;
|
||||
let code = code.ok_or_else(|| ParseError::Production {
|
||||
production: "suppress-attr",
|
||||
message: "suppress is missing required `(code ...)`".into(),
|
||||
pos: 0,
|
||||
})?;
|
||||
let because = because.ok_or_else(|| ParseError::Production {
|
||||
production: "suppress-attr",
|
||||
message: "suppress is missing required `(because ...)`".into(),
|
||||
pos: 0,
|
||||
})?;
|
||||
Ok(Suppress { code, because })
|
||||
}
|
||||
|
||||
/// Iter 19b: helper — consume one string-literal token. Used by
|
||||
/// [`Self::parse_suppress_attr`].
|
||||
fn expect_string(&mut self, ctx: &'static str) -> Result<String, ParseError> {
|
||||
match self.peek().cloned() {
|
||||
Some(Token { tok: Tok::Str(s), .. }) => {
|
||||
self.cur += 1;
|
||||
Ok(s)
|
||||
}
|
||||
Some(t) => Err(ParseError::Unexpected {
|
||||
expected: format!("string literal ({ctx})"),
|
||||
got: tok_label(&t.tok),
|
||||
pos: t.span.start,
|
||||
}),
|
||||
None => Err(ParseError::UnexpectedEof {
|
||||
expected: format!("string literal ({ctx})"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_type_attr(&mut self) -> Result<Type, ParseError> {
|
||||
self.expect_lparen("type-attr")?;
|
||||
self.expect_keyword("type")?;
|
||||
@@ -1632,4 +1711,133 @@ mod tests {
|
||||
other => panic!("expected LetRec, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 19b: a `(fn ...)` carrying a single
|
||||
/// `(suppress (code "...") (because "..."))` clause parses into
|
||||
/// [`FnDef::suppress`] with the corresponding [`Suppress`] entry.
|
||||
#[test]
|
||||
fn parses_single_suppress_clause_on_fn_def() {
|
||||
let m = crate::parse::parse(
|
||||
r#"
|
||||
(module t
|
||||
(fn f
|
||||
(suppress (code "over-strict-mode") (because "test reason"))
|
||||
(type (fn-type (params) (ret (con Int))))
|
||||
(params)
|
||||
(body 0)))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
match &m.defs[0] {
|
||||
Def::Fn(fd) => {
|
||||
assert_eq!(fd.suppress.len(), 1);
|
||||
assert_eq!(fd.suppress[0].code, "over-strict-mode");
|
||||
assert_eq!(fd.suppress[0].because, "test reason");
|
||||
}
|
||||
_ => panic!("expected fn"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 19b: multiple `(suppress ...)` clauses accumulate into
|
||||
/// `FnDef::suppress` in declaration order. A second clause does
|
||||
/// NOT overwrite the first.
|
||||
#[test]
|
||||
fn parses_multiple_suppress_clauses_in_order() {
|
||||
let m = crate::parse::parse(
|
||||
r#"
|
||||
(module t
|
||||
(fn f
|
||||
(suppress (code "over-strict-mode") (because "first"))
|
||||
(suppress (code "other-code") (because "second"))
|
||||
(type (fn-type (params) (ret (con Int))))
|
||||
(params)
|
||||
(body 0)))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
match &m.defs[0] {
|
||||
Def::Fn(fd) => {
|
||||
assert_eq!(fd.suppress.len(), 2);
|
||||
assert_eq!(fd.suppress[0].code, "over-strict-mode");
|
||||
assert_eq!(fd.suppress[0].because, "first");
|
||||
assert_eq!(fd.suppress[1].code, "other-code");
|
||||
assert_eq!(fd.suppress[1].because, "second");
|
||||
}
|
||||
_ => panic!("expected fn"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 19b: a `(fn ...)` without a `(suppress ...)` clause has
|
||||
/// `FnDef::suppress` empty (the default — round-trip identity
|
||||
/// with pre-19b fixtures).
|
||||
#[test]
|
||||
fn parses_fn_def_without_suppress_has_empty_vec() {
|
||||
let m = crate::parse::parse(
|
||||
r#"
|
||||
(module t
|
||||
(fn f
|
||||
(type (fn-type (params) (ret (con Int))))
|
||||
(params)
|
||||
(body 0)))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
match &m.defs[0] {
|
||||
Def::Fn(fd) => {
|
||||
assert!(fd.suppress.is_empty());
|
||||
}
|
||||
_ => panic!("expected fn"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 19b: a `(suppress ...)` with `(because "")` (empty
|
||||
/// reason) still parses cleanly — the parser is intentionally
|
||||
/// permissive; the typechecker is what emits
|
||||
/// `empty-suppress-reason`. This keeps the surface symmetric
|
||||
/// (a malformed input round-trips through the printer for
|
||||
/// diagnostic display).
|
||||
#[test]
|
||||
fn parses_suppress_with_empty_because_string() {
|
||||
let m = crate::parse::parse(
|
||||
r#"
|
||||
(module t
|
||||
(fn f
|
||||
(suppress (code "over-strict-mode") (because ""))
|
||||
(type (fn-type (params) (ret (con Int))))
|
||||
(params)
|
||||
(body 0)))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
match &m.defs[0] {
|
||||
Def::Fn(fd) => {
|
||||
assert_eq!(fd.suppress.len(), 1);
|
||||
assert_eq!(fd.suppress[0].because, "");
|
||||
}
|
||||
_ => panic!("expected fn"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 19b: an unknown sub-attribute inside `(suppress ...)`
|
||||
/// produces a `ParseError::Production` naming the bad keyword and
|
||||
/// listing the legal ones (`code` / `because`).
|
||||
#[test]
|
||||
fn rejects_suppress_with_unknown_subattribute() {
|
||||
let err = crate::parse::parse(
|
||||
r#"
|
||||
(module t
|
||||
(fn f
|
||||
(suppress (code "over-strict-mode") (because "ok") (oops "x"))
|
||||
(type (fn-type (params) (ret (con Int))))
|
||||
(params)
|
||||
(body 0)))
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("oops") && msg.contains("code") && msg.contains("because"),
|
||||
"error should name the bad keyword and the legal ones; got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//! per level. Comments are NOT emitted.
|
||||
|
||||
use ailang_core::ast::{
|
||||
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type,
|
||||
TypeDef,
|
||||
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term,
|
||||
Type, TypeDef,
|
||||
};
|
||||
|
||||
/// Print a module in form (A).
|
||||
@@ -153,6 +153,13 @@ fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
|
||||
write_string_lit(out, doc);
|
||||
out.push(')');
|
||||
}
|
||||
// Iter 19b: emit one `(suppress ...)` clause per entry, after the
|
||||
// doc string and before the type. Round-trip stable: parser
|
||||
// re-reads each clause back into [`FnDef::suppress`].
|
||||
for s in &fd.suppress {
|
||||
out.push('\n');
|
||||
write_suppress(out, s, level + 1);
|
||||
}
|
||||
out.push('\n');
|
||||
indent(out, level + 1);
|
||||
out.push_str("(type ");
|
||||
@@ -174,6 +181,18 @@ fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
|
||||
out.push(')');
|
||||
}
|
||||
|
||||
/// Iter 19b: print one `(suppress (code "<c>") (because "<r>"))`
|
||||
/// clause. Indentation matches the rest of the fn-def (one level
|
||||
/// deeper than the `(fn ...)` head).
|
||||
fn write_suppress(out: &mut String, s: &Suppress, level: usize) {
|
||||
indent(out, level);
|
||||
out.push_str("(suppress (code ");
|
||||
write_string_lit(out, &s.code);
|
||||
out.push_str(") (because ");
|
||||
write_string_lit(out, &s.because);
|
||||
out.push_str("))");
|
||||
}
|
||||
|
||||
fn write_const_def(out: &mut String, cd: &ConstDef, level: usize) {
|
||||
indent(out, level);
|
||||
out.push_str("(const ");
|
||||
|
||||
Reference in New Issue
Block a user