diff --git a/Cargo.lock b/Cargo.lock index b232d450..e882dd65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -685,6 +685,7 @@ dependencies = [ "paredit-feature-lint-custom", "paredit-feature-lint-documentation", "paredit-feature-lint-elisp-idiom", + "paredit-feature-lint-fennel-janet-idiom", "paredit-feature-lint-form-shape", "paredit-feature-lint-introspection", "paredit-feature-lint-iteration-flow", @@ -700,6 +701,7 @@ dependencies = [ "paredit-feature-lint-sequence", "paredit-feature-lint-string-char", "paredit-feature-lint-testing", + "paredit-feature-lint-type-declaration", "paredit-feature-lisp-analysis", "paredit-feature-migrate", "paredit-feature-package", @@ -1104,6 +1106,14 @@ dependencies = [ "paredit-core-syntax", ] +[[package]] +name = "paredit-feature-lint-fennel-janet-idiom" +version = "1.4.0" +dependencies = [ + "paredit-core-lint-engine", + "paredit-core-syntax", +] + [[package]] name = "paredit-feature-lint-form-shape" version = "1.4.0" @@ -1280,6 +1290,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "paredit-feature-lint-type-declaration" +version = "1.4.0" +dependencies = [ + "paredit-core-lint-engine", + "paredit-core-syntax", +] + [[package]] name = "paredit-feature-lisp-analysis" version = "1.4.0" diff --git a/Cargo.toml b/Cargo.toml index 8b2e4896..2d4d60cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,8 @@ paredit-feature-lint-elisp-idiom = { path = "packages/feature/lint-elisp-idiom" paredit-feature-lint-pathname-io = { path = "packages/feature/lint-pathname-io" } paredit-feature-lint-clojure-idiom = { path = "packages/feature/lint-clojure-idiom" } paredit-feature-lint-scheme-idiom = { path = "packages/feature/lint-scheme-idiom" } +paredit-feature-lint-fennel-janet-idiom = { path = "packages/feature/lint-fennel-janet-idiom" } +paredit-feature-lint-type-declaration = { path = "packages/feature/lint-type-declaration" } paredit-feature-emacs-lisp = { path = "packages/feature/emacs-lisp" } paredit-feature-conditional-conversion = { path = "packages/feature/conditional-conversion" } paredit-feature-external-check = { path = "packages/feature/external-check" } diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index 4d39e5bb..cb9ce1e1 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -421,7 +421,7 @@ adds each finding's full field set as indented lines under its row. ### Choosing and tuning lint rules -With 303 rules, `inspect lint` needs more than an on/off switch per rule. The +With 313 rules, `inspect lint` needs more than an on/off switch per rule. The flags below are about the rule *set* rather than about any one rule, and all of them work with `--list-rules` as well as with a scan — so a run can be inspected before it is made. @@ -446,7 +446,7 @@ key for baselines and suppression tooling. ### Rules a project writes for itself -The 303 shipped rules are the ones everybody gets. A rule like "in *this* +The 313 shipped rules are the ones everybody gets. A rule like "in *this* codebase, `defentity` must always be given a `:table`" is the majority of what a mature project wants and none of what a linter can ship, so a project writes those itself, in Lisp, in `.paredit/rules/*.lisp`: diff --git a/docs/src/reference/architecture.md b/docs/src/reference/architecture.md index f35ebbc0..a971ebc5 100644 --- a/docs/src/reference/architecture.md +++ b/docs/src/reference/architecture.md @@ -1,6 +1,6 @@ # Architecture -`paredit-cli` is a Cargo workspace: a thin composition root plus 62 packages +`paredit-cli` is a Cargo workspace: a thin composition root plus 64 packages under `packages/core/` and `packages/feature/`. Knowing which package owns a thing is the fastest way to know where a change belongs. @@ -17,7 +17,7 @@ core/syntax ──▶ core/semantics ──▶ core/edit ──▶ core/cli └──▶ core/workspace core/lint-engine ──┘ │ ▼ - feature/* (53 packages, mostly independent of each other) + feature/* (55 packages, mostly independent of each other) │ ▼ paredit-cli (command tree, dispatch, REGISTRY) @@ -65,10 +65,10 @@ src/ A contract test walks `src/` and refuses anything else. The lint `REGISTRY` is the canonical example of what *must* live here. It names -all 303 rules, and every rule depends on the engine; putting the registry in +all 313 rules, and every rule depends on the engine; putting the registry in either would be a cycle. So the engine takes a `RuleCatalog` as an argument and never learns which rules exist, the rules never learn the registry does, and -the registry sits in the root reaching twenty-eight feature packages for their +the registry sits in the root reaching thirty feature packages for their `META` and `RULE`. That is the criterion: **a module that enumerates or aggregates several features** belongs in neither core nor any one feature. @@ -127,7 +127,7 @@ semantic enum (`ReportLimit::{Complete, Limited(NonZeroUsize)}`, Derive redundant presentation values (booleans, counts) at the serialization boundary instead of storing them. -## Lint rules: one trait, one registry line, twenty-eight packages +## Lint rules: one trait, one registry line, thirty packages The lint suite is the clearest example of the split's shape, and the most frequently extended part of the tree. @@ -141,8 +141,8 @@ frequently extended part of the tree. | `policy` | Dialect scope, rule selection and gate decisions: logic that needs no tree. | | `engine` | The single pass, which walks the document once and dispatches each node to every rule whose `head_filter` matches. | -298 of the 303 shipped rules live in twenty-seven themed packages, split seven -ways. A twenty-eighth, `feature/lint-custom`, holds no rules at all: it is the +308 of the 313 shipped rules live in twenty-nine themed packages, split seven +ways. A thirtieth, `feature/lint-custom`, holds no rules at all: it is the pattern language and the second pass that run the rules a *project* writes for itself. @@ -321,7 +321,7 @@ specifies the two cases R7RS 6.4 leaves open — fixnums compare `eq?` by guarantee and characters have been normatively `eq?` since 9.0.0.10 — so every finding there would complain about code the language promises will work. -**`REGISTRY` is in neither.** It names all 303 rules, and every rule depends on +**`REGISTRY` is in neither.** It names all 313 rules, and every rule depends on the engine, so putting it in the engine or in a rule package would be a cycle. It sits in the root crate, and the engine receives a `RuleCatalog` as an argument — which is why the engine can be a package at all. diff --git a/docs/src/reference/configuration.md b/docs/src/reference/configuration.md index 769269d9..fa22da54 100644 --- a/docs/src/reference/configuration.md +++ b/docs/src/reference/configuration.md @@ -1,6 +1,6 @@ # Configuration -With 303 lint rules and 460 commands, passing every knob as a flag +With 313 lint rules and 460 commands, passing every knob as a flag stopped scaling. `paredit.toml` is the answer: a small, strictly validated file that sets the defaults a repository wants, so a command line carries only what is unusual about *this* invocation. diff --git a/packages/feature/lint-fennel-janet-idiom/Cargo.toml b/packages/feature/lint-fennel-janet-idiom/Cargo.toml new file mode 100644 index 00000000..d046b7b9 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "paredit-feature-lint-fennel-janet-idiom" +description = "Lint rules for Fennel and Janet idiom, the two dialects the catalogue had no dedicated rules for" +readme = "README.md" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +paredit-core-syntax = { path = "../../core/syntax" } +paredit-core-lint-engine = { path = "../../core/lint-engine" } + +# Mandatory: without it this package silently opts out of the workspace lint +# table, including `unsafe_code = "deny"`, with no error at all. +[lints] +workspace = true diff --git a/packages/feature/lint-fennel-janet-idiom/README.md b/packages/feature/lint-fennel-janet-idiom/README.md new file mode 100644 index 00000000..0ffcd8a2 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/README.md @@ -0,0 +1,51 @@ +# paredit-feature-lint-fennel-janet-idiom + +Lint rules whose subject is Fennel or Janet specifically. + +Both dialects are first-class in the parser, and until this package the +catalogue had seven rules in scope for either of them — six of which model +several dialects at once and none of which encodes a fact about Fennel or Janet +that is not also true elsewhere. Every rule here is keyed on something the +language's own reference, compiler, or shipped linter states. + +| rule | dialects | keyed on | primary source | +| --- | --- | --- | --- | +| `var-never-set` | Fennel, Janet | `var`, `var-` | Fennel `src/linter.fnl` `check-unused`, `"declared as var but never set"` | +| `fennel-deprecated-form` | Fennel | `global`, `require-macros`, `pick-args` | `reference.md`, "Deprecated Forms" | +| `fennel-each-over-non-iterator` | Fennel | `each` | `specials.fnl` `SPECIALS.each`, which emits Lua's generic `for … in` | +| `janet-empty-loop-body` | Janet | `loop`, `seq`, `catseq` | `boot.janet` `check-empty-body`, `maclintf :normal "empty loop body"` | +| `janet-mutating-immutable-literal` | Janet | `put`, `array/*`, `buffer/*` | `src/core/value.c` `janet_put`, which panics on a struct | + +## Third-party audit + +Run over code nobody here wrote: 288 `.fnl` files (`fennel-lang/fennel`, +`Olical/conjure`, `rktjmp/hotpot.nvim`, `udayvir-singh/tangerine.nvim`, +`min-love2d-fennel`) and 241 `.janet` files (`janet-lang/janet`, `spork`, +`jpm`, `circlet`, `andrewchambers/janet-sh`). + +| rule | candidates | findings | adjudication | +| --- | --- | --- | --- | +| `var-never-set` (Fennel) | 210 | 8 | all true; 5 more were false positives from a project-local macro expanding to `set`, now suppressed | +| `var-never-set` (Janet) | 464 | 31 | all true; 5 more were the same macro false positive, now suppressed | +| `fennel-deprecated-form` | 29 | 28 | all true; the 29th is a malformed `(global)` the arity guard declines | +| `fennel-each-over-non-iterator` | 194 | 1 | true — and it is `fennel-lang/fennel`'s own assertion that this shape raises | +| `janet-empty-loop-body` | 174 | 0 | 2 findings before the `:iterate` narrowing, both the deliberate drain idiom | +| `janet-mutating-immutable-literal` | 695 | 0 | unproven: a real denominator, no instance in this corpus | + +## Known parser limitation for Janet + +Janet's long strings are delimited by a run of backticks of any length +(`src/core/parse.c`, `longstring`), and this repository's reader does not +implement them: a backtick is neither a delimiter nor whitespace for +`Dialect::Janet`, so it is absorbed into an atom and the string's contents are +read as code. Over 241 files from `janet-lang/janet`, `spork`, `jpm`, `circlet` +and `janet-sh`, 9 fail to parse outright and 41 more parse into a tree +containing nodes that lie inside a long string's body. Docstrings written with +```` ``` ```` are the dominant cause, and `src/boot/boot.janet` — Janet's own +core library — is one of the nine. + +Every rule here is therefore blind on roughly 4% of real Janet files and can be +handed phantom forms on another 17%. The rules' quote guard does not help: +these nodes are not quoted, they are prose. Fixing the reader is out of this +package's scope; the measurement is recorded here so the limitation is not +rediscovered. diff --git a/packages/feature/lint-fennel-janet-idiom/src/corpus_tests.rs b/packages/feature/lint-fennel-janet-idiom/src/corpus_tests.rs new file mode 100644 index 00000000..5088d40b --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/corpus_tests.rs @@ -0,0 +1,232 @@ +//! A permanent corpus per dialect: realistic *correct* code that must stay +//! silent, and a dangerous twin that must fire every rule exactly once. +//! +//! The silent half is worthless on its own. A rule whose head is misspelled, +//! whose dialect scope is wrong, or that was deleted entirely also produces +//! zero findings here — so each corpus asserts a **candidate count** as well, +//! taken from the same `candidate_count` the audit used. A zero-finding sweep +//! over zero candidates says nothing; a zero-finding sweep over sixteen +//! candidates says the rules looked and declined. +//! +//! The idioms are taken from real code: the Fennel corpus follows the shapes in +//! `fennel-lang/fennel`'s own `test/` and `reference.md`, and the Janet corpus +//! follows `janet-lang/spork` and `jpm` — accumulate into a `buffer`, group into +//! an `@{}`, `+=` a counter. + +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::SyntaxTree; + +use crate::engine_pass_tests::fired; +use crate::{ + fennel_deprecated_form, fennel_each_over_non_iterator, janet_empty_loop_body, + janet_mutating_immutable_literal, var_never_set, +}; + +/// Correct, idiomatic Fennel. Every rule in this package must decline it. +const FENNEL_CORPUS: &str = r#";; A small module in the style of the reference's own examples. +(local {: view} (require :fennel.view)) +(import-macros {: when-some} :my.macros) + +(fn count-keys [tbl] + "How many keys tbl has." + (var total 0) + (each [_ _ (pairs tbl)] + (set total (+ total 1))) + total) + +(fn render-all [items] + (local out []) + (each [_ item (ipairs items)] + (table.insert out (view item))) + (table.concat out "\n")) + +(fn tally [xs] + (var running 0) + (for [i 1 (length xs)] + (set running (+ running (. xs i)))) + running) + +(fn first-match [xs pred] + (var found nil) + (each [_ x (ipairs xs) &until found] + (when (pred x) + (set found x))) + found) + +(fn lines-of [handle] + (local acc []) + (each [line (handle:lines)] + (table.insert acc line)) + acc) + +(macro incr [place] + `(set ,place (+ ,place 1))) + +{: count-keys : render-all : tally : first-match : lines-of} +"#; + +/// Correct, idiomatic Janet. +const JANET_CORPUS: &str = r#"# A small module in the style of spork and jpm. +(import spork/path) + +(defn tally + "Sum every value of ds." + [ds] + (var total 0) + (each x ds + (+= total x)) + total) + +(defn group-by + "Bucket ds by (f x)." + [f ds] + (def groups @{}) + (loop [x :in ds] + (def k (f x)) + (put groups k (array/push (or (get groups k) @[]) x))) + groups) + +(defn render + [rows] + (def out @"") + (loop [row :in rows :when (next row)] + (buffer/push-string out (string/join row " ")) + (buffer/push-string out "\n")) + (string out)) + +(defn take-evens + [ds] + (seq [x :in ds :when (even? x)] x)) + +(defn normalize + [p] + (var cleaned (path/abspath p)) + (set cleaned (string/replace-all "\\" "/" cleaned)) + cleaned) + +(defmacro bump [place] + ~(set ,place (+ ,place 1))) +"#; + +/// The same shapes, each broken in exactly one way. +const FENNEL_TWIN: &str = r#"(global registry {}) + +(fn count-keys [tbl] + (var total 0) + (each [_ _ {:a 1}] + (print total)) + total) +"#; + +const JANET_TWIN: &str = r#"(defn tally [ds] + (var total 0) + (loop [x :in ds]) + (put {:a 1} :b 2) + total) +"#; + +fn parse(source: &str, dialect: Dialect) -> SyntaxTree { + SyntaxTree::parse_with_dialect(source, dialect).expect("the corpus must parse") +} + +// -- the silent half ------------------------------------------------------ + +#[test] +fn correct_fennel_yields_no_findings() { + assert_eq!(fired(FENNEL_CORPUS, Dialect::Fennel), Vec::<&str>::new()); +} + +#[test] +fn correct_janet_yields_no_findings() { + assert_eq!(fired(JANET_CORPUS, Dialect::Janet), Vec::<&str>::new()); +} + +// -- and the denominator that makes it mean something --------------------- + +#[test] +fn the_fennel_corpus_gives_every_rule_something_to_decline() { + let tree = parse(FENNEL_CORPUS, Dialect::Fennel); + assert_eq!( + var_never_set::domain::candidate_count(Dialect::Fennel, &tree), + 3, + "var bindings the rule looked at" + ); + assert_eq!( + fennel_each_over_non_iterator::domain::candidate_count(Dialect::Fennel, &tree), + 4, + "each forms the rule looked at" + ); + // `fennel-deprecated-form` is the one rule whose denominator *is* its + // numerator: a deprecated special has no correct use, so correct code + // contains none. Pinned at zero deliberately, and the twin below is what + // proves the rule can fire at all. + assert_eq!( + fennel_deprecated_form::domain::candidate_count(Dialect::Fennel, &tree), + 0 + ); +} + +#[test] +fn the_janet_corpus_gives_every_rule_something_to_decline() { + let tree = parse(JANET_CORPUS, Dialect::Janet); + assert_eq!( + var_never_set::domain::candidate_count(Dialect::Janet, &tree), + 2, + "var bindings the rule looked at" + ); + assert_eq!( + janet_empty_loop_body::domain::candidate_count(Dialect::Janet, &tree), + 3, + "loop/seq/catseq forms the rule looked at" + ); + assert_eq!( + janet_mutating_immutable_literal::domain::candidate_count(Dialect::Janet, &tree), + 4, + "mutating calls the rule looked at" + ); +} + +// -- the dangerous twin --------------------------------------------------- + +#[test] +fn the_fennel_twin_fires_each_fennel_rule_exactly_once() { + assert_eq!( + fired(FENNEL_TWIN, Dialect::Fennel), + vec![ + "fennel-deprecated-form", + "fennel-each-over-non-iterator", + "var-never-set" + ] + ); +} + +#[test] +fn the_janet_twin_fires_each_janet_rule_exactly_once() { + assert_eq!( + fired(JANET_TWIN, Dialect::Janet), + vec![ + "janet-empty-loop-body", + "janet-mutating-immutable-literal", + "var-never-set" + ] + ); +} + +/// Reading the Fennel twin as Janet must leave exactly the one rule that is in +/// scope for both dialects, and drop the two Fennel-specific ones. +/// +/// `(var total 0)` really is an unassigned Janet `var` as well, so asserting +/// silence here would be asserting a bug. What the dialect scope owes is that +/// `global` and the `each` binder — neither of which means anything in Janet — +/// stop being reported. +#[test] +fn the_fennel_twin_read_as_janet_keeps_only_the_shared_rule() { + assert_eq!(fired(FENNEL_TWIN, Dialect::Janet), vec!["var-never-set"]); +} + +/// And the reverse. The Janet twin's `(loop [x :in ds])` and +/// `(put {:a 1} :b 2)` are ordinary function calls in Fennel. +#[test] +fn the_janet_twin_read_as_fennel_keeps_only_the_shared_rule() { + assert_eq!(fired(JANET_TWIN, Dialect::Fennel), vec!["var-never-set"]); +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/engine_pass_tests.rs b/packages/feature/lint-fennel-janet-idiom/src/engine_pass_tests.rs new file mode 100644 index 00000000..0cdbe1e0 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/engine_pass_tests.rs @@ -0,0 +1,301 @@ +//! Every rule driven through the *real* engine rather than by calling +//! `examine` or `check` directly. +//! +//! Calling `check` bypasses the head index and the dialect filter, which is +//! where the two mistakes this package is most exposed to would hide: +//! +//! - A `HeadFilter::Heads` entry that does not match the source spelling. For +//! Fennel and Janet `head_key` returns the head *verbatim* +//! (`head_index.rs:82-87`), so `NormalizedHead::new` and the source have to +//! agree byte for byte — there is no case folding to rescue a mismatch, and a +//! rule with a wrong head simply never runs. +//! - A forgotten `dialect_scope`. The trait's default is +//! `COMMON_LISP_ONLY` (`rule.rs:30-31`), so a rule that omits the override +//! silently never fires on its own dialect while every unit test on +//! `examine`, which takes the dialect as an argument, still passes. + +use std::path::Path; + +use paredit_core_lint_engine::engine::{build_head_index, collect_lint_outcomes}; +use paredit_core_lint_engine::policy::RuleSelection; +use paredit_core_lint_engine::rule::RuleCatalog; +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::SyntaxTree; + +use crate::ENTRIES; + +fn path_for(dialect: Dialect) -> &'static Path { + match dialect { + Dialect::Janet => Path::new("t.janet"), + Dialect::Fennel => Path::new("t.fnl"), + _ => Path::new("t.lisp"), + } +} + +/// The rule names that fire on `source`, sorted so the assertions do not +/// depend on registration order. +pub(crate) fn fired(source: &str, dialect: Dialect) -> Vec<&'static str> { + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + let mut names: Vec<&'static str> = collect_lint_outcomes( + catalog, + &index, + path_for(dialect), + dialect, + &tree, + source, + RuleSelection::All, + ) + .expect("lint pass") + .into_iter() + .map(|outcome| outcome.into_parts().0.rule) + .collect(); + names.sort_unstable(); + names +} + +/// The findings' messages, for the assertions that care what was said. +fn messages(source: &str, dialect: Dialect) -> Vec { + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + collect_lint_outcomes( + catalog, + &index, + path_for(dialect), + dialect, + &tree, + source, + RuleSelection::All, + ) + .expect("lint pass") + .into_iter() + .map(|outcome| outcome.into_parts().0.message) + .collect() +} + +// -- each rule reaches the engine ---------------------------------------- + +#[test] +fn every_rule_fires_through_the_real_dispatch() { + assert_eq!( + fired("(var total 0)\n(print total)", Dialect::Fennel), + vec!["var-never-set"] + ); + assert_eq!( + fired("(var total 0)\n(print total)", Dialect::Janet), + vec!["var-never-set"] + ); + assert_eq!( + fired("(require-macros :my.macros)", Dialect::Fennel), + vec!["fennel-deprecated-form"] + ); + assert_eq!( + fired("(each [k v {:a 1}] (print k v))", Dialect::Fennel), + vec!["fennel-each-over-non-iterator"] + ); + assert_eq!( + fired("(loop [x :in xs])", Dialect::Janet), + vec!["janet-empty-loop-body"] + ); + assert_eq!( + fired("(put {:a 1} :b 2)", Dialect::Janet), + vec!["janet-mutating-immutable-literal"] + ); +} + +/// `var-never-set` declares `var-` as well as `var`. A head registered in the +/// index but never produced by any test would be indistinguishable from a +/// typo, so it gets its own arrival. +#[test] +fn the_private_janet_var_spelling_reaches_the_index() { + assert_eq!( + fired("(var- total :private 0)\n(print total)", Dialect::Janet), + vec!["var-never-set"] + ); +} + +/// Every head in `janet-mutating-immutable-literal`'s seventeen-entry filter, +/// exercised through the index. A misspelled entry there is invisible to the +/// domain tests, which never consult the index at all. +#[test] +fn every_declared_mutator_head_is_reachable_through_the_index() { + for (head, arguments) in [ + ("put", ":b 2"), + ("put-in", "[:b] 2"), + ("update", ":b inc"), + ("update-in", "[:b] inc"), + ("array/push", "4"), + ("array/pop", ""), + ("array/concat", "@[4]"), + ("array/insert", "0 4"), + ("array/remove", "0"), + ("array/fill", "0"), + ("array/clear", ""), + ("array/ensure", "8 2"), + ("array/trim", ""), + ("buffer/push", "65"), + ("buffer/push-string", "\"x\""), + ("buffer/clear", ""), + ("buffer/format", "\"%d\" 1"), + ] { + let source = format!("({head} [1 2] {arguments})"); + assert_eq!( + fired(&source, Dialect::Janet), + vec!["janet-mutating-immutable-literal"], + "{head} did not reach the rule through the head index" + ); + } +} + +// -- the dialect scope, which only the engine applies --------------------- + +/// `var` is a head in Common Lisp (`(defvar …)` is not it, but `var` is a +/// perfectly ordinary function name) and in Clojure it is `#'var`. Neither is +/// in scope, and the dispatcher must drop the rule before the walk rather than +/// report and filter afterwards. +#[test] +fn no_rule_fires_on_a_dialect_outside_its_scope() { + for dialect in [ + Dialect::CommonLisp, + Dialect::Clojure, + Dialect::Scheme, + Dialect::EmacsLisp, + Dialect::Hy, + ] { + assert_eq!( + fired("(var total 0)\n(print total)", dialect), + Vec::<&str>::new(), + "var-never-set fired on {dialect:?}" + ); + } +} + +#[test] +fn the_fennel_rules_never_fire_on_janet() { + // `(global x 1)` and `(each [k v {:a 1}] …)` are both readable Janet — the + // second is a call to a function named `each` — and neither means what the + // Fennel rules describe. + assert_eq!( + fired( + "(global x 1)\n(each [k v {:a 1}] (print k))", + Dialect::Janet + ), + Vec::<&str>::new() + ); +} + +#[test] +fn the_janet_rules_never_fire_on_fennel() { + // In Fennel `[1 2]` is a mutable Lua table and `(loop …)` is not a special + // at all, so both of these are ordinary function calls. + assert_eq!( + fired("(put [1 2] 0 3)\n(loop [x :in xs])", Dialect::Fennel), + Vec::<&str>::new() + ); +} + +// -- the quote guard, which only the engine can exercise ------------------ + +/// The dispatcher descends into quoted data unconditionally +/// (`dispatch.rs:291`), so every one of these reaches its rule's `check` and is +/// rejected there. Without the guard each line fires. +#[test] +fn a_form_inside_a_macro_template_is_not_reported() { + assert_eq!( + fired("(macro m [] `(var total 0))", Dialect::Fennel), + Vec::<&str>::new() + ); + assert_eq!( + fired("(macro m [] '(global x 1))", Dialect::Fennel), + Vec::<&str>::new() + ); + assert_eq!( + fired( + "(macro m [] `(each [k v {:a 1}] (print k)))", + Dialect::Fennel + ), + Vec::<&str>::new() + ); + assert_eq!( + fired("(defmacro m [] ~(loop [x :in xs]))", Dialect::Janet), + Vec::<&str>::new() + ); + assert_eq!( + fired("(defmacro m [] ~(put {:a 1} :b 2))", Dialect::Janet), + Vec::<&str>::new() + ); +} + +/// The other half of the guard: an unquoted escape inside a quasiquote is +/// code again, and a rule that stopped at the quasiquote would miss it. Pairs +/// with the test above so neither passes for the wrong reason. +#[test] +fn an_unquoted_escape_inside_a_template_is_still_code() { + assert_eq!( + fired("(defmacro m [] ~(do ,(put {:a 1} :b 2)))", Dialect::Janet), + vec!["janet-mutating-immutable-literal"] + ); + assert_eq!( + fired( + "(macro m [] `(do ,(each [k v {:a 1}] (print k))))", + Dialect::Fennel + ), + vec!["fennel-each-over-non-iterator"] + ); +} + +/// A hard quote never clears, so a comma inside one is a comma character. +#[test] +fn a_hard_quote_swallows_its_own_unquote() { + assert_eq!( + fired( + "(macro m [] '(do ,(each [k v {:a 1}] (print k))))", + Dialect::Fennel + ), + Vec::<&str>::new() + ); +} + +// -- messages ------------------------------------------------------------- + +#[test] +fn each_message_names_the_replacement_the_reader_needs() { + let fennel = messages("(var total 0)\n(print total)", Dialect::Fennel); + assert!(fennel[0].contains("local"), "{fennel:?}"); + let janet = messages("(var total 0)\n(print total)", Dialect::Janet); + assert!(janet[0].contains("def"), "{janet:?}"); + let deprecated = messages("(require-macros :m)", Dialect::Fennel); + assert!(deprecated[0].contains("import-macros"), "{deprecated:?}"); +} + +// -- more than one rule at a time ----------------------------------------- + +#[test] +fn several_rules_share_one_pass_over_one_file() { + let mut names = fired( + "(var total 0)\n(global cache {})\n(each [k v {:a 1}] (print k))", + Dialect::Fennel, + ); + names.dedup(); + assert_eq!( + names, + vec![ + "fennel-deprecated-form", + "fennel-each-over-non-iterator", + "var-never-set" + ] + ); +} + +/// Two `var`s in one file both arrive: the head index dispatches per node, and +/// a rule that reported only the first would still pass every single-finding +/// test above. +#[test] +fn every_matching_node_is_dispatched_not_just_the_first() { + assert_eq!( + fired("(var a 0)\n(var b 0)\n(print a b)", Dialect::Fennel), + vec!["var-never-set", "var-never-set"] + ); +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/domain.rs b/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/domain.rs new file mode 100644 index 00000000..e959c6de --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/domain.rs @@ -0,0 +1,194 @@ +//! `fennel-deprecated-form` detection: a special the Fennel reference lists +//! under "Deprecated Forms". +//! +//! The list is not a judgement call. Fennel's `reference.md` has a +//! `## Deprecated Forms` section, and every entry below is one of its +//! subsections; two of them also say so in the compiler's own doc metadata +//! (`specials.fnl:422`, `"Set name as a global with val. Deprecated."`). +//! +//! Each entry carries the replacement the reference itself names, because a +//! deprecation notice without the replacement is a rule that tells you to stop +//! and not what to do instead. + +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView, SyntaxTree}; + +use crate::support::head_symbol; + +/// Fennel only. `global`, `require-macros` and `pick-args` all exist in other +/// dialects' vocabularies with unrelated meanings — Janet has no `global` +/// special at all, and a Clojure `require` is not this — so widening the scope +/// would report on code the reference says nothing about. +pub const DIALECTS: [Dialect; 1] = [Dialect::Fennel]; + +/// One deprecated special, with what the reference says to use instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Deprecation { + pub head: &'static str, + pub replacement: &'static str, + /// The reference subsection the deprecation is stated in. + pub citation: &'static str, +} + +/// Every special under `reference.md`'s "Deprecated Forms" heading. +/// +/// `require-macros` and `pick-args` are the two other subsections of that +/// section; "Rest destructuring metamethod" is the fourth and is not a form, so +/// there is nothing to key a head on. +pub const DEPRECATIONS: [Deprecation; 3] = [ + Deprecation { + head: "global", + replacement: "a `local` in the module, or an explicit `(tset _G :name …)` if a true global is meant", + citation: "reference.md, \"Deprecated Forms\" -> \"`global` set global variable\"", + }, + Deprecation { + head: "require-macros", + replacement: "`import-macros`, which binds the macro module to a name", + citation: "reference.md, \"Deprecated Forms\" -> \"`require-macros` load macros with less flexibility\"", + }, + Deprecation { + head: "pick-args", + replacement: "a `fn` with the arity written out, or `#(f $1 $2)`", + citation: "reference.md, \"Deprecated Forms\" -> \"`pick-args` create a function of fixed arity\"", + }, +]; + +/// The deprecation for `head`, if it names one. +#[must_use] +pub fn deprecation_for(head: &str) -> Option { + DEPRECATIONS + .iter() + .copied() + .find(|entry| entry.head == head) +} + +/// One use of a deprecated special. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeprecatedUse { + pub span: ByteSpan, + pub deprecation: Deprecation, +} + +/// Examines one form. +#[must_use] +pub fn examine(dialect: Dialect, view: &ExpressionView) -> Option { + if !DIALECTS.contains(&dialect) { + return None; + } + let head = head_symbol(view)?; + // A bare `(global)` with no name is malformed and the compiler says so; + // this rule is about a form that works and should not be written. + if view.children.len() < 2 { + return None; + } + deprecation_for(head).map(|deprecation| DeprecatedUse { + span: view.span, + deprecation, + }) +} + +/// Every deprecated form in one file. +#[must_use] +pub fn collect(dialect: Dialect, tree: &SyntaxTree) -> Vec { + let root = tree.root_view(); + let mut found = Vec::new(); + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if let Some(item) = examine(dialect, view) { + found.push(item); + } + stack.extend(view.children.iter()); + } + found.sort_by_key(|item| item.span.start().get()); + found +} + +/// How many forms this rule could have looked at: every `(head …)` call whose +/// head is one of the three, before the arity guard. The denominator a +/// zero-finding sweep needs. +#[must_use] +pub fn candidate_count(dialect: Dialect, tree: &SyntaxTree) -> usize { + if !DIALECTS.contains(&dialect) { + return 0; + } + let root = tree.root_view(); + let mut count = 0; + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if head_symbol(view).is_some_and(|head| deprecation_for(head).is_some()) { + count += 1; + } + stack.extend(view.children.iter()); + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn heads(source: &str, dialect: Dialect) -> Vec<&'static str> { + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + collect(dialect, &tree) + .into_iter() + .map(|item| item.deprecation.head) + .collect() + } + + #[test] + fn flags_every_deprecated_special() { + assert_eq!(heads("(global x 1)", Dialect::Fennel), vec!["global"]); + assert_eq!( + heads("(require-macros :my.macros)", Dialect::Fennel), + vec!["require-macros"] + ); + assert_eq!(heads("(pick-args 2 f)", Dialect::Fennel), vec!["pick-args"]); + } + + #[test] + fn names_the_replacement_the_reference_names() { + assert!( + deprecation_for("require-macros") + .expect("entry") + .replacement + .contains("import-macros") + ); + } + + #[test] + fn leaves_the_supported_spellings_alone() { + assert!(heads("(local x 1)", Dialect::Fennel).is_empty()); + assert!(heads("(import-macros m :my.macros)", Dialect::Fennel).is_empty()); + assert!(heads("(fn f [a b] (g a b))", Dialect::Fennel).is_empty()); + } + + #[test] + fn a_head_that_merely_starts_the_same_is_not_one() { + assert!(heads("(globalize x)", Dialect::Fennel).is_empty()); + assert!(heads("(require :socket)", Dialect::Fennel).is_empty()); + } + + #[test] + fn a_nested_use_is_reached() { + assert_eq!( + heads("(fn setup []\n (global cache {}))", Dialect::Fennel), + vec!["global"] + ); + } + + #[test] + fn other_dialects_are_out_of_scope() { + // Janet has no `global` special; Clojure's `require` is unrelated. + assert!(heads("(global x 1)", Dialect::Janet).is_empty()); + assert!(heads("(global x 1)", Dialect::CommonLisp).is_empty()); + } + + #[test] + fn the_candidate_count_counts_the_forms_looked_at() { + let tree = + SyntaxTree::parse_with_dialect("(global x 1) (global) (local y 2)", Dialect::Fennel) + .expect("parse"); + assert_eq!(candidate_count(Dialect::Fennel, &tree), 2); + assert_eq!(collect(Dialect::Fennel, &tree).len(), 1); + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/mod.rs b/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/mod.rs new file mode 100644 index 00000000..27249544 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/mod.rs @@ -0,0 +1,4 @@ +//! `fennel-deprecated-form`: a special the Fennel reference deprecates. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/rule.rs b/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/rule.rs new file mode 100644 index 00000000..162a9667 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/fennel_deprecated_form/rule.rs @@ -0,0 +1,87 @@ +//! `fennel-deprecated-form`: a special the Fennel reference deprecates. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleExplanation, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::fennel_deprecated_form::domain::{self, examine}; +use crate::support::is_unevaluated_at; + +pub const META: RuleMeta = RuleMeta::new( + "fennel-deprecated-form", + RuleCategory::Portability, + Severity::Warning, + "a Fennel special the language reference lists under \"Deprecated Forms\"", + Fixability::ReportOnly, +) +.with_explanation( + RuleExplanation::new( + "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for \ + each, what replaced it. They still compile, which is exactly why they survive in code \ + long after the replacement landed.", + ) + .with_example( + "(require-macros :my.macros)", + "(import-macros m :my.macros)", + ) + .with_caveat( + "A file that deliberately targets a Fennel old enough to lack the replacement will be \ + reported. `import-macros` has been available since 0.4.0 and `global` has been \ + deprecated for longer than that, so the case is narrow, but it is real.", + ), +); + +/// One head per entry of [`domain::DEPRECATIONS`], written out because +/// `NormalizedHead::new` is `const` and the table is not a `const` iterator. +const HEADS: [NormalizedHead; 3] = [ + NormalizedHead::new("global"), + NormalizedHead::new("require-macros"), + NormalizedHead::new("pick-args"), +]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + RuleDialectScope::new(&domain::DIALECTS) + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult { + let Some(item) = examine(context.dialect(), view) else { + return Ok(()); + }; + // Asked only once a finding exists. The dispatcher hands a rule + // quoted nodes like any other, so a `(global …)` inside a macro + // template reaches here — but `is_unevaluated_at` materializes the + // whole document, so asking before `examine` charges every `global` + // call in the file for a walk that almost always answers "no". + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + sink.report( + item.span, + format!( + "{} is deprecated; use {} ({})", + item.deprecation.head, item.deprecation.replacement, item.deprecation.citation + ), + ); + Ok(()) + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/domain.rs b/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/domain.rs new file mode 100644 index 00000000..2758d442 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/domain.rs @@ -0,0 +1,309 @@ +//! `fennel-each-over-non-iterator` detection: a Fennel `each` handed a literal +//! where an iterator belongs. +//! +//! `(each [k v tbl] …)` compiles to Lua's generic `for k, v in tbl do` — +//! literally that string, emitted at `specials.fnl:670-672` — and Lua's generic +//! `for` *calls* its iterator on every round. A table is not a function, so +//! `(each [k v {:a 1}] …)` raises "attempt to call a table value" the first time +//! the loop runs. The mistake is writing the collection where `(pairs coll)` or +//! `(ipairs coll)` belongs, which is the single most common Fennel beginner +//! error and the reason the reference opens the section with "Runs the body once +//! for each value provided by the iterator. Commonly used with `ipairs` … or +//! `pairs`" (reference.md, "`each` general iteration"). +//! +//! # Why only literals +//! +//! `(each [k v coll] …)` where `coll` is a symbol is *indistinguishable* from +//! `(each [k v my-iterator] …)`, and the second is legal and idiomatic — the +//! reference says so in the same paragraph ("can be used with any iterator"). +//! Deciding between them needs to know what `coll` holds, which the binding and +//! value tables cannot say for Fennel (they are empty for this dialect). So the +//! rule fires only where the iterator position holds a value that is provably +//! not callable at the point it is written: +//! +//! - a `[…]` sequence literal or a `{…}` table literal — a fresh literal has no +//! metatable, so it cannot have a `__call`; +//! - a `"…"` or `:…` string literal, or a number. +//! +//! Every one of those is a runtime error with certainty, and nothing else is +//! reported. That makes a false positive impossible rather than unlikely. + +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::{ByteSpan, Delimiter, ExpressionKind, ExpressionView, SyntaxTree}; + +use crate::support::{head_symbol, symbol_text}; + +pub const DIALECTS: [Dialect; 1] = [Dialect::Fennel]; + +/// The clause keywords that end the driver list rather than drive it. +/// +/// `&until` is the current spelling and `:until` the pre-1.2.0 one the parser +/// still accepts (reference.md, "`each` general iteration"). Both are removed +/// before the last driver is read, exactly as `iterator-bindings` does it +/// (`specials.fnl:637-648`). +const CLAUSE_KEYWORDS: [&str; 2] = ["&until", ":until"]; + +/// What was found in the iterator position. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NonIterator { + SequenceLiteral, + TableLiteral, + StringLiteral, + NumberLiteral, +} + +impl NonIterator { + #[must_use] + pub const fn describe(self) -> &'static str { + match self { + Self::SequenceLiteral => "a sequence literal", + Self::TableLiteral => "a table literal", + Self::StringLiteral => "a string literal", + Self::NumberLiteral => "a number literal", + } + } + + /// What to wrap it in. A sequence wants `ipairs`; anything else that is a + /// collection wants `pairs`. + #[must_use] + pub const fn suggestion(self) -> &'static str { + match self { + Self::SequenceLiteral => "(ipairs …)", + _ => "(pairs …)", + } + } +} + +/// One `each` whose iterator position holds something uncallable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonIteratorEach { + /// The whole `(each …)` form. + pub span: ByteSpan, + /// The offending expression alone. + pub iterator_span: ByteSpan, + pub kind: NonIterator, +} + +/// Whether an atom's text is a literal rather than a name. +/// +/// Fennel string literals are `"…"`, and a `:`-prefixed token is also a string +/// as long as it is symbol-shaped (reference.md, "Syntax": "Fennel also +/// supports certain kinds of strings that begin with a colon"). Numbers are Lua +/// numbers plus `.inf`/`.nan`. +fn literal_kind(text: &str) -> Option { + if text.starts_with('"') { + return Some(NonIterator::StringLiteral); + } + if text.starts_with(':') && text.len() > 1 { + return Some(NonIterator::StringLiteral); + } + let unsigned = text.strip_prefix('-').unwrap_or(text); + let numeric = unsigned.starts_with(|byte: char| byte.is_ascii_digit()) + || matches!(unsigned, ".inf" | ".nan"); + numeric.then_some(NonIterator::NumberLiteral) +} + +/// Classifies the expression sitting in the iterator position, or `None` when +/// it is anything this rule refuses to judge. +fn classify(view: &ExpressionView) -> Option { + match view.kind { + ExpressionKind::List => match view.delimiter { + Some(Delimiter::Bracket) => Some(NonIterator::SequenceLiteral), + Some(Delimiter::Brace) => Some(NonIterator::TableLiteral), + // A `(…)` call may well return an iterator; that is the correct + // spelling and the overwhelmingly common one. + _ => None, + }, + ExpressionKind::Atom => literal_kind(symbol_text(view)?), + ExpressionKind::Root => None, + } +} + +/// The expression that drives an `each`, given its binder bracket. +/// +/// `iterator-bindings` (`specials.fnl:637-648`) removes the `&until` clause and +/// then takes the *last* remaining item; everything before it is a name. This +/// reproduces that, and returns `None` for a binder too short to have both. +fn iterator_of(binder: &ExpressionView) -> Option<&ExpressionView> { + let mut drivers: &[ExpressionView] = &binder.children; + for (index, child) in binder.children.iter().enumerate() { + let is_clause = symbol_text(child).is_some_and(|text| CLAUSE_KEYWORDS.contains(&text)); + if is_clause { + drivers = &binder.children[..index]; + break; + } + } + // One name and one iterator is the minimum `each` accepts + // (`specials.fnl:669`, "expected binding and iterator"). + (drivers.len() >= 2).then(|| drivers.last())? +} + +/// Examines one form. +#[must_use] +pub fn examine(dialect: Dialect, view: &ExpressionView) -> Option { + if !DIALECTS.contains(&dialect) { + return None; + } + if head_symbol(view) != Some("each") { + return None; + } + // `(each [k v tbl])` with no body is malformed and the compiler says so + // (`specials.fnl:651`, "expected body expression"). + if view.children.len() < 3 { + return None; + } + let binder = view.children.get(1)?; + if binder.delimiter != Some(Delimiter::Bracket) { + return None; + } + let iterator = iterator_of(binder)?; + let kind = classify(iterator)?; + Some(NonIteratorEach { + span: view.span, + iterator_span: iterator.span, + kind, + }) +} + +/// Every offending `each` in one file. +#[must_use] +pub fn collect(dialect: Dialect, tree: &SyntaxTree) -> Vec { + let root = tree.root_view(); + let mut found = Vec::new(); + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if let Some(item) = examine(dialect, view) { + found.push(item); + } + stack.extend(view.children.iter()); + } + found.sort_by_key(|item| item.span.start().get()); + found +} + +/// Every `(each …)` form in the file, judged or not. The denominator. +#[must_use] +pub fn candidate_count(dialect: Dialect, tree: &SyntaxTree) -> usize { + if !DIALECTS.contains(&dialect) { + return 0; + } + let root = tree.root_view(); + let mut count = 0; + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if head_symbol(view) == Some("each") { + count += 1; + } + stack.extend(view.children.iter()); + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(source: &str) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::Fennel).expect("parse"); + collect(Dialect::Fennel, &tree) + .into_iter() + .map(|item| item.kind) + .collect() + } + + #[test] + fn flags_a_table_literal_in_the_iterator_position() { + assert_eq!( + kinds("(each [k v {:a 1}] (print k v))"), + vec![NonIterator::TableLiteral] + ); + } + + #[test] + fn flags_a_sequence_literal_and_suggests_ipairs() { + assert_eq!( + kinds("(each [i x [1 2 3]] (print x))"), + vec![NonIterator::SequenceLiteral] + ); + assert_eq!(NonIterator::SequenceLiteral.suggestion(), "(ipairs …)"); + } + + #[test] + fn flags_string_and_number_literals() { + assert_eq!( + kinds("(each [c \"abc\"] (print c))"), + vec![NonIterator::StringLiteral] + ); + assert_eq!( + kinds("(each [c :abc] (print c))"), + vec![NonIterator::StringLiteral] + ); + assert_eq!( + kinds("(each [n 10] (print n))"), + vec![NonIterator::NumberLiteral] + ); + } + + #[test] + fn the_correct_spellings_are_left_alone() { + assert!(kinds("(each [k v (pairs t)] (print k v))").is_empty()); + assert!(kinds("(each [i x (ipairs xs)] (print x))").is_empty()); + } + + #[test] + fn a_bare_symbol_iterator_is_never_judged() { + // Indistinguishable from a real iterator function, which the + // reference explicitly permits. + assert!(kinds("(each [k v coll] (print k v))").is_empty()); + assert!(kinds("(each [line my-iterator] (print line))").is_empty()); + } + + #[test] + fn the_until_clause_does_not_shift_which_child_is_the_iterator() { + // Without removing `&until` first, `done?` would be read as the + // iterator and `(pairs t)` as a name — the finding would be missed, + // and a literal `&until` bound would be reported. + assert!(kinds("(each [k v (pairs t) &until done?] (print k))").is_empty()); + assert_eq!( + kinds("(each [k v {:a 1} &until done?] (print k))"), + vec![NonIterator::TableLiteral] + ); + assert!(kinds("(each [k v (pairs t) :until done?] (print k))").is_empty()); + } + + #[test] + fn a_binder_with_no_room_for_both_is_not_judged() { + assert!(kinds("(each [{:a 1}] (print 1))").is_empty()); + } + + #[test] + fn a_malformed_each_is_left_to_the_compiler() { + assert!(kinds("(each [k v {:a 1}])").is_empty()); + assert!(kinds("(each)").is_empty()); + } + + #[test] + fn a_non_bracket_binder_is_not_judged() { + assert!(kinds("(each (k v {:a 1}) (print k))").is_empty()); + } + + #[test] + fn other_dialects_are_out_of_scope() { + // Janet's `(each x ds body)` has no binder bracket and iterates a data + // structure directly, so a literal there is correct code. + let tree = SyntaxTree::parse_with_dialect("(each x [1 2 3] (print x))", Dialect::Janet) + .expect("parse"); + assert!(collect(Dialect::Janet, &tree).is_empty()); + } + + #[test] + fn the_candidate_count_counts_every_each() { + let tree = SyntaxTree::parse_with_dialect( + "(each [k v (pairs t)] (f k)) (each [k v {:a 1}] (f k))", + Dialect::Fennel, + ) + .expect("parse"); + assert_eq!(candidate_count(Dialect::Fennel, &tree), 2); + assert_eq!(collect(Dialect::Fennel, &tree).len(), 1); + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/mod.rs b/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/mod.rs new file mode 100644 index 00000000..c2515dfd --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/mod.rs @@ -0,0 +1,5 @@ +//! `fennel-each-over-non-iterator`: a Fennel `each` handed an uncallable +//! literal where an iterator belongs. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/rule.rs b/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/rule.rs new file mode 100644 index 00000000..99ccbd3f --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/fennel_each_over_non_iterator/rule.rs @@ -0,0 +1,82 @@ +//! `fennel-each-over-non-iterator`: a Fennel `each` handed an uncallable +//! literal where an iterator belongs. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleExplanation, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::fennel_each_over_non_iterator::domain::{self, examine}; +use crate::support::is_unevaluated_at; + +pub const META: RuleMeta = RuleMeta::new( + "fennel-each-over-non-iterator", + RuleCategory::Suspicious, + Severity::Error, + "a Fennel `each` whose iterator position holds a literal that cannot be called", + Fixability::ReportOnly, +) +.with_explanation( + RuleExplanation::new( + "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on \ + every round. A table, sequence, string or number literal is not callable, so the loop \ + raises \"attempt to call a table value\" the first time it runs. The collection belongs \ + inside `pairs` or `ipairs`.", + ) + .with_example( + "(each [k v {:a 1}] (print k v))", + "(each [k v (pairs {:a 1})] (print k v))", + ) + .with_caveat( + "Only a literal is judged. `(each [k v coll] …)` is left alone however suspicious it \ + looks, because a bare symbol is exactly how a real iterator function is spelled and the \ + reference permits it.", + ), +); + +const HEADS: [NormalizedHead; 1] = [NormalizedHead::new("each")]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + RuleDialectScope::new(&domain::DIALECTS) + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult { + let Some(item) = examine(context.dialect(), view) else { + return Ok(()); + }; + // After the domain check, not before: the guard materializes the whole + // document, and every `each` in the file would pay for it. + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + sink.report( + item.span, + format!( + "each is given {} where an iterator belongs; Lua's generic for calls it, so wrap \ + it in {}", + item.kind.describe(), + item.kind.suggestion() + ), + ); + Ok(()) + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/domain.rs b/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/domain.rs new file mode 100644 index 00000000..1cd06e0f --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/domain.rs @@ -0,0 +1,209 @@ +//! `janet-empty-loop-body` detection: a Janet loop macro with a head and no +//! body. +//! +//! This is Janet's own lint, reproduced. `boot.janet` defines +//! +//! ```janet +//! (defn- check-empty-body +//! [body] +//! (if (= (length body) 0) +//! (maclintf :normal "empty loop body"))) +//! ``` +//! +//! (`src/boot/boot.janet:626-629`) and calls it from `loop` (`:631`), `seq` +//! (`:709`) and `catseq` (`:717`). Those three heads are exactly what this rule +//! covers — not one more — because `maclintf` fires only at macro expansion +//! time, which means it reaches nobody who is reading a diff, reviewing a pull +//! request, or running a linter over a file they have not executed. +//! +//! `(loop [x :in xs])` iterates and does nothing: the head's `:when`/`:let` +//! clauses still run, so it is not a syntax error, and the usual cause is a +//! body that was deleted or that ended up outside the parentheses. + +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::{ByteSpan, Delimiter, ExpressionView, SyntaxTree}; + +use crate::support::{head_symbol, symbol_text}; + +pub const DIALECTS: [Dialect; 1] = [Dialect::Janet]; + +/// The three macros `check-empty-body` guards, and nothing else. +/// +/// `tabseq` deliberately does not call it (`boot.janet:720-725`) — its +/// `key-body` is mandatory and its value body may legitimately be absent — and +/// `each`/`while`/`for` are C-level or template macros that never had the +/// check. Extending the list would be this rule's opinion rather than Janet's. +pub const HEADS: [&str; 3] = ["loop", "seq", "catseq"]; + +/// The one loop verb whose expression is the work. +/// +/// `:iterate` "repeatedly evaluate and bind to the expression while it is +/// truthy" (`boot.janet:647-648`), so `(loop [_ :iterate (parser/produce p)])` +/// drains a parser and *means* to have no body. Janet's own +/// `check-empty-body` does not know that and warns anyway; both of the two +/// findings this rule produced over 241 third-party Janet files were this +/// idiom, in `janet-lang/janet`'s own `test/suite-parse.janet:180` and `:185`. +/// +/// Excluding it is this rule departing from Janet's check, deliberately: a +/// warning whose every real-world instance is correct code is a warning +/// nobody keeps switched on. +const EFFECT_VERB: &str = ":iterate"; + +/// One loop macro whose body is empty. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmptyLoopBody { + pub span: ByteSpan, + pub head: String, +} + +/// Examines one form. +#[must_use] +pub fn examine(dialect: Dialect, view: &ExpressionView) -> Option { + if !DIALECTS.contains(&dialect) { + return None; + } + let head = head_symbol(view)?; + if !HEADS.contains(&head) { + return None; + } + // `(loop)` with no head at all is malformed and Janet's own + // `check-empty-body` never runs on it; the compiler's error is the better + // message. + let binder = view.children.get(1)?; + if binder.delimiter != Some(Delimiter::Bracket) { + return None; + } + let drains = binder + .children + .iter() + .any(|child| symbol_text(child) == Some(EFFECT_VERB)); + if drains { + return None; + } + // Head plus binder is two children; a body is anything after that. + (view.children.len() == 2).then(|| EmptyLoopBody { + span: view.span, + head: head.to_owned(), + }) +} + +/// Every empty-bodied loop macro in one file. +#[must_use] +pub fn collect(dialect: Dialect, tree: &SyntaxTree) -> Vec { + let root = tree.root_view(); + let mut found = Vec::new(); + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if let Some(item) = examine(dialect, view) { + found.push(item); + } + stack.extend(view.children.iter()); + } + found.sort_by_key(|item| item.span.start().get()); + found +} + +/// Every `loop`/`seq`/`catseq` form in the file, empty or not. The denominator. +#[must_use] +pub fn candidate_count(dialect: Dialect, tree: &SyntaxTree) -> usize { + if !DIALECTS.contains(&dialect) { + return 0; + } + let root = tree.root_view(); + let mut count = 0; + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if head_symbol(view).is_some_and(|head| HEADS.contains(&head)) { + count += 1; + } + stack.extend(view.children.iter()); + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn heads(source: &str, dialect: Dialect) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + collect(dialect, &tree) + .into_iter() + .map(|item| item.head) + .collect() + } + + #[test] + fn flags_each_of_the_three_macros_janet_itself_guards() { + assert_eq!(heads("(loop [x :in xs])", Dialect::Janet), vec!["loop"]); + assert_eq!(heads("(seq [x :in xs])", Dialect::Janet), vec!["seq"]); + assert_eq!(heads("(catseq [x :in xs])", Dialect::Janet), vec!["catseq"]); + } + + #[test] + fn a_loop_with_a_body_is_left_alone() { + assert!(heads("(loop [x :in xs] (print x))", Dialect::Janet).is_empty()); + assert!(heads("(seq [x :in xs] x)", Dialect::Janet).is_empty()); + } + + #[test] + fn a_body_of_nil_still_counts_as_a_body() { + // Writing `nil` is a deliberate statement; an absent body is not. + assert!(heads("(loop [x :in xs] nil)", Dialect::Janet).is_empty()); + } + + #[test] + fn a_multi_clause_head_does_not_count_as_a_body() { + assert_eq!( + heads("(loop [i :range [0 10] :when (even? i)])", Dialect::Janet), + vec!["loop"] + ); + } + + #[test] + fn the_iterate_drain_idiom_is_not_reported() { + // janet-lang/janet test/suite-parse.janet:180. + assert!(heads("(loop [_ :iterate (parser/produce p1)])", Dialect::Janet).is_empty()); + // The control: the same shape with any other verb still reports, so + // the exclusion is the verb and not the underscore or the call. + assert_eq!( + heads("(loop [_ :in (parser/produce p1)])", Dialect::Janet), + vec!["loop"] + ); + } + + #[test] + fn a_malformed_loop_is_left_to_the_compiler() { + assert!(heads("(loop)", Dialect::Janet).is_empty()); + assert!(heads("(loop x)", Dialect::Janet).is_empty()); + } + + #[test] + fn heads_outside_janets_own_list_are_not_covered() { + // `each`, `while` and `for` have no `check-empty-body` call. + assert!(heads("(each x xs)", Dialect::Janet).is_empty()); + assert!(heads("(while true)", Dialect::Janet).is_empty()); + assert!(heads("(tabseq [x :in xs] x)", Dialect::Janet).is_empty()); + } + + #[test] + fn other_dialects_are_out_of_scope() { + // Common Lisp's `loop` with only clauses is complete code. Its reader + // has no bracket delimiter, so the clause list is written `(…)`. + assert!(heads("(loop for x in xs count x)", Dialect::CommonLisp).is_empty()); + // Fennel has no `loop` special at all; this is a call to a function + // named `loop` with one sequence argument. + assert!(heads("(loop [x :in xs])", Dialect::Fennel).is_empty()); + } + + #[test] + fn the_candidate_count_counts_every_loop_macro() { + let tree = SyntaxTree::parse_with_dialect( + "(loop [x :in a] (f x)) (loop [x :in b])", + Dialect::Janet, + ) + .expect("parse"); + assert_eq!(candidate_count(Dialect::Janet, &tree), 2); + assert_eq!(collect(Dialect::Janet, &tree).len(), 1); + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/mod.rs b/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/mod.rs new file mode 100644 index 00000000..c9e96af0 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/mod.rs @@ -0,0 +1,5 @@ +//! `janet-empty-loop-body`: a Janet `loop`/`seq`/`catseq` with a head and no +//! body. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/rule.rs b/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/rule.rs new file mode 100644 index 00000000..3708201d --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/janet_empty_loop_body/rule.rs @@ -0,0 +1,83 @@ +//! `janet-empty-loop-body`: a Janet `loop`/`seq`/`catseq` with a head and no +//! body. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleExplanation, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::janet_empty_loop_body::domain::{self, examine}; +use crate::support::is_unevaluated_at; + +pub const META: RuleMeta = RuleMeta::new( + "janet-empty-loop-body", + RuleCategory::Suspicious, + Severity::Warning, + "a Janet `loop`, `seq`, or `catseq` whose body is empty", + Fixability::ReportOnly, +) +.with_explanation( + RuleExplanation::new( + "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits \ + `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is \ + expanded, so it never reaches a reader of the source. The usual cause is a body that \ + was deleted, or one that ended up outside the closing parenthesis.", + ) + .with_example("(loop [x :in xs])", "(loop [x :in xs] (print x))") + .with_caveat( + "Only the three heads Janet itself guards are covered. `each`, `while`, `for` and \ + `tabseq` have no such check in `boot.janet`, and adding them would be this rule's \ + opinion rather than the language's. In the other direction, a head containing \ + `:iterate` is *not* reported even though Janet's check does report it: `:iterate` \ + evaluates its expression for effect on every round, so an empty body is the point.", + ), +); + +const HEADS: [NormalizedHead; 3] = [ + NormalizedHead::new("loop"), + NormalizedHead::new("seq"), + NormalizedHead::new("catseq"), +]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + RuleDialectScope::new(&domain::DIALECTS) + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult { + let Some(item) = examine(context.dialect(), view) else { + return Ok(()); + }; + // After the domain check, not before: the guard materializes the whole + // document, and every `loop` in the file would pay for it. + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + sink.report( + item.span, + format!( + "{} has a head and no body, so it iterates and does nothing", + item.head + ), + ); + Ok(()) + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/domain.rs b/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/domain.rs new file mode 100644 index 00000000..f9d17ea2 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/domain.rs @@ -0,0 +1,248 @@ +//! `janet-mutating-immutable-literal` detection: a Janet mutation applied to a +//! literal Janet cannot mutate. +//! +//! Janet pairs every mutable container with an immutable twin and separates +//! them by exactly one character, the `@` prefix: `@[…]` array / `[…]` tuple, +//! `@{…}` table / `{…}` struct, `@"…"` buffer / `"…"` string. Dropping the `@` +//! is a one-keystroke mistake that produces code which reads correctly and +//! panics on the first call: `janet_put` ends its `switch` with +//! `janet_panicf("expected %T, got %v", JANET_TFLAG_ARRAY | JANET_TFLAG_BUFFER +//! | JANET_TFLAG_TABLE, ds)` (`src/core/value.c:764-769`), and the `array/*` +//! and `buffer/*` families type-check their first argument the same way. +//! +//! The rule only fires on a literal written at the call site, where the type is +//! decided by the source text and nothing else can change it. A symbol, a call, +//! or anything else in the target position is never judged — the value it holds +//! is exactly the question the rule declines to answer. + +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::{ByteSpan, Delimiter, ExpressionKind, ExpressionView, SyntaxTree}; + +use crate::support::{head_symbol, is_immutable_janet_literal}; + +pub const DIALECTS: [Dialect; 1] = [Dialect::Janet]; + +/// What a mutating operator requires of its first argument. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Requirement { + /// `put`/`update` and friends: array, buffer, or table. + MutableDataStructure, + /// `array/*`: an array specifically. + Array, + /// `buffer/*`: a buffer specifically. + Buffer, +} + +impl Requirement { + #[must_use] + pub const fn describe(self) -> &'static str { + match self { + Self::MutableDataStructure => "an array, buffer, or table", + Self::Array => "an array", + Self::Buffer => "a buffer", + } + } + + /// How to spell the mutable twin of the literal that was written. + #[must_use] + pub const fn remedy(self) -> &'static str { + match self { + Self::Buffer => "write the buffer literal `@\"…\"`", + _ => "prefix the literal with `@`", + } + } +} + +/// The operators this rule knows, each with what it needs. +/// +/// Restricted to the ones whose first argument is unambiguously the mutated +/// container. `array/concat`'s later arguments may be tuples and that is legal, +/// so only index 1 is ever inspected. +pub const MUTATORS: [(&str, Requirement); 17] = [ + ("put", Requirement::MutableDataStructure), + ("put-in", Requirement::MutableDataStructure), + ("update", Requirement::MutableDataStructure), + ("update-in", Requirement::MutableDataStructure), + ("array/push", Requirement::Array), + ("array/pop", Requirement::Array), + ("array/concat", Requirement::Array), + ("array/insert", Requirement::Array), + ("array/remove", Requirement::Array), + ("array/fill", Requirement::Array), + ("array/clear", Requirement::Array), + ("array/ensure", Requirement::Array), + ("array/trim", Requirement::Array), + ("buffer/push", Requirement::Buffer), + ("buffer/push-string", Requirement::Buffer), + ("buffer/clear", Requirement::Buffer), + ("buffer/format", Requirement::Buffer), +]; + +/// The requirement `head` imposes, if it is one of the known mutators. +#[must_use] +pub fn requirement_for(head: &str) -> Option { + MUTATORS + .iter() + .find(|(name, _)| *name == head) + .map(|(_, requirement)| *requirement) +} + +/// The literal kind that was written, for the message. +#[must_use] +const fn literal_name(view: &ExpressionView) -> &'static str { + match view.kind { + ExpressionKind::List => match view.delimiter { + Some(Delimiter::Bracket) => "a tuple literal", + Some(Delimiter::Brace) => "a struct literal", + _ => "an immutable literal", + }, + _ => "a string literal", + } +} + +/// One mutation applied to something that cannot be mutated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImmutableMutation { + pub span: ByteSpan, + pub target_span: ByteSpan, + pub head: String, + pub requirement: Requirement, + pub literal: &'static str, +} + +/// Examines one form. +#[must_use] +pub fn examine(dialect: Dialect, view: &ExpressionView) -> Option { + if !DIALECTS.contains(&dialect) { + return None; + } + let head = head_symbol(view)?; + let requirement = requirement_for(head)?; + let target = view.children.get(1)?; + if !is_immutable_janet_literal(target) { + return None; + } + Some(ImmutableMutation { + span: view.span, + target_span: target.span, + head: head.to_owned(), + requirement, + literal: literal_name(target), + }) +} + +/// Every such mutation in one file. +#[must_use] +pub fn collect(dialect: Dialect, tree: &SyntaxTree) -> Vec { + let root = tree.root_view(); + let mut found = Vec::new(); + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if let Some(item) = examine(dialect, view) { + found.push(item); + } + stack.extend(view.children.iter()); + } + found.sort_by_key(|item| item.span.start().get()); + found +} + +/// Every call to a known mutator, judged or not. The denominator. +#[must_use] +pub fn candidate_count(dialect: Dialect, tree: &SyntaxTree) -> usize { + if !DIALECTS.contains(&dialect) { + return 0; + } + let root = tree.root_view(); + let mut count = 0; + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if head_symbol(view).is_some_and(|head| requirement_for(head).is_some()) { + count += 1; + } + stack.extend(view.children.iter()); + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn heads(source: &str) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::Janet).expect("parse"); + collect(Dialect::Janet, &tree) + .into_iter() + .map(|item| item.head) + .collect() + } + + #[test] + fn flags_put_on_a_struct_literal() { + assert_eq!(heads("(put {:a 1} :b 2)"), vec!["put"]); + } + + #[test] + fn flags_array_push_on_a_tuple_literal() { + assert_eq!(heads("(array/push [1 2 3] 4)"), vec!["array/push"]); + } + + #[test] + fn flags_buffer_push_on_a_string_literal() { + assert_eq!(heads("(buffer/push \"seed\" 65)"), vec!["buffer/push"]); + } + + #[test] + fn the_at_prefix_is_the_whole_difference() { + assert!(heads("(put @{:a 1} :b 2)").is_empty()); + assert!(heads("(array/push @[1 2 3] 4)").is_empty()); + assert!(heads("(buffer/push @\"seed\" 65)").is_empty()); + } + + #[test] + fn a_symbol_or_a_call_in_the_target_is_never_judged() { + assert!(heads("(put state :b 2)").is_empty()); + assert!(heads("(array/push (make-buf) 4)").is_empty()); + } + + #[test] + fn only_the_first_argument_is_inspected() { + // Concatenating a tuple *into* an array is correct Janet. + assert!(heads("(array/concat @[1] [2 3])").is_empty()); + } + + #[test] + fn an_unrelated_head_is_not_a_mutator() { + assert!(heads("(get {:a 1} :a)").is_empty()); + assert!(heads("(length [1 2 3])").is_empty()); + assert!(heads("(string/split \",\" \"a,b\")").is_empty()); + } + + #[test] + fn other_dialects_are_out_of_scope() { + // Fennel has no `@` prefix and no `put`; `[1 2 3]` there is a mutable + // Lua table, so the same source is correct code. + let tree = + SyntaxTree::parse_with_dialect("(put {:a 1} :b 2)", Dialect::Fennel).expect("parse"); + assert!(collect(Dialect::Fennel, &tree).is_empty()); + } + + #[test] + fn the_candidate_count_counts_every_mutator_call() { + let tree = SyntaxTree::parse_with_dialect( + "(put @{} :a 1) (put {} :a 1) (get {} :a)", + Dialect::Janet, + ) + .expect("parse"); + assert_eq!(candidate_count(Dialect::Janet, &tree), 2); + assert_eq!(collect(Dialect::Janet, &tree).len(), 1); + } + + #[test] + fn the_message_names_which_twin_was_written() { + let tree = SyntaxTree::parse_with_dialect("(put [1] 0 2)", Dialect::Janet).expect("parse"); + let found = collect(Dialect::Janet, &tree); + assert_eq!(found[0].literal, "a tuple literal"); + assert_eq!(found[0].requirement, Requirement::MutableDataStructure); + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/mod.rs b/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/mod.rs new file mode 100644 index 00000000..e5420513 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/mod.rs @@ -0,0 +1,5 @@ +//! `janet-mutating-immutable-literal`: a Janet mutation applied to a literal +//! Janet cannot mutate. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/rule.rs b/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/rule.rs new file mode 100644 index 00000000..44a28f51 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/janet_mutating_immutable_literal/rule.rs @@ -0,0 +1,99 @@ +//! `janet-mutating-immutable-literal`: a Janet mutation applied to a literal +//! Janet cannot mutate. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleExplanation, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::janet_mutating_immutable_literal::domain::{self, examine}; +use crate::support::is_unevaluated_at; + +pub const META: RuleMeta = RuleMeta::new( + "janet-mutating-immutable-literal", + RuleCategory::Suspicious, + Severity::Error, + "a Janet mutating call whose target is a tuple, struct, or string literal", + Fixability::ReportOnly, +) +.with_explanation( + RuleExplanation::new( + "Janet's mutable containers differ from their immutable twins by one character: `@[…]` \ + is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and \ + `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when \ + handed the immutable twin, so a dropped `@` is code that reads correctly and dies on \ + its first call.", + ) + .with_example("(put {:a 1} :b 2)", "(put @{:a 1} :b 2)") + .with_caveat( + "Only a literal written at the call site is judged. `(put state :b 2)` is left alone \ + whatever `state` holds — deciding that is the question the rule declines to answer.", + ), +); + +/// One head per entry of [`domain::MUTATORS`]. +const HEADS: [NormalizedHead; 17] = [ + NormalizedHead::new("put"), + NormalizedHead::new("put-in"), + NormalizedHead::new("update"), + NormalizedHead::new("update-in"), + NormalizedHead::new("array/push"), + NormalizedHead::new("array/pop"), + NormalizedHead::new("array/concat"), + NormalizedHead::new("array/insert"), + NormalizedHead::new("array/remove"), + NormalizedHead::new("array/fill"), + NormalizedHead::new("array/clear"), + NormalizedHead::new("array/ensure"), + NormalizedHead::new("array/trim"), + NormalizedHead::new("buffer/push"), + NormalizedHead::new("buffer/push-string"), + NormalizedHead::new("buffer/clear"), + NormalizedHead::new("buffer/format"), +]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + RuleDialectScope::new(&domain::DIALECTS) + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult { + let Some(item) = examine(context.dialect(), view) else { + return Ok(()); + }; + // After the domain check, not before: the guard materializes the whole + // document, and every `put`/`array/*`/`buffer/*` call would pay. + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + sink.report( + item.span, + format!( + "{} needs {} and is given {}, which panics at run time; {}", + item.head, + item.requirement.describe(), + item.literal, + item.requirement.remedy() + ), + ); + Ok(()) + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/lib.rs b/packages/feature/lint-fennel-janet-idiom/src/lib.rs new file mode 100644 index 00000000..a726eaed --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/lib.rs @@ -0,0 +1,42 @@ +#![doc = include_str!("../README.md")] + +pub mod fennel_deprecated_form; +pub mod fennel_each_over_non_iterator; +pub mod janet_empty_loop_body; +pub mod janet_mutating_immutable_literal; +pub mod support; +pub mod var_never_set; + +#[cfg(test)] +mod corpus_tests; +#[cfg(test)] +mod engine_pass_tests; + +/// The rules this package publishes, in the order a registry should list them. +/// +/// Exposed as a function rather than a `RuleCatalog` constant because the root +/// crate owns the catalogue (section 4.2); this exists so the package's own +/// engine-driven tests and the eventual wiring pass name the same five rules. +#[cfg(test)] +pub(crate) static ENTRIES: [paredit_core_lint_engine::rule::RuleEntry; 5] = [ + paredit_core_lint_engine::rule::RuleEntry::new( + &fennel_deprecated_form::rule::META, + &fennel_deprecated_form::rule::RULE, + ), + paredit_core_lint_engine::rule::RuleEntry::new( + &fennel_each_over_non_iterator::rule::META, + &fennel_each_over_non_iterator::rule::RULE, + ), + paredit_core_lint_engine::rule::RuleEntry::new( + &janet_empty_loop_body::rule::META, + &janet_empty_loop_body::rule::RULE, + ), + paredit_core_lint_engine::rule::RuleEntry::new( + &janet_mutating_immutable_literal::rule::META, + &janet_mutating_immutable_literal::rule::RULE, + ), + paredit_core_lint_engine::rule::RuleEntry::new( + &var_never_set::rule::META, + &var_never_set::rule::RULE, + ), +]; diff --git a/packages/feature/lint-fennel-janet-idiom/src/support.rs b/packages/feature/lint-fennel-janet-idiom/src/support.rs new file mode 100644 index 00000000..d033390a --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/support.rs @@ -0,0 +1,297 @@ +//! Shared primitives for the Fennel and Janet rules. +//! +//! Three of the shared one-liners in [`paredit_core_syntax::view_query`] are +//! *wrong* for these two dialects and are deliberately not used here: +//! +//! - `symbol_is`/`symbol_in`/`unqualified` case-fold and strip a package +//! qualifier, which is Common Lisp reader behaviour. Fennel and Janet are +//! both case-sensitive, and `:` is not a package marker in either: in Fennel +//! it introduces a method multi-sym (`handle:read`) and a string literal +//! (`:keyword`), and in Janet it introduces a keyword. `unqualified` turns +//! `handle:read` into `read`, so `symbol_is("handle:read", "read")` is true — +//! a rule keyed on a core operator would match an unrelated method call. +//! Everything here compares with `==`. +//! - `atom_text` returns the atom's *whole* text, reader prefixes included, so +//! the `,x` of a macro template reads as `",x"`. [`symbol_text`] strips them. +//! +//! The quote model is the two-counter one from +//! `paredit_feature_lint_condition_system::support`, which is private to that +//! crate; a single `i32` depth counter is wrong (`'` never clears, `` ` `` +//! does) and has shipped as a false-positive source before. Both dialects have +//! `'`/`` ` ``/`,` — Fennel spells quasiquote `` ` `` and Janet spells it `~`, +//! and the reader maps both onto [`ReaderPrefix::Quasiquote`] — so the guard is +//! needed here for exactly the same reason it is needed in Common Lisp. + +use paredit_core_syntax::sexpr::{ + ByteSpan, Delimiter, ExpressionKind, ExpressionView, ReaderPrefix, SyntaxTree, +}; + +/// The atom's own symbol text, with any reader prefix removed. +/// +/// `text` spans the prefixes too, so `,x` in a Fennel macro body reads as +/// `",x"` without this and would never compare equal to `x`. +#[must_use] +pub fn symbol_text(view: &ExpressionView) -> Option<&str> { + if view.kind != ExpressionKind::Atom { + return None; + } + view.text.as_deref()?.get(view.symbol_offset..) +} + +/// The head symbol of a `(...)` list, exactly as written. +/// +/// Bracket and brace forms have no head: they are data literals in both +/// dialects, and the engine's head index only consults `list_head` for paren +/// lists anyway (`paredit_core_lint_engine::engine::dispatch`), so a rule +/// using [`HeadFilter::Heads`] can never be handed one. +/// +/// [`HeadFilter::Heads`]: paredit_core_lint_engine::model::HeadFilter::Heads +#[must_use] +pub fn head_symbol(view: &ExpressionView) -> Option<&str> { + if view.kind != ExpressionKind::List || view.delimiter != Some(Delimiter::Paren) { + return None; + } + view.children.first().and_then(symbol_text) +} + +/// Whether `view` is a `(...)` call to exactly one of `heads`, compared +/// byte-for-byte. +#[must_use] +pub fn calls_exactly(view: &ExpressionView, heads: &[&str]) -> bool { + head_symbol(view).is_some_and(|head| heads.contains(&head)) +} + +/// Whether any atom anywhere under `view` spells exactly `name`. +/// +/// Deliberately blind to quoting and to shadowing: every caller here uses it +/// to *suppress* a finding, so over-matching costs a false negative and +/// under-matching would cost a false positive. +#[must_use] +pub fn mentions_symbol(view: &ExpressionView, name: &str) -> bool { + let mut stack = vec![view]; + while let Some(node) = stack.pop() { + if symbol_text(node) == Some(name) { + return true; + } + stack.extend(node.children.iter()); + } + false +} + +/// Whether `view` is a literal whose value is immutable in Janet. +/// +/// Janet's mutable containers all carry the `@` prefix, which the reader +/// records as [`ReaderPrefix::HashLiteral`]: `@[…]` is an array and `[…]` a +/// tuple, `@{…}` a table and `{…}` a struct, `@"…"` a buffer and `"…"` a +/// string. The absence of that one prefix is the whole distinction. +#[must_use] +pub fn is_immutable_janet_literal(view: &ExpressionView) -> bool { + if view.reader_prefixes.contains(&ReaderPrefix::HashLiteral) { + return false; + } + match view.kind { + ExpressionKind::List => { + matches!(view.delimiter, Some(Delimiter::Bracket | Delimiter::Brace)) + } + ExpressionKind::Atom => symbol_text(view).is_some_and(|text| text.starts_with('"')), + ExpressionKind::Root => false, + } +} + +/// How much of the surrounding reader syntax says "this is data". +/// +/// Two independent counters, because `'` and `` ` `` are not the same thing. A +/// comma inside `'(…)` is a comma character in a literal list, so `hard` never +/// clears; a comma inside `` `(…) `` escapes back to code, so `quasi` counts up +/// and down. A single depth counter cannot express that. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QuoteState { + hard: bool, + quasi: u32, +} + +impl QuoteState { + const EVALUATED: Self = Self { + hard: false, + quasi: 0, + }; + + const fn is_data(self) -> bool { + self.hard || self.quasi > 0 + } + + /// The state inside a node, given the state outside it and the node's own + /// reader prefixes. + /// + /// `HashLiteral` is deliberately neutral: Janet's `@` marks mutability, + /// not quoting, and `@[(f x)]` evaluates `(f x)`. + fn after_prefixes(mut self, view: &ExpressionView) -> Self { + for prefix in &view.reader_prefixes { + match prefix { + ReaderPrefix::Quote => self.hard = true, + ReaderPrefix::Quasiquote => self.quasi += 1, + ReaderPrefix::Unquote | ReaderPrefix::UnquoteSplicing => { + self.quasi = self.quasi.saturating_sub(1); + } + _ => {} + } + } + self + } + + const fn quoted(mut self) -> Self { + self.hard = true; + self + } +} + +/// The long-hand `(quote …)`. Fennel's reader expands `'x` and `` `x `` to it, +/// and a macro body written by hand spells it out. +fn is_quote_form(view: &ExpressionView) -> bool { + head_symbol(view) == Some("quote") +} + +const fn span_contains(outer: ByteSpan, inner: ByteSpan) -> bool { + outer.start().get() <= inner.start().get() && inner.end().get() <= outer.end().get() +} + +/// Whether the node at `target` is unevaluated data rather than code. +/// +/// # Cost +/// +/// [`SyntaxTree::root_view`] materializes the whole document — a `Vec` per node +/// and a `String` per atom — so this is O(file), not O(depth), despite the +/// descent below being O(depth). **Call it only once a finding exists.** A +/// measured pass over a 63 KB Fennel file charged 450 µs per invocation to a +/// rule that asked on every head-matched node, against 39 ns for a shipped +/// `AllNodes` rule over the same tree; asking after the domain check instead +/// removed the cost entirely, because correct code produces no findings to ask +/// about. Use [`is_unevaluated_in`] when a root view is already in hand. +#[must_use] +pub fn is_unevaluated_at(tree: &SyntaxTree, target: ByteSpan) -> bool { + is_unevaluated_in(&tree.root_view(), target) +} + +/// [`is_unevaluated_at`] against a root view the caller already has. +/// +/// Descends from the root through the one child at each level whose span +/// contains `target`, so the cost really is the node's depth. +/// +/// The verdict is read *at* the target and nowhere shallower: `` `(do ,(f)) `` +/// has a quasiquoted ancestor and an evaluated target. Being inside a hard `'` +/// does settle it, and that is already modelled by `hard` never clearing. +#[must_use] +pub fn is_unevaluated_in(root: &ExpressionView, target: ByteSpan) -> bool { + let mut view: &ExpressionView = root; + let mut state = QuoteState::EVALUATED; + + loop { + let quoting = is_quote_form(view); + let Some(child) = view + .children + .iter() + .find(|child| span_contains(child.span, target)) + else { + return state.is_data(); + }; + state = state.after_prefixes(child); + if quoting { + state = state.quoted(); + } + view = child; + if view.span == target { + return state.is_data(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + + fn forms(source: &str, dialect: Dialect) -> Vec { + SyntaxTree::parse_with_dialect(source, dialect) + .expect("parse") + .root_view() + .children + .clone() + } + + #[test] + fn symbol_text_strips_a_reader_prefix() { + let form = forms("`(do ,x)", Dialect::Fennel).remove(0); + let unquoted = &form.children[1]; + assert_eq!(unquoted.text.as_deref(), Some(",x")); + assert_eq!(symbol_text(unquoted), Some("x")); + } + + #[test] + fn head_comparison_is_case_sensitive_and_keeps_the_colon() { + // `symbol_is` from view_query would answer true to both of these. + let method = forms("(handle:read)", Dialect::Fennel).remove(0); + assert_eq!(head_symbol(&method), Some("handle:read")); + assert!(!calls_exactly(&method, &["read"])); + + let upper = forms("(Var x 1)", Dialect::Fennel).remove(0); + assert!(!calls_exactly(&upper, &["var"])); + } + + #[test] + fn a_bracket_form_has_no_head() { + let bracket = forms("[var x 1]", Dialect::Fennel).remove(0); + assert_eq!(head_symbol(&bracket), None); + } + + #[test] + fn janet_mutability_is_read_off_the_at_prefix() { + let form = forms( + "(f [1] @[1] {:a 1} @{:a 1} \"s\" @\"s\" xs)", + Dialect::Janet, + ) + .remove(0); + let verdicts: Vec = form.children[1..] + .iter() + .map(is_immutable_janet_literal) + .collect(); + assert_eq!( + verdicts, + vec![true, false, true, false, true, false, false], + "tuple/struct/string are immutable; array/table/buffer and a symbol are not" + ); + } + + #[test] + fn a_hard_quote_never_clears_but_a_quasiquote_does() { + let tree = SyntaxTree::parse_with_dialect("'(a ,(b))", Dialect::Fennel).expect("parse"); + let inner = tree.root_view().children[0].children[1].span; + assert!( + is_unevaluated_at(&tree, inner), + "a comma inside a hard quote is a comma character" + ); + + let tree = SyntaxTree::parse_with_dialect("`(a ,(b))", Dialect::Fennel).expect("parse"); + let inner = tree.root_view().children[0].children[1].span; + assert!( + !is_unevaluated_at(&tree, inner), + "a comma inside a quasiquote escapes back to code" + ); + } + + #[test] + fn janets_tilde_quasiquote_is_the_same_state() { + let tree = SyntaxTree::parse_with_dialect("~(a ,(b))", Dialect::Janet).expect("parse"); + let quoted = tree.root_view().children[0].children[0].span; + let unquoted = tree.root_view().children[0].children[1].span; + assert!(is_unevaluated_at(&tree, quoted)); + assert!(!is_unevaluated_at(&tree, unquoted)); + } + + #[test] + fn mentions_symbol_walks_the_whole_subtree() { + let form = forms("(fn [] (let [y 1] (set acc y)))", Dialect::Fennel).remove(0); + assert!(mentions_symbol(&form, "acc")); + assert!(!mentions_symbol(&form, "ACC")); + assert!(!mentions_symbol(&form, "ac")); + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/var_never_set/domain.rs b/packages/feature/lint-fennel-janet-idiom/src/var_never_set/domain.rs new file mode 100644 index 00000000..e073e9ed --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/var_never_set/domain.rs @@ -0,0 +1,658 @@ +//! `var-never-set` detection: a mutable binding nothing ever reassigns. +//! +//! Both dialects split their binding forms the same way — Fennel has +//! `local`/`var`, Janet has `def`/`var` — and in both the mutable spelling +//! exists only so that a later assignment is legal. A `var` with no assignment +//! anywhere states a mutability the code does not use, and both languages' +//! communities read `var` as "watch this, it changes". +//! +//! This is not an invented rule. Fennel ships a linter plugin whose +//! `check-unused` asserts `(or (not meta.var) meta.set)` and reports +//! `"%s declared as var but never set"` (`src/linter.fnl:80-82`), and its test +//! suite pins the behaviour: `(var x 1) (+ x 9)` must fail to compile under the +//! plugin while `(var x 1) (set x 9)` must succeed (`test/linter.fnl`, +//! `test-var-never-set`). Janet has no such plugin, but the same `def`/`var` +//! distinction and the same `set` special (`src/boot/boot.janet`), so the +//! analysis transfers unchanged; only the mutator vocabulary differs. +//! +//! # Why the whole file, and why that is the safe direction +//! +//! The engine hands a `HeadFilter::Heads` rule one node with no parent pointer +//! and no depth, so "is this name assigned anywhere in its scope" cannot be +//! asked of the node alone — and +//! [`paredit_core_lint_engine::engine::RuleContext::binding_table`] is empty +//! for both of these dialects (`build_binding_table` returns early for +//! anything outside Common Lisp / Emacs Lisp / Scheme / Racket), so there is no +//! resolved binding to consult either. +//! +//! What is left is a name search over the whole file, and the direction of its +//! error matters: the search *suppresses* findings. It is deliberately blind to +//! scope, to shadowing, and to quoting, so every one of those blindnesses can +//! only hide a true positive, never invent one. A module-level `var` assigned +//! from a function defined three hundred lines later is found; so is one +//! assigned only inside a macro template; so is an unrelated `x` in a different +//! function's `(set x …)`. +//! +//! The scan is not cached in +//! [`paredit_core_lint_engine::engine::RuleContext::scratch_cache`] on purpose: +//! that slot holds one type per file's pass and panics on a second, and +//! `leftover-print-debug` — which is in scope for both of these dialects — +//! already claims it. +//! +//! # Measured cost, and the shape this rule wants +//! +//! One invocation costs what a whole-tree rule's single invocation costs, +//! because it does the same thing: 484 µs against `leftover-print-debug`'s +//! 531 µs over the same 63 KB Fennel document. The difference is how often. +//! `HeadFilter::Heads` dispatches per node, so a file with *n* `var` forms pays +//! *n* times — 97 ms at 200 `var`s and 381 ms at 400, a doubling ratio of 3.93 +//! where the two shipped rules measured beside it are 1.85 and 1.93. +//! +//! Nearly all of that is +//! [`paredit_core_syntax::sexpr::SyntaxTree::root_view`] materializing the +//! document, and there is no cheaper door: the borrowed node accessors are +//! `pub(in crate::sexpr)`, so a feature package's only whole-tree access is the +//! materializing one. [`is_candidate`] keeps everything else out of that path, +//! which is what makes the real-world cost bearable — the 288-file Fennel +//! corpus averages 0.73 `var` forms per file and the 241-file Janet corpus +//! 1.93, against the 200 the measurement above uses. +//! +//! The shape that fixes this properly is `HeadFilter::WholeTree`: the +//! dispatcher materializes the root exactly once per file and hands it over +//! (`dispatch.rs`, before the walk), which is one materialization instead of +//! *n*. This rule uses `Heads` because that is what it was specified to use. + +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionKind, ExpressionView, SyntaxTree}; + +use crate::support::{head_symbol, symbol_text}; + +/// The dialects this rule is meaningful for. `rule.rs` passes this same +/// constant to `RuleDialectScope::new`, so the scope and the per-dialect +/// vocabulary below cannot drift apart. +pub const DIALECTS: [Dialect; 2] = [Dialect::Fennel, Dialect::Janet]; + +/// The heads that introduce a mutable binding in `dialect`, or `&[]` for a +/// dialect this rule does not model. +/// +/// Janet's `var-` is `(var x :private …)` (`boot.janet:73-77`). `varfn` is +/// deliberately absent: it is redefined by writing `varfn` again rather than by +/// `set`, so it needs a different mutator vocabulary than the one below. +#[must_use] +pub const fn binder_heads_for(dialect: Dialect) -> &'static [&'static str] { + match dialect { + Dialect::Fennel => &["var"], + Dialect::Janet => &["var", "var-"], + _ => &[], + } +} + +/// The heads that assign to an existing binding in `dialect`. +/// +/// Fennel: `set` (`specials.fnl:424`) and `set-forcibly!` (`:434`) are the only +/// two specials that destructure with `declaration` unset, which is what makes +/// an assignment an assignment there. +/// +/// Janet: `set` plus every macro in `boot.janet:138-144` and `:79-82` that +/// expands to it — `++`, `--`, `+=`, `-=`, `*=`, `/=`, `%=`, `toggle`. Omitting +/// one of those would turn `(var i 0) (while … (++ i))` into a false positive, +/// which is precisely the shape the rule is aimed at. +#[must_use] +pub const fn mutator_heads_for(dialect: Dialect) -> &'static [&'static str] { + match dialect { + Dialect::Fennel => &["set", "set-forcibly!"], + Dialect::Janet => &["set", "++", "--", "+=", "-=", "*=", "/=", "%=", "toggle"], + _ => &[], + } +} + +/// One mutable binding with no assignment anywhere in its file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsetVar { + /// The whole `(var …)` form, which is what the finding points at. + pub span: ByteSpan, + /// The span of the name atom alone. + pub name_span: ByteSpan, + pub name: String, + /// The head as written, so the message can say `var-` when that is what + /// the author wrote. + pub head: String, + /// What to suggest instead: `local` in Fennel, `def` in Janet. + pub immutable_head: &'static str, +} + +/// The name a `(var …)` form binds, if it binds exactly one plain name. +/// +/// Destructuring binders (`(var [a b] …)`, `(var {: x} …)`) return `None`: the +/// rule would have to decide which of several names is unassigned, and a +/// partially-assigned destructure is a different claim than this rule makes. +/// +/// A Fennel multi-sym (`t.x`, `t:x`) also returns `None` — it is not a new +/// binding at all, and `var` rejects it (`nomulti`, `specials.fnl:417`). +fn bound_name(view: &ExpressionView) -> Option<&ExpressionView> { + let name = view.children.get(1)?; + if name.kind != ExpressionKind::Atom { + return None; + } + let text = symbol_text(name)?; + let plain = !text.is_empty() + && !text.contains('.') + && !text.contains(':') + && !text.starts_with('"') + && !text.starts_with(|byte: char| byte.is_ascii_digit()); + plain.then_some(name) +} + +/// The macro names a file binds, which is what makes a call opaque. +/// +/// A macro can expand to a `set` on any argument it is handed, and nothing in +/// the call site says so. Two independent third-party corpora produced the +/// same false positive from exactly that: +/// +/// - `tangerine.nvim`'s `serialize.fnl:26` defines +/// `` (macro append! [name ...] … `(set-forcibly! ,name (.. ,name …))) `` +/// and calls `(append! out …)` three times, so `out` is assigned and no +/// literal `(set out …)` exists; +/// - `jpm`'s `cgen.janet:15` defines +/// `` (defmacro- setfn [name & body] ~(set ,name (fn ,name ,;body))) `` +/// and uses it for five forward-declared `var`s. +/// +/// Both were reported before this existed. The suppression is per file, which +/// is the only scope available: a macro imported from elsewhere has no body +/// here to read. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MacroVocabulary { + /// Names that head a macro call in this file. + names: Vec, + /// Prefixes from `(import-macros m :mod)`, whose calls read `(m.name …)`. + prefixes: Vec, + /// Set when the file pulls in macros whose names cannot be enumerated — + /// `require-macros` imports a whole module into scope without naming what + /// it brought. Every call in such a file is potentially a macro call, so + /// the rule declines the file entirely. + opaque: bool, +} + +impl MacroVocabulary { + /// Whether a call to `head` could be a macro expanding to an assignment. + #[must_use] + pub fn is_macro_call(&self, head: &str) -> bool { + self.names.iter().any(|name| name == head) + || self + .prefixes + .iter() + .any(|prefix| head.starts_with(prefix) && head[prefix.len()..].starts_with('.')) + } + + #[must_use] + pub const fn is_opaque(&self) -> bool { + self.opaque + } +} + +/// Every atom under `view`, with a leading `:` stripped. +/// +/// Used on the binding table of `(macros {…})` and `(import-macros {…} …)`. +/// Fennel's `{: name}` shorthand reads as the two atoms `:` and `name`, and +/// the explicit `{:name local-name}` reads as `:name` and `local-name`, so +/// taking every atom rather than trying to pick out keys covers both — and +/// over-collecting a macro name only widens the suppression. +fn collect_atom_names(view: &ExpressionView, out: &mut Vec) { + let mut stack = vec![view]; + while let Some(node) = stack.pop() { + if let Some(text) = symbol_text(node) { + let name = text.strip_prefix(':').unwrap_or(text); + if !name.is_empty() { + out.push(name.to_owned()); + } + } + stack.extend(node.children.iter()); + } +} + +/// Reads the macro vocabulary a file establishes for itself. +#[must_use] +pub fn macro_vocabulary(dialect: Dialect, root: &ExpressionView) -> MacroVocabulary { + let mut vocabulary = MacroVocabulary::default(); + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if let Some(head) = head_symbol(view) { + match (dialect, head) { + // `(macro name [args] body)`. + (Dialect::Fennel, "macro") | (Dialect::Janet, "defmacro" | "defmacro-") => { + if let Some(name) = view.children.get(1).and_then(symbol_text) { + vocabulary.names.push(name.to_owned()); + } + } + // `(macros {: a : b})` defines several at once. + (Dialect::Fennel, "macros") => { + if let Some(table) = view.children.get(1) { + collect_atom_names(table, &mut vocabulary.names); + } + } + // `(import-macros {: a} :mod)` names what it brought; + // `(import-macros m :mod)` does not, and its calls read `m.a`. + (Dialect::Fennel, "import-macros") => match view.children.get(1) { + Some(binding) if binding.kind == ExpressionKind::Atom => { + if let Some(name) = symbol_text(binding) { + vocabulary.prefixes.push(name.to_owned()); + } + } + Some(binding) => collect_atom_names(binding, &mut vocabulary.names), + None => {} + }, + // `(require-macros :mod)` brings every macro of a module into + // scope under its own name, and the module is not this file. + (Dialect::Fennel, "require-macros") => vocabulary.opaque = true, + _ => {} + } + } + stack.extend(view.children.iter()); + } + vocabulary +} + +/// Whether any assignment form anywhere in `root` names `name` as a target, +/// or any file-local macro call could have expanded into one. +/// +/// Only the *target* of an assignment counts, which is what separates this +/// from a plain reference: `(var x 1) (print x)` still reports, and that is +/// the whole point of the rule. +/// +/// Every atom of the target position is collected rather than just a bare +/// symbol, so `(set [a b] …)`, `(set {: a} …)` and `(set (. t k) …)` all mark +/// every name they mention. That over-marks — `(set (. t k) 1)` marks `t` — +/// and over-marking suppresses. +fn is_assigned_anywhere( + root: &ExpressionView, + mutators: &[&str], + macros: &MacroVocabulary, + name: &str, +) -> bool { + let mut stack = vec![root]; + while let Some(view) = stack.pop() { + // A bound `let` in an `&&` chain is a let chain, which is edition-2024 + // syntax this workspace's 1.85 MSRV does not have and which only the + // `msrv` check catches. `is_some_and` says the same thing at 1.85. + let assigns_here = head_symbol(view).is_some_and(|head| mutators.contains(&head)) + && view + .children + .get(1) + .is_some_and(|target| mentions(target, name)); + if assigns_here { + return true; + } + // A macro sees its arguments unevaluated and may expand any of them + // into an assignment target, so every argument counts, not just the + // first. + let macro_could_assign = head_symbol(view).is_some_and(|head| macros.is_macro_call(head)) + && view.children[1..].iter().any(|arg| mentions(arg, name)); + if macro_could_assign { + return true; + } + stack.extend(view.children.iter()); + } + false +} + +fn mentions(view: &ExpressionView, name: &str) -> bool { + let mut stack = vec![view]; + while let Some(node) = stack.pop() { + if symbol_text(node) == Some(name) { + return true; + } + stack.extend(node.children.iter()); + } + false +} + +/// Whether `view` is a `(var name init)` this rule could report, judged from +/// the node alone. +/// +/// Split out from [`examine`] because everything `examine` does past this point +/// needs the whole document, and +/// [`paredit_core_syntax::sexpr::SyntaxTree::root_view`] materializes it — a +/// `Vec` per node and a `String` per atom. Asking this first means only a real +/// `(var …)` form pays, instead of every node the head index dispatches. +#[must_use] +pub fn is_candidate(dialect: Dialect, view: &ExpressionView) -> bool { + let binders = binder_heads_for(dialect); + if binders.is_empty() { + return false; + } + if !head_symbol(view).is_some_and(|head| binders.contains(&head)) { + return false; + } + // `(var)` and `(var x)` are malformed in both dialects — Fennel asserts + // `(= (length ast) 3)` (`specials.fnl:449`). Reporting a form that does not + // compile would be noise on top of the compiler's own message. + view.children.len() >= 3 && bound_name(view).is_some() +} + +/// Examines one `(var …)` form against the whole file it sits in. +/// +/// `macros` is the file's own macro vocabulary, read once by the caller — the +/// rule is dispatched per node and would otherwise re-read it per `var`. +#[must_use] +pub fn examine( + dialect: Dialect, + root: &ExpressionView, + macros: &MacroVocabulary, + view: &ExpressionView, +) -> Option { + if macros.is_opaque() || !is_candidate(dialect, view) { + return None; + } + let head = head_symbol(view)?; + let name_view = bound_name(view)?; + let name = symbol_text(name_view)?; + if is_assigned_anywhere(root, mutator_heads_for(dialect), macros, name) { + return None; + } + Some(UnsetVar { + span: view.span, + name_span: name_view.span, + name: name.to_owned(), + head: head.to_owned(), + immutable_head: immutable_head_for(dialect), + }) +} + +/// The binding form to suggest instead. +const fn immutable_head_for(dialect: Dialect) -> &'static str { + match dialect { + Dialect::Janet => "def", + _ => "local", + } +} + +/// Every unassigned mutable binding in one file. The standalone entry point, +/// used by the tests and by any future report that wants the same list without +/// going through the lint engine. +#[must_use] +pub fn collect(dialect: Dialect, tree: &SyntaxTree) -> Vec { + let root = tree.root_view(); + let macros = macro_vocabulary(dialect, &root); + let mut found = Vec::new(); + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if let Some(item) = examine(dialect, &root, ¯os, view) { + found.push(item); + } + stack.extend(view.children.iter()); + } + found.sort_by_key(|item| item.span.start().get()); + found +} + +/// How many binding forms this rule could have reported on: the denominator a +/// zero-finding sweep needs in order to mean anything. +#[must_use] +pub fn candidate_count(dialect: Dialect, tree: &SyntaxTree) -> usize { + let binders = binder_heads_for(dialect); + if binders.is_empty() { + return 0; + } + let root = tree.root_view(); + let mut count = 0; + let mut stack: Vec<&ExpressionView> = root.children.iter().collect(); + while let Some(view) = stack.pop() { + if head_symbol(view).is_some_and(|head| binders.contains(&head)) { + count += 1; + } + stack.extend(view.children.iter()); + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(source: &str, dialect: Dialect) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + collect(dialect, &tree) + .into_iter() + .map(|item| item.name) + .collect() + } + + #[test] + fn flags_a_fennel_var_that_is_never_set() { + assert_eq!(names("(var x 1)\n(print x)", Dialect::Fennel), vec!["x"]); + } + + #[test] + fn leaves_a_fennel_var_that_is_set() { + assert!(names("(var x 1)\n(set x 2)", Dialect::Fennel).is_empty()); + } + + #[test] + fn the_two_cases_fennels_own_linter_test_pins() { + // test/linter.fnl, `test-var-never-set`. + assert_eq!(names("(var x 1) (+ x 9)", Dialect::Fennel), vec!["x"]); + assert!(names("(var x 1) (set x 9)", Dialect::Fennel).is_empty()); + } + + #[test] + fn a_set_from_a_later_function_counts() { + assert!( + names( + "(var count 0)\n(fn inc []\n (set count (+ count 1)))", + Dialect::Fennel + ) + .is_empty() + ); + } + + #[test] + fn set_forcibly_counts_as_an_assignment() { + assert!(names("(var x 1) (set-forcibly! x 2)", Dialect::Fennel).is_empty()); + } + + #[test] + fn a_destructuring_set_marks_every_name_it_mentions() { + assert!(names("(var x 1)\n(set [x y] [2 3])", Dialect::Fennel).is_empty()); + } + + #[test] + fn flags_a_janet_var_and_names_def_as_the_alternative() { + let tree = + SyntaxTree::parse_with_dialect("(var x 1)\n(print x)", Dialect::Janet).expect("parse"); + let found = collect(Dialect::Janet, &tree); + assert_eq!(found.len(), 1); + assert_eq!(found[0].immutable_head, "def"); + assert_eq!(found[0].head, "var"); + } + + #[test] + fn every_janet_assignment_macro_counts() { + // boot.janet:138-144 and :79-82 all expand to `set`. A rule that + // knew only `set` would report every counter loop in the language. + for mutator in [ + "set x 2", "++ x", "-- x", "+= x 1", "-= x 1", "*= x 2", "/= x 2", "%= x 2", "toggle x", + ] { + assert!( + names(&format!("(var x 1)\n({mutator})"), Dialect::Janet).is_empty(), + "({mutator}) was not read as an assignment" + ); + } + } + + #[test] + fn janets_private_var_spelling_is_covered() { + assert_eq!( + names("(var- x :private 1)\n(print x)", Dialect::Janet), + vec!["x"] + ); + } + + #[test] + fn a_destructuring_binder_is_not_reported() { + // Which of `a` and `b` is unassigned is a different claim. + assert!(names("(var [a b] [1 2])\n(print a)", Dialect::Fennel).is_empty()); + } + + /// The four sub-conditions of `bound_name`'s plain-name test, each of + /// which is a binder position holding something that is not a new name. + /// Removing the test made none of the other cases fail, so it gets its + /// own. + #[test] + fn a_binder_that_is_not_a_plain_new_name_is_left_to_the_compiler() { + // A multi symbol names a field of an existing table, and `var` + // rejects it outright (`nomulti`, `specials.fnl:417`). + assert!(names("(var t.x 1)\n(print t.x)", Dialect::Fennel).is_empty()); + assert!(names("(var t:x 1)\n(print t:x)", Dialect::Fennel).is_empty()); + // Neither a number nor a string literal is a name. + assert!(names("(var 1 2)\n(print 1)", Dialect::Fennel).is_empty()); + assert!(names("(var \"s\" 2)\n(print 1)", Dialect::Fennel).is_empty()); + // The control: the same shape with a plain name does report, so the + // four assertions above cannot pass because the rule stopped working. + assert_eq!(names("(var tx 1)\n(print tx)", Dialect::Fennel), vec!["tx"]); + } + + #[test] + fn a_malformed_var_is_left_to_the_compiler() { + assert!(names("(var x)", Dialect::Fennel).is_empty()); + assert!(names("(var)", Dialect::Fennel).is_empty()); + } + + #[test] + fn a_var_nested_in_a_function_body_is_reached() { + assert_eq!( + names("(fn f []\n (var acc 0)\n acc)", Dialect::Fennel), + vec!["acc"] + ); + } + + #[test] + fn an_unmodelled_dialect_reports_nothing() { + assert!(names("(var x 1)", Dialect::Clojure).is_empty()); + assert!(names("(var x 1)", Dialect::CommonLisp).is_empty()); + } + + #[test] + fn the_candidate_count_is_the_denominator_not_the_finding_count() { + let tree = SyntaxTree::parse_with_dialect("(var a 1) (set a 2) (var b 1)", Dialect::Fennel) + .expect("parse"); + assert_eq!(candidate_count(Dialect::Fennel, &tree), 2); + assert_eq!(collect(Dialect::Fennel, &tree).len(), 1); + } + + // -- the macro-opacity suppression, and its symmetric controls --------- + + #[test] + fn a_file_local_fennel_macro_taking_the_name_suppresses() { + // tangerine.nvim serialize.fnl:26 — `append!` expands to + // `(set-forcibly! ,name (.. ,name …))`, so `out` *is* assigned. + let source = "(macro append! [name ...]\n `(set-forcibly! ,name (.. ,name ,...)))\n\ + (fn render [xs]\n (var out \"\")\n (each [_ x (ipairs xs)]\n \ + (append! out x))\n out)"; + assert!(names(source, Dialect::Fennel).is_empty()); + } + + /// The control the suppression needs: the same file with the macro call + /// replaced by a call to something that is *not* a macro must still + /// report. Without this the assertion above passes for a rule that has + /// stopped working. + #[test] + fn an_ordinary_call_taking_the_name_does_not_suppress() { + let source = "(macro append! [name ...]\n `(set-forcibly! ,name (.. ,name ,...)))\n\ + (fn render [xs]\n (var out \"\")\n (each [_ x (ipairs xs)]\n \ + (print out x))\n out)"; + assert_eq!(names(source, Dialect::Fennel), vec!["out"]); + } + + #[test] + fn a_file_local_janet_macro_taking_the_name_suppresses() { + // jpm cgen.janet:15 — `setfn` expands to `(set ,name (fn ,name …))`. + let source = "(defmacro- setfn [name & body]\n ~(set ,name (fn ,name ,;body)))\n\ + (var emit-type nil)\n(setfn emit-type [x] x)"; + assert!(names(source, Dialect::Janet).is_empty()); + } + + #[test] + fn an_undeclared_janet_macro_name_does_not_suppress() { + // Same call shape, but nothing in the file defines `setfn`, so the + // suppression must not fire on the head alone. + assert_eq!( + names( + "(var emit-type nil)\n(setfn emit-type [x] x)", + Dialect::Janet + ), + vec!["emit-type"] + ); + } + + #[test] + fn macros_and_import_macros_both_contribute_names() { + assert!( + names( + "(macros {:bump! (fn [n] `(set ,n 1))})\n(var total 0)\n(bump! total)", + Dialect::Fennel + ) + .is_empty() + ); + assert!( + names( + "(import-macros {: bump!} :my.macros)\n(var total 0)\n(bump! total)", + Dialect::Fennel + ) + .is_empty() + ); + } + + #[test] + fn a_module_bound_import_macros_suppresses_its_dotted_calls() { + assert!( + names( + "(import-macros m :my.macros)\n(var total 0)\n(m.bump! total)", + Dialect::Fennel + ) + .is_empty() + ); + // …and only those. A different module's dotted call is not it. + assert_eq!( + names( + "(import-macros m :my.macros)\n(var total 0)\n(other.bump! total)", + Dialect::Fennel + ), + vec!["total"] + ); + } + + #[test] + fn require_macros_makes_the_whole_file_opaque() { + // It imports every macro of a module under its own name and says + // nothing about what those names are. + assert!( + names( + "(require-macros :my.macros)\n(var total 0)\n(print total)", + Dialect::Fennel + ) + .is_empty() + ); + } + + #[test] + fn the_macro_vocabulary_reads_only_what_the_file_declares() { + let tree = SyntaxTree::parse_with_dialect( + "(macro a [] nil)\n(import-macros {: b} :m)\n(import-macros p :n)", + Dialect::Fennel, + ) + .expect("parse"); + let vocabulary = macro_vocabulary(Dialect::Fennel, &tree.root_view()); + assert!(vocabulary.is_macro_call("a")); + assert!(vocabulary.is_macro_call("b")); + assert!(vocabulary.is_macro_call("p.c")); + assert!(!vocabulary.is_macro_call("pc")); + assert!(!vocabulary.is_macro_call("d")); + assert!(!vocabulary.is_opaque()); + } + + #[test] + fn a_head_that_only_looks_like_var_is_not_one() { + assert!(names("(variable x 1) (print x)", Dialect::Fennel).is_empty()); + assert!(names("(my.var x 1) (print x)", Dialect::Fennel).is_empty()); + } +} diff --git a/packages/feature/lint-fennel-janet-idiom/src/var_never_set/mod.rs b/packages/feature/lint-fennel-janet-idiom/src/var_never_set/mod.rs new file mode 100644 index 00000000..9cc7aaf0 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/var_never_set/mod.rs @@ -0,0 +1,4 @@ +//! `var-never-set`: a Fennel or Janet mutable binding nothing ever reassigns. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-fennel-janet-idiom/src/var_never_set/rule.rs b/packages/feature/lint-fennel-janet-idiom/src/var_never_set/rule.rs new file mode 100644 index 00000000..91bfaab4 --- /dev/null +++ b/packages/feature/lint-fennel-janet-idiom/src/var_never_set/rule.rs @@ -0,0 +1,104 @@ +//! `var-never-set`: a Fennel or Janet mutable binding nothing ever reassigns. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleExplanation, RuleMeta, RuleTag, + Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::support::is_unevaluated_in; +use crate::var_never_set::domain::{self, examine, macro_vocabulary}; + +pub const META: RuleMeta = RuleMeta::new( + "var-never-set", + RuleCategory::Suspicious, + Severity::Warning, + "a Fennel or Janet `var` binding that nothing ever assigns to", + Fixability::ReportOnly, +) +.with_tags(&[RuleTag::Style]) +.with_explanation( + RuleExplanation::new( + "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. \ + The mutable spelling exists only so a later assignment is legal, so a `var` with no \ + assignment anywhere in the file states a mutability the code never uses and makes a \ + reader look for a reassignment that is not there. Fennel's own linter plugin reports \ + it as `\" declared as var but never set\"`.", + ) + .with_example( + "(var total 0)\n(print total)", + "(local total 0)\n(print total)", + ) + .with_caveat( + "The assignment search covers the whole file and is blind to scope and to quoting, so a \ + `var` assigned only from an unrelated scope, or only inside a macro template, is left \ + alone. So is a `var` handed to a macro this file defines or imports, since a macro can \ + expand into an assignment on any argument. That direction is deliberate: every \ + blindness here hides a finding rather than inventing one. A macro imported by \ + `require-macros`, whose names cannot be enumerated, makes the rule decline the file.", + ), +); + +/// Every binder head [`domain::binder_heads_for`] models, across both +/// dialects. The index is a pre-filter, so a head that belongs to only one of +/// them is harmless here — `check` re-reads the per-dialect table. +const HEADS: [NormalizedHead; 2] = [NormalizedHead::new("var"), NormalizedHead::new("var-")]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + // The same constant the domain's vocabulary tables are written + // against, so a dialect can never be in scope with no vocabulary or + // have vocabulary with no scope. + RuleDialectScope::new(&domain::DIALECTS) + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult { + // A *performance* guard, not a correctness one: `examine` re-applies + // `is_candidate` itself, so deleting this line changes no output and + // no test fails — a mutation run confirmed exactly that. What it + // changes is cost. Everything below materializes the document, and + // without this the head index's over-approximation pays for it. + if !domain::is_candidate(context.dialect(), view) { + return Ok(()); + } + let root = context.tree().root_view(); + // The dispatcher walks into quoted data like any other subtree, so a + // `(var …)` inside a macro template reaches this rule; it is a + // template, not a binding. Answered against the root view already in + // hand rather than through `is_unevaluated_at`, which would build a + // second one. + if is_unevaluated_in(&root, view.span) { + return Ok(()); + } + let macros = macro_vocabulary(context.dialect(), &root); + let Some(item) = examine(context.dialect(), &root, ¯os, view) else { + return Ok(()); + }; + sink.report( + item.span, + format!( + "{} is declared with {} but never assigned; {} says what this binding is", + item.name, item.head, item.immutable_head + ), + ); + Ok(()) + } +} diff --git a/packages/feature/lint-type-declaration/Cargo.toml b/packages/feature/lint-type-declaration/Cargo.toml new file mode 100644 index 00000000..e6f1b702 --- /dev/null +++ b/packages/feature/lint-type-declaration/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "paredit-feature-lint-type-declaration" +description = "Lint rules for Common Lisp declaration forms: declare, declaim, the, and the type specifiers they carry" +readme = "README.md" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +paredit-core-syntax = { path = "../../core/syntax" } +paredit-core-lint-engine = { path = "../../core/lint-engine" } + +# Mandatory: without it this package silently opts out of the workspace lint +# table, including `unsafe_code = "deny"`, with no error at all. +[lints] +workspace = true diff --git a/packages/feature/lint-type-declaration/README.md b/packages/feature/lint-type-declaration/README.md new file mode 100644 index 00000000..fdba7c15 --- /dev/null +++ b/packages/feature/lint-type-declaration/README.md @@ -0,0 +1,163 @@ +# paredit-feature-lint-type-declaration + +Lint rules for Common Lisp's declaration system — `declare`, `declaim`, `the`, +and the type specifiers they carry. This is where "the compiler trusted me and I +was wrong" bugs live: a declaration is a *promise*, and an optimising compiler is +entitled to generate code that assumes it. + +Five rules, every one Common Lisp only, every one `HeadFilter::Heads`, every one +report-only. + +| Rule | Category | Severity | What SBCL 2.6.0 says | +|---|---|---|---| +| `declare-not-at-head-of-body` | `Malformed` | `Error` | `caught ERROR: There is no function named DECLARE` | +| `declaim-inside-body` | `Declaration` | `Warning` | `STYLE-WARNING: DECLAIM where DECLARE was probably intended` | +| `type-declaration-contradicts-initform` | `Declaration` | `Warning` | `WARNING: Constant 0 conflicts with its asserted type STRING` | +| `the-form-with-impossible-type` | `Declaration` | `Warning` | `WARNING: Derived type of (LIST 1 2) is (VALUES CONS &OPTIONAL), conflicting with its asserted type NULL` | +| `type-declaration-on-rest-parameter` | `Declaration` | `Warning` | `WARNING: Derived type of (SB-C:%LISTIFY-REST-ARGS ...) is (VALUES LIST &OPTIONAL), conflicting with its asserted type FIXNUM` | + +Each rule's own module documents the CLHS section it rests on and the exact +expression that was run against SBCL to check it. + +## The design rule this package is built around + +**Decline every compound type specifier.** `(or null hash-table)` around a `nil` +initform is correct, extremely common code, and a type lattice that tried to +reason about compound specifiers would fire on it. So +[`support::type_excludes`] answers only "can this *atomic* specifier definitely +not contain this literal", for a list of specifiers whose membership is fully +enumerable, and says nothing about anything else. `t`, `atom`, `sequence`, +`array` and `vector` are deliberately unmodelled — a string *is* a vector and *is* +a sequence, and those are exactly the questions a linter gets wrong. + +That costs findings. It is still the right trade, and the corpus audit is why. + +## What the corpus audit changed + +Run over **2217 third-party Common Lisp files** (SBCL's own `src/`, ASDF, and +Quicklisp distributions) containing 21239 `(declare`, 3979 `(declaim`, 3661 +`(the ` and 8446 `&rest`/`&body` occurrences. The first pass produced 16 +findings. Every one was a false positive, and each taught the package something +it did not know: + +- **CLHS 3.2.3.1.** The body of a top-level `locally`, `macrolet` or + `symbol-macrolet` is processed as *top level forms*, so a `declaim` there is an + ordinary proclamation. SBCL relies on this in `constraint.lisp` and + `target-unicode.lisp`. Those three heads were dropped from + `declaim-inside-body`. +- **A reader conditional can be the head.** `globaldb.lisp` opens a definition + with `(#+sb-xc-host cl:defmacro #-sb-xc-host sb-xc:defmacro …)`. The folded + `#+` atom still normalizes to `defmacro`, so the head index dispatches, and the + two conditional atoms shift every later index. The guard now scans from index + zero rather than from the body start. +- **`#.` builds docstrings.** `save.lisp` writes `#.(format nil "…")` where a + documentation string goes. Statically that is a `format` call, so the + declarations after it read as displaced. + +After those narrowings the audit reports **zero findings** over the same corpus, +while the package's dangerous-twin test proves each rule still fires. + +## Cost + +Every rule declares `HeadFilter::Heads`. None is about an absence with no head to +anchor on, so none needs `WholeTree` — which the `clean/forms/*` benchmark gate +measures on every file whether a rule matches or not. + +`src/cost_tests.rs` measures per-rule ns/invocation and invocation counts at four +file sizes, and caught one real bug: [`support::is_unevaluated_at`] descends from +the file's *root*, and a linear `find` at that first level costs one pass over +every top-level form **per finding**. It measured 646ms at 500 reporting +definitions and 2442ms at 1000 — 3.8× the work for 2× the input. It now +binary-searches each level. + +The residual **per-finding** cost (~1.2µs on a file where every form reports, +against ~30ns per declining invocation, and itself doubling as the file doubles) +is *not* this package's. Two rules with different heads, different analyses and +no shared code measure the same number and the same growth; what they share is +the engine's finding materialisation downstream of `sink.report`. See +`ignored_bench_the_per_finding_cost_is_shared_by_unrelated_rules`. + +## Not shipped + +Five candidates were investigated and dropped. + +**`ignore-declared-variable-then-used` — a true duplicate.** `lint-convention` +already ships `ignore-declaration-conflict` +(`packages/feature/lint-convention/src/ignore_declaration_conflict.rs:33`), with +the same diagnosis, the same category, the same `Fixability`, and literally the +same worked example. It was built here anyway before the duplicate was found, so +the FP work is not wasted — see the note below. + +The other four rest on premises that did not survive contact with SBCL: + +- **`optimize-safety-zero`** — `(safety 0)` is a deliberate choice, and SBCL's + own `constraint.lisp` sets it in a `locally` on purpose. Also adjacent to the + shipped `contradictory-optimize` + (`packages/feature/lint-convention/src/contradictory_optimize.rs:29`), which + reports one quality named twice. +- **`ftype-declaimed-after-definition`** — the premise is that the compiler never + sees the declaration. SBCL refutes it: a late `declaim` *does* constrain later + calls, identically to an early one. The real effect is only that the body's own + conflict is demoted from `WARNING` to `STYLE-WARNING`, which SBCL already + reports itself. +- **`special-declaration-without-defvar`** — SBCL says nothing at all, because + declaring a special defined in another file is the ordinary way to reference + one. Not soundly decidable at file scope. +- **`inline-declaimed-for-recursive-function`** — real (SBCL notes + `*INLINE-EXPANSION-LIMIT* (50) was exceeded`), but only an optimisation *note*, + and correlating a `declaim` with its `defun` is the whole-file shape these cost + tests exist to prevent. + +### Handover: false-positive classes in `ignore-declaration-conflict` + +> **Superseded, and two of its claims were wrong.** A later pass fixed the rule +> and measured it over 1291 files (SBCL `src/` plus the installed Quicklisp +> dist, 2224 `(ignore` occurrences). It found **45 findings, not 21**, and **2 +> of them are true positives** — `bordeaux-threads/apiv2/impl-corman.lisp:30` +> and `:39`, where `(defun %thread-yield () (declare (ignore thread)))` carries +> a declaration copy-pasted from a neighbouring definition. So "all of them are +> false positives" below is wrong. +> +> More importantly the four classes below are only **17 of the 43** genuine +> false positives. The two largest were missed entirely, and neither is a +> body-walk bug — the rule simply could not read a lambda list: +> **destructuring macro lambda lists** (21 findings; `parameter_names` took the +> *first* child of a sublist, so `(defmacro m ((a b) …))` lost `b`) and +> **`supplied-p` variables** (5). Together, 60% of the false positives. +> +> Two further corrections. The "macro arguments" class has **zero instances**, +> and no guard was written for it — whether an unknown macro evaluates its +> argument is not decidable at file scope. And the `QuoteState` offered below +> uses `saturating_sub`, which cannot fix `srctran.lisp:5344`, where the +> reference sits inside `,(…)` in a template the `lambda` itself is inside and +> the unquote escapes *outward*; `quasi` has to be **signed**, with only depth +> exactly 0 counting as a reference. +> +> Kept for the three classes it does describe correctly, and as a record of how +> far a spot-check gets you. + +The duplicate was found *after* its false positives had been characterised, so +the evidence is recorded here rather than thrown away. Run over 2217 files, the +shipped rule produces **21 findings**, and spot-checking says all of them are +false positives on code SBCL compiles clean: + +- **Quoted/templated declarations.** `body_uses` walks with + `view_query::for_each_subview`, which is unfiltered, so a `(declare (ignore + ,@dummies))` inside a macro template is read as a real declaration. Five + findings name variables literally spelled `,@ignore-list`, `,@dummies` and + `,@ignored` (`ir1opt.lisp:2004`, `seqtran.lisp:3939`, + `closer-clozure.lisp:28`). +- **Shadowing.** No rebinding check, so an inner `lambda` that rebinds the name + counts as a use of the outer one. `insts.lisp:662` (`posn`) and + `target-error.lisp:507` (`condition`) are both exactly this, and both are + SBCL's own source. +- **Lisp-2 namespaces.** A symbol in operator position names a *function*. + `asdf.lisp` declares a `&key` parameter `builtin-system-p` `ignore` and calls + the accessor of the same name. +- **Macro arguments.** A macro decides whether to evaluate its arguments. + Verified against SBCL: a macro that does not evaluate its argument produces no + warning, one that does produces `reading an ignored variable`. + +The corresponding guards, each with a test and each mutation-tested, are in +`support.rs` (`is_unevaluated_at`, `for_each_evaluated_subview_where`'s +`Position::is_operator`) and can be lifted across. diff --git a/packages/feature/lint-type-declaration/src/corpus_audit.rs b/packages/feature/lint-type-declaration/src/corpus_audit.rs new file mode 100644 index 00000000..f16bb2cc --- /dev/null +++ b/packages/feature/lint-type-declaration/src/corpus_audit.rs @@ -0,0 +1,128 @@ +//! A false-positive audit harness: runs every rule in this package over a +//! corpus of third-party Common Lisp and prints findings with their locations. +//! +//! Author-written tests encode the author's model of the language, not the +//! language. A sibling batch shipped ten rules that passed their own suites and +//! a sweep of this repository's fixtures; an audit over 3163 files nobody +//! involved had written produced 653 findings, killed one rule outright and +//! narrowed four more. So this exists, and it is `#[ignore]`d only because the +//! corpus is not checked in. +//! +//! ```text +//! PAREDIT_DECL_CORPUS=/path/to/MANIFEST.txt \ +//! cargo test -p paredit-feature-lint-type-declaration \ +//! -- --ignored --nocapture ignored_audit_corpus +//! ``` +//! +//! The manifest is one absolute path per line. The output reports, per rule, +//! both the finding count **and the denominator** — how many files and how many +//! candidate occurrences the rule was actually offered. A zero-finding sweep +//! over zero candidates is a false clean and proves nothing at all. + +use std::path::Path; + +use paredit_core_lint_engine::engine::{build_head_index, collect_lint_outcomes}; +use paredit_core_lint_engine::policy::RuleSelection; +use paredit_core_lint_engine::rule::RuleCatalog; +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::SyntaxTree; + +use crate::ENTRIES; + +/// Counts the textual occurrences of each construct a rule keys on, so that a +/// finding count has a denominator to be read against. +fn candidate_counts(source: &str) -> [usize; 4] { + let lower = source.to_ascii_lowercase(); + [ + lower.matches("(declare").count(), + lower.matches("(declaim").count(), + lower.matches("(the ").count(), + lower.matches("&rest").count() + lower.matches("&body").count(), + ] +} + +#[test] +#[ignore = "needs a third-party corpus; set PAREDIT_DECL_CORPUS to a manifest"] +fn ignored_audit_corpus() { + let Ok(manifest_path) = std::env::var("PAREDIT_DECL_CORPUS") else { + panic!("set PAREDIT_DECL_CORPUS to a manifest of absolute .lisp paths"); + }; + let manifest = std::fs::read_to_string(&manifest_path).expect("read the manifest"); + + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + + let mut files_scanned = 0usize; + let mut files_parsed = 0usize; + let mut totals = [0usize; 4]; + let mut findings: Vec<(String, String, usize, String)> = Vec::new(); + + for line in manifest.lines() { + let path = line.trim(); + if path.is_empty() { + continue; + } + files_scanned += 1; + let Ok(source) = std::fs::read_to_string(path) else { + continue; + }; + for (slot, count) in candidate_counts(&source).into_iter().enumerate() { + totals[slot] += count; + } + let Ok(tree) = SyntaxTree::parse_with_dialect(&source, Dialect::CommonLisp) else { + continue; + }; + files_parsed += 1; + let Ok(outcomes) = collect_lint_outcomes( + catalog, + &index, + Path::new(path), + Dialect::CommonLisp, + &tree, + &source, + RuleSelection::All, + ) else { + continue; + }; + for outcome in outcomes { + let (finding, _) = outcome.into_parts(); + let offset = finding.span.start().get(); + let line_number = source[..offset].matches('\n').count() + 1; + let excerpt = source[offset..] + .lines() + .next() + .unwrap_or_default() + .trim() + .chars() + .take(110) + .collect::(); + findings.push(( + finding.rule.to_owned(), + path.to_owned(), + line_number, + excerpt, + )); + } + } + + println!("\n===== DENOMINATOR ====="); + println!("files in manifest : {files_scanned}"); + println!("files parsed as CL : {files_parsed}"); + println!("(declare occurrences : {}", totals[0]); + println!("(declaim occurrences : {}", totals[1]); + println!("(the occurrences : {}", totals[2]); + println!("&rest/&body occurrences: {}", totals[3]); + + println!("\n===== FINDINGS BY RULE ====="); + for entry in catalog.entries() { + let name = entry.meta().name().as_str(); + let count = findings.iter().filter(|(rule, ..)| rule == name).count(); + println!("{count:>6} {name}"); + } + + println!("\n===== EVERY FINDING ====="); + for (rule, path, line, excerpt) in &findings { + println!("{rule}\t{path}:{line}\t{excerpt}"); + } + println!("\ntotal findings: {}", findings.len()); +} diff --git a/packages/feature/lint-type-declaration/src/cost_tests.rs b/packages/feature/lint-type-declaration/src/cost_tests.rs new file mode 100644 index 00000000..3a8f60cc --- /dev/null +++ b/packages/feature/lint-type-declaration/src/cost_tests.rs @@ -0,0 +1,450 @@ +//! What each rule here costs when it is handed thousands of its own heads. +//! +//! Every rule in this package reaches *outside* the node it is given: each asks +//! [`crate::support::is_unevaluated_at`] once a finding is in hand, and that +//! descends from the file's root. That is the exact shape that has twice +//! produced a rule which is linear per invocation and therefore quadratic per +//! file: two shipped rules that re-scanned every top-level form per match were +//! 98% of a whole lint run at 480 definitions, at 3.7 per doubling where linear +//! is 2.0. It is why `is_unevaluated_at` binary-searches each level rather than +//! scanning it. +//! +//! Nothing here can be caught by a correctness test: those rules produced +//! exactly the right findings. So the measurement is the test. +//! +//! # How to read the numbers +//! +//! ```text +//! cargo test -p paredit-feature-lint-type-declaration --lib cost_ -- --nocapture +//! ``` +//! +//! prints, per rule and per file size, the microseconds the dispatcher +//! attributes to that rule's `check` calls and how many times it was called. +//! Two controls make those numbers mean something: +//! +//! - a **no-op rule** declaring the *same* heads and the same dialect scope, so +//! the difference between the two columns is this package's own work rather +//! than the dispatcher's; +//! - a **doubling ratio** across an 8× range of file sizes. Linear work gives +//! ≈8×; the quadratic shape gives ≈64×. +//! +//! A comparator from another *shipped* lint package would be better still, but +//! `tests/cli/feature_dependency_contract.rs` scans the whole manifest text for +//! `paredit-feature-` — `[dev-dependencies]` included — so a cross-feature dev +//! dependency would trip a contract this package is not allowed to edit. The +//! no-op control is this repository's established substitute. +//! +//! # What runs unattended +//! +//! Only the **invocation counts**. They are decided by the head index and the +//! dialect scope before any `check` body runs, so they are the same number on an +//! idle machine and a loaded one. +//! +//! The **doubling ratio is a benchmark, not a test**, and is `#[ignore]`d: a +//! ratio between two wall-clock durations is unstable under parallel load at any +//! threshold, and a sibling package's version of the same idea failed CI on a +//! busy runner. Run it deliberately: +//! +//! ```text +//! cargo test -p paredit-feature-lint-type-declaration \ +//! -- --ignored --nocapture ignored_bench_ +//! ``` + +use std::path::Path; +use std::time::Duration; + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{ + PassOptions, RuleContext, RuleSink, build_head_index, collect_lint_pass, +}; +use paredit_core_lint_engine::model::{Fixability, HeadFilter, RuleCategory, RuleMeta, Severity}; +use paredit_core_lint_engine::policy::{RuleDialectScope, RuleSelection}; +use paredit_core_lint_engine::rule::{LintRule, RuleCatalog, RuleEntry}; +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::{ExpressionView, SyntaxTree}; + +use crate::support::{COMMON_LISP_ONLY, DECLARATION_BODY_RULE_HEADS}; + +/// A rule that matches the same heads and does nothing, so the difference +/// between its column and a real rule's is that rule's own work. +#[derive(Debug)] +struct NoopRule; + +const NOOP_META: RuleMeta = RuleMeta::new( + "cost-control-noop", + RuleCategory::Suspicious, + Severity::Warning, + "a control that matches the same heads and does nothing", + Fixability::ReportOnly, +); + +static NOOP_RULE: NoopRule = NoopRule; + +impl LintRule for NoopRule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&DECLARATION_BODY_RULE_HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + COMMON_LISP_ONLY + } + + fn check( + &self, + _context: &RuleContext<'_>, + _view: &ExpressionView, + _sink: &mut RuleSink<'_, '_>, + ) -> LintResult<()> { + Ok(()) + } +} + +static ENTRIES: [RuleEntry; 6] = [ + RuleEntry::new(&NOOP_META, &NOOP_RULE), + RuleEntry::new( + &crate::declare_not_at_head_of_body::rule::META, + &crate::declare_not_at_head_of_body::rule::RULE, + ), + RuleEntry::new( + &crate::declaim_inside_body::rule::META, + &crate::declaim_inside_body::rule::RULE, + ), + RuleEntry::new( + &crate::type_declaration_contradicts_initform::rule::META, + &crate::type_declaration_contradicts_initform::rule::RULE, + ), + RuleEntry::new( + &crate::the_form_with_impossible_type::rule::META, + &crate::the_form_with_impossible_type::rule::RULE, + ), + RuleEntry::new( + &crate::type_declaration_on_rest_parameter::rule::META, + &crate::type_declaration_on_rest_parameter::rule::RULE, + ), +]; + +const RULES: [&str; 5] = [ + "declare-not-at-head-of-body", + "declaim-inside-body", + "type-declaration-contradicts-initform", + "the-form-with-impossible-type", + "type-declaration-on-rest-parameter", +]; + +/// One measured pass: per-rule microseconds and invocation counts. +fn measure(source: &str) -> Vec<(&'static str, Duration, u64)> { + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + let outcome = collect_lint_pass( + catalog, + &index, + Path::new("cost.lisp"), + Dialect::CommonLisp, + &tree, + source, + RuleSelection::All, + PassOptions { + settings: None, + measure: true, + }, + ) + .expect("measured lint pass"); + + let timings = outcome.timings.expect("measure: true produces timings"); + timings + .entries() + .map(|(position, elapsed, invocations)| { + ( + catalog.entries()[position].meta().name().as_str(), + elapsed, + invocations, + ) + }) + .collect() +} + +fn micros_of(rows: &[(&'static str, Duration, u64)], rule: &str) -> u128 { + rows.iter() + .find(|(name, _, _)| *name == rule) + .map(|(_, elapsed, _)| elapsed.as_micros()) + .expect("the rule is in the catalogue") +} + +fn invocations_of(rows: &[(&'static str, Duration, u64)], rule: &str) -> u64 { + rows.iter() + .find(|(name, _, _)| *name == rule) + .map(|(_, _, invocations)| *invocations) + .expect("the rule is in the catalogue") +} + +/// `count` **correct** declaration-carrying definitions — the zero-finding shape +/// the `clean/forms/*` benchmarks measure. Every rule here must decline all of +/// them, and the cost of declining is what these numbers are. +fn clean_source(count: usize) -> String { + let mut out = String::from(";;; correct declarations throughout\n"); + for index in 0..count { + out.push_str(&format!( + "(defun f{index} (a b &rest more)\n \"Doc.\"\n (declare (fixnum a) (ignore b) \ + (list more) (optimize (speed 3)))\n (let ((total 0))\n (declare (fixnum total))\n\ + \x20 (the fixnum (+ a total))))\n" + )); + } + out +} + +/// An adversarial shape for `declare-not-at-head-of-body`: every definition +/// carries a misplaced declaration, so the rule reports on every one. +/// +/// The fixtures that *report* are what expose a per-finding cost. A rule that is +/// linear while declining can still be quadratic once it starts reaching outside +/// its node to confirm a finding, and only a fixture like this shows it. +fn misplaced_heavy_source(count: usize) -> String { + (0..count) + .map(|index| format!("(defun h{index} (a) (print a) (declare (fixnum a)) a)\n")) + .collect() +} + +/// The same, for `the-form-with-impossible-type` — an unrelated rule with a +/// different head, a different analysis and a different reason to report. +/// +/// The control for +/// [`ignored_bench_the_per_finding_cost_is_shared_by_unrelated_rules`]: two +/// rules that share nothing but `sink.report` and cost the same per finding are +/// not both implemented wrongly in the same way. +fn the_heavy_source(count: usize) -> String { + (0..count) + .map(|index| format!("(defun k{index} () (the null (list {index} 2)))\n")) + .collect() +} + +const SIZES: [usize; 4] = [500, 1000, 2000, 4000]; + +/// Every node in `source`, so an invocation count can be read against the number +/// of nodes a per-node rule would have been called on. +fn node_count(source: &str) -> u64 { + fn walk(view: &ExpressionView) -> u64 { + 1 + view.children.iter().map(walk).sum::() + } + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + tree.root_view().children.iter().map(walk).sum() +} + +fn report(label: &str, rows: &[(&'static str, Duration, u64)], size: usize) { + for rule in RULES.iter().chain(std::iter::once(&"cost-control-noop")) { + let invocations = invocations_of(rows, rule); + let micros = micros_of(rows, rule); + let per_invocation = if invocations == 0 { + 0 + } else { + micros * 1000 / u128::from(invocations) + }; + println!( + "{label:>8} n={size:<5} {rule:<40} {micros:>7}us invocations={invocations:<6} \ + {per_invocation:>6}ns/inv" + ); + } +} + +/// Guards against a zero-denominator ratio on a machine fast enough to report +/// 0µs for the smallest size. +fn ratio(small: u128, large: u128) -> u128 { + large / small.max(1) +} + +/// The head index must hand each rule one node per *form it declares a head +/// for*, and no more. A rule invoked per node rather than per head is one of the +/// two ways this cost goes wrong, and it is the way that can be pinned exactly: +/// the count is a property of the head index, not of the machine. +/// +/// The node count is the control. Without it, "N invocations at n=N" is also +/// what a per-node dispatch would report on a file that happened to have N +/// nodes. +#[test] +fn cost_each_rule_is_dispatched_once_per_head_not_per_node() { + for size in SIZES { + let source = clean_source(size); + let nodes = node_count(&source); + let rows = measure(&source); + report("clean", &rows, size); + + assert!( + nodes > (size as u64) * 20, + "the fixture has {nodes} nodes for {size} definitions; a per-head count cannot be \ + told apart from a per-node one" + ); + + // One `defun` and one `let` per definition. + for rule in ["declare-not-at-head-of-body", "declaim-inside-body"] { + assert_eq!( + invocations_of(&rows, rule), + (size as u64) * 2, + "{rule}: one per defun and one per let, not per node ({nodes} nodes)" + ); + } + // `the` occurs once per definition and matches nothing else. + assert_eq!( + invocations_of(&rows, "the-form-with-impossible-type"), + size as u64, + "the-form-with-impossible-type is dispatched once per `the` form" + ); + // Only the `let` carries this rule's heads. + assert_eq!( + invocations_of(&rows, "type-declaration-contradicts-initform"), + size as u64 + ); + // Only the `defun` carries this one's. + assert_eq!( + invocations_of(&rows, "type-declaration-on-rest-parameter"), + size as u64 + ); + } +} + +/// A file with none of these heads must not reach a single `check` body. This is +/// what the CI `bench-compare` gate measures, and the reason every rule here +/// declares [`HeadFilter::Heads`] rather than `WholeTree`. +#[test] +fn cost_a_file_without_these_heads_never_reaches_a_check() { + let source: String = (0..4000) + .map(|index| format!("(setq x{index} (+ {index} 1))\n")) + .collect(); + let rows = measure(&source); + for rule in RULES.iter().chain(std::iter::once(&"cost-control-noop")) { + assert_eq!( + invocations_of(&rows, rule), + 0, + "{rule} was invoked on a file with none of its heads" + ); + } +} + +/// A rule must not be dispatched at all for a dialect it does not model — the +/// dispatcher resolves the dialect scope once, before the walk. Every rule here +/// is Common Lisp only, so on a Clojure file whose every form head-matches, this +/// package's whole per-file cost is zero. +#[test] +fn cost_is_zero_for_a_dialect_no_rule_here_models() { + let source: String = (0..2000) + .map(|index| format!("(let [x{index} 1] (inc x{index}))\n")) + .collect(); + let rows = measure_as(&source, Dialect::Clojure); + for (name, elapsed, invocations) in rows { + assert_eq!(invocations, 0, "{name} ran for Clojure"); + assert_eq!(elapsed, Duration::ZERO, "{name} was timed for Clojure"); + } +} + +/// [`measure`], for a dialect other than Common Lisp. +fn measure_as(source: &str, dialect: Dialect) -> Vec<(&'static str, Duration, u64)> { + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + let outcome = collect_lint_pass( + catalog, + &index, + Path::new("cost.src"), + dialect, + &tree, + source, + RuleSelection::All, + PassOptions { + settings: None, + measure: true, + }, + ) + .expect("measured lint pass"); + let timings = outcome.timings.expect("timings"); + timings + .entries() + .map(|(position, elapsed, invocations)| { + ( + catalog.entries()[position].meta().name().as_str(), + elapsed, + invocations, + ) + }) + .collect() +} + +/// A rule that *reports* on every form is still dispatched once per form. The +/// invocation count is linear — that is what this pins — but the work per +/// finding is a separate question, which the benchmark below measures. +#[test] +fn cost_a_reporting_rule_is_still_dispatched_once_per_form() { + for size in [500, 1000] { + let rows = measure(&misplaced_heavy_source(size)); + report("misplcd", &rows, size); + assert_eq!( + invocations_of(&rows, "declare-not-at-head-of-body"), + size as u64, + "one per defun even when every one of them reports" + ); + } +} + +/// Where the per-*finding* cost lives. +/// +/// A rule here costs roughly 1.2µs per finding on a file where every form +/// reports, against ~30ns per declining invocation — and that per-finding number +/// itself **doubles when the file doubles**, which is a linear scan per finding +/// somewhere downstream of `sink.report`. +/// +/// It is not this package's analysis. Two rules with different heads, different +/// analyses and no shared code measured 1.13µs and 1.24µs per finding at n=500, +/// and 2.27µs and 2.44µs at n=1000 — the same number and the same growth. What +/// they share is the engine's finding materialisation. +/// +/// So the number is reported rather than optimised. It prints and does not +/// assert, because it is wall-clock; what matters is that the two columns track +/// each other, which is the evidence that the cost is common to both. +#[test] +#[ignore = "a benchmark: wall-clock per-finding costs are unstable under load"] +fn ignored_bench_the_per_finding_cost_is_shared_by_unrelated_rules() { + for size in [500, 1000] { + let rows = measure(&misplaced_heavy_source(size)); + report("misplcd", &rows, size); + let rows = measure(&the_heavy_source(size)); + report("the", &rows, size); + } +} + +/// The other way the cost goes wrong: the right number of invocations, each of +/// which re-derives a whole-file answer. Invocation counts cannot see that — +/// only the growth rate can, and the growth rate is wall-clock. +/// +/// Over the 8× range in [`SIZES`], linear work gives ≈8 and the quadratic shape +/// this exists to catch gives ≈64. +#[test] +#[ignore = "a benchmark: wall-clock ratios are unstable under parallel load"] +fn ignored_bench_doubling_ratio() { + for (label, generate) in [ + ("clean", clean_source as fn(usize) -> String), + ("misplcd", misplaced_heavy_source as fn(usize) -> String), + ] { + let mut columns: Vec> = vec![Vec::new(); RULES.len() + 1]; + for size in SIZES { + let rows = measure(&generate(size)); + report(label, &rows, size); + for (slot, rule) in RULES + .iter() + .chain(std::iter::once(&"cost-control-noop")) + .enumerate() + { + columns[slot].push(micros_of(&rows, rule)); + } + } + for (slot, rule) in RULES + .iter() + .chain(std::iter::once(&"cost-control-noop")) + .enumerate() + { + let micros = &columns[slot]; + println!( + "{label:>8} {rule:<40} 8x ratio = {:>3} ({micros:?}us over {SIZES:?}) \ + -- linear is ~8, a per-match whole-file scan is ~64", + ratio(micros[0], micros[3]) + ); + } + } +} diff --git a/packages/feature/lint-type-declaration/src/declaim_inside_body/domain.rs b/packages/feature/lint-type-declaration/src/declaim_inside_body/domain.rs new file mode 100644 index 00000000..6873d40a --- /dev/null +++ b/packages/feature/lint-type-declaration/src/declaim_inside_body/domain.rs @@ -0,0 +1,173 @@ +//! A `(declaim …)` sitting where a `(declare …)` belongs. +//! +//! # What CLHS says +//! +//! `declaim` (CLHS macro `declaim`) makes a **global** proclamation and is a +//! top-level form; `declare` (CLHS symbol `declare`) attaches a declaration to +//! the form it is inside. They are not interchangeable, and the difference is +//! invisible at a glance: `(declaim (fixnum x))` inside a function does not +//! constrain the parameter `x`, it proclaims a global type for the *symbol* `x`, +//! affecting every special binding of that name in the image. +//! +//! # What SBCL 2.6.0 does +//! +//! It has a message for exactly this confusion: +//! +//! ```text +//! ; in: DEFUN F11B +//! ; (DEFUN F11B (X) (DECLAIM (FIXNUM X)) (+ X 1)) +//! ; caught STYLE-WARNING: +//! ; DECLAIM where DECLARE was probably intended +//! ``` +//! +//! on +//! +//! ```lisp +//! (defun f11b (x) (declaim (fixnum x)) (+ x 1)) +//! ``` +//! +//! The rule is scoped to the same position SBCL's own check is: a `declaim` +//! among a body's *leading* declarations. A `declaim` deeper in a body is a +//! deliberate runtime call to change global policy — unusual, but a different +//! thing, and not this rule's subject. + +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView}; + +use crate::support::{ + body_start_of, is_declaim, is_declare, is_reader_conditional, is_string_literal, +}; + +/// One `declaim` in a declaration section. +#[derive(Debug, Clone, Copy)] +pub struct MisusedDeclaim { + pub span: ByteSpan, +} + +impl MisusedDeclaim { + #[must_use] + pub fn message(&self) -> String { + "(declaim ...) here makes a global proclamation about these symbols rather than \ + declaring anything about this form's own bindings; DECLARE was probably intended" + .to_owned() + } +} + +/// Every `declaim` among `view`'s leading declarations. +/// +/// The scan walks the same prefix [`crate::support::declaration_section_end`] +/// does — declarations and at most one documentation string — but treats a +/// `declaim` as part of that prefix so that a second, correctly spelled +/// `declare` after it is still reached. +#[must_use] +pub fn examine_body(view: &ExpressionView) -> Vec { + let Some(start) = body_start_of(view) else { + return Vec::new(); + }; + let mut found = Vec::new(); + let mut index = start; + let mut seen_doc = false; + while let Some(child) = view.children.get(index) { + if is_reader_conditional(child) { + // The prefix cannot be read past a form the reader folded away. + break; + } + if is_declaim(child) { + found.push(MisusedDeclaim { span: child.span }); + } else if !is_declare(child) { + let is_doc = !seen_doc && is_string_literal(child) && index + 1 < view.children.len(); + if !is_doc { + break; + } + seen_doc = true; + } + index += 1; + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + use paredit_core_syntax::sexpr::SyntaxTree; + + fn findings(source: &str) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + examine_body(&tree.root_view().children[0]) + } + + #[test] + fn flags_a_declaim_as_the_first_body_form() { + assert_eq!( + findings("(defun f (x) (declaim (fixnum x)) (+ x 1))").len(), + 1 + ); + } + + #[test] + fn flags_a_declaim_after_a_correct_declaration() { + assert_eq!( + findings("(defun f (x y) (declare (ignore y)) (declaim (fixnum x)) x)").len(), + 1 + ); + } + + #[test] + fn flags_a_declaim_after_a_docstring() { + assert_eq!( + findings("(defun f (x) \"doc\" (declaim (fixnum x)) (+ x 1))").len(), + 1 + ); + } + + #[test] + fn flags_a_declaim_in_a_let_and_a_lambda() { + assert_eq!(findings("(let ((x 1)) (declaim (fixnum x)) x)").len(), 1); + assert_eq!(findings("(lambda (x) (declaim (fixnum x)) x)").len(), 1); + } + + // -- what must stay silent ----------------------------------------------- + + #[test] + fn accepts_a_correctly_spelled_declaration() { + assert!(findings("(defun f (x) (declare (fixnum x)) (+ x 1))").is_empty()); + } + + /// A `declaim` past the declaration section is a deliberate runtime call to + /// change global policy, not the `declare`/`declaim` confusion. + #[test] + fn accepts_a_declaim_past_the_declaration_section() { + assert!(findings("(defun f (x) (print x) (declaim (optimize speed)) x)").is_empty()); + } + + #[test] + fn accepts_a_body_with_no_declaim() { + assert!(findings("(defun f (x) (print x) (+ x 1))").is_empty()); + } + + #[test] + fn declines_a_head_with_no_declaration_section() { + assert!(findings("(progn (declaim (optimize speed)) 1)").is_empty()); + assert!(findings("(if a (declaim (optimize speed)) 1)").is_empty()); + } + + #[test] + fn declines_a_body_whose_prefix_is_hidden_behind_a_reader_conditional() { + assert!( + findings("(defun f (x) #+sbcl (declare (fixnum x)) (declaim (fixnum x)) 1)").is_empty() + ); + } + + #[test] + fn declines_a_form_too_short_to_have_a_body() { + assert!(findings("(defun f (x))").is_empty()); + } + + #[test] + fn flags_each_declaim_in_one_section() { + assert_eq!( + findings("(defun f (x y) (declaim (fixnum x)) (declaim (fixnum y)) 1)").len(), + 2 + ); + } +} diff --git a/packages/feature/lint-type-declaration/src/declaim_inside_body/mod.rs b/packages/feature/lint-type-declaration/src/declaim_inside_body/mod.rs new file mode 100644 index 00000000..a3518b57 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/declaim_inside_body/mod.rs @@ -0,0 +1,5 @@ +//! `declaim-inside-body`: a `(declaim …)` in a body's declaration section, +//! where `declare` was meant. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-type-declaration/src/declaim_inside_body/rule.rs b/packages/feature/lint-type-declaration/src/declaim_inside_body/rule.rs new file mode 100644 index 00000000..6d3741af --- /dev/null +++ b/packages/feature/lint-type-declaration/src/declaim_inside_body/rule.rs @@ -0,0 +1,106 @@ +//! Registration for `declaim-inside-body`. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::declaim_inside_body::domain::examine_body; +use crate::support::{COMMON_LISP_ONLY, is_unevaluated_at}; + +pub const META: RuleMeta = RuleMeta::new( + "declaim-inside-body", + RuleCategory::Declaration, + // SBCL emits a STYLE-WARNING rather than an error: the form is legal, it + // just does something global that the author almost certainly did not want. + Severity::Warning, + "a (declaim ...) among a body's leading declarations, where (declare ...) was meant", + // Rewriting `declaim` to `declare` is usually right and occasionally + // destructive: an author who really did want a global proclamation at load + // time would have it silently turned into a local declaration. + Fixability::ReportOnly, +); + +/// [`DECLARATION_BODY_RULE_HEADS`] **minus** `locally`, `macrolet` and +/// `symbol-macrolet`. +/// +/// CLHS 3.2.3.1 ("Processing of Top Level Forms") says the body forms of a +/// top-level `progn`, `locally`, `macrolet` or `symbol-macrolet` are themselves +/// processed as top level forms. A `declaim` in one of those bodies is therefore +/// an ordinary top-level proclamation, correctly spelled, and SBCL's own sources +/// rely on it — the corpus audit found both +/// +/// ```lisp +/// (locally (declare (optimize (speed 3) (safety 0))) +/// (declaim (inline %constraint-number)) +/// (defun %constraint-number (constraint) ...)) +/// ``` +/// +/// in `sbcl/src/compiler/constraint.lisp` and a `macrolet` doing the same in +/// `sbcl/src/code/target-unicode.lisp`. Both were false positives. +/// +/// A `locally` *nested inside a function* is not a top-level context and a +/// `declaim` there really is the confusion this rule is about, but telling the +/// two apart needs the parent chain, and the nested case is rare enough that +/// dropping the heads outright is the better trade. +/// +/// [`DECLARATION_BODY_RULE_HEADS`]: crate::support::DECLARATION_BODY_RULE_HEADS +const HEADS: [NormalizedHead; 19] = [ + NormalizedHead::new("defun"), + NormalizedHead::new("defmacro"), + NormalizedHead::new("define-compiler-macro"), + NormalizedHead::new("deftype"), + NormalizedHead::new("lambda"), + NormalizedHead::new("let"), + NormalizedHead::new("let*"), + NormalizedHead::new("flet"), + NormalizedHead::new("labels"), + NormalizedHead::new("prog"), + NormalizedHead::new("prog*"), + NormalizedHead::new("multiple-value-bind"), + NormalizedHead::new("destructuring-bind"), + NormalizedHead::new("with-slots"), + NormalizedHead::new("with-accessors"), + NormalizedHead::new("do"), + NormalizedHead::new("do*"), + NormalizedHead::new("dolist"), + NormalizedHead::new("defmethod"), +]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + COMMON_LISP_ONLY + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult<()> { + let items = examine_body(view); + if items.is_empty() { + return Ok(()); + } + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + for item in items { + sink.report(item.span, item.message()); + } + Ok(()) + } +} diff --git a/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/domain.rs b/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/domain.rs new file mode 100644 index 00000000..d8aff4c6 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/domain.rs @@ -0,0 +1,261 @@ +//! A `(declare …)` that is not in the declaration section of the body it sits +//! in. +//! +//! # What CLHS says +//! +//! CLHS 3.3.4 ("Declaration Scope") and the `declare` special operator's own +//! page restrict declaration expressions to the *beginning* of the bodies of the +//! forms that accept them; CLHS 3.4.11 fixes the one exception, a documentation +//! string that may sit among them. A `declare` anywhere else "is not a +//! declaration and may be a violation" — the standard's phrasing for undefined +//! behaviour. +//! +//! # What SBCL 2.6.0 does +//! +//! It is a full `ERROR`, not a warning and not silence: +//! +//! ```text +//! $ sbcl --eval '(compile-file "p1.lisp")' +//! ; in: DEFUN BAD-1 +//! ; (DECLARE (FIXNUM X)) +//! ; caught ERROR: +//! ; There is no function named DECLARE. References to DECLARE in some +//! ; contexts (like starts of blocks) are unevaluated expressions, but here +//! ; the expression is being evaluated, which invokes undefined behaviour. +//! ``` +//! +//! on both +//! +//! ```lisp +//! (defun bad-1 (x) (print x) (declare (fixnum x)) (+ x 1)) +//! (defun bad-1b (x) (let ((y (* x 2))) (print y) (declare (fixnum y)) y)) +//! ``` +//! +//! and it stays silent on every correct placement — declaration first, +//! docstring then declaration, and a trailing string that is a return value +//! rather than a docstring. That `ERROR` is why this rule is +//! [`Severity::Error`] and [`RuleCategory::Malformed`]: the form does not mean +//! what it says, and the declaration it looks like is simply not in effect. +//! +//! [`Severity::Error`]: paredit_core_lint_engine::model::Severity::Error +//! [`RuleCategory::Malformed`]: paredit_core_lint_engine::model::RuleCategory::Malformed + +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView}; + +use crate::support::{body_start_of, declaration_section_end, form_shape_is_opaque, is_declare}; + +/// One misplaced declaration. +#[derive(Debug, Clone, Copy)] +pub struct MisplacedDeclare { + /// The `(declare …)` form itself. + pub span: ByteSpan, + /// How many body forms precede it, so the message can say what displaced it. + pub preceding_forms: usize, +} + +impl MisplacedDeclare { + #[must_use] + pub fn message(&self) -> String { + let forms = self.preceding_forms; + let plural = if forms == 1 { "form" } else { "forms" }; + format!( + "this (declare ...) comes after {forms} body {plural}, so it is not a declaration \ + at all but a call to the undefined function DECLARE; move it to the head of the \ + body, before the first form" + ) + } +} + +/// Every misplaced `declare` directly inside `view`'s body. +/// +/// Only `view`'s **own** body: a nested `let` with its own misplaced declaration +/// is reported when the dispatcher reaches that `let`, not from here, so a +/// finding is never attributed to the wrong form and never reported twice. +/// +/// Declines entirely when the body contains a reader conditional. `#+sbcl +/// (declare …)` reads as a single atom under this repository's dialect-aware +/// parse, so the position of everything after it is unknowable: the guarded form +/// may or may not be a declaration depending on the feature set, and either +/// answer would be invented. +#[must_use] +pub fn examine_body(view: &ExpressionView) -> Vec { + let Some(start) = body_start_of(view) else { + return Vec::new(); + }; + if start >= view.children.len() || form_shape_is_opaque(view, start) { + return Vec::new(); + } + let section_end = declaration_section_end(view, start); + view.children + .iter() + .enumerate() + .skip(section_end) + .filter(|(_, child)| is_declare(child)) + .map(|(index, child)| MisplacedDeclare { + span: child.span, + // Body forms between the end of the declaration section and this + // declaration. Declarations and the docstring do not displace + // anything, so the section itself contributes nothing. + preceding_forms: index - section_end, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + use paredit_core_syntax::sexpr::SyntaxTree; + + fn findings(source: &str) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + examine_body(&tree.root_view().children[0]) + } + + #[test] + fn flags_a_declare_after_a_body_form_in_a_defun() { + let found = findings("(defun f (x) (print x) (declare (fixnum x)) (+ x 1))"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].preceding_forms, 1); + } + + #[test] + fn flags_a_declare_after_a_body_form_in_a_let() { + assert_eq!( + findings("(let ((y 1)) (print y) (declare (fixnum y)) y)").len(), + 1 + ); + } + + #[test] + fn flags_a_declare_in_a_lambda_and_a_locally() { + assert_eq!( + findings("(lambda (x) (print x) (declare (fixnum x)))").len(), + 1 + ); + assert_eq!( + findings("(locally (print 1) (declare (optimize speed)))").len(), + 1 + ); + } + + #[test] + fn flags_a_declare_past_a_defmethods_qualifier_and_lambda_list() { + assert_eq!( + findings("(defmethod area :before ((s square)) (print s) (declare (ignore s)))").len(), + 1 + ); + } + + // -- the correct placements ---------------------------------------------- + + #[test] + fn accepts_a_declaration_at_the_head_of_the_body() { + assert!(findings("(defun f (x) (declare (fixnum x)) (+ x 1))").is_empty()); + } + + #[test] + fn accepts_a_declaration_after_a_docstring() { + assert!(findings("(defun f (x) \"doc\" (declare (fixnum x)) (+ x 1))").is_empty()); + } + + #[test] + fn accepts_several_declarations_in_a_row() { + assert!( + findings("(defun f (x y) \"doc\" (declare (fixnum x)) (declare (ignore y)) x)") + .is_empty() + ); + } + + /// A string that is the last form is the return value, and a declaration + /// before it is correctly placed. + #[test] + fn accepts_a_declaration_before_a_trailing_string_return_value() { + assert!(findings("(defun f (x) (declare (fixnum x)) \"result\")").is_empty()); + } + + #[test] + fn accepts_a_body_with_no_declaration_at_all() { + assert!(findings("(defun f (x) (print x) (+ x 1))").is_empty()); + } + + /// The nested case is the enclosing form's finding, not this one's: a + /// `defun` whose inner `let` is wrong reports nothing *here*, because the + /// dispatcher visits the `let` separately. + #[test] + fn a_nested_forms_misplaced_declaration_is_not_this_forms_finding() { + assert!( + findings("(defun f (x) (let ((y 1)) (print y) (declare (fixnum y)) y))").is_empty() + ); + } + + /// The reader-conditional guard. `#+sbcl (declare (fixnum x))` is one atom, + /// so the `(declare …)` after it *looks* displaced by one form when it may + /// in fact be the second declaration of a perfectly ordinary section. + #[test] + fn declines_a_body_containing_a_reader_conditional() { + assert!( + findings("(defun f (x) #+sbcl (declare (fixnum x)) (declare (ignore x)) 1)").is_empty() + ); + assert!(findings("(defun f (x) #+sbcl (print x) (declare (fixnum x)) 1)").is_empty()); + } + + /// Reduced from `sbcl/src/compiler/globaldb.lisp`, which the corpus audit + /// reported as a false positive. The *head* is a folded `#+` atom that still + /// normalizes to `defmacro`, so the head index dispatches — and the two + /// conditional atoms shift every later index, putting the lambda list where + /// the first body form is counted. + #[test] + fn declines_a_form_whose_head_is_a_reader_conditional() { + assert!( + findings( + "(#+sb-xc-host cl:defmacro\n #-sb-xc-host sb-xc:defmacro\n define-info-type \ + ((category kind) &key default)\n (declare (type keyword category kind))\n \ + (pick category))" + ) + .is_empty() + ); + } + + /// Reduced from `sbcl/src/code/save.lisp`, likewise a false positive found + /// by the audit: the documentation string is built by `#.` at read time, so + /// statically it is a `format` call sitting where a docstring belongs. + #[test] + fn declines_a_read_time_evaluated_docstring() { + assert!( + findings( + "(defun save-lisp-and-die (name &key environment-name)\n \ + #.(format nil \"Save a core.~%\")\n (declare (ignore environment-name))\n \ + (save name))" + ) + .is_empty() + ); + } + + #[test] + fn declines_a_head_with_no_declaration_section() { + assert!(findings("(if (print 1) (declare (fixnum x)) 2)").is_empty()); + assert!(findings("(progn (print 1) (declare (fixnum x)))").is_empty()); + } + + #[test] + fn declines_a_form_too_short_to_have_a_body() { + assert!(findings("(defun f (x))").is_empty()); + assert!(findings("(let ())").is_empty()); + } + + #[test] + fn counts_the_forms_that_displaced_the_declaration() { + let found = findings("(defun f (x) (print x) (print x) (declare (fixnum x)) 1)"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].preceding_forms, 2); + } + + #[test] + fn flags_every_misplaced_declaration_in_one_body() { + assert_eq!( + findings("(defun f (x y) (print x) (declare (fixnum x)) (declare (ignore y)))").len(), + 2 + ); + } +} diff --git a/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/mod.rs b/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/mod.rs new file mode 100644 index 00000000..d5e76eb2 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/mod.rs @@ -0,0 +1,5 @@ +//! `declare-not-at-head-of-body`: a `(declare …)` past the head of a body, +//! where it is a call to an undefined function rather than a declaration. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/rule.rs b/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/rule.rs new file mode 100644 index 00000000..94be2036 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/declare_not_at_head_of_body/rule.rs @@ -0,0 +1,66 @@ +//! Registration for `declare-not-at-head-of-body`. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::declare_not_at_head_of_body::domain::examine_body; +use crate::support::{COMMON_LISP_ONLY, DECLARATION_BODY_RULE_HEADS, is_unevaluated_at}; + +pub const META: RuleMeta = RuleMeta::new( + "declare-not-at-head-of-body", + // Not `Declaration`: the form is not a bad declaration, it is not a + // declaration at all. SBCL calls it a call to an undefined function. + RuleCategory::Malformed, + // SBCL 2.6.0 emits a full `caught ERROR`; see the domain module. + Severity::Error, + "a (declare ...) after the first body form, where it is a call to an undefined function", + // Moving the declaration to the head of the body is usually right, but not + // always: an author who wrote it late may have meant a `the` or a `check-type` + // on a value computed by the forms above it, and silently hoisting the + // declaration would then assert something about a different value. + Fixability::ReportOnly, +); + +const HEADS: [NormalizedHead; 22] = DECLARATION_BODY_RULE_HEADS; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + COMMON_LISP_ONLY + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult<()> { + let items = examine_body(view); + if items.is_empty() { + return Ok(()); + } + // Asked once per candidate, after a finding is already in hand: a + // `(defun …)` inside `'(…)` is a list of symbols. + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + for item in items { + sink.report(item.span, item.message()); + } + Ok(()) + } +} diff --git a/packages/feature/lint-type-declaration/src/engine_pass_tests.rs b/packages/feature/lint-type-declaration/src/engine_pass_tests.rs new file mode 100644 index 00000000..fdf0825a --- /dev/null +++ b/packages/feature/lint-type-declaration/src/engine_pass_tests.rs @@ -0,0 +1,475 @@ +//! Every rule here driven through the *engine*, rather than through its own +//! `examine_*`. +//! +//! Two declarations decide whether a rule is reachable from the CLI at all, and +//! neither is visible to a domain test, which calls `examine_*` on a node it +//! picked itself: +//! +//! - the [`HeadFilter::Heads`] list, which is what the dispatcher's head index is +//! built from. A head spelled wrongly — or a head the domain matches but the +//! list omits — leaves every `examine_*` test green while the rule never +//! receives a single node in production. +//! - the [`RuleDialectScope`], which the dispatcher consults *before* walking +//! anything. +//! +//! [`HeadFilter::Heads`]: paredit_core_lint_engine::model::HeadFilter::Heads +//! [`RuleDialectScope`]: paredit_core_lint_engine::policy::RuleDialectScope + +use std::path::Path; + +use paredit_core_lint_engine::engine::{ + PassOptions, build_head_index, collect_lint_outcomes, collect_lint_pass, +}; +use paredit_core_lint_engine::model::{Fixability, HeadFilter, RuleCategory, Severity}; +use paredit_core_lint_engine::policy::{RuleDialectScope, RuleSelection}; +use paredit_core_lint_engine::rule::RuleCatalog; +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::SyntaxTree; + +use crate::ENTRIES; + +/// The rule names that fire on `source`, sorted so the assertions do not depend +/// on registration order. +fn fired(source: &str, dialect: Dialect) -> Vec<&'static str> { + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + let mut names: Vec<&'static str> = collect_lint_outcomes( + catalog, + &index, + Path::new("t.lisp"), + dialect, + &tree, + source, + RuleSelection::All, + ) + .expect("lint pass") + .into_iter() + .map(|outcome| outcome.into_parts().0.rule) + .collect(); + names.sort_unstable(); + names.dedup(); + names +} + +/// How many nodes each rule's `check` was actually handed. +/// +/// This is the **denominator**. A zero-finding sweep over zero candidates is a +/// false clean: it passes just as well when a rule's head list is misspelled and +/// it never runs at all. +fn invocations(source: &str, dialect: Dialect) -> Vec<(&'static str, u64)> { + let catalog = RuleCatalog::new(&ENTRIES); + let index = build_head_index(catalog); + let tree = SyntaxTree::parse_with_dialect(source, dialect).expect("parse"); + let outcome = collect_lint_pass( + catalog, + &index, + Path::new("t.lisp"), + dialect, + &tree, + source, + RuleSelection::All, + PassOptions { + settings: None, + measure: true, + }, + ) + .expect("measured pass"); + outcome + .timings + .expect("timings") + .entries() + .map(|(position, _, count)| (catalog.entries()[position].meta().name().as_str(), count)) + .collect() +} + +/// One source per rule that triggers exactly that rule and no other. +const TRIGGERS: [(&str, &str); 5] = [ + ( + "declare-not-at-head-of-body", + "(defun f (x) (print x) (declare (fixnum x)) x)", + ), + ( + "declaim-inside-body", + "(defun f (x) (declaim (fixnum x)) x)", + ), + ( + "type-declaration-contradicts-initform", + "(let ((x 0)) (declare (string x)) x)", + ), + ("the-form-with-impossible-type", "(the null (list 1 2))"), + ( + "type-declaration-on-rest-parameter", + "(defun f (&rest args) (declare (fixnum args)) args)", + ), +]; + +// -- (a) each rule is reached through the head index -------------------------- + +#[test] +fn every_rule_fires_through_the_real_dispatch() { + for (rule, source) in TRIGGERS { + assert_eq!( + fired(source, Dialect::CommonLisp), + vec![rule], + "{rule} is unreachable through the head index, or another rule fires with it" + ); + } +} + +/// Six rules, six distinct names: a copy-paste in `ENTRIES` that registered one +/// slice twice would otherwise leave the loop above green. +#[test] +fn the_catalog_holds_every_rule_once() { + let mut names: Vec<&'static str> = RuleCatalog::new(&ENTRIES) + .entries() + .iter() + .map(|entry| entry.meta().name().as_str()) + .collect(); + assert_eq!(names.len(), 5); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), 5, "two entries share a name"); +} + +// -- (b) a file with none of these heads trips nothing ------------------------ + +/// What the `clean/forms/*` benchmarks measure: ordinary code, none of it +/// declaring anything, must not reach a single `check` body. +#[test] +fn a_file_with_none_of_these_heads_produces_no_findings() { + let source = "(in-package :app)\n\ + (defparameter *limit* 10)\n\ + (defvar *state* nil)\n\ + (defstruct point x y)\n\ + (setq *state* (list 1 2 3))\n"; + assert_eq!(fired(source, Dialect::CommonLisp), Vec::<&str>::new()); + for (rule, count) in invocations(source, Dialect::CommonLisp) { + assert_eq!( + count, 0, + "{rule} was invoked on a file with none of its heads" + ); + } +} + +// -- (c) realistic, *correct* Common Lisp ------------------------------------- + +/// Idiomatic, correct Common Lisp using every shape these rules come near: +/// declarations at the head of a body, after a docstring, several in a row; the +/// placeholder-then-assign idiom; `ignorable` where a variable may go unread; +/// widened `(or null …)` types; `&rest` declared as a list and as +/// `dynamic-extent`; `the` asserting types that hold; a top-level `declaim`; a +/// `declaim` inside a top-level `locally` and `macrolet`, which CLHS 3.2.3.1 +/// makes correct; a shadowed `ignore`; and a Lisp-2 accessor sharing a name with +/// an ignored parameter. +/// +/// Every line of it is a shape the corpus audit either found in SBCL's own +/// sources or that SBCL 2.6.0 compiles without a word. +/// +/// Paired with [`the_dangerous_twin_is_still_detected`], without which a rule +/// that had silently stopped matching anything would pass this too. +const CORRECT_COMMON_LISP: &str = r#"(in-package :inventory) + +(declaim (optimize (speed 3) (safety 1))) +(declaim (ftype (function (fixnum fixnum) fixnum) combine)) + +(defun combine (a b) + "Adds two counts." + (declare (fixnum a b)) + (declare (optimize speed)) + (the fixnum (+ a b))) + +(defun tally (items &rest extra) + "Sums ITEMS, ignoring EXTRA." + (declare (list items)) + (declare (ignore extra)) + (let ((total 0) + (label "")) + (declare (fixnum total) (string label)) + (dolist (item items) + (declare (fixnum item)) + (incf total item)) + (values total label))) + +(defun collect (&rest args) + (declare (dynamic-extent args)) + (declare (list args)) + (apply #'+ args)) + +(defun lookup (key table) + (let ((cache nil)) + (declare (type (or null hash-table) cache)) + (setf cache table) + (gethash key cache))) + +(defun maybe-use (a b) + (declare (ignorable b)) + a) + +(defun shadowed (x y) + (declare (ignore y)) + (let ((y (* x 2))) + (declare (fixnum y)) + y)) + +(defun accessor-shares-a-name (component builtin-system-p) + (declare (ignore builtin-system-p)) + (setf (builtin-system-p component) t)) + +(defmethod area :before ((s square)) + (declare (optimize safety)) + (print s)) + +(locally (declare (optimize (speed 3) (safety 0))) + (declaim (inline fast-path)) + (defun fast-path (n) (declare (fixnum n)) n)) + +(macrolet ((with-guard (form) `(progn ,form))) + (declaim (inline guarded)) + (defun guarded (n) (with-guard n))) + +(defun returns-a-string (x) + (declare (fixnum x)) + "the return value, not a docstring") + +(defun uses-a-quiet-macro () + (let ((v 3)) + (declare (ignore v)) + (macrolet ((name-of (s) `',s)) + (name-of v)))) +"#; + +/// Every rule declines all of it — **and** every rule was actually offered +/// candidates while doing so. +#[test] +fn realistic_correct_common_lisp_produces_no_findings() { + assert_eq!( + fired(CORRECT_COMMON_LISP, Dialect::CommonLisp), + Vec::<&str>::new(), + "a false positive on idiomatic Common Lisp" + ); +} + +/// The denominator for the sweep above, without which it proves nothing: each +/// rule's `check` really did run on this file, many times over. +#[test] +fn the_correct_sample_offers_every_rule_real_candidates() { + for (rule, count) in invocations(CORRECT_COMMON_LISP, Dialect::CommonLisp) { + assert!( + count > 0, + "{rule} was never invoked on the correct sample; its clean sweep is a false clean" + ); + } + // The two rules with the narrowest head lists still see several forms each, + // so "greater than zero" is not one lucky node. + let counts = invocations(CORRECT_COMMON_LISP, Dialect::CommonLisp); + let of = |name: &str| { + counts + .iter() + .find(|(rule, _)| *rule == name) + .map(|(_, count)| *count) + .expect("the rule is in the catalogue") + }; + assert!(of("the-form-with-impossible-type") >= 1); + assert!(of("type-declaration-on-rest-parameter") >= 8); + assert!(of("declare-not-at-head-of-body") >= 15); +} + +/// The control for the sweep above. Each twin is the *correct* file with exactly +/// one thing made wrong, and each proves one detector still works on it. +/// +/// One twin per rule rather than one combined twin: a combined one is easy to +/// get wrong, and a twin that expected six findings while producing five would +/// have to be weakened to pass, quietly costing the control its value. +#[test] +fn the_dangerous_twin_is_still_detected() { + let twins: [(&str, &str, &str); 5] = [ + ( + "declare-not-at-head-of-body", + " (declare (fixnum a b))\n (declare (optimize speed))\n (the fixnum (+ a b)))", + " (declare (fixnum a b))\n (the fixnum (+ a b))\n (declare (optimize speed)))", + ), + ( + "declaim-inside-body", + "(defun maybe-use (a b)\n (declare (ignorable b))", + "(defun maybe-use (a b)\n (declaim (ignorable b))", + ), + ( + "type-declaration-contradicts-initform", + " (declare (fixnum total) (string label))", + " (declare (string total) (fixnum label))", + ), + ( + "the-form-with-impossible-type", + " (the fixnum (+ a b)))", + " (the null (list a b)))", + ), + ( + "type-declaration-on-rest-parameter", + " (declare (dynamic-extent args))\n (declare (list args))", + " (declare (dynamic-extent args))\n (declare (fixnum args))", + ), + ]; + + for (rule, before, after) in twins { + let twin = CORRECT_COMMON_LISP.replace(before, after); + assert_ne!(twin, CORRECT_COMMON_LISP, "{rule}: the twin must differ"); + assert_eq!( + fired(&twin, Dialect::CommonLisp), + vec![rule], + "{rule}: its twin must trip it and nothing else" + ); + } +} + +// -- (d) quoted and templated code -------------------------------------------- + +/// The dispatcher hands a rule every head-matched node, quoted data included; +/// each `check` calls `is_unevaluated_at` to decline those. +#[test] +fn no_rule_fires_on_quoted_or_templated_code() { + for (rule, source) in TRIGGERS { + assert_eq!( + fired(&format!("'{source}"), Dialect::CommonLisp), + Vec::<&str>::new(), + "{rule}: {source} is quoted data" + ); + assert_eq!( + fired(&format!("`{source}"), Dialect::CommonLisp), + Vec::<&str>::new(), + "{rule}: {source} is a macro template" + ); + assert_eq!( + fired(&format!("(quote {source})"), Dialect::CommonLisp), + Vec::<&str>::new(), + "{rule}: {source} is long-hand quoted data" + ); + } +} + +/// ...but an unquote inside a quasiquote is code again, so the declines above +/// are the quote model talking and not a rule that stopped working. +#[test] +fn a_rule_still_fires_under_an_unquote() { + assert_eq!( + fired( + "(defmacro m () `(list ,(the null (list 1 2))))", + Dialect::CommonLisp + ), + vec!["the-form-with-impossible-type"] + ); +} + +// -- (e) dialect scope -------------------------------------------------------- + +/// Every rule models Common Lisp's declaration system, so none may run for a +/// dialect that has no such system. Scheme is the sharpest control: it reads +/// these bytes happily and means something entirely different by them. +#[test] +fn no_rule_runs_outside_common_lisp() { + for (rule, source) in TRIGGERS { + for dialect in [ + Dialect::Scheme, + Dialect::Racket, + Dialect::Clojure, + Dialect::EmacsLisp, + Dialect::Fennel, + ] { + if SyntaxTree::parse_with_dialect(source, dialect).is_ok() { + assert_eq!( + fired(source, dialect), + Vec::<&str>::new(), + "{rule} fired for {dialect:?}" + ); + } + } + } + // At least one other reader really does accept a trigger, so the loop above + // is not vacuously true. + assert!( + TRIGGERS + .iter() + .any(|(_, source)| { SyntaxTree::parse_with_dialect(source, Dialect::Scheme).is_ok() }), + "no non-CL reader accepts any trigger; the scope is untested" + ); +} + +/// The scope as a declaration, so a rule that loses its `dialect_scope` +/// override fails here and not only through a sample that might stop triggering +/// for some other reason. +/// +/// Common Lisp is also the trait *default*, so this cannot distinguish +/// "declared" from "defaulted" — [`crate::support::COMMON_LISP_ONLY`] exists so +/// that every rule reads one constant, and this pins that they all do. +#[test] +fn every_rule_declares_common_lisp_alone() { + for entry in RuleCatalog::new(&ENTRIES).entries() { + let name = entry.meta().name().as_str(); + assert_eq!( + entry.rule().dialect_scope(), + RuleDialectScope::new(&[Dialect::CommonLisp]), + "{name} declares the wrong dialect scope" + ); + } +} + +// -- (f) no rule declares anything but Heads ---------------------------------- + +/// `AllNodes` and `WholeTree` are paid for on every file whether or not a rule +/// matches, which is exactly what the zero-finding benchmarks measure. No rule +/// here is about an *absence* with no head to anchor on, so none needs one. +#[test] +fn every_rule_declares_a_non_empty_heads_filter() { + for entry in RuleCatalog::new(&ENTRIES).entries() { + let name = entry.meta().name().as_str(); + let HeadFilter::Heads(heads) = entry.rule().head_filter() else { + panic!("{name} declares something other than HeadFilter::Heads"); + }; + assert!(!heads.is_empty(), "{name} declares an empty head list"); + } +} + +/// Every rule here is report-only: in each case either half of what the author +/// wrote could be the wrong half, and no rewrite is right more often than not. +#[test] +fn every_rule_is_report_only() { + for entry in RuleCatalog::new(&ENTRIES).entries() { + assert_eq!( + entry.meta().fixability(), + Fixability::ReportOnly, + "{}", + entry.meta().name().as_str() + ); + } +} + +/// The severities, pinned against what SBCL 2.6.0 actually emits: a misplaced +/// `declare` is a `caught ERROR`, and everything else is a `WARNING` or +/// `STYLE-WARNING`. +#[test] +fn severities_match_what_sbcl_emits() { + for entry in RuleCatalog::new(&ENTRIES).entries() { + let name = entry.meta().name().as_str(); + let expected = if name == "declare-not-at-head-of-body" { + Severity::Error + } else { + Severity::Warning + }; + assert_eq!(entry.meta().severity(), expected, "{name}"); + } +} + +/// `declare-not-at-head-of-body` is `Malformed` rather than `Declaration`: the +/// form is not a bad declaration, it is not a declaration at all. +#[test] +fn categories_separate_the_malformed_case_from_the_declaration_ones() { + for entry in RuleCatalog::new(&ENTRIES).entries() { + let name = entry.meta().name().as_str(); + let expected = if name == "declare-not-at-head-of-body" { + RuleCategory::Malformed + } else { + RuleCategory::Declaration + }; + assert_eq!(entry.meta().category(), expected, "{name}"); + } +} diff --git a/packages/feature/lint-type-declaration/src/lib.rs b/packages/feature/lint-type-declaration/src/lib.rs new file mode 100644 index 00000000..714f7a28 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/lib.rs @@ -0,0 +1,56 @@ +#![doc = include_str!("../README.md")] + +pub mod declaim_inside_body; +pub mod declare_not_at_head_of_body; +pub mod support; +pub mod the_form_with_impossible_type; +pub mod type_declaration_contradicts_initform; +pub mod type_declaration_on_rest_parameter; + +#[cfg(test)] +mod corpus_audit; + +#[cfg(test)] +mod cost_tests; + +#[cfg(test)] +mod engine_pass_tests; + +#[cfg(test)] +use paredit_core_lint_engine::rule::RuleEntry; + +/// Every rule this package ships. +/// +/// The root's `REGISTRY` names each rule's `META` and `RULE` across the crate +/// boundary; this array is the package's own copy, used by the engine tests, the +/// cost measurements and the corpus audit so that all three run the rules +/// through the *real* dispatcher rather than by calling `examine_*` directly. +/// +/// A domain test that calls `examine_*` on a node it picked itself stays green +/// no matter what the [`HeadFilter`] says, so nothing below the dispatcher can +/// catch a rule that is unreachable in production. +/// +/// [`HeadFilter`]: paredit_core_lint_engine::model::HeadFilter +#[cfg(test)] +pub(crate) static ENTRIES: [RuleEntry; 5] = [ + RuleEntry::new( + &declare_not_at_head_of_body::rule::META, + &declare_not_at_head_of_body::rule::RULE, + ), + RuleEntry::new( + &declaim_inside_body::rule::META, + &declaim_inside_body::rule::RULE, + ), + RuleEntry::new( + &type_declaration_contradicts_initform::rule::META, + &type_declaration_contradicts_initform::rule::RULE, + ), + RuleEntry::new( + &the_form_with_impossible_type::rule::META, + &the_form_with_impossible_type::rule::RULE, + ), + RuleEntry::new( + &type_declaration_on_rest_parameter::rule::META, + &type_declaration_on_rest_parameter::rule::RULE, + ), +]; diff --git a/packages/feature/lint-type-declaration/src/support.rs b/packages/feature/lint-type-declaration/src/support.rs new file mode 100644 index 00000000..e84b19bb --- /dev/null +++ b/packages/feature/lint-type-declaration/src/support.rs @@ -0,0 +1,1171 @@ +//! What the declaration rules share: where a form's body starts, which of its +//! leading children are still the declaration section, and how much a literal's +//! type and a declared type can be said to disagree. +//! +//! Three things every rule here needs and neither the engine nor `core/syntax` +//! provides: +//! +//! - **Evaluation context.** A `(declare (fixnum x))` inside `'(…)` is a list of +//! symbols. The lint engine's dispatch walks into quoted data like any other +//! subtree and [`RuleContext`] carries no parent pointer, so a head-matched +//! node cannot tell on its own whether it is code. [`is_unevaluated_at`] +//! answers that by descending from the root along the single chain of nodes +//! whose span contains the candidate's — depth-many steps, not tree-many — and +//! is called only once a rule already has a finding to report. +//! - **Body geometry.** `(defun name (args) . body)` and `(let (bindings) . +//! body)` put their body at different indices, and CLHS 3.4.11 lets the +//! declaration section interleave declarations with one documentation string. +//! [`body_start`] and [`declaration_section_end`] are that geometry. +//! - **A type lattice small enough to be right.** [`TypeExcludes`] answers only +//! "can this type specifier definitely *not* contain this literal", and only +//! for atomic specifiers whose relationship to the modelled literals is +//! enumerable. Every compound specifier — `(or null string)` above all — is +//! declined, because the whole value of these rules is that they do not fire +//! on the widening idiom. +//! +//! Nothing here is called per visited node. That is deliberate: the +//! `clean/forms/*` benchmarks lint files with zero findings, so the per-file +//! cost of a rule that matches nothing is exactly what they measure. +//! +//! [`RuleContext`]: paredit_core_lint_engine::engine::RuleContext + +use paredit_core_lint_engine::model::NormalizedHead; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_syntax::dialect::Dialect; +use paredit_core_syntax::sexpr::reader::atom_symbol_text; +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView, ReaderPrefix, SyntaxTree}; +use paredit_core_syntax::view_query::{ + atom_text, is_paren_list, list_head, symbol_is, unqualified, +}; + +// -- evaluation context ------------------------------------------------------ + +/// How much of the surrounding reader syntax says "this is data". +/// +/// Two independent counters, because `'` and `` ` `` are not the same thing. A +/// comma inside `'(…)` is a comma character in a literal list, so `hard` never +/// clears; a comma inside `` `(…) `` escapes back to code, so `quasi` counts up +/// and down. A single depth counter cannot express that difference and gets +/// `'(a ,(f))` wrong in the direction that produces false positives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QuoteState { + hard: bool, + quasi: u32, +} + +impl QuoteState { + const EVALUATED: Self = Self { + hard: false, + quasi: 0, + }; + + const fn is_data(self) -> bool { + self.hard || self.quasi > 0 + } + + /// The state inside a node, given the state outside it and the node's own + /// reader prefixes. + /// + /// `#'`, `#.`, `#+`, metadata and the rest are deliberately neutral: none of + /// them turns code into data. + fn after_prefixes(mut self, view: &ExpressionView) -> Self { + for prefix in &view.reader_prefixes { + match prefix { + ReaderPrefix::Quote => self.hard = true, + ReaderPrefix::Quasiquote => self.quasi += 1, + ReaderPrefix::Unquote | ReaderPrefix::UnquoteSplicing => { + self.quasi = self.quasi.saturating_sub(1); + } + _ => {} + } + } + self + } + + const fn quoted(mut self) -> Self { + self.hard = true; + self + } +} + +/// The long-hand `(quote …)`, which the reader also produces for `'…` but which +/// hand-written code and macro output both spell out. +fn is_quote_form(view: &ExpressionView) -> bool { + list_head(view).is_some_and(|head| symbol_is(head, "quote")) +} + +const fn span_contains(outer: ByteSpan, inner: ByteSpan) -> bool { + outer.start().get() <= inner.start().get() && inner.end().get() <= outer.end().get() +} + +/// The one child of `view` whose span contains `target`, by **binary search**. +/// +/// Children are in source order and their spans do not overlap, so the first +/// child whose span ends after `target` starts is the only one that can contain +/// it. That has to be a search rather than a scan: the first level of the +/// descent is the file's *root*, whose children are its top-level forms, and a +/// linear `find` there costs one pass over every top-level form **per finding**. +/// +/// That is not hypothetical. With a linear scan here, the `ignore` rule measured +/// 646ms at 500 definitions and 2442ms at 1000 — 3.8× the work for 2× the input +/// on a fixture where every definition reports. The rule's own analysis was +/// already linear; all of the growth was this lookup, reached once per finding +/// from `check`. Only the cost tests could see it, because every finding was +/// correct. +fn containing_child(view: &ExpressionView, target: ByteSpan) -> Option<&ExpressionView> { + let index = view + .children + .partition_point(|child| child.span.end().get() <= target.start().get()); + view.children + .get(index) + .filter(|child| span_contains(child.span, target)) +} + +/// Whether the node at `target` is unevaluated data rather than code. +/// +/// Descends from the root through the one child at each level whose span +/// contains `target`, so the cost is the node's depth, not the file's size. +/// +/// The verdict is read *at* the target and nowhere shallower. An ancestor being +/// data does not settle it: `` `(a ,(let ((x 0)) (declare (string x)))) `` has a +/// quasiquoted ancestor and an evaluated target. Being inside a hard `'` does +/// settle it, and that is already modelled by `hard` never clearing. +/// +/// The root's own span is never consulted. A file with one top-level form has a +/// root whose span equals that form's, and comparing them would call every such +/// form evaluated before looking at its prefixes at all. +#[must_use] +pub fn is_unevaluated_at(tree: &SyntaxTree, target: ByteSpan) -> bool { + let root = tree.root_view(); + let mut view: &ExpressionView = &root; + let mut state = QuoteState::EVALUATED; + + loop { + let quoting = is_quote_form(view); + let Some(child) = containing_child(view, target) else { + return state.is_data(); + }; + state = state.after_prefixes(child); + if quoting { + state = state.quoted(); + } + view = child; + if view.span == target { + return state.is_data(); + } + } +} + +/// Calls `visit` on every node of `root` reachable as evaluated code, with a say +/// in where the walk stops. +/// +/// `descend` is asked about each visited node *before* its children are queued; +/// answering `false` visits that node and nothing under it. That is how the +/// `ignore` rule steps over a nested `(declare …)`: naming a variable in a +/// declaration is not *using* it, and counting it as one would make +/// `(declare (ignore x))` followed by `(declare (ignorable x))` report itself. +/// +/// Quoted subtrees are still descended — `` `(a ,(f x)) `` has code inside data — +/// but their data nodes are never visited. A pruned node's subtree is skipped +/// including any quoted data in it, which is correct for finding *uses* and +/// would not be for finding data. +/// Where a visited node sits relative to its parent. +#[derive(Debug, Clone, Copy)] +pub struct Position<'a> { + /// Whether the node is in **operator position** — index 0 of a `(…)` list. + /// + /// Common Lisp is a Lisp-2, so a symbol there names a function and a symbol + /// anywhere else names a variable, and no rule about variables may conflate + /// them. The root is never in operator position. + pub is_operator: bool, + /// The head symbol of the enclosing `(…)` list, when there is one. + /// + /// Lets a caller decide whether the *operator* of the call a node sits in is + /// one whose evaluation semantics it knows, without walking back up. + pub enclosing_head: Option<&'a str>, +} + +impl Position<'_> { + const ROOT: Self = Self { + is_operator: false, + enclosing_head: None, + }; +} + +/// Calls `visit` on every node of `root` reachable as evaluated code, with a say +/// in where the walk stops and with each node's [`Position`]. +pub fn for_each_evaluated_subview_where<'a>( + root: &'a ExpressionView, + mut descend: impl FnMut(&ExpressionView) -> bool, + mut visit: impl FnMut(&'a ExpressionView, Position<'a>), +) { + let mut stack = vec![(root, QuoteState::EVALUATED, Position::ROOT)]; + while let Some((view, outer, position)) = stack.pop() { + let state = outer.after_prefixes(view); + if !state.is_data() { + visit(view, position); + if !descend(view) { + continue; + } + } + let inside = if is_quote_form(view) { + state.quoted() + } else { + state + }; + let is_call = is_paren_list(view); + let head = if is_call { list_head(view) } else { None }; + for (index, child) in view.children.iter().enumerate().rev() { + stack.push(( + child, + inside, + Position { + is_operator: is_call && index == 0, + enclosing_head: head, + }, + )); + } + } +} + +// -- symbols ----------------------------------------------------------------- + +/// An atom's symbol text, past any reader prefix, lowercased and stripped of its +/// package qualifier — the spelling every comparison here is written in. +#[must_use] +pub fn normalized_symbol(text: &str) -> String { + unqualified(text).to_ascii_lowercase() +} + +/// The symbol an atom names, in the normalized spelling. +#[must_use] +pub fn symbol_name(view: &ExpressionView) -> Option { + atom_symbol_text(view) + .filter(|text| !text.is_empty()) + .map(normalized_symbol) +} + +/// Whether an atom is a lambda-list keyword such as `&rest` or `&optional`. +/// +/// Compared on the *unqualified* text, because `&rest` is read as a symbol in +/// whatever package is current and a qualified spelling is legal if unusual. +#[must_use] +pub fn is_lambda_list_keyword(name: &str) -> bool { + name.starts_with('&') +} + +/// Whether this node is a form the reader folded away behind `#+` or `#-`. +/// +/// Under this repository's dialect-aware parse a reader conditional and the form +/// it guards are read as a **single atom**, and `atom_text` returns that atom's +/// text with the `#+`/`#-` prefix still attached. So `#+sbcl (declare (fixnum +/// x))` is not a list with head `declare`; it is one atom whose text begins +/// `#+`. Every rule here that reasons about the *position* of a body form has to +/// decline such a body outright: it cannot see whether the hidden form is a +/// declaration, and guessing either way is a false positive in one direction and +/// a missed finding in the other. +/// +/// Several rules in sibling packages have been written with guards that were +/// unreachable because they expected `#+sbcl` to be a separate node. It is not. +#[must_use] +pub fn is_reader_conditional(view: &ExpressionView) -> bool { + atom_text(view).is_some_and(|text| text.starts_with("#+") || text.starts_with("#-")) +} + +// -- body geometry ----------------------------------------------------------- + +/// A `(declare …)` form. +#[must_use] +pub fn is_declare(view: &ExpressionView) -> bool { + list_head(view).is_some_and(|head| symbol_is(head, "declare")) +} + +/// A `(declaim …)` form. +#[must_use] +pub fn is_declaim(view: &ExpressionView) -> bool { + list_head(view).is_some_and(|head| symbol_is(head, "declaim")) +} + +/// A string literal, which in a declaration section is a documentation string. +#[must_use] +pub fn is_string_literal(view: &ExpressionView) -> bool { + atom_text(view).is_some_and(|text| text.starts_with('"')) +} + +/// Where the body of a declaration-accepting form begins, by head. +/// +/// Only forms whose prefix length is *fixed* are listed. `defmethod` is absent +/// on purpose: its qualifiers sit between the name and the specialized lambda +/// list, so its body starts at a position that has to be searched for rather +/// than counted to, and every rule here that needs it uses +/// [`defmethod_body_start`]. +/// +/// `handler-case`, `restart-case` and friends are absent because CLHS does not +/// admit a declaration at the position that looks like their body. +#[must_use] +pub fn body_start(head: &str) -> Option { + let index = match head { + // (defun name lambda-list . body) — the name may itself be a list, as in + // `(defun (setf foo) …)`, so this counts to a fixed index rather than + // looking for the first list. + "defun" | "defmacro" | "define-compiler-macro" | "deftype" => 3, + // (lambda lambda-list . body) + "lambda" => 2, + // (let bindings . body) + "let" | "let*" | "flet" | "labels" | "macrolet" | "symbol-macrolet" | "prog" | "prog*" => 2, + // (locally . body) + "locally" => 1, + // (multiple-value-bind vars form . body) + "multiple-value-bind" | "destructuring-bind" | "with-slots" | "with-accessors" => 3, + // (do bindings (end-test . result) . body) + "do" | "do*" => 3, + // (dolist (var list . result) . body) + "dolist" | "dotimes" => 2, + _ => return None, + }; + Some(index) +} + +/// Every head [`body_start`] answers for, as the head list a rule registers. +/// +/// Kept beside `body_start` so that a head added to one and not the other is a +/// visible inconsistency rather than a rule that silently stops being reached. +pub const DECLARATION_BODY_HEADS: [&str; 21] = [ + "defun", + "defmacro", + "define-compiler-macro", + "deftype", + "lambda", + "let", + "let*", + "flet", + "labels", + "macrolet", + "symbol-macrolet", + "prog", + "prog*", + "locally", + "multiple-value-bind", + "destructuring-bind", + "with-slots", + "with-accessors", + "do", + "do*", + "dolist", +]; + +/// The head list every rule about a *body* registers, as the engine's head +/// index wants it: [`DECLARATION_BODY_HEADS`] plus `defmethod`, whose body start +/// is searched for rather than counted to. +/// +/// A single constant so that the head index and [`body_start_of`] cannot drift +/// apart. A head in the index that `body_start_of` does not answer for is a +/// wasted dispatch; a head `body_start_of` answers for that the index omits is a +/// rule that silently never sees those forms, and no domain test can catch it. +pub const DECLARATION_BODY_RULE_HEADS: [NormalizedHead; 22] = [ + NormalizedHead::new("defun"), + NormalizedHead::new("defmacro"), + NormalizedHead::new("define-compiler-macro"), + NormalizedHead::new("deftype"), + NormalizedHead::new("lambda"), + NormalizedHead::new("let"), + NormalizedHead::new("let*"), + NormalizedHead::new("flet"), + NormalizedHead::new("labels"), + NormalizedHead::new("macrolet"), + NormalizedHead::new("symbol-macrolet"), + NormalizedHead::new("prog"), + NormalizedHead::new("prog*"), + NormalizedHead::new("locally"), + NormalizedHead::new("multiple-value-bind"), + NormalizedHead::new("destructuring-bind"), + NormalizedHead::new("with-slots"), + NormalizedHead::new("with-accessors"), + NormalizedHead::new("do"), + NormalizedHead::new("do*"), + NormalizedHead::new("dolist"), + NormalizedHead::new("defmethod"), +]; + +/// Every rule in this package models Common Lisp's declaration system and +/// nothing else. +/// +/// Stated explicitly rather than left to the [`RuleDialectScope`] trait default, +/// which is also Common Lisp: a rule that *defaulted* to it and one that +/// *declares* it are indistinguishable at the call site, and the package's +/// engine tests assert the declaration. +pub const COMMON_LISP_ONLY: RuleDialectScope = RuleDialectScope::new(&[Dialect::CommonLisp]); + +/// `defmethod`'s body start, which has to be searched for. +/// +/// `(defmethod name qualifier* specialized-lambda-list . body)`: the qualifiers +/// are non-list objects, so the lambda list is the first `(…)` at or after index +/// 2 and the body starts just past it. A `defmethod` whose lambda list cannot be +/// found is `None` rather than a guess. +#[must_use] +pub fn defmethod_body_start(view: &ExpressionView) -> Option { + view.children + .iter() + .enumerate() + .skip(2) + .find(|(_, child)| is_paren_list(child)) + .map(|(index, _)| index + 1) +} + +/// The body start of any form this module models, `defmethod` included. +#[must_use] +pub fn body_start_of(view: &ExpressionView) -> Option { + let head = list_head(view)?; + if symbol_is(head, "defmethod") { + return defmethod_body_start(view); + } + body_start(&normalized_symbol(head)) +} + +/// The index just past a form's declaration section. +/// +/// CLHS 3.4.11 lets a body begin with any number of `declare` expressions and at +/// most one documentation string, in either order. A trailing string is *not* a +/// documentation string — `(defun f (x) (declare (fixnum x)) "result")` returns +/// that string — so a string is only counted into the section when some form +/// follows it. +#[must_use] +pub fn declaration_section_end(view: &ExpressionView, start: usize) -> usize { + let mut index = start; + let mut seen_doc = false; + while let Some(child) = view.children.get(index) { + if is_declare(child) { + index += 1; + } else if !seen_doc && is_string_literal(child) && index + 1 < view.children.len() { + seen_doc = true; + index += 1; + } else { + break; + } + } + index +} + +/// Whether a node's value is produced by `#.` at read time. +/// +/// `#.(format nil "…")` is a *string* by the time the compiler sees the form, so +/// it is a documentation string — but statically it is a call to `format`, and +/// nothing here can tell that it will evaluate to a string rather than to a +/// declaration or an ordinary body form. +#[must_use] +pub fn carries_read_eval(view: &ExpressionView) -> bool { + view.reader_prefixes.contains(&ReaderPrefix::ReadEval) +} + +/// Whether a form's shape is too opaque for any positional judgement about its +/// body. +/// +/// Both conditions were found by auditing SBCL's own sources, and neither was +/// anticipated: +/// +/// - **A reader conditional anywhere among the form's children, the head +/// included.** `sbcl/src/compiler/globaldb.lisp` opens a definition with +/// `(#+sb-xc-host cl:defmacro #-sb-xc-host sb-xc:defmacro define-info-type +/// (…) (declare …) …)`. The head is a folded `#+` atom that still normalizes +/// to `defmacro`, so the head index dispatches — and every subsequent index is +/// shifted by the two conditional atoms, which put the lambda list where the +/// first body form is counted and made a correctly placed `declare` look +/// displaced. Scanning only from `start` misses this entirely, which is why +/// the scan begins at zero. +/// - **A `#.` read-time evaluation in the declaration section.** +/// `sbcl/src/code/save.lisp` writes its documentation string as `#.(format +/// nil "…" #+elf "…" #-elf "")`. That is a docstring at read time and a +/// `format` call statically, so the two `declare` forms after it read as +/// coming *after* a body form. +/// +/// Declining both costs findings in exactly the files most saturated with +/// declarations. It is still the right trade: each was a false positive on +/// working, shipped code. +#[must_use] +pub fn form_shape_is_opaque(view: &ExpressionView, start: usize) -> bool { + if view.children.iter().any(is_reader_conditional) { + return true; + } + let section_end = declaration_section_end(view, start); + view.children + .iter() + .take(section_end.saturating_add(1)) + .skip(start) + .any(carries_read_eval) +} + +// -- declaration specifiers -------------------------------------------------- + +/// The declaration identifiers that are *not* type specifiers, so that +/// `(fixnum x)` can be read as the abbreviated type declaration it is while +/// `(ignore x)` is not. +/// +/// CLHS 3.3.1 lists these; `declaration` and `values` are included because both +/// are declaration identifiers a program may write and neither declares a +/// variable's type. +const NON_TYPE_DECLARATION_IDENTIFIERS: [&str; 11] = [ + "ignore", + "ignorable", + "special", + "dynamic-extent", + "optimize", + "inline", + "notinline", + "ftype", + "type", + "declaration", + "values", +]; + +/// One `(identifier …)` specifier inside a `declare` or `declaim`. +#[derive(Debug, Clone, Copy)] +pub struct Specifier<'a> { + /// The normalized declaration identifier, e.g. `ignore` or `fixnum`. + pub identifier: &'a ExpressionView, + /// The whole specifier list. + pub form: &'a ExpressionView, +} + +/// Every `(identifier …)` specifier of a `declare`/`declaim` form. +/// +/// The head itself is skipped, and any specifier that is not a `(…)` list is +/// dropped: `(declare optimize)` is malformed, and a malformed declaration is a +/// different rule's subject. +pub fn specifiers(view: &ExpressionView) -> impl Iterator> { + view.children.iter().skip(1).filter_map(|form| { + if !is_paren_list(form) { + return None; + } + let identifier = form.children.first()?; + Some(Specifier { identifier, form }) + }) +} + +/// The variables a `(ignore …)` or `(ignorable …)` specifier names. +/// +/// `(ignore (function f))` names a *function*, not a variable, and is skipped: +/// only plain symbols are returned. +pub fn ignored_variables(view: &ExpressionView, identifier: &str) -> Vec { + let mut names = Vec::new(); + for specifier in specifiers(view) { + if symbol_name(specifier.identifier).as_deref() != Some(identifier) { + continue; + } + names.extend( + specifier + .form + .children + .iter() + .skip(1) + .filter_map(symbol_name), + ); + } + names +} + +/// A type declaration found in a `declare`: the declared type and the variables +/// it applies to. +#[derive(Debug, Clone, Copy)] +pub struct TypeDeclaration<'a> { + /// The type specifier as written. + pub type_spec: &'a ExpressionView, + /// The specifier list the declaration came from, for the finding's span. + pub form: &'a ExpressionView, + /// Index of the first variable name within [`TypeDeclaration::form`]. + first_variable: usize, +} + +impl<'a> TypeDeclaration<'a> { + /// The variables this declaration applies to. + pub fn variables(&self) -> impl Iterator + use<'a> { + self.form + .children + .iter() + .skip(self.first_variable) + .filter_map(symbol_name) + } +} + +/// Every variable type declaration in one `declare` form. +/// +/// Both spellings: the long `(type fixnum x y)` and the abbreviated `(fixnum x +/// y)`. An abbreviated specifier whose identifier is one of +/// [`NON_TYPE_DECLARATION_IDENTIFIERS`] is not a type declaration, and neither +/// is one whose identifier is a `(…)` list — `((integer 0 10) x)` is not legal +/// abbreviated syntax, only `(type (integer 0 10) x)` is. +#[must_use] +pub fn type_declarations(view: &ExpressionView) -> Vec> { + let mut found = Vec::new(); + for specifier in specifiers(view) { + let Some(identifier) = symbol_name(specifier.identifier) else { + continue; + }; + if identifier == "type" { + if let Some(type_spec) = specifier.form.children.get(1) { + found.push(TypeDeclaration { + type_spec, + form: specifier.form, + first_variable: 2, + }); + } + continue; + } + if NON_TYPE_DECLARATION_IDENTIFIERS.contains(&identifier.as_str()) { + continue; + } + found.push(TypeDeclaration { + type_spec: specifier.identifier, + form: specifier.form, + first_variable: 1, + }); + } + found +} + +// -- the literal lattice ----------------------------------------------------- + +/// The kinds of value this package is willing to read off a literal. +/// +/// Deliberately coarse and deliberately incomplete. Every rule that consults it +/// reports only when a literal's kind is known *and* the declared type provably +/// excludes it, so an unmodelled expression costs a missed finding and never a +/// false one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiteralKind { + Integer, + Float, + Ratio, + Str, + Character, + /// `nil`, which is both the empty list and a symbol. + Null, + /// `t`. + True, + Keyword, + /// A non-empty list: `(list 1 2)`, `(cons 1 2)`, `'(1 2)`. + Cons, +} + +/// Whether a token is an integer in the CL reader's sense. +/// +/// `123`, `-4`, and `7.` are all integers; a trailing decimal point is a decimal +/// radix marker, not a float. +fn is_integer_token(text: &str) -> bool { + let digits = text.strip_prefix(['+', '-']).unwrap_or(text); + let digits = digits.strip_suffix('.').unwrap_or(digits); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Whether a token is a float: digits, a decimal point with at least one digit +/// after it, and an optional exponent. +/// +/// The "digit after the point" requirement is what keeps `7.` an integer. +fn is_float_token(text: &str) -> bool { + let body = text.strip_prefix(['+', '-']).unwrap_or(text); + let Some((whole, rest)) = body.split_once('.') else { + return false; + }; + if !whole.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + let fraction = match rest.split_once(['e', 'E', 's', 'S', 'f', 'F', 'd', 'D', 'l', 'L']) { + Some((fraction, exponent)) => { + let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent); + if exponent.is_empty() || !exponent.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + fraction + } + None => rest, + }; + !fraction.is_empty() && fraction.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Whether a token is a ratio: `1/2`, `-3/4`. +fn is_ratio_token(text: &str) -> bool { + let body = text.strip_prefix(['+', '-']).unwrap_or(text); + let Some((numerator, denominator)) = body.split_once('/') else { + return false; + }; + !numerator.is_empty() + && !denominator.is_empty() + && numerator.bytes().all(|byte| byte.is_ascii_digit()) + && denominator.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// What kind of value an expression obviously is, when that is obvious. +/// +/// `None` for everything else, which is most things: a variable reference, a +/// function call whose return type is not modelled, an arithmetic form. Callers +/// treat `None` as "say nothing". +/// +/// A node carrying a reader-conditional prefix is `None` by construction: its +/// text begins `#+` and matches no literal shape. +#[must_use] +pub fn literal_kind(view: &ExpressionView) -> Option { + // A quoted expression: `'foo`, `'(1 2)`, `'()`. + if view.reader_prefixes.contains(&ReaderPrefix::Quote) { + return quoted_literal_kind(view); + } + if let Some(text) = atom_text(view) { + return atom_literal_kind(text); + } + if is_quote_form(view) { + return view.children.get(1).and_then(quoted_literal_kind); + } + let head = list_head(view)?; + let head = normalized_symbol(head); + match head.as_str() { + // `(list)` is nil; `(list x …)` is always a cons. + "list" => Some(if view.children.len() > 1 { + LiteralKind::Cons + } else { + LiteralKind::Null + }), + // `(cons a b)` and `(list* a … b)` with at least one argument are conses. + "cons" => Some(LiteralKind::Cons), + "list*" if view.children.len() > 2 => Some(LiteralKind::Cons), + _ => None, + } +} + +/// The kind of a *quoted* expression, where a list is literal data rather than a +/// call. +fn quoted_literal_kind(view: &ExpressionView) -> Option { + if let Some(text) = atom_text(view) { + // `'nil` and `'t` read as the same objects `nil` and `t` do; any other + // quoted atom is a symbol, which this lattice does not model beyond + // keywords. + let stripped = text.trim_start_matches('\''); + return atom_literal_kind(stripped); + } + if !is_paren_list(view) { + return None; + } + Some(if view.children.is_empty() { + LiteralKind::Null + } else { + LiteralKind::Cons + }) +} + +/// The kind of a bare atom's text. +fn atom_literal_kind(text: &str) -> Option { + if text.starts_with('"') { + return Some(LiteralKind::Str); + } + if text.starts_with("#\\") { + return Some(LiteralKind::Character); + } + if text.starts_with('#') { + // `#(1 2)`, `#+sbcl …`, `#'f`, `#b1010` — none of them modelled. + return None; + } + let normalized = normalized_symbol(text); + if normalized == "nil" || normalized == "()" { + return Some(LiteralKind::Null); + } + if normalized == "t" { + return Some(LiteralKind::True); + } + if text.starts_with(':') && text.len() > 1 { + return Some(LiteralKind::Keyword); + } + if is_integer_token(text) { + return Some(LiteralKind::Integer); + } + if is_float_token(text) { + return Some(LiteralKind::Float); + } + if is_ratio_token(text) { + return Some(LiteralKind::Ratio); + } + None +} + +/// The exact set of [`LiteralKind`]s an atomic type specifier admits, for the +/// specifiers whose relationship to this lattice is fully enumerable. +/// +/// `None` means "not modelled", and no rule may conclude anything from it. That +/// is the case for every compound specifier — `(or null string)`, `(integer 0 +/// 10)`, `(satisfies p)` — and for the wide specifiers `t`, `atom`, `sequence`, +/// `array` and `vector`, whose membership questions are exactly the ones a +/// linter gets wrong. A string *is* a vector and *is* a sequence, and a rule +/// that forgot it would fire on correct code. +fn admitted_kinds(type_name: &str) -> Option<&'static [LiteralKind]> { + use LiteralKind::{Character, Cons, Float, Integer, Keyword, Null, Ratio, Str, True}; + Some(match type_name { + "null" => &[Null], + "boolean" => &[Null, True], + "keyword" => &[Keyword], + "symbol" => &[Null, True, Keyword], + "cons" => &[Cons], + "list" => &[Null, Cons], + "string" | "simple-string" | "base-string" | "simple-base-string" => &[Str], + "character" | "base-char" | "standard-char" | "extended-char" => &[Character], + "fixnum" | "bignum" | "integer" | "signed-byte" | "unsigned-byte" | "bit" => &[Integer], + "float" | "single-float" | "double-float" | "short-float" | "long-float" => &[Float], + "ratio" => &[Ratio], + "rational" => &[Integer, Ratio], + "real" | "number" => &[Integer, Float, Ratio], + "hash-table" | "function" | "package" | "stream" | "pathname" | "readtable" + | "random-state" | "restart" | "condition" => &[], + _ => return None, + }) +} + +/// Whether `type_spec` provably cannot contain a value of `kind`. +/// +/// The one question this package's type reasoning is allowed to ask. `false` for +/// every specifier that is not modelled, so an unfamiliar type is silence. +#[must_use] +pub fn type_excludes(type_spec: &ExpressionView, kind: LiteralKind) -> bool { + // Any compound specifier is declined outright. `(or null string)` is the + // idiom these rules exist not to fire on. + let Some(name) = symbol_name(type_spec) else { + return false; + }; + admitted_kinds(&name).is_some_and(|admitted| !admitted.contains(&kind)) +} + +/// Whether `type_spec` is a modelled specifier at all — used to state in a +/// message that the type was understood rather than merely unrecognised. +#[must_use] +pub fn is_modelled_type(type_spec: &ExpressionView) -> bool { + symbol_name(type_spec).is_some_and(|name| admitted_kinds(&name).is_some()) +} + +/// A human-readable rendering of a modelled type specifier. +#[must_use] +pub fn type_text(type_spec: &ExpressionView) -> String { + symbol_name(type_spec).unwrap_or_else(|| "the declared type".to_owned()) +} + +/// How a literal kind reads in a finding's message. +#[must_use] +pub const fn kind_text(kind: LiteralKind) -> &'static str { + match kind { + LiteralKind::Integer => "an integer", + LiteralKind::Float => "a float", + LiteralKind::Ratio => "a ratio", + LiteralKind::Str => "a string", + LiteralKind::Character => "a character", + LiteralKind::Null => "NIL", + LiteralKind::True => "T", + LiteralKind::Keyword => "a keyword", + LiteralKind::Cons => "a non-empty list", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + + fn tree(source: &str) -> SyntaxTree { + SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse") + } + + fn first_form(source: &str) -> (SyntaxTree, ByteSpan) { + let parsed = tree(source); + let span = parsed.root_view().children[0].span; + (parsed, span) + } + + #[test] + fn a_declaration_in_plain_code_reads_as_evaluated() { + let (parsed, span) = first_form("(let ((x 0)) (declare (string x)) x)"); + assert!(!is_unevaluated_at(&parsed, span)); + } + + #[test] + fn a_declaration_inside_a_quote_reads_as_data() { + let (parsed, span) = first_form("'(let ((x 0)) (declare (string x)) x)"); + assert!(is_unevaluated_at(&parsed, span)); + } + + #[test] + fn a_declaration_inside_a_backquote_reads_as_data() { + let (parsed, span) = first_form("`(let ((x 0)) (declare (string x)) x)"); + assert!(is_unevaluated_at(&parsed, span)); + } + + /// The two-counter model's reason for existing: a comma inside a hard quote + /// is a comma character, not an escape back to code. + #[test] + fn a_comma_inside_a_hard_quote_stays_data() { + let parsed = tree("'(a ,(let ((x 0)) (declare (string x)) x))"); + let mut span = None; + paredit_core_syntax::view_query::for_each_subview(&parsed.root_view(), |view| { + if span.is_none() && list_head(view).is_some_and(|head| head == "let") { + span = Some(view.span); + } + }); + assert!(is_unevaluated_at(&parsed, span.expect("the let"))); + } + + #[test] + fn an_unquote_inside_a_backquote_is_code_again() { + let parsed = tree("`(a ,(let ((x 0)) (declare (string x)) x))"); + let mut span = None; + paredit_core_syntax::view_query::for_each_subview(&parsed.root_view(), |view| { + if span.is_none() && list_head(view).is_some_and(|head| head == "let") { + span = Some(view.span); + } + }); + assert!(!is_unevaluated_at(&parsed, span.expect("the let"))); + } + + // -- body geometry ------------------------------------------------------- + + #[test] + fn a_defuns_body_starts_after_the_lambda_list() { + assert_eq!(body_start("defun"), Some(3)); + assert_eq!(body_start("let"), Some(2)); + assert_eq!(body_start("locally"), Some(1)); + assert_eq!(body_start("if"), None); + } + + #[test] + fn every_listed_head_has_a_body_start() { + for head in DECLARATION_BODY_HEADS { + assert!(body_start(head).is_some(), "{head} has no body start"); + } + } + + #[test] + fn a_defmethods_body_starts_past_its_qualifiers() { + let parsed = tree("(defmethod area :before ((s square)) (print s))"); + let form = &parsed.root_view().children[0]; + assert_eq!(defmethod_body_start(form), Some(4)); + + let parsed = tree("(defmethod area ((s square)) (print s))"); + let form = &parsed.root_view().children[0]; + assert_eq!(defmethod_body_start(form), Some(3)); + } + + #[test] + fn the_declaration_section_covers_declarations_and_one_docstring() { + let parsed = tree("(defun f (x) \"doc\" (declare (fixnum x)) (+ x 1))"); + let form = &parsed.root_view().children[0]; + assert_eq!(declaration_section_end(form, 3), 5); + } + + /// A string with nothing after it is the return value, not a docstring. + #[test] + fn a_trailing_string_is_not_a_docstring() { + let parsed = tree("(defun f (x) (declare (fixnum x)) \"result\")"); + let form = &parsed.root_view().children[0]; + assert_eq!(declaration_section_end(form, 3), 4); + } + + /// The prompt's trap, verified as a property of the parse rather than + /// assumed: `#+sbcl (declare …)` is one atom whose text carries the prefix. + #[test] + fn a_reader_conditional_is_a_single_atom_carrying_its_prefix() { + let parsed = tree("(defun f (x) #+sbcl (declare (fixnum x)) (+ x 1))"); + let form = &parsed.root_view().children[0]; + let guarded = &form.children[3]; + assert!( + is_reader_conditional(guarded), + "expected one atom beginning #+, got {:?}", + atom_text(guarded) + ); + assert!( + !is_declare(guarded), + "a reader-conditional declare must not read as a declare list" + ); + assert!(form_shape_is_opaque(form, 3)); + } + + /// The two shapes the SBCL corpus audit turned up, as properties of + /// [`form_shape_is_opaque`] rather than only of the rules that consult it. + #[test] + fn a_reader_conditional_head_makes_the_whole_form_opaque() { + let parsed = tree( + "(#+sb-xc-host cl:defmacro #-sb-xc-host sb-xc:defmacro m (a) (declare (fixnum a)) a)", + ); + let form = &parsed.root_view().children[0]; + assert!( + form_shape_is_opaque(form, 3), + "a folded #+ head shifts every later index" + ); + } + + #[test] + fn a_read_time_evaluated_docstring_makes_the_body_opaque() { + let parsed = tree("(defun f (x) #.(format nil \"doc\") (declare (fixnum x)) x)"); + let form = &parsed.root_view().children[0]; + assert!(carries_read_eval(&form.children[3])); + assert!(form_shape_is_opaque(form, 3)); + } + + /// The guard must not swallow ordinary forms: a `defun` with a plain + /// docstring and no reader syntax is transparent. + #[test] + fn an_ordinary_form_is_not_opaque() { + let parsed = tree("(defun f (x) \"doc\" (declare (fixnum x)) x)"); + let form = &parsed.root_view().children[0]; + assert!(!form_shape_is_opaque(form, 3)); + } + + // -- specifiers ---------------------------------------------------------- + + #[test] + fn ignore_specifiers_name_their_variables() { + let parsed = tree("(declare (ignore a b) (ignorable c))"); + let form = &parsed.root_view().children[0]; + assert_eq!(ignored_variables(form, "ignore"), vec!["a", "b"]); + assert_eq!(ignored_variables(form, "ignorable"), vec!["c"]); + } + + #[test] + fn an_ignored_function_is_not_an_ignored_variable() { + let parsed = tree("(declare (ignore (function f) x))"); + let form = &parsed.root_view().children[0]; + assert_eq!(ignored_variables(form, "ignore"), vec!["x"]); + } + + #[test] + fn both_type_declaration_spellings_are_read() { + let parsed = tree("(declare (fixnum a b) (type string c))"); + let form = &parsed.root_view().children[0]; + let declarations = type_declarations(form); + assert_eq!(declarations.len(), 2); + assert_eq!(type_text(declarations[0].type_spec), "fixnum"); + assert_eq!( + declarations[0].variables().collect::>(), + vec!["a", "b"] + ); + assert_eq!(type_text(declarations[1].type_spec), "string"); + assert_eq!(declarations[1].variables().collect::>(), vec!["c"]); + } + + #[test] + fn a_non_type_declaration_identifier_is_not_a_type() { + let parsed = tree("(declare (ignore x) (special *y*) (optimize (speed 3)) (inline f))"); + let form = &parsed.root_view().children[0]; + assert!(type_declarations(form).is_empty()); + } + + // -- literals ------------------------------------------------------------ + + fn kind_of(source: &str) -> Option { + let parsed = tree(source); + literal_kind(&parsed.root_view().children[0]) + } + + #[test] + fn literals_read_as_their_kinds() { + assert_eq!(kind_of("0"), Some(LiteralKind::Integer)); + assert_eq!(kind_of("-42"), Some(LiteralKind::Integer)); + assert_eq!(kind_of("7."), Some(LiteralKind::Integer)); + assert_eq!(kind_of("1.5"), Some(LiteralKind::Float)); + assert_eq!(kind_of("1.0e10"), Some(LiteralKind::Float)); + assert_eq!(kind_of("1/2"), Some(LiteralKind::Ratio)); + assert_eq!(kind_of("\"hi\""), Some(LiteralKind::Str)); + assert_eq!(kind_of("#\\a"), Some(LiteralKind::Character)); + assert_eq!(kind_of("nil"), Some(LiteralKind::Null)); + assert_eq!(kind_of("t"), Some(LiteralKind::True)); + assert_eq!(kind_of(":key"), Some(LiteralKind::Keyword)); + } + + #[test] + fn an_unmodelled_expression_has_no_kind() { + assert_eq!(kind_of("x"), None); + assert_eq!(kind_of("(f x)"), None); + assert_eq!(kind_of("(+ 1 2)"), None); + assert_eq!(kind_of("#(1 2)"), None); + assert_eq!(kind_of("#'car"), None); + } + + #[test] + fn list_building_calls_read_as_conses() { + assert_eq!(kind_of("(list 1 2)"), Some(LiteralKind::Cons)); + assert_eq!(kind_of("(list)"), Some(LiteralKind::Null)); + assert_eq!(kind_of("(cons 1 2)"), Some(LiteralKind::Cons)); + } + + #[test] + fn quoted_data_reads_as_its_kind() { + assert_eq!(kind_of("'(1 2)"), Some(LiteralKind::Cons)); + assert_eq!(kind_of("'()"), Some(LiteralKind::Null)); + assert_eq!(kind_of("'nil"), Some(LiteralKind::Null)); + } + + // -- the lattice --------------------------------------------------------- + + fn excludes(type_source: &str, kind: LiteralKind) -> bool { + let parsed = tree(type_source); + type_excludes(&parsed.root_view().children[0], kind) + } + + #[test] + fn a_type_excludes_a_kind_it_cannot_contain() { + assert!(excludes("string", LiteralKind::Integer)); + assert!(excludes("fixnum", LiteralKind::Str)); + assert!(excludes("fixnum", LiteralKind::Null)); + assert!(excludes("null", LiteralKind::Cons)); + assert!(excludes("cons", LiteralKind::Null)); + assert!(excludes("integer", LiteralKind::Float)); + } + + #[test] + fn a_type_does_not_exclude_a_kind_it_contains() { + assert!(!excludes("fixnum", LiteralKind::Integer)); + assert!(!excludes("string", LiteralKind::Str)); + assert!(!excludes("list", LiteralKind::Null)); + assert!(!excludes("list", LiteralKind::Cons)); + assert!(!excludes("symbol", LiteralKind::Null)); + assert!(!excludes("number", LiteralKind::Float)); + assert!(!excludes("real", LiteralKind::Ratio)); + } + + /// The whole point of declining compound specifiers: the widening idiom + /// `(or null hash-table)` around a `nil` initform is correct code. + #[test] + fn a_compound_specifier_excludes_nothing() { + assert!(!excludes("(or null string)", LiteralKind::Null)); + assert!(!excludes("(or null hash-table)", LiteralKind::Null)); + assert!(!excludes("(integer 0 10)", LiteralKind::Str)); + assert!(!excludes("(satisfies evenp)", LiteralKind::Str)); + assert!(!excludes( + "(simple-array double-float (*))", + LiteralKind::Null + )); + } + + /// A string is a vector and a sequence, and an unmodelled wide type must + /// stay unmodelled rather than be guessed at. + #[test] + fn wide_types_are_not_modelled() { + for wide in ["t", "atom", "sequence", "array", "vector", "simple-vector"] { + let parsed = tree(wide); + assert!( + !is_modelled_type(&parsed.root_view().children[0]), + "{wide} must not be modelled" + ); + for kind in [ + LiteralKind::Str, + LiteralKind::Null, + LiteralKind::Cons, + LiteralKind::Integer, + ] { + assert!(!excludes(wide, kind), "{wide} must exclude nothing"); + } + } + } + + #[test] + fn an_unknown_user_type_excludes_nothing() { + assert!(!excludes("my-widget", LiteralKind::Str)); + assert!(!excludes("point", LiteralKind::Null)); + } + + #[test] + fn a_lambda_list_keyword_is_recognised_by_its_ampersand() { + assert!(is_lambda_list_keyword("&rest")); + assert!(is_lambda_list_keyword("&optional")); + assert!(!is_lambda_list_keyword("x")); + } +} diff --git a/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/domain.rs b/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/domain.rs new file mode 100644 index 00000000..5752ca1f --- /dev/null +++ b/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/domain.rs @@ -0,0 +1,188 @@ +//! A `(the TYPE EXPR)` whose `TYPE` provably excludes `EXPR`. +//! +//! # What CLHS says +//! +//! CLHS special operator `the`: the values yielded by the form "must be of the +//! types specified", and the consequences are undefined if they are not. Unlike +//! `check-type`, `the` is an *assertion the compiler may believe* rather than +//! one it must verify: at low safety SBCL will propagate the declared type and +//! generate code that cannot cope with the real one. +//! +//! # What SBCL 2.6.0 does +//! +//! A full `WARNING` on each of the four shapes, including both of the ones in +//! the original proposal: +//! +//! ```lisp +//! (defun bad-6a () (the null (list 1 2))) +//! (defun bad-6b () (the fixnum "s")) +//! (defun bad-6c () (the string 42)) +//! (defun bad-6d () (the integer (list 1))) +//! ``` +//! +//! ```text +//! ; caught WARNING: +//! ; Derived type of (LIST 1 2) is (VALUES CONS &OPTIONAL), +//! ; conflicting with its asserted type NULL. +//! ; caught WARNING: +//! ; Constant "s" conflicts with its asserted type FIXNUM. +//! ``` +//! +//! and it is silent on all eight correct uses tried alongside them, including +//! `(the (values integer integer) (floor 7 2))`, `(the null nil)`, +//! `(the (or null string) x)` and `(the fixnum (funcall f))`. This rule matches +//! that split: it fires only where the expression's type is *obvious* and the +//! declared type is one whose membership is fully modelled. + +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView}; +use paredit_core_syntax::view_query::{list_head, symbol_is}; + +use crate::support::{LiteralKind, kind_text, literal_kind, type_excludes, type_text}; + +/// One impossible assertion. +#[derive(Debug, Clone)] +pub struct ImpossibleAssertion { + /// The whole `(the …)` form. + pub span: ByteSpan, + pub declared: String, + kind: LiteralKind, +} + +impl ImpossibleAssertion { + #[must_use] + pub fn message(&self) -> String { + format!( + "this (the {} ...) asserts a type that cannot contain {}, which is what the \ + expression plainly is; at low safety the compiler is entitled to believe the \ + assertion and generate code for a value that never arrives", + self.declared, + kind_text(self.kind) + ) + } +} + +/// Reads one `(the TYPE EXPR)`. +/// +/// A `the` with any other arity is malformed and is not this rule's subject. +#[must_use] +pub fn examine_the(view: &ExpressionView) -> Option { + if !list_head(view).is_some_and(|head| symbol_is(head, "the")) { + return None; + } + if view.children.len() != 3 { + return None; + } + let type_spec = view.children.get(1)?; + let expression = view.children.get(2)?; + let kind = literal_kind(expression)?; + if !type_excludes(type_spec, kind) { + return None; + } + Some(ImpossibleAssertion { + span: view.span, + declared: type_text(type_spec), + kind, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + use paredit_core_syntax::sexpr::SyntaxTree; + + fn finding(source: &str) -> Option { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + examine_the(&tree.root_view().children[0]) + } + + #[test] + fn flags_the_references_own_examples() { + assert!(finding("(the null (list 1 2))").is_some()); + assert!(finding("(the fixnum \"s\")").is_some()); + assert!(finding("(the string 42)").is_some()); + assert!(finding("(the integer (list 1))").is_some()); + } + + #[test] + fn names_the_declared_type_in_the_finding() { + let found = finding("(the null (list 1 2))").expect("a finding"); + assert_eq!(found.declared, "null"); + assert!(found.message().contains("a non-empty list")); + } + + #[test] + fn flags_a_quoted_list_asserted_to_be_a_number() { + assert!(finding("(the fixnum '(1 2))").is_some()); + } + + #[test] + fn flags_a_character_asserted_to_be_a_string() { + assert!(finding("(the string #\\a)").is_some()); + } + + // -- the correct uses ---------------------------------------------------- + + /// Every one of these compiles without a peep from SBCL 2.6.0. + #[test] + fn accepts_assertions_that_hold() { + for source in [ + "(the list (list 1 2))", + "(the null nil)", + "(the string \"s\")", + "(the integer 42)", + "(the fixnum 0)", + "(the symbol nil)", + "(the boolean t)", + "(the number 1.5)", + "(the real 1/2)", + ] { + assert!(finding(source).is_none(), "{source} is correct"); + } + } + + /// An expression whose type is not obvious is not this rule's business. + #[test] + fn accepts_an_expression_whose_type_cannot_be_read() { + assert!(finding("(the fixnum (funcall f))").is_none()); + assert!(finding("(the fixnum (+ x 1))").is_none()); + assert!(finding("(the string x)").is_none()); + } + + /// A compound type specifier is declined outright, `(values …)` included. + #[test] + fn accepts_a_compound_type_specifier() { + assert!(finding("(the (values integer integer) (floor 7 2))").is_none()); + assert!(finding("(the (or null string) nil)").is_none()); + assert!(finding("(the (integer 0 10) \"s\")").is_none()); + assert!(finding("(the (simple-array double-float (*)) nil)").is_none()); + } + + #[test] + fn accepts_an_unmodelled_type_name() { + assert!(finding("(the my-widget 42)").is_none()); + assert!(finding("(the t 42)").is_none()); + assert!(finding("(the sequence \"s\")").is_none()); + } + + #[test] + fn declines_a_malformed_the() { + assert!(finding("(the fixnum)").is_none()); + assert!(finding("(the)").is_none()); + assert!(finding("(the fixnum \"s\" extra)").is_none()); + } + + #[test] + fn declines_a_form_that_is_not_a_the() { + assert!(finding("(list null (list 1 2))").is_none()); + } + + /// A string is a vector and a sequence; asserting either of those about a + /// string is correct and must stay silent. + #[test] + fn accepts_a_string_asserted_to_be_a_wider_type() { + assert!(finding("(the vector \"s\")").is_none()); + assert!(finding("(the sequence \"s\")").is_none()); + assert!(finding("(the array \"s\")").is_none()); + } +} diff --git a/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/mod.rs b/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/mod.rs new file mode 100644 index 00000000..1e55904e --- /dev/null +++ b/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/mod.rs @@ -0,0 +1,5 @@ +//! `the-form-with-impossible-type`: a `(the TYPE EXPR)` whose declared type +//! cannot contain the value `EXPR` obviously is. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/rule.rs b/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/rule.rs new file mode 100644 index 00000000..5aa220cc --- /dev/null +++ b/packages/feature/lint-type-declaration/src/the_form_with_impossible_type/rule.rs @@ -0,0 +1,60 @@ +//! Registration for `the-form-with-impossible-type`. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::support::{COMMON_LISP_ONLY, is_unevaluated_at}; +use crate::the_form_with_impossible_type::domain::examine_the; + +pub const META: RuleMeta = RuleMeta::new( + "the-form-with-impossible-type", + RuleCategory::Declaration, + // SBCL emits a full WARNING, not a style warning: `the` is an assertion the + // optimiser may act on. + Severity::Warning, + "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is", + // Either the assertion or the expression is wrong, and the linter cannot + // know which. + Fixability::ReportOnly, +); + +/// The one head this rule is about, and the reason its per-file cost is close to +/// nothing: a file with no `the` form never reaches `check` at all. +const HEADS: [NormalizedHead; 1] = [NormalizedHead::new("the")]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + COMMON_LISP_ONLY + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult<()> { + let Some(item) = examine_the(view) else { + return Ok(()); + }; + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + sink.report(item.span, item.message()); + Ok(()) + } +} diff --git a/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/domain.rs b/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/domain.rs new file mode 100644 index 00000000..b3e16abf --- /dev/null +++ b/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/domain.rs @@ -0,0 +1,263 @@ +//! A `let` binding initialised to a literal its own declared type excludes. +//! +//! # What CLHS says +//! +//! CLHS 3.3.4 and the `type` declaration's page: a `type` declaration is a +//! promise that the variable's value is always of that type. Binding it to a +//! value outside the type breaks the promise, and CLHS says the consequences are +//! undefined — a safe implementation may signal, and an optimising one may +//! generate code that simply assumes the declaration. +//! +//! # What SBCL 2.6.0 does +//! +//! A full `WARNING` on each: +//! +//! ```lisp +//! (defun bad-3a () (let ((x 0)) (declare (string x)) x)) +//! (defun bad-3b () (let ((s "hi")) (declare (fixnum s)) s)) +//! (defun bad-3c () (let ((n nil)) (declare (fixnum n)) n)) +//! ``` +//! +//! ```text +//! ; caught WARNING: +//! ; Constant 0 conflicts with its asserted type STRING. +//! ``` +//! +//! # The idiom this must not fire on +//! +//! A placeholder initial value that a later `setf` replaces is *correct* code +//! and extremely common: +//! +//! ```lisp +//! (let ((total 0)) (declare (fixnum total)) (incf total 1) total) +//! (let ((cache nil)) (declare (type (or null hash-table) cache)) cache) +//! ``` +//! +//! Both stay silent, and SBCL agrees: the first because `0` *is* a `fixnum`, the +//! second because `(or null hash-table)` is a compound specifier and +//! [`crate::support::type_excludes`] declines every compound specifier by +//! construction. That decision is what keeps this rule off the widening idiom, +//! and it is worth more than the findings it costs. + +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView}; +use paredit_core_syntax::view_query::{is_paren_list, list_head, symbol_is}; + +use crate::support::{ + LiteralKind, body_start_of, declaration_section_end, is_declare, is_reader_conditional, + kind_text, literal_kind, symbol_name, type_declarations, type_excludes, type_text, +}; + +/// One binding whose declared type excludes its initial value. +#[derive(Debug, Clone)] +pub struct ContradictedBinding { + /// The binding form `(var initform)`. + pub span: ByteSpan, + pub variable: String, + pub declared: String, + kind: LiteralKind, +} + +impl ContradictedBinding { + #[must_use] + pub fn message(&self) -> String { + format!( + "{} is declared {} but is bound to {}, which that type cannot contain; the \ + declaration is a promise the binding already breaks", + self.variable, + self.declared, + kind_text(self.kind) + ) + } +} + +/// Reads one binding of a `let`/`let*` bindings list as `(name, initform)`. +/// +/// A bare symbol and a `(var)` binding both initialise to `nil`, but neither is +/// reported: a declaration on a placeholder the author never wrote a value for +/// is far more likely to be a deliberate "assigned later" than a mistake, and +/// the finding is not worth the argument. +fn binding_with_initform(binding: &ExpressionView) -> Option<(String, &ExpressionView)> { + if !is_paren_list(binding) || binding.children.len() < 2 { + return None; + } + let name = symbol_name(binding.children.first()?)?; + Some((name, binding.children.get(1)?)) +} + +/// Every contradicted binding of one `let` or `let*`. +#[must_use] +pub fn examine_let(view: &ExpressionView) -> Vec { + if !list_head(view).is_some_and(|head| symbol_is(head, "let") || symbol_is(head, "let*")) { + return Vec::new(); + } + let Some(start) = body_start_of(view) else { + return Vec::new(); + }; + let Some(bindings) = view.children.get(1).filter(|list| is_paren_list(list)) else { + return Vec::new(); + }; + let section_end = declaration_section_end(view, start); + if section_end == start { + // No declaration section at all: nothing to contradict, and no walk. + return Vec::new(); + } + + let mut found = Vec::new(); + for child in view.children.iter().take(section_end).skip(start) { + if !is_declare(child) { + continue; + } + for declaration in type_declarations(child) { + if is_reader_conditional(declaration.type_spec) { + continue; + } + let declared = type_text(declaration.type_spec); + for name in declaration.variables() { + let Some((_, initform)) = bindings + .children + .iter() + .filter_map(binding_with_initform) + .find(|(bound, _)| *bound == name) + else { + continue; + }; + let Some(kind) = literal_kind(initform) else { + continue; + }; + if !type_excludes(declaration.type_spec, kind) { + continue; + } + let span = bindings + .children + .iter() + .find(|binding| { + binding_with_initform(binding).is_some_and(|(bound, _)| bound == name) + }) + .map_or(initform.span, |binding| binding.span); + found.push(ContradictedBinding { + span, + variable: name, + declared: declared.clone(), + kind, + }); + } + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + use paredit_core_syntax::sexpr::SyntaxTree; + + fn findings(source: &str) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + examine_let(&tree.root_view().children[0]) + } + + #[test] + fn flags_the_references_own_example() { + let found = findings("(let ((x 0)) (declare (string x)) x)"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].variable, "x"); + assert_eq!(found[0].declared, "string"); + } + + #[test] + fn flags_a_string_bound_where_a_fixnum_is_declared() { + assert_eq!( + findings("(let ((s \"hi\")) (declare (fixnum s)) s)").len(), + 1 + ); + } + + #[test] + fn flags_nil_bound_where_a_number_is_declared() { + assert_eq!(findings("(let ((n nil)) (declare (fixnum n)) n)").len(), 1); + } + + #[test] + fn flags_the_long_hand_type_spelling_too() { + assert_eq!( + findings("(let ((x 0)) (declare (type string x)) x)").len(), + 1 + ); + } + + #[test] + fn flags_a_let_star_the_same_way() { + assert_eq!(findings("(let* ((x 0)) (declare (string x)) x)").len(), 1); + } + + // -- the correct idioms -------------------------------------------------- + + /// The placeholder-then-assign idiom, which is what makes this rule + /// dangerous if it is done carelessly. + #[test] + fn accepts_a_placeholder_whose_type_contains_it() { + assert!( + findings("(let ((total 0)) (declare (fixnum total)) (incf total) total)").is_empty() + ); + assert!(findings("(let ((s \"\")) (declare (string s)) s)").is_empty()); + } + + /// The widening idiom. A compound specifier is declined outright. + #[test] + fn accepts_nil_under_a_widened_compound_type() { + assert!( + findings("(let ((cache nil)) (declare (type (or null hash-table) cache)) cache)") + .is_empty() + ); + assert!(findings("(let ((x nil)) (declare (type (or null string) x)) x)").is_empty()); + } + + #[test] + fn accepts_a_binding_with_no_type_declaration() { + assert!(findings("(let ((x 0)) (declare (ignore x)) 1)").is_empty()); + assert!(findings("(let ((x 0)) x)").is_empty()); + } + + #[test] + fn accepts_an_initform_whose_type_cannot_be_read() { + assert!(findings("(let ((x (compute))) (declare (string x)) x)").is_empty()); + assert!(findings("(let ((x y)) (declare (fixnum x)) x)").is_empty()); + } + + #[test] + fn accepts_an_unmodelled_declared_type() { + assert!(findings("(let ((x 0)) (declare (my-widget x)) x)").is_empty()); + assert!(findings("(let ((x 0)) (declare (t x)) x)").is_empty()); + } + + /// `nil` is a `list`, a `symbol` and a `boolean`; none of those may fire. + #[test] + fn accepts_nil_under_every_type_that_contains_it() { + for declared in ["list", "symbol", "boolean", "null"] { + assert!( + findings(&format!("(let ((x nil)) (declare ({declared} x)) x)")).is_empty(), + "{declared} contains NIL" + ); + } + } + + #[test] + fn accepts_a_declaration_naming_a_variable_bound_elsewhere() { + assert!(findings("(let ((x 0)) (declare (string y)) x)").is_empty()); + } + + #[test] + fn declines_a_binding_with_no_initform() { + assert!(findings("(let (x) (declare (fixnum x)) x)").is_empty()); + assert!(findings("(let ((x)) (declare (fixnum x)) x)").is_empty()); + } + + #[test] + fn flags_each_contradicted_binding() { + assert_eq!( + findings("(let ((x 0) (s \"a\")) (declare (string x) (fixnum s)) 1)").len(), + 2 + ); + } +} diff --git a/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/mod.rs b/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/mod.rs new file mode 100644 index 00000000..208f0a1b --- /dev/null +++ b/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/mod.rs @@ -0,0 +1,5 @@ +//! `type-declaration-contradicts-initform`: a `let` binding whose declared type +//! cannot contain the literal it is initialised to. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/rule.rs b/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/rule.rs new file mode 100644 index 00000000..534eb6fd --- /dev/null +++ b/packages/feature/lint-type-declaration/src/type_declaration_contradicts_initform/rule.rs @@ -0,0 +1,65 @@ +//! Registration for `type-declaration-contradicts-initform`. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::support::{COMMON_LISP_ONLY, is_unevaluated_at}; +use crate::type_declaration_contradicts_initform::domain::examine_let; + +pub const META: RuleMeta = RuleMeta::new( + "type-declaration-contradicts-initform", + RuleCategory::Declaration, + // SBCL emits a full WARNING: the declaration is a promise the binding + // breaks, and an optimising compiler is entitled to believe it. + Severity::Warning, + "a let binding whose declared type cannot contain the literal it is initialised to", + // Which half is wrong — the type or the initial value — is the author's + // call, and neither repair is right more often than the other. + Fixability::ReportOnly, +); + +/// Only the two binding forms that pair a name with an initial value in a shape +/// this rule can read. `flet` and `labels` bind functions, `do` puts its step +/// form where an initform is not, and `multiple-value-bind` has no per-variable +/// initial value at all. +const HEADS: [NormalizedHead; 2] = [NormalizedHead::new("let"), NormalizedHead::new("let*")]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + COMMON_LISP_ONLY + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult<()> { + let items = examine_let(view); + if items.is_empty() { + return Ok(()); + } + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + for item in items { + sink.report(item.span, item.message()); + } + Ok(()) + } +} diff --git a/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/domain.rs b/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/domain.rs new file mode 100644 index 00000000..754c78bf --- /dev/null +++ b/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/domain.rs @@ -0,0 +1,273 @@ +//! A `&rest` parameter declared to be the type of its *elements*. +//! +//! # What CLHS says +//! +//! CLHS 3.4.1.3: the `&rest` variable is bound to "a list of the remaining +//! arguments". Whatever the arguments are, the variable itself is always a list. +//! So `(defun f (&rest args) (declare (fixnum args)) …)` declares the list to be +//! a number — the author meant "the arguments are fixnums", which a `&rest` +//! declaration cannot express at all. +//! +//! # What SBCL 2.6.0 does +//! +//! A full `WARNING`, and its wording names the mechanism exactly: +//! +//! ```lisp +//! (defun bad-9 (&rest args) (declare (fixnum args)) (apply #'+ args)) +//! ``` +//! +//! ```text +//! ; caught WARNING: +//! ; Derived type of (SB-C:%LISTIFY-REST-ARGS #:N-CONTEXT-2 #:N-COUNT-3) is +//! ; (VALUES LIST &OPTIONAL), +//! ; conflicting with its asserted type +//! ; FIXNUM. +//! ``` +//! +//! It is silent on `(declare (list args))` and on `(declare (dynamic-extent +//! args))`, the two things a `&rest` variable is legitimately declared to be. +//! +//! # Why this is a separate rule from the `let` one +//! +//! The contradiction here is not with a literal initial value — there is none — +//! but with the *binding mechanism*. The variable's type is known from the +//! lambda list alone, which is why the test is a type-versus-type question and +//! not a type-versus-literal one. + +use paredit_core_syntax::sexpr::{ByteSpan, ExpressionView}; +use paredit_core_syntax::view_query::{is_paren_list, list_head}; + +use crate::support::{ + LiteralKind, body_start_of, declaration_section_end, is_declare, is_reader_conditional, + normalized_symbol, symbol_name, type_declarations, type_excludes, type_text, +}; + +/// One `&rest` parameter with an impossible type declaration. +#[derive(Debug, Clone)] +pub struct RestTypeDeclaration { + /// The offending declaration specifier. + pub span: ByteSpan, + pub variable: String, + pub declared: String, +} + +impl RestTypeDeclaration { + #[must_use] + pub fn message(&self) -> String { + format!( + "{} is a &rest parameter, so it is always bound to a list, but it is declared {}; \ + a &rest declaration describes the list itself and cannot describe its elements", + self.variable, self.declared + ) + } +} + +/// The lambda list of a form this rule inspects. +/// +/// `(defun name lambda-list . body)` and `(defmacro name lambda-list . body)` +/// put it at index 2; `(lambda lambda-list . body)` at index 1. `defmethod` is +/// excluded: a specialized lambda list may not contain `&rest` specializers, and +/// its body start is searched for rather than counted to, so the two indices +/// would have to be derived separately for no additional coverage. +fn lambda_list_index(head: &str) -> Option { + match head { + "defun" | "defmacro" | "define-compiler-macro" => Some(2), + "lambda" => Some(1), + _ => None, + } +} + +/// The variables bound by `&rest` or `&body` in a lambda list. +/// +/// Only symbols: `defmacro` permits a destructuring pattern after `&rest`, and a +/// pattern binds several variables of which none is the list. +fn rest_variables(lambda_list: &ExpressionView) -> Vec { + let mut names = Vec::new(); + let mut expecting = false; + for child in &lambda_list.children { + let Some(name) = symbol_name(child) else { + expecting = false; + continue; + }; + if expecting { + if !name.starts_with('&') { + names.push(name); + } + expecting = false; + continue; + } + if name == "&rest" || name == "&body" { + expecting = true; + } + } + names +} + +/// Every impossible `&rest` type declaration in one form. +#[must_use] +pub fn examine_form(view: &ExpressionView) -> Vec { + let Some(head) = list_head(view) else { + return Vec::new(); + }; + let Some(index) = lambda_list_index(&normalized_symbol(head)) else { + return Vec::new(); + }; + let Some(lambda_list) = view.children.get(index).filter(|list| is_paren_list(list)) else { + return Vec::new(); + }; + let rest = rest_variables(lambda_list); + if rest.is_empty() { + // The common case, and the one that keeps this rule off the benchmark + // path: no `&rest`, no declaration section walk. + return Vec::new(); + } + let Some(start) = body_start_of(view) else { + return Vec::new(); + }; + let section_end = declaration_section_end(view, start); + + let mut found = Vec::new(); + for child in view.children.iter().take(section_end).skip(start) { + if !is_declare(child) { + continue; + } + for declaration in type_declarations(child) { + if is_reader_conditional(declaration.type_spec) { + continue; + } + // A `&rest` variable is a list, so its declared type must admit both + // the empty list and a non-empty one. A type that excludes either is + // a type no `&rest` variable can ever have. + let excludes_list = type_excludes(declaration.type_spec, LiteralKind::Null) + || type_excludes(declaration.type_spec, LiteralKind::Cons); + if !excludes_list { + continue; + } + let declared = type_text(declaration.type_spec); + for name in declaration.variables() { + if !rest.contains(&name) { + continue; + } + found.push(RestTypeDeclaration { + span: declaration.form.span, + variable: name, + declared: declared.clone(), + }); + } + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + use paredit_core_syntax::dialect::Dialect; + use paredit_core_syntax::sexpr::SyntaxTree; + + fn findings(source: &str) -> Vec { + let tree = SyntaxTree::parse_with_dialect(source, Dialect::CommonLisp).expect("parse"); + examine_form(&tree.root_view().children[0]) + } + + #[test] + fn flags_the_references_own_example() { + let found = findings("(defun f (&rest args) (declare (fixnum args)) (apply #'+ args))"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].variable, "args"); + assert_eq!(found[0].declared, "fixnum"); + } + + #[test] + fn flags_the_long_hand_spelling() { + assert_eq!( + findings("(defun f (&rest args) (declare (type string args)) args)").len(), + 1 + ); + } + + #[test] + fn flags_it_after_required_parameters() { + assert_eq!( + findings("(defun f (a b &rest args) (declare (fixnum args)) args)").len(), + 1 + ); + } + + #[test] + fn flags_it_in_a_lambda_and_a_defmacro_body_variable() { + assert_eq!( + findings("(lambda (&rest args) (declare (fixnum args)) args)").len(), + 1 + ); + assert_eq!( + findings("(defmacro m (&body forms) (declare (fixnum forms)) forms)").len(), + 1 + ); + } + + // -- the correct declarations -------------------------------------------- + + /// The two things a `&rest` variable is legitimately declared to be. SBCL + /// accepts both without a word. + #[test] + fn accepts_the_declarations_a_rest_variable_can_have() { + assert!(findings("(defun f (&rest args) (declare (list args)) args)").is_empty()); + assert!(findings("(defun f (&rest args) (declare (type list args)) args)").is_empty()); + assert!(findings("(defun f (&rest args) (declare (dynamic-extent args)) args)").is_empty()); + assert!(findings("(defun f (&rest args) (declare (ignore args)) 1)").is_empty()); + assert!(findings("(defun f (&rest args) (declare (ignorable args)) 1)").is_empty()); + } + + /// `sequence` and `t` are not modelled, so they are silence rather than a + /// guess — and both are in fact correct for a list. + #[test] + fn accepts_a_wider_or_unmodelled_type() { + assert!(findings("(defun f (&rest args) (declare (sequence args)) args)").is_empty()); + assert!(findings("(defun f (&rest args) (declare (t args)) args)").is_empty()); + assert!(findings("(defun f (&rest args) (declare (my-type args)) args)").is_empty()); + } + + /// `null` admits the empty list but not a non-empty one, so it is still an + /// impossible declaration for a `&rest` variable — but `list` and `cons` are + /// not symmetric here, and only `list` is right. + #[test] + fn flags_a_type_that_admits_only_part_of_what_a_rest_list_can_be() { + assert_eq!( + findings("(defun f (&rest args) (declare (null args)) args)").len(), + 1 + ); + assert_eq!( + findings("(defun f (&rest args) (declare (cons args)) args)").len(), + 1 + ); + } + + #[test] + fn accepts_a_declaration_on_a_different_parameter() { + assert!(findings("(defun f (a &rest args) (declare (fixnum a)) args)").is_empty()); + } + + #[test] + fn accepts_a_lambda_list_with_no_rest_parameter() { + assert!(findings("(defun f (a b) (declare (fixnum a b)) (+ a b))").is_empty()); + } + + #[test] + fn declines_a_destructuring_pattern_after_rest() { + assert!(findings("(defmacro m (&rest (a b)) (declare (fixnum a)) a)").is_empty()); + } + + #[test] + fn declines_a_form_with_no_lambda_list() { + assert!(findings("(let ((args 1)) (declare (fixnum args)) args)").is_empty()); + assert!(findings("(defun f)").is_empty()); + } + + #[test] + fn accepts_a_compound_type_specifier() { + assert!( + findings("(defun f (&rest args) (declare (type (or null cons) args)) args)").is_empty() + ); + } +} diff --git a/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/mod.rs b/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/mod.rs new file mode 100644 index 00000000..47c71823 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/mod.rs @@ -0,0 +1,5 @@ +//! `type-declaration-on-rest-parameter`: a `&rest` parameter declared to be its +//! own element type rather than a list. + +pub mod domain; +pub mod rule; diff --git a/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/rule.rs b/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/rule.rs new file mode 100644 index 00000000..179af119 --- /dev/null +++ b/packages/feature/lint-type-declaration/src/type_declaration_on_rest_parameter/rule.rs @@ -0,0 +1,69 @@ +//! Registration for `type-declaration-on-rest-parameter`. + +use paredit_core_lint_engine::LintResult; +use paredit_core_lint_engine::engine::{RuleContext, RuleSink}; +use paredit_core_lint_engine::model::{ + Fixability, HeadFilter, NormalizedHead, RuleCategory, RuleMeta, Severity, +}; +use paredit_core_lint_engine::policy::RuleDialectScope; +use paredit_core_lint_engine::rule::LintRule; +use paredit_core_syntax::sexpr::ExpressionView; + +use crate::support::{COMMON_LISP_ONLY, is_unevaluated_at}; +use crate::type_declaration_on_rest_parameter::domain::examine_form; + +pub const META: RuleMeta = RuleMeta::new( + "type-declaration-on-rest-parameter", + RuleCategory::Declaration, + // SBCL emits a full WARNING: the declared type and the binding mechanism + // cannot both be right. + Severity::Warning, + "a &rest parameter declared to be its element type, though it is always bound to a list", + // The author wanted to say something about the *elements*, which a &rest + // declaration cannot express at all; there is no mechanical rewrite that + // says it. + Fixability::ReportOnly, +); + +/// Only the three heads whose lambda list sits at a fixed index. `defmethod` is +/// deliberately absent — see the domain module. +const HEADS: [NormalizedHead; 4] = [ + NormalizedHead::new("defun"), + NormalizedHead::new("defmacro"), + NormalizedHead::new("define-compiler-macro"), + NormalizedHead::new("lambda"), +]; + +#[derive(Debug)] +pub struct Rule; + +pub const RULE: Rule = Rule; + +impl LintRule for Rule { + fn head_filter(&self) -> HeadFilter { + HeadFilter::Heads(&HEADS) + } + + fn dialect_scope(&self) -> RuleDialectScope { + COMMON_LISP_ONLY + } + + fn check( + &self, + context: &RuleContext<'_>, + view: &ExpressionView, + sink: &mut RuleSink<'_, '_>, + ) -> LintResult<()> { + let items = examine_form(view); + if items.is_empty() { + return Ok(()); + } + if is_unevaluated_at(context.tree(), view.span) { + return Ok(()); + } + for item in items { + sink.report(item.span, item.message()); + } + Ok(()) + } +} diff --git a/src/lint/registry/catalog.rs b/src/lint/registry/catalog.rs index 8de98633..8172350d 100644 --- a/src/lint/registry/catalog.rs +++ b/src/lint/registry/catalog.rs @@ -200,7 +200,14 @@ pub const PEDANTIC_RULES: [&str; tagged_count(RuleTag::Pedantic)] = { // both new packages, neither of which runs on Common Lisp. All 8 ship with a // standalone `inspect ` command as well, so `INTROSPECTION_COMMANDS` // moves with this number where the batch above left it alone. -const _: () = assert!(RULE_COUNT == 303); +// +// 303 + this batch's 10: 5 (`lint-fennel-janet-idiom`) and 5 +// (`lint-type-declaration`), both new packages. Neither ships a `cli/` +// directory, so unlike the batch above this one adds no standalone command and +// `INTROSPECTION_COMMANDS` stays where it was. `lint-type-declaration` +// proposed six and ships five: `ignore-declared-variable-then-used` was dropped +// as a true duplicate of `lint-convention`'s `ignore-declaration-conflict`. +const _: () = assert!(RULE_COUNT == 313); // Unchanged at 99: every one of this branch's 37 rules is // `Fixability::ReportOnly`. Each one reports a judgment the tool cannot make // on the author's behalf — whether an annotation or the parameter list under it @@ -232,6 +239,17 @@ const _: () = assert!(RULE_COUNT == 303); // `lint-clojure-idiom` rules are `ReportOnly`: each of them has a repair with // more than one shape (`doall` versus `into` versus `reduce`; `let` versus a // top-level `defonce`), and picking one would be picking for the author. +// +// Back to standing still: 103 after this batch's 10, every one of which is +// `Fixability::ReportOnly`. The two packages decline for the same reason from +// opposite ends. The Fennel/Janet rules report a mismatch between a spelling +// and an intent — a `var` that could be a `local`, a `{…}` that should have +// been `@{…}` — where the tool cannot tell which half the author meant, and +// `var-never-set` says outright that its assignment search is blind to scope +// and quoting. The declaration rules are the same shape: which of the type and +// the initform is wrong, or whether a late `declare` wanted hoisting or wanted +// to be a `the`, is the author's call and neither repair is right more often +// than the other. const _: () = assert!(fixable_count() == 103); // 164 (through PR #82) + 31 of this branch's 37 rules. The other 6 are // `Severity::Error`: `when-unless-implicit-nil-misused` and the five @@ -266,7 +284,21 @@ const _: () = assert!(fixable_count() == 103); // `def-inside-function-body` (the Var does not exist until the function runs, // and concurrent callers race on it — which is also clj-kondo's judgement, // where `:inline-def` is on by default). -const _: () = assert!(warning_count() == 225); +// +// 225 + 7 of this batch's 10 = 232. The other 3 are `Severity::Error`, each +// because a real implementation refuses or dies on the code rather than merely +// disliking it: `fennel-each-over-non-iterator` (`each` compiles to Lua's +// generic `for … in`, which *calls* the iterator, so a table or string literal +// raises "attempt to call a table value" on the first round), +// `janet-mutating-immutable-literal` (`put` and the `array/*` and `buffer/*` +// families panic when handed the immutable twin of a mutable container — a +// dropped `@` is code that reads correctly and dies on its first call), and +// `declare-not-at-head-of-body` (past the first body form a `(declare …)` is +// not a declaration at all but a call to an undefined function, which SBCL +// 2.6.0 reports as a full `caught ERROR`). `var-never-set` carries +// `RuleTag::Style`, which is not `Pedantic` and so does not hold it back from +// any preset; it counts as a warning here like the other 6. +const _: () = assert!(warning_count() == 232); const _: () = assert!(EXPERIMENTAL_RULES.is_empty()); // 6 (through PR #82) + 8 of this branch's rules: `lint-call-shape`'s four // threshold rules, whose limits are conventions a codebase either adopted or @@ -284,6 +316,11 @@ const _: () = assert!(EXPERIMENTAL_RULES.is_empty()); // demonstrated failure and stay untagged. This batch's 8 leave it at 15 as // well: none of them carries a tag, so `PEDANTIC_RULES` and // `EXPERIMENTAL_RULES` are both unchanged by it. +// This batch's 10 leave it at 15 too. Nine carry no tag at all, and the tenth, +// `var-never-set`, carries `RuleTag::Style` — which says the rule's subject is +// layout or naming rather than behaviour, and which no preset filters on. Only +// `Pedantic` and `Experimental` gate admission, so `PEDANTIC_RULES` and +// `EXPERIMENTAL_RULES` are both unchanged. const _: () = assert!(PEDANTIC_RULES.len() == 15); fn meta_of(name: &str) -> Option<&'static crate::lint::model::RuleMeta> { diff --git a/src/lint/registry/mod.rs b/src/lint/registry/mod.rs index ed198c7a..2b5a1090 100644 --- a/src/lint/registry/mod.rs +++ b/src/lint/registry/mod.rs @@ -51,7 +51,32 @@ use super::rule::RuleEntry; // explicitly, and three of them cover Scheme *and* Racket. That pair is a // first: every rule scoped away from Common Lisp until now named exactly one // dialect, which `contract.rs`'s dialect matrix had quietly assumed. -pub const RULE_COUNT: usize = 303; +// +// 303 + this batch's 10, both packages new: 5 (`lint-fennel-janet-idiom`) and 5 +// (`lint-type-declaration`) = 313. Both are registry-only — neither ships a +// `cli/` directory, so no standalone `inspect ` command comes with them +// and the command-oriented lists do not move. +// +// The Fennel/Janet five carry the dialect story one step further than the batch +// above. Three name exactly one dialect (`fennel-deprecated-form` and +// `fennel-each-over-non-iterator` Fennel, `janet-empty-loop-body` and +// `janet-mutating-immutable-literal` Janet — four, rather), and `var-never-set` +// names *two*, Fennel and Janet together, because `var` is the mutable binder +// in both and the rule carries a per-dialect vocabulary table keyed off the +// same `DIALECTS` constant it hands to `RuleDialectScope::new`. The +// `..._for_a_proper_subset_of_dialects` relaxation the batch above made to +// `contract.rs` already admits that shape. +// +// The `lint-type-declaration` five are Common Lisp only and lean on +// `dialect_scope()`'s default. The package proposed six; the sixth, +// `ignore-declared-variable-then-used`, was dropped before wiring as a true +// duplicate of `lint-convention`'s shipped `ignore-declaration-conflict`. That +// investigation left a handover in the package's README: the *shipped* rule +// produces 21 findings over SBCL's sources and all 21 look like false +// positives, with the fixed guards sitting in this package's `support.rs` +// ready to lift across. That is a pre-existing defect and deliberately not +// repaired here. +pub const RULE_COUNT: usize = 313; /// Every rule, in report order: findings are grouped by this order, and the /// public `RULES`/`RULE_DOCS` arrays preserve it. @@ -1282,4 +1307,44 @@ pub const REGISTRY: [RuleEntry; RULE_COUNT] = [ &paredit_feature_lint_scheme_idiom::named_let_never_recurs::rule::META, &paredit_feature_lint_scheme_idiom::named_let_never_recurs::rule::RULE, ), + RuleEntry::new( + &paredit_feature_lint_fennel_janet_idiom::var_never_set::rule::META, + &paredit_feature_lint_fennel_janet_idiom::var_never_set::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_fennel_janet_idiom::fennel_deprecated_form::rule::META, + &paredit_feature_lint_fennel_janet_idiom::fennel_deprecated_form::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_fennel_janet_idiom::fennel_each_over_non_iterator::rule::META, + &paredit_feature_lint_fennel_janet_idiom::fennel_each_over_non_iterator::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_fennel_janet_idiom::janet_empty_loop_body::rule::META, + &paredit_feature_lint_fennel_janet_idiom::janet_empty_loop_body::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_fennel_janet_idiom::janet_mutating_immutable_literal::rule::META, + &paredit_feature_lint_fennel_janet_idiom::janet_mutating_immutable_literal::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_type_declaration::declare_not_at_head_of_body::rule::META, + &paredit_feature_lint_type_declaration::declare_not_at_head_of_body::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_type_declaration::declaim_inside_body::rule::META, + &paredit_feature_lint_type_declaration::declaim_inside_body::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_type_declaration::type_declaration_contradicts_initform::rule::META, + &paredit_feature_lint_type_declaration::type_declaration_contradicts_initform::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_type_declaration::the_form_with_impossible_type::rule::META, + &paredit_feature_lint_type_declaration::the_form_with_impossible_type::rule::RULE, + ), + RuleEntry::new( + &paredit_feature_lint_type_declaration::type_declaration_on_rest_parameter::rule::META, + &paredit_feature_lint_type_declaration::type_declaration_on_rest_parameter::rule::RULE, + ), ]; diff --git a/tests/cli/lint_report.rs b/tests/cli/lint_report.rs index 56c50072..3be9ed7a 100644 --- a/tests/cli/lint_report.rs +++ b/tests/cli/lint_report.rs @@ -295,9 +295,11 @@ fn cli_lint_list_rules_prints_the_catalog_without_files() { // + 8 of the 9-rule batch = 280 — the ninth, `elisp-hook-lambda`, is // `pedantic`. + all 8 of this branch's = 288, the whole suite's 303 // less the 15 `pedantic` rules the default `recommended` preset holds - // back. This branch tags none of its rules, so the rise here is the - // full count. - .stdout(predicate::str::contains("\"rule_count\": 288")) + // back. + all 10 of this branch's = 298, the whole suite's 313 less + // the same 15. Nine of the 10 are untagged and the tenth carries + // `RuleTag::Style`, which no preset filters on, so the rise here is + // again the full count. + .stdout(predicate::str::contains("\"rule_count\": 298")) .stdout(predicate::str::contains("\"self-assignment\"")) .stdout(predicate::str::contains( "a setq/setf/psetq/psetf that assigns a place to itself", @@ -973,9 +975,12 @@ fn cli_lint_list_rules_marks_severity() { // `pedantic`, so the default preset holds it back — = 204. + 6 of this // branch's 8, the other 2 being `with-open-returns-lazy-seq` and // `def-inside-function-body`, both `Severity::Error`, and none of the 8 - // `pedantic` — = 210, which is the suite's 225 warnings less the 15 + // `pedantic` — = 210. + 7 of this branch's 10, the other 3 being + // `fennel-each-over-non-iterator`, `janet-mutating-immutable-literal` and + // `declare-not-at-head-of-body`, all `Severity::Error`, and none of the 10 + // `pedantic` — = 217, which is the suite's 232 warnings less the 15 // `pedantic` rules, all of them warnings. - assert_eq!(warnings, 210); + assert_eq!(warnings, 217); } #[test] @@ -998,7 +1003,8 @@ fn cli_lint_list_rules_marks_fixability() { // batches — the `lint-scheme-idiom` four are the first fixable rules added // since — and it is also the only place the *preset-filtered* fixable // count is pinned, so a fixable rule that arrived tagged `pedantic` would - // show up here and nowhere else. + // show up here and nowhere else. Unmoved by this branch's 10: every one of + // them is `ReportOnly`. assert_eq!( fixable_count, 103, "the fixable rules the default preset admits" diff --git a/tests/fixtures/lint_golden/expected/broad.json.golden b/tests/fixtures/lint_golden/expected/broad.json.golden index 4a92f2f8..12d497eb 100644 --- a/tests/fixtures/lint_golden/expected/broad.json.golden +++ b/tests/fixtures/lint_golden/expected/broad.json.golden @@ -3564,6 +3564,66 @@ "count": 0, "description": "a named let whose loop name is never mentioned in its body, so it can never iterate", "rule": "scheme-named-let-never-recurs" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel or Janet `var` binding that nothing ever assigns to", + "rule": "var-never-set" + }, + { + "category": "portability", + "count": 0, + "description": "a Fennel special the language reference lists under \"Deprecated Forms\"", + "rule": "fennel-deprecated-form" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel `each` whose iterator position holds a literal that cannot be called", + "rule": "fennel-each-over-non-iterator" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet `loop`, `seq`, or `catseq` whose body is empty", + "rule": "janet-empty-loop-body" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet mutating call whose target is a tuple, struct, or string literal", + "rule": "janet-mutating-immutable-literal" + }, + { + "category": "malformed", + "count": 0, + "description": "a (declare ...) after the first body form, where it is a call to an undefined function", + "rule": "declare-not-at-head-of-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant", + "rule": "declaim-inside-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a let binding whose declared type cannot contain the literal it is initialised to", + "rule": "type-declaration-contradicts-initform" + }, + { + "category": "declaration", + "count": 0, + "description": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is", + "rule": "the-form-with-impossible-type" + }, + { + "category": "declaration", + "count": 0, + "description": "a &rest parameter declared to be its element type, though it is always bound to a list", + "rule": "type-declaration-on-rest-parameter" } ], "policy": { diff --git a/tests/fixtures/lint_golden/expected/broad.sarif.golden b/tests/fixtures/lint_golden/expected/broad.sarif.golden index d5873557..8a92b990 100644 --- a/tests/fixtures/lint_golden/expected/broad.sarif.golden +++ b/tests/fixtures/lint_golden/expected/broad.sarif.golden @@ -9878,6 +9878,148 @@ "shortDescription": { "text": "a named let whose loop name is never mentioned in its body, so it can never iterate" } + }, + { + "fullDescription": { + "text": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`." + }, + "id": "var-never-set", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`.", + "severity": "warning", + "tags": [ + "style" + ] + }, + "shortDescription": { + "text": "a Fennel or Janet `var` binding that nothing ever assigns to" + } + }, + { + "fullDescription": { + "text": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed." + }, + "id": "fennel-deprecated-form", + "properties": { + "category": "portability", + "fixable": false, + "rationale": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel special the language reference lists under \"Deprecated Forms\"" + } + }, + { + "fullDescription": { + "text": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`." + }, + "id": "fennel-each-over-non-iterator", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel `each` whose iterator position holds a literal that cannot be called" + } + }, + { + "fullDescription": { + "text": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis." + }, + "id": "janet-empty-loop-body", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Janet `loop`, `seq`, or `catseq` whose body is empty" + } + }, + { + "fullDescription": { + "text": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call." + }, + "id": "janet-mutating-immutable-literal", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Janet mutating call whose target is a tuple, struct, or string literal" + } + }, + { + "id": "declare-not-at-head-of-body", + "properties": { + "category": "malformed", + "fixable": false, + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a (declare ...) after the first body form, where it is a call to an undefined function" + } + }, + { + "id": "declaim-inside-body", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant" + } + }, + { + "id": "type-declaration-contradicts-initform", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a let binding whose declared type cannot contain the literal it is initialised to" + } + }, + { + "id": "the-form-with-impossible-type", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is" + } + }, + { + "id": "type-declaration-on-rest-parameter", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a &rest parameter declared to be its element type, though it is always bound to a list" + } } ] } diff --git a/tests/fixtures/lint_golden/expected/broad.text.golden b/tests/fixtures/lint_golden/expected/broad.text.golden index d25d02c7..043d1158 100644 --- a/tests/fixtures/lint_golden/expected/broad.text.golden +++ b/tests/fixtures/lint_golden/expected/broad.text.golden @@ -287,6 +287,16 @@ rule scheme-begin-single-form 0 rule scheme-let-star-independent-bindings 0 rule scheme-memq-assq-literal-key 0 rule scheme-named-let-never-recurs 0 +rule var-never-set 0 +rule fennel-deprecated-form 0 +rule fennel-each-over-non-iterator 0 +rule janet-empty-loop-body 0 +rule janet-mutating-immutable-literal 0 +rule declare-not-at-head-of-body 0 +rule declaim-inside-body 0 +rule type-declaration-contradicts-initform 0 +rule the-form-with-impossible-type 0 +rule type-declaration-on-rest-parameter 0 finding self-assignment error suspicious fixable=false broad.lisp 6875 setq assigns place x to itself self-assignment/5f1a5b802aa99b47/0 finding duplicate-setf-places error duplicate fixable=false broad.lisp 6961 setf assigns variable total more than once; the earlier assignment is dead duplicate-setf-places/8104810ed815b9fd/0 finding setf-arity error arity fixable=false broad.lisp 7021 setf has 3 arguments; place/value pairs require an even count setf-arity/b0a5b422aad6d4d2/0 diff --git a/tests/fixtures/lint_golden/expected/emacs-lisp.json.golden b/tests/fixtures/lint_golden/expected/emacs-lisp.json.golden index eb4b8a64..ac856be1 100644 --- a/tests/fixtures/lint_golden/expected/emacs-lisp.json.golden +++ b/tests/fixtures/lint_golden/expected/emacs-lisp.json.golden @@ -1874,6 +1874,66 @@ "count": 0, "description": "a named let whose loop name is never mentioned in its body, so it can never iterate", "rule": "scheme-named-let-never-recurs" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel or Janet `var` binding that nothing ever assigns to", + "rule": "var-never-set" + }, + { + "category": "portability", + "count": 0, + "description": "a Fennel special the language reference lists under \"Deprecated Forms\"", + "rule": "fennel-deprecated-form" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel `each` whose iterator position holds a literal that cannot be called", + "rule": "fennel-each-over-non-iterator" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet `loop`, `seq`, or `catseq` whose body is empty", + "rule": "janet-empty-loop-body" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet mutating call whose target is a tuple, struct, or string literal", + "rule": "janet-mutating-immutable-literal" + }, + { + "category": "malformed", + "count": 0, + "description": "a (declare ...) after the first body form, where it is a call to an undefined function", + "rule": "declare-not-at-head-of-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant", + "rule": "declaim-inside-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a let binding whose declared type cannot contain the literal it is initialised to", + "rule": "type-declaration-contradicts-initform" + }, + { + "category": "declaration", + "count": 0, + "description": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is", + "rule": "the-form-with-impossible-type" + }, + { + "category": "declaration", + "count": 0, + "description": "a &rest parameter declared to be its element type, though it is always bound to a list", + "rule": "type-declaration-on-rest-parameter" } ], "policy": { diff --git a/tests/fixtures/lint_golden/expected/emacs-lisp.sarif.golden b/tests/fixtures/lint_golden/expected/emacs-lisp.sarif.golden index 005e4fdd..90d0da8e 100644 --- a/tests/fixtures/lint_golden/expected/emacs-lisp.sarif.golden +++ b/tests/fixtures/lint_golden/expected/emacs-lisp.sarif.golden @@ -4305,6 +4305,148 @@ "shortDescription": { "text": "a named let whose loop name is never mentioned in its body, so it can never iterate" } + }, + { + "fullDescription": { + "text": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`." + }, + "id": "var-never-set", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`.", + "severity": "warning", + "tags": [ + "style" + ] + }, + "shortDescription": { + "text": "a Fennel or Janet `var` binding that nothing ever assigns to" + } + }, + { + "fullDescription": { + "text": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed." + }, + "id": "fennel-deprecated-form", + "properties": { + "category": "portability", + "fixable": false, + "rationale": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel special the language reference lists under \"Deprecated Forms\"" + } + }, + { + "fullDescription": { + "text": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`." + }, + "id": "fennel-each-over-non-iterator", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel `each` whose iterator position holds a literal that cannot be called" + } + }, + { + "fullDescription": { + "text": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis." + }, + "id": "janet-empty-loop-body", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Janet `loop`, `seq`, or `catseq` whose body is empty" + } + }, + { + "fullDescription": { + "text": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call." + }, + "id": "janet-mutating-immutable-literal", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Janet mutating call whose target is a tuple, struct, or string literal" + } + }, + { + "id": "declare-not-at-head-of-body", + "properties": { + "category": "malformed", + "fixable": false, + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a (declare ...) after the first body form, where it is a call to an undefined function" + } + }, + { + "id": "declaim-inside-body", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant" + } + }, + { + "id": "type-declaration-contradicts-initform", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a let binding whose declared type cannot contain the literal it is initialised to" + } + }, + { + "id": "the-form-with-impossible-type", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is" + } + }, + { + "id": "type-declaration-on-rest-parameter", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a &rest parameter declared to be its element type, though it is always bound to a list" + } } ] } diff --git a/tests/fixtures/lint_golden/expected/emacs-lisp.text.golden b/tests/fixtures/lint_golden/expected/emacs-lisp.text.golden index d90ba644..914f4070 100644 --- a/tests/fixtures/lint_golden/expected/emacs-lisp.text.golden +++ b/tests/fixtures/lint_golden/expected/emacs-lisp.text.golden @@ -287,6 +287,16 @@ rule scheme-begin-single-form 0 rule scheme-let-star-independent-bindings 0 rule scheme-memq-assq-literal-key 0 rule scheme-named-let-never-recurs 0 +rule var-never-set 0 +rule fennel-deprecated-form 0 +rule fennel-each-over-non-iterator 0 +rule janet-empty-loop-body 0 +rule janet-mutating-immutable-literal 0 +rule declare-not-at-head-of-body 0 +rule declaim-inside-body 0 +rule type-declaration-contradicts-initform 0 +rule the-form-with-impossible-type 0 +rule type-declaration-on-rest-parameter 0 finding elisp-macro-missing-declare warning malformed fixable=false emacs-lisp.el 344 missing editor declaration: macro `fixture-macro` is defined without one; add a leading (declare (indent ...)) and/or (declare (debug ...)) form so Emacs indents and Edebug instruments calls to this macro correctly elisp-macro-missing-declare/3f509c1d426873fb/0 finding elisp-missing-lexical-binding warning suspicious fixable=false emacs-lisp.el 0 no `lexical-binding` setting on the first line, so this file is evaluated with dynamic binding; add `-*- lexical-binding: t -*-` elisp-missing-lexical-binding/48686428ba8a57d0/0 finding elisp-unreachable-lexical-binding error suspicious fixable=false emacs-lisp.el 911 `lexical-binding` is read from the first line only, so this setting has no effect and the file is evaluated with dynamic binding elisp-unreachable-lexical-binding/7bec796cbb2809fb/0 diff --git a/tests/fixtures/lint_golden/expected/nested.json.golden b/tests/fixtures/lint_golden/expected/nested.json.golden index 9bbcf10c..fca54cc2 100644 --- a/tests/fixtures/lint_golden/expected/nested.json.golden +++ b/tests/fixtures/lint_golden/expected/nested.json.golden @@ -1978,6 +1978,66 @@ "count": 0, "description": "a named let whose loop name is never mentioned in its body, so it can never iterate", "rule": "scheme-named-let-never-recurs" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel or Janet `var` binding that nothing ever assigns to", + "rule": "var-never-set" + }, + { + "category": "portability", + "count": 0, + "description": "a Fennel special the language reference lists under \"Deprecated Forms\"", + "rule": "fennel-deprecated-form" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel `each` whose iterator position holds a literal that cannot be called", + "rule": "fennel-each-over-non-iterator" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet `loop`, `seq`, or `catseq` whose body is empty", + "rule": "janet-empty-loop-body" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet mutating call whose target is a tuple, struct, or string literal", + "rule": "janet-mutating-immutable-literal" + }, + { + "category": "malformed", + "count": 0, + "description": "a (declare ...) after the first body form, where it is a call to an undefined function", + "rule": "declare-not-at-head-of-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant", + "rule": "declaim-inside-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a let binding whose declared type cannot contain the literal it is initialised to", + "rule": "type-declaration-contradicts-initform" + }, + { + "category": "declaration", + "count": 0, + "description": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is", + "rule": "the-form-with-impossible-type" + }, + { + "category": "declaration", + "count": 0, + "description": "a &rest parameter declared to be its element type, though it is always bound to a list", + "rule": "type-declaration-on-rest-parameter" } ], "policy": { diff --git a/tests/fixtures/lint_golden/expected/nested.sarif.golden b/tests/fixtures/lint_golden/expected/nested.sarif.golden index 35a6ef58..6c4a0bdf 100644 --- a/tests/fixtures/lint_golden/expected/nested.sarif.golden +++ b/tests/fixtures/lint_golden/expected/nested.sarif.golden @@ -4963,6 +4963,148 @@ "shortDescription": { "text": "a named let whose loop name is never mentioned in its body, so it can never iterate" } + }, + { + "fullDescription": { + "text": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`." + }, + "id": "var-never-set", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`.", + "severity": "warning", + "tags": [ + "style" + ] + }, + "shortDescription": { + "text": "a Fennel or Janet `var` binding that nothing ever assigns to" + } + }, + { + "fullDescription": { + "text": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed." + }, + "id": "fennel-deprecated-form", + "properties": { + "category": "portability", + "fixable": false, + "rationale": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel special the language reference lists under \"Deprecated Forms\"" + } + }, + { + "fullDescription": { + "text": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`." + }, + "id": "fennel-each-over-non-iterator", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel `each` whose iterator position holds a literal that cannot be called" + } + }, + { + "fullDescription": { + "text": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis." + }, + "id": "janet-empty-loop-body", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Janet `loop`, `seq`, or `catseq` whose body is empty" + } + }, + { + "fullDescription": { + "text": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call." + }, + "id": "janet-mutating-immutable-literal", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Janet mutating call whose target is a tuple, struct, or string literal" + } + }, + { + "id": "declare-not-at-head-of-body", + "properties": { + "category": "malformed", + "fixable": false, + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a (declare ...) after the first body form, where it is a call to an undefined function" + } + }, + { + "id": "declaim-inside-body", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant" + } + }, + { + "id": "type-declaration-contradicts-initform", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a let binding whose declared type cannot contain the literal it is initialised to" + } + }, + { + "id": "the-form-with-impossible-type", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is" + } + }, + { + "id": "type-declaration-on-rest-parameter", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a &rest parameter declared to be its element type, though it is always bound to a list" + } } ] } diff --git a/tests/fixtures/lint_golden/expected/nested.text.golden b/tests/fixtures/lint_golden/expected/nested.text.golden index a8933538..692d4e4e 100644 --- a/tests/fixtures/lint_golden/expected/nested.text.golden +++ b/tests/fixtures/lint_golden/expected/nested.text.golden @@ -287,6 +287,16 @@ rule scheme-begin-single-form 0 rule scheme-let-star-independent-bindings 0 rule scheme-memq-assq-literal-key 0 rule scheme-named-let-never-recurs 0 +rule var-never-set 0 +rule fennel-deprecated-form 0 +rule fennel-each-over-non-iterator 0 +rule janet-empty-loop-body 0 +rule janet-mutating-immutable-literal 0 +rule declare-not-at-head-of-body 0 +rule declaim-inside-body 0 +rule type-declaration-contradicts-initform 0 +rule the-form-with-impossible-type 0 +rule type-declaration-on-rest-parameter 0 finding redundant-quote warning suspicious fixable=true nested.lisp 840 quoting number 5 is redundant redundant-quote/07bb8307b4864109/0 finding redundant-progn warning suspicious fixable=true nested.lisp 268 redundant progn: progn wraps a single form; it is equivalent to that form redundant-progn/9715182cb9f63314/0 finding redundant-progn warning suspicious fixable=true nested.lisp 833 redundant progn: progn wraps a single form; it is equivalent to that form redundant-progn/8d949e5fcf0552a8/0 diff --git a/tests/fixtures/lint_golden/expected/suppressed.json.golden b/tests/fixtures/lint_golden/expected/suppressed.json.golden index 89562f5a..67b87d16 100644 --- a/tests/fixtures/lint_golden/expected/suppressed.json.golden +++ b/tests/fixtures/lint_golden/expected/suppressed.json.golden @@ -1757,6 +1757,66 @@ "count": 0, "description": "a named let whose loop name is never mentioned in its body, so it can never iterate", "rule": "scheme-named-let-never-recurs" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel or Janet `var` binding that nothing ever assigns to", + "rule": "var-never-set" + }, + { + "category": "portability", + "count": 0, + "description": "a Fennel special the language reference lists under \"Deprecated Forms\"", + "rule": "fennel-deprecated-form" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Fennel `each` whose iterator position holds a literal that cannot be called", + "rule": "fennel-each-over-non-iterator" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet `loop`, `seq`, or `catseq` whose body is empty", + "rule": "janet-empty-loop-body" + }, + { + "category": "suspicious", + "count": 0, + "description": "a Janet mutating call whose target is a tuple, struct, or string literal", + "rule": "janet-mutating-immutable-literal" + }, + { + "category": "malformed", + "count": 0, + "description": "a (declare ...) after the first body form, where it is a call to an undefined function", + "rule": "declare-not-at-head-of-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant", + "rule": "declaim-inside-body" + }, + { + "category": "declaration", + "count": 0, + "description": "a let binding whose declared type cannot contain the literal it is initialised to", + "rule": "type-declaration-contradicts-initform" + }, + { + "category": "declaration", + "count": 0, + "description": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is", + "rule": "the-form-with-impossible-type" + }, + { + "category": "declaration", + "count": 0, + "description": "a &rest parameter declared to be its element type, though it is always bound to a list", + "rule": "type-declaration-on-rest-parameter" } ], "policy": { diff --git a/tests/fixtures/lint_golden/expected/suppressed.sarif.golden b/tests/fixtures/lint_golden/expected/suppressed.sarif.golden index c93587bc..3132f99a 100644 --- a/tests/fixtures/lint_golden/expected/suppressed.sarif.golden +++ b/tests/fixtures/lint_golden/expected/suppressed.sarif.golden @@ -4071,6 +4071,148 @@ "shortDescription": { "text": "a named let whose loop name is never mentioned in its body, so it can never iterate" } + }, + { + "fullDescription": { + "text": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`." + }, + "id": "var-never-set", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Fennel's `local`/`var` and Janet's `def`/`var` split immutable from mutable bindings. The mutable spelling exists only so a later assignment is legal, so a `var` with no assignment anywhere in the file states a mutability the code never uses and makes a reader look for a reassignment that is not there. Fennel's own linter plugin reports it as `\" declared as var but never set\"`.", + "severity": "warning", + "tags": [ + "style" + ] + }, + "shortDescription": { + "text": "a Fennel or Janet `var` binding that nothing ever assigns to" + } + }, + { + "fullDescription": { + "text": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed." + }, + "id": "fennel-deprecated-form", + "properties": { + "category": "portability", + "fixable": false, + "rationale": "Fennel's reference has a \"Deprecated Forms\" section naming three specials and, for each, what replaced it. They still compile, which is exactly why they survive in code long after the replacement landed.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel special the language reference lists under \"Deprecated Forms\"" + } + }, + { + "fullDescription": { + "text": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`." + }, + "id": "fennel-each-over-non-iterator", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "`each` compiles to Lua's generic `for … in do`, which *calls* the iterator on every round. A table, sequence, string or number literal is not callable, so the loop raises \"attempt to call a table value\" the first time it runs. The collection belongs inside `pairs` or `ipairs`.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Fennel `each` whose iterator position holds a literal that cannot be called" + } + }, + { + "fullDescription": { + "text": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis." + }, + "id": "janet-empty-loop-body", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's own `loop`, `seq` and `catseq` macros call `check-empty-body`, which emits `\"empty loop body\"` through `maclintf`. That warning only appears when the macro is expanded, so it never reaches a reader of the source. The usual cause is a body that was deleted, or one that ended up outside the closing parenthesis.", + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a Janet `loop`, `seq`, or `catseq` whose body is empty" + } + }, + { + "fullDescription": { + "text": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call." + }, + "id": "janet-mutating-immutable-literal", + "properties": { + "category": "suspicious", + "fixable": false, + "rationale": "Janet's mutable containers differ from their immutable twins by one character: `@[…]` is an array and `[…]` a tuple, `@{…}` a table and `{…}` a struct, `@\"…\"` a buffer and `\"…\"` a string. `put`, the `array/*` family and the `buffer/*` family all panic when handed the immutable twin, so a dropped `@` is code that reads correctly and dies on its first call.", + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a Janet mutating call whose target is a tuple, struct, or string literal" + } + }, + { + "id": "declare-not-at-head-of-body", + "properties": { + "category": "malformed", + "fixable": false, + "severity": "error", + "tags": [] + }, + "shortDescription": { + "text": "a (declare ...) after the first body form, where it is a call to an undefined function" + } + }, + { + "id": "declaim-inside-body", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (declaim ...) among a body's leading declarations, where (declare ...) was meant" + } + }, + { + "id": "type-declaration-contradicts-initform", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a let binding whose declared type cannot contain the literal it is initialised to" + } + }, + { + "id": "the-form-with-impossible-type", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a (the TYPE EXPR) whose declared type cannot contain the value the expression plainly is" + } + }, + { + "id": "type-declaration-on-rest-parameter", + "properties": { + "category": "declaration", + "fixable": false, + "severity": "warning", + "tags": [] + }, + "shortDescription": { + "text": "a &rest parameter declared to be its element type, though it is always bound to a list" + } } ] } diff --git a/tests/fixtures/lint_golden/expected/suppressed.text.golden b/tests/fixtures/lint_golden/expected/suppressed.text.golden index 1659bd06..c3d54774 100644 --- a/tests/fixtures/lint_golden/expected/suppressed.text.golden +++ b/tests/fixtures/lint_golden/expected/suppressed.text.golden @@ -287,5 +287,15 @@ rule scheme-begin-single-form 0 rule scheme-let-star-independent-bindings 0 rule scheme-memq-assq-literal-key 0 rule scheme-named-let-never-recurs 0 +rule var-never-set 0 +rule fennel-deprecated-form 0 +rule fennel-each-over-non-iterator 0 +rule janet-empty-loop-body 0 +rule janet-mutating-immutable-literal 0 +rule declare-not-at-head-of-body 0 +rule declaim-inside-body 0 +rule type-declaration-contradicts-initform 0 +rule the-form-with-impossible-type 0 +rule type-declaration-on-rest-parameter 0 finding redundant-quote warning suspicious fixable=true suppressed.lisp 338 quoting number 6 is redundant redundant-quote/07bb8007b4863bf0/0 finding declarative-style-score warning suspicious fixable=false suppressed.lisp 0 this file's declarative-style score is 0% (5 top-level forms), below the 80% threshold declarative-style-score/2f534fdd67304360/0