Skip to main content

sva_cli/
lint.rs

1// Concern: what a composition can be told about itself without rendering a sample | Non-concern: rendering, or judging how it sounds | IO: (dir[, target]) -> Vec<Finding> or CliError
2
3use std::collections::BTreeSet;
4use std::path::Path;
5
6use sva_ast::{Binds, Expr, Graph, Skip, Source, children, ref_spans, resolve_ref_path};
7use sva_ast::{MAX_TAG_CHARS, MAX_TAGS, is_plain_tag, parse_doc_comment};
8use sva_core::{
9    CliError, Diagnostic, LintCode, LintViolation, ROOT, Severity, lint_diagnostic, prepared,
10    refuse_unresolved_bars, settled,
11};
12use sva_engine::EngineError;
13
14/// These ride a `success` envelope; a refusal raises [`sva_core::CliError::LintRefused`].
15pub struct Finding {
16    pub code: LintCode,
17    pub severity: Severity,
18    pub subject: String,
19    pub message: String,
20    /// The 1-based line the check found it on, where it knows one.
21    pub line: Option<usize>,
22}
23
24impl Finding {
25    pub fn diagnostic(&self) -> Diagnostic {
26        lint_diagnostic(
27            self.code,
28            &self.subject,
29            &self.message,
30            self.severity,
31            self.line,
32        )
33    }
34}
35
36pub struct LintReport {
37    pub nodes: usize,
38    pub findings: Vec<Finding>,
39}
40
41/// With no target, lints the whole directory against `master`; with one, lints only what it
42/// reaches, against it instead.
43pub fn lint(dir: &Path, target: Option<&str>) -> Result<LintReport, CliError> {
44    let source = sva_ast::Dir::at(dir);
45    match target {
46        None => lint_whole(&source),
47        Some(target) => lint_reaching(&source, target),
48    }
49}
50
51/// A note or a rendering may sit beside a composition; the walk says which it passed over,
52/// less the documents. A dot directory never reaches here: `Dir::paths` skips one.
53fn not_nodes(graph: &Graph) -> Vec<Finding> {
54    graph
55        .skipped()
56        .iter()
57        .filter(|s| s.reason != Skip::Unnameable || !is_document(&s.path))
58        .map(|s| {
59            let path = &s.path;
60            let (code, message) = match s.reason {
61                Skip::Unnameable => (
62                    LintCode::NotANode,
63                    format!("no ref can name `{path}`, so it is not read as a node"),
64                ),
65                Skip::Special => (
66                    LintCode::NotAFile,
67                    format!("`{path}` is a socket, FIFO or device, so no text was read from it"),
68                ),
69            };
70            Finding {
71                code,
72                severity: Severity::Advice,
73                subject: path.clone(),
74                message,
75                line: None,
76            }
77        })
78        .collect()
79}
80
81/// Prose and a reference reading sit beside a composition's nodes, under any casing.
82fn is_document(path: &str) -> bool {
83    match path.rsplit_once('.') {
84        Some((_, suffix)) => matches!(suffix.to_ascii_lowercase().as_str(), "md" | "json"),
85        None => false,
86    }
87}
88
89fn lint_whole(source: &dyn Source) -> Result<LintReport, CliError> {
90    let graph = prepared(source)?;
91    refuse_unresolved_bars(&graph)?;
92    let violations = lint_violations(source, &graph);
93    // A lint violation refuses before a structural one.
94    if violations.is_empty() && graph.defines(ROOT) {
95        sva_engine::check_structure(&graph, ROOT).map_err(CliError::Engine)?;
96    }
97
98    let mut findings = entry_typing(&graph);
99    if !graph.defines(ROOT) {
100        findings.push(Finding {
101            code: LintCode::NoDefaultRoot,
102            severity: Severity::Advice,
103            subject: ROOT.to_string(),
104            message: format!(
105                "no `{ROOT}` file, so a render must name the node it wants — every node is \
106                 still a root"
107            ),
108            line: None,
109        });
110    }
111    findings.extend(not_nodes(&graph));
112    findings.extend(unreached(&graph));
113    findings.extend(grid_row_counts(&graph));
114    findings.extend(tag_findings(source, &graph));
115    findings.extend(crate::variables::key_findings(&graph));
116    findings.extend(crate::rates::rate_findings(&graph));
117    findings.extend(crate::windows::window_findings(
118        &graph,
119        &whole_roots(&graph),
120    ));
121    verdict(graph.paths().count(), violations, findings)
122}
123
124/// The status is the verdict; `data.diagnostics[]` carries everything the scan found.
125fn verdict(
126    nodes: usize,
127    violations: Vec<LintViolation>,
128    findings: Vec<Finding>,
129) -> Result<LintReport, CliError> {
130    if violations.is_empty() {
131        return Ok(LintReport { nodes, findings });
132    }
133    let mut every = violations;
134    every.extend(findings.into_iter().map(|f| LintViolation {
135        code: f.code,
136        severity: f.severity,
137        subject: f.subject,
138        message: f.message,
139        line: f.line,
140    }));
141    Err(CliError::LintRefused(every))
142}
143
144fn whole_roots(graph: &Graph) -> Vec<String> {
145    let mut out: Vec<String> = graph
146        .defines(ROOT)
147        .then(|| ROOT.to_string())
148        .into_iter()
149        .collect();
150    out.extend(entry_points(graph));
151    out
152}
153
154/// Every node nothing references is a root of its own, and a whole-directory lint says what
155/// typing each one found. One template nothing binds is not a broken directory, so this
156/// reports rather than stopping the pass; `master`, which a bare render targets, refuses.
157fn entry_typing(graph: &Graph) -> Vec<Finding> {
158    entry_points(graph)
159        .into_iter()
160        .filter(|p| p != ROOT)
161        .filter_map(|path| {
162            let refused = sva_engine::check_structure(graph, &path).err()?;
163            Some(Finding {
164                code: LintCode::EntryPointRefused,
165                severity: Severity::Warning,
166                subject: path,
167                message: refused.to_string(),
168                line: None,
169            })
170        })
171        .collect()
172}
173
174fn lint_reaching(source: &dyn Source, target: &str) -> Result<LintReport, CliError> {
175    let held = sva_core::roots_of(source, Some(target))?;
176    let roots: Vec<&str> = held.iter().map(String::as_str).collect();
177    let mut graph = settled(sva_ast::load_reaching(source, &roots))?;
178    refuse_unresolved_bars(&graph)?;
179    let violations = lint_violations(source, &graph);
180    // A target nothing defines is the same `probe` a render builds, so a typo refuses here.
181    let root = match graph.defines(target) {
182        true => target.to_string(),
183        false => {
184            sva_core::define_probe_for(&mut graph, target)?;
185            sva_core::PROBE.to_string()
186        }
187    };
188    if let Err(refused) = sva_engine::check_structure(&graph, &root)
189        && violations.is_empty()
190    {
191        // A closure loaded from the target alone never sees the call sites that bind it.
192        return match sva_core::instances_behind(source, target, &refused).as_deref() {
193            Some([only]) if only != target => lint_reaching(source, only),
194            Some(held) => Err(CliError::Engine(EngineError::AmbiguousNode(
195                target.to_string(),
196                held.to_vec(),
197            ))),
198            None => Err(CliError::Engine(refused)),
199        };
200    }
201    // No `unreached` here (every node is reached by construction); the other checks are per-node.
202    let mut findings = grid_row_counts(&graph);
203    findings.extend(tag_findings(source, &graph));
204    findings.extend(crate::variables::key_findings(&graph));
205    findings.extend(crate::rates::rate_findings(&graph));
206    findings.extend(crate::windows::window_findings(&graph, &[root]));
207    verdict(graph.paths().count(), violations, findings)
208}
209
210/// A row count tiles a span as a subdivision of a bar or as whole bars; both count.
211fn grid_row_counts(graph: &Graph) -> Vec<Finding> {
212    let mut findings = Vec::new();
213    for path in graph.paths() {
214        let Some(grid) = graph.grid(path) else {
215            continue;
216        };
217        let Some(bars) = grid.bar_span.filter(|b| *b > 0.0) else {
218            continue;
219        };
220        let rows_per_bar = grid.row_count as f64 / bars;
221        let bars_per_row = bars / grid.row_count as f64;
222        let whole = |v: f64| (v - v.round()).abs() <= 1e-9;
223        if !whole(rows_per_bar) && !whole(bars_per_row) {
224            findings.push(Finding {
225                code: LintCode::GridRowsPerBar,
226                severity: Severity::Warning,
227                subject: path.to_string(),
228                message: format!(
229                    "`{path}` has {} rows over {bars} bar(s), {rows_per_bar:.4} rows/bar and \
230                     {bars_per_row:.4} bars/row — neither a whole subdivision of a bar nor a \
231                     whole number of bars a row, likely a stray or missing row",
232                    grid.row_count
233                ),
234                line: None,
235            });
236        }
237    }
238    findings
239}
240
241/// Refuses with every violation found, not just the first: a tree-wide scan, unlike
242/// `check_arity`/`check_feedback`'s single graph evaluation.
243fn lint_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
244    let mut violations = Vec::new();
245    violations.extend(doc_comment_violations(source, graph));
246    violations.extend(long_comment_block_violations(source, graph));
247    violations.extend(long_expression_body_violations(source, graph));
248    violations
249}
250
251/// Even a justified multi-line block stays under this.
252const LONG_COMMENT_BLOCK_CHARS: usize = 1000;
253
254/// The line-1 doc-comment's own run is excluded from the count.
255fn long_comment_block_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
256    let mut violations = Vec::new();
257    for path in graph.paths() {
258        let Ok(Some(text)) = source.get(path) else {
259            continue;
260        };
261        // start line, running char total, first line's own char length.
262        let mut run: Option<(usize, usize, usize)> = None;
263        for (idx, line) in text.lines().enumerate() {
264            if line.trim_start().starts_with(';') {
265                let line_chars = line.chars().count();
266                run = Some(match run {
267                    Some((start, chars, first)) => (start, chars + line_chars, first),
268                    None => (idx + 1, line_chars, line_chars),
269                });
270            } else {
271                flush_comment_run(run.take(), path, &mut violations);
272            }
273        }
274        flush_comment_run(run, path, &mut violations);
275    }
276    violations
277}
278
279/// Drops the header line's own char count (not a flat `1`) when the run starts at line 1.
280fn flush_comment_run(
281    run: Option<(usize, usize, usize)>,
282    path: &str,
283    violations: &mut Vec<LintViolation>,
284) {
285    let Some((start, chars, first)) = run else {
286        return;
287    };
288    let chars = if start == 1 {
289        chars.saturating_sub(first)
290    } else {
291        chars
292    };
293    if chars > LONG_COMMENT_BLOCK_CHARS {
294        violations.push(LintViolation {
295            code: LintCode::LongCommentBlock,
296            severity: Severity::Error,
297            subject: path.to_string(),
298            message: format!(
299                "`{path}` has a {chars}-character comment block, over the \
300                 {LONG_COMMENT_BLOCK_CHARS}-character threshold — split it or trim it"
301            ),
302            line: Some(start),
303        });
304    }
305}
306
307/// Exactly one doc-comment line at the top, or `lint` refuses it. Only the leading run is
308/// judged — a grid's own inline `; lane` comments stay out of scope.
309fn doc_comment_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
310    let mut violations = Vec::new();
311    for path in graph.paths() {
312        let Ok(Some(text)) = source.get(path) else {
313            continue;
314        };
315        let header: Vec<&str> = text
316            .lines()
317            .take_while(|line| line.trim_start().starts_with(';'))
318            .collect();
319        match header.len() {
320            0 => violations.push(LintViolation {
321                code: LintCode::MissingComment,
322                severity: Severity::Error,
323                subject: path.to_string(),
324                message: format!(
325                    "`{path}` has no `;`-comment — every composition node must carry one `; \
326                     Models: ... | Neglects: ... | IO: ... -> ... | Tags: ...` line \
327                     documenting it"
328                ),
329                line: None,
330            }),
331            1 => {
332                if let Err(reason) = parse_doc_comment(header[0]) {
333                    violations.push(LintViolation {
334                        code: LintCode::MalformedComment,
335                        severity: Severity::Error,
336                        subject: path.to_string(),
337                        message: format!(
338                            "`{path}`'s comment does not match `Models: ... | Neglects: ... \
339                             | IO: ... -> ... | Tags: ...` ({reason})"
340                        ),
341                        line: None,
342                    });
343                }
344            }
345            n => violations.push(LintViolation {
346                code: LintCode::MultilineComment,
347                severity: Severity::Error,
348                subject: path.to_string(),
349                message: format!(
350                    "`{path}` has {n} `;`-comment lines — exactly one is required, written \
351                     denser instead of split across lines"
352                ),
353                line: None,
354            }),
355        }
356    }
357    violations
358}
359
360/// FORMAT 15.1 writes the shape as `Tags: <tag>[, <tag>]` and refuses a comment that does
361/// not parse into its four fields. How many tags and what a tag looks like is house style,
362/// so a numeral or a fourth tag is advised here and never refuses a composition.
363fn tag_advice(line: &str) -> Option<String> {
364    let held = parse_doc_comment(line).ok()?.tags;
365    let odd: Vec<&str> = held
366        .iter()
367        .map(String::as_str)
368        .filter(|t| !is_plain_tag(t))
369        .collect();
370    let mut notes = Vec::new();
371    if held.len() > MAX_TAGS {
372        notes.push(format!(
373            "{} tags; {MAX_TAGS} keeps `Tags:` a triage aid rather than a second `Neglects:`",
374            held.len()
375        ));
376    }
377    if !odd.is_empty() {
378        notes.push(format!(
379            "`{}` reads as free text; a lowercase word of up to {MAX_TAG_CHARS} characters, \
380             hyphens between segments, sorts and greps with the rest",
381            odd.join("`, `")
382        ));
383    }
384    (!notes.is_empty()).then(|| notes.join("; "))
385}
386
387/// One advisory per node whose tags are not house style.
388fn tag_findings(source: &dyn Source, graph: &Graph) -> Vec<Finding> {
389    let mut findings = Vec::new();
390    for path in graph.paths() {
391        let Ok(Some(text)) = source.get(path) else {
392            continue;
393        };
394        let Some(line) = text.lines().find(|l| l.trim_start().starts_with(';')) else {
395            continue;
396        };
397        if let Some(message) = tag_advice(line) {
398            findings.push(Finding {
399                code: LintCode::TagShape,
400                severity: Severity::Advice,
401                subject: path.to_string(),
402                message: format!("`{path}`'s `Tags:` field: {message}"),
403                line: None,
404            });
405        }
406    }
407    findings
408}
409
410/// Generous on purpose: today's longest real body is ~5000 chars — a backstop against
411/// runaway generation, not a tight budget on complex physical models.
412const EXPRESSION_BODY_CHARS: usize = 10_000;
413
414/// A verbose local `@ref` name is a naming choice, not equation complexity.
415const REF_PLACEHOLDER: &str = "@x";
416
417/// Excludes the leading `;`-comment run. A node over budget on the raw count gets one more,
418/// cheap recount with ref names collapsed, so a verbose `@ref` name alone cannot trip the cap.
419fn long_expression_body_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
420    let mut violations = Vec::new();
421    for path in graph.paths() {
422        let Ok(Some(text)) = source.get(path) else {
423            continue;
424        };
425        let body: String = text
426            .lines()
427            .skip_while(|line| line.trim_start().starts_with(';'))
428            .collect::<Vec<_>>()
429            .join("\n");
430        let chars = body.chars().count();
431        if chars <= EXPRESSION_BODY_CHARS {
432            continue;
433        }
434        let (chars, has_refs) = ref_stripped_char_count(&body);
435        if chars > EXPRESSION_BODY_CHARS {
436            let message = if has_refs {
437                format!(
438                    "`{path}` has a {chars}-character expression body even with every `@ref` \
439                     name collapsed to `{REF_PLACEHOLDER}`, over the \
440                     {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
441                )
442            } else {
443                format!(
444                    "`{path}` has a {chars}-character expression body, over the \
445                     {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
446                )
447            };
448            violations.push(LintViolation {
449                code: LintCode::LongExpressionBody,
450                severity: Severity::Error,
451                subject: path.to_string(),
452                message,
453                line: None,
454            });
455        }
456    }
457    violations
458}
459
460/// Only reached once the raw count already exceeds the cap. Collapses each `@ref`'s `@path`
461/// head to a placeholder; a trailing `(...)` invocation stays, being real expression content.
462/// The `bool` says whether any ref was actually collapsed, so the error message can be worded
463/// accurately for a body already over budget on pure literal content.
464fn ref_stripped_char_count(body: &str) -> (usize, bool) {
465    let spans = ref_spans(body);
466    let has_refs = !spans.is_empty();
467    let mut reduced = String::with_capacity(body.len());
468    let mut cursor = 0;
469    for span in spans {
470        reduced.push_str(&body[cursor..span.start]);
471        reduced.push_str(REF_PLACEHOLDER);
472        cursor = span.end;
473    }
474    reduced.push_str(&body[cursor..]);
475    (reduced.chars().count(), has_refs)
476}
477
478pub fn referenced(graph: &Graph) -> BTreeSet<String> {
479    let mut reached: BTreeSet<String> = BTreeSet::new();
480    for path in graph.paths() {
481        if let Some(expr) = graph.expr(path) {
482            collect_refs(path, expr, &mut reached);
483        }
484    }
485    reached
486}
487
488/// Every node nothing references, `master` included — the roots a whole composition has.
489pub fn entry_points(graph: &Graph) -> Vec<String> {
490    let reached = referenced(graph);
491    graph
492        .paths()
493        .filter(|p| !reached.contains(*p))
494        .filter(|p| !sva_core::RESERVED_VARIABLES.contains(&p.rsplit('/').next().unwrap_or(p)))
495        .map(str::to_string)
496        .collect()
497}
498
499/// Not a waste warning — an unreached node costs no evaluation. It is what a typo looks like.
500fn unreached(graph: &Graph) -> Vec<Finding> {
501    entry_points(graph)
502        .into_iter()
503        .filter(|p| p != ROOT)
504        .map(|p| Finding {
505            code: LintCode::EntryPoint,
506            severity: Severity::Advice,
507            subject: p.to_string(),
508            message: format!("nothing refs `{p}`, so it is an entry point — or a typo"),
509            line: None,
510        })
511        .collect()
512}
513
514fn collect_refs(from: &str, expr: &Expr, out: &mut BTreeSet<String>) {
515    if let Expr::Ref { path, .. } = expr
516        && let Some(resolved) = resolve_ref_path(from, path)
517    {
518        out.insert(resolved);
519    }
520    for child in children(expr, Binds::Substitute) {
521        collect_refs(from, child, out);
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use std::fs;
529
530    fn dir_of(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
531        let dir =
532            std::env::temp_dir().join(format!("sva-cli-lint-{name}-{:x}", std::process::id()));
533        let _ = fs::remove_dir_all(&dir);
534        fs::create_dir_all(&dir).unwrap();
535        for (rel, content) in files {
536            fs::write(dir.join(rel), content).unwrap();
537        }
538        dir
539    }
540
541    /// A well-formed doc comment, for a fixture node the test isn't about.
542    fn doc(models: &str) -> String {
543        format!(
544            "; Models: {models} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
545             fixture\n"
546        )
547    }
548
549    /// Both response paths are one external contract: a reader branches on `severity`, and
550    /// gets the same object shape whether `lint` succeeded or refused.
551    #[test]
552    fn both_lint_response_paths_answer_the_same_diagnostic_shape() {
553        let dir = dir_of(
554            "one-envelope",
555            &[
556                ("master", &(doc("the mix") + "@kick*0.5\n")),
557                ("kick", &(doc("a thump") + "sin(2*pi*50*t)\n")),
558                ("spare", &(doc("a spare") + "sin(2*pi*80*t)\n")),
559            ],
560        );
561        let report = lint(&dir, None).expect("a documented composition lints clean");
562        let found: Vec<Diagnostic> = report.findings.iter().map(Finding::diagnostic).collect();
563        let json = sva_core::success_envelope(
564            &crate::output::lint_data(&dir.display().to_string(), None, report.nodes),
565            &found,
566        );
567        assert!(json.contains("\"diagnostics\""), "{json}");
568        assert!(json.contains("\"code\": \"lint.entry_point\""), "{json}");
569        assert!(json.contains("\"severity\": \"advice\""), "{json}");
570        assert!(
571            json.contains("\"location\": { \"file\": \"spare\", \"span\": null, \"start\": null, \"end\": null }"),
572            "{json}"
573        );
574        assert!(json.contains("\"help\""), "{json}");
575
576        let undocumented = dir_of(
577            "one-envelope-refused",
578            &[("master", "@kick*0.5\n"), ("kick", "sin(t)\n")],
579        );
580        let Err(err) = lint(&undocumented, None) else {
581            panic!("an undocumented node must refuse");
582        };
583        let refused = sva_core::error_envelope(err.code(), &err.message(), &err.diagnostics());
584        assert!(refused.contains("\"diagnostics\""), "{refused}");
585        assert!(
586            refused.contains("\"code\": \"lint.missing_comment\""),
587            "{refused}"
588        );
589        assert!(refused.contains("\"severity\": \"error\""), "{refused}");
590        assert!(
591            refused
592                .contains("\"location\": { \"file\": \"kick\", \"span\": null, \"start\": null, \"end\": null }"),
593            "{refused}"
594        );
595        assert!(refused.contains("\"help\""), "{refused}");
596
597        let _ = fs::remove_dir_all(&dir);
598        let _ = fs::remove_dir_all(&undocumented);
599    }
600
601    /// The codes a clean-but-advised node reports, so a rule that only advises is asserted
602    /// by what it says rather than by what it refuses.
603    fn advised(result: Result<LintReport, CliError>, subject: &str) -> Vec<LintCode> {
604        let report = result.unwrap_or_else(|e| panic!("`{subject}` should lint: {}", e.message()));
605        report
606            .findings
607            .iter()
608            .filter(|f| f.subject == subject)
609            .map(|f| f.code)
610            .collect()
611    }
612
613    fn assert_refused(result: Result<LintReport, CliError>, code: LintCode, subject: &str) {
614        match result {
615            Ok(report) => panic!(
616                "expected a `{code:?}` refusal for `{subject}`, got a clean report: {:?}",
617                report.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
618            ),
619            Err(CliError::LintRefused(violations)) => assert!(
620                violations
621                    .iter()
622                    .any(|v| v.code == code && v.subject == subject),
623                "expected a `{code:?}` refusal for `{subject}`, got: {:?}",
624                violations
625                    .iter()
626                    .map(|v| (v.code, &v.subject))
627                    .collect::<Vec<_>>()
628            ),
629            Err(other) => panic!("expected a `{code:?}` refusal, got a different error: {other:?}"),
630        }
631    }
632
633    #[test]
634    fn a_loop_the_engine_can_schedule_lints_clean() {
635        let dir = dir_of(
636            "long-loop",
637            &[
638                (
639                    "a",
640                    &(doc("a fixture signal") + "sin(t) + @b(t - 0.01s)*0.5\n"),
641                ),
642                ("b", &(doc("a fixture signal") + "@a(t - 0.01s)*0.5\n")),
643                ("master", &(doc("a fixture signal") + "@a\n")),
644            ],
645        );
646        assert!(
647            lint(&dir, None).is_ok(),
648            "a schedulable loop is not a finding"
649        );
650    }
651
652    /// A target lints only what it reaches, so an orphan elsewhere in the directory is never
653    /// seen — `unreached` does not run in this mode at all (see `lint`'s doc comment).
654    #[test]
655    fn a_target_skips_the_entry_point_check_a_whole_directory_lint_would_raise() {
656        let dir = dir_of(
657            "orphan",
658            &[
659                ("master", &(doc("a fixture signal") + "@drums\n")),
660                ("drums", &(doc("a fixture signal") + "sin(t)\n")),
661                ("orphan", &(doc("a fixture signal") + "sin(t)*0.5\n")),
662            ],
663        );
664        let whole = lint(&dir, None).unwrap();
665        assert!(
666            whole
667                .findings
668                .iter()
669                .any(|f| f.code == LintCode::EntryPoint && f.subject == "orphan"),
670            "a whole-directory lint must flag the unreferenced `orphan`"
671        );
672
673        let targeted = lint(&dir, Some("master")).unwrap();
674        assert!(
675            !targeted
676                .findings
677                .iter()
678                .any(|f| f.code == LintCode::EntryPoint),
679            "a targeted lint must not run the entry-point check at all"
680        );
681    }
682
683    /// The dogfooding bug: a trailing blank row makes 33, and 33/4 is not a whole subdivision —
684    /// yet every intended note is still there, which is why counting notes alone missed it.
685    #[test]
686    fn a_grid_with_a_trailing_blank_row_over_its_bar_span_is_flagged() {
687        let dir = dir_of(
688            "trailing-blank-row",
689            &[
690                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
691                (
692                    "pattern-4b",
693                    &(doc("a fixture pattern") + &("@kick\n".repeat(32) + "\n")),
694                ),
695                ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
696            ],
697        );
698        let whole = lint(&dir, None).unwrap();
699        assert!(
700            whole
701                .findings
702                .iter()
703                .any(|f| f.code == LintCode::GridRowsPerBar && f.subject == "pattern-4b"),
704            "33 rows over 4 bars must be flagged: {:?}",
705            whole.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
706        );
707
708        let targeted = lint(&dir, Some("master")).unwrap();
709        assert!(
710            targeted
711                .findings
712                .iter()
713                .any(|f| f.code == LintCode::GridRowsPerBar),
714            "the target-scoped path must run this check too"
715        );
716    }
717
718    #[test]
719    fn a_grid_whose_rows_tile_its_bar_span_evenly_is_clean() {
720        let dir = dir_of(
721            "clean-grid",
722            &[
723                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
724                (
725                    "pattern-4b",
726                    &(doc("a fixture pattern") + &"@kick\n".repeat(32)),
727                ),
728                ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
729            ],
730        );
731        assert!(
732            !lint(&dir, None)
733                .unwrap()
734                .findings
735                .iter()
736                .any(|f| f.code == LintCode::GridRowsPerBar),
737            "32 rows over 4 bars is an exact subdivision"
738        );
739        assert!(
740            !lint(&dir, Some("master"))
741                .unwrap()
742                .findings
743                .iter()
744                .any(|f| f.code == LintCode::GridRowsPerBar)
745        );
746    }
747
748    #[test]
749    fn a_grid_spanned_in_seconds_has_no_bar_count_to_check() {
750        let dir = dir_of(
751            "seconds-spanned",
752            &[
753                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
754                (
755                    "pattern-2s",
756                    &(doc("a fixture pattern") + &("@kick\n".repeat(33) + "\n")),
757                ),
758                ("master", &(doc("a fixture signal") + "@pattern-2s\n")),
759            ],
760        );
761        assert!(
762            !lint(&dir, None)
763                .unwrap()
764                .findings
765                .iter()
766                .any(|f| f.code == LintCode::GridRowsPerBar),
767            "a `-Ns` grid declares no bars, so there is nothing to divide"
768        );
769    }
770
771    type ThresholdCase = (&'static str, std::path::PathBuf, Box<dyn Fn()>);
772
773    #[test]
774    fn every_length_threshold_sits_at_its_boundary_and_trips_one_char_over() {
775        let cases: Vec<ThresholdCase> = vec![
776            (
777                "long-comment-block, 1000 chars",
778                dir_of(
779                    "boundary-comment",
780                    &[(
781                        "master",
782                        &(doc("a fixture signal")
783                            + "sin(t)\n"
784                            + &format!(";{}", "x".repeat(999))
785                            + "\n"),
786                    )],
787                ),
788                Box::new(|| {
789                    let trailing = format!(";{}", "x".repeat(1000));
790                    let dir = dir_of(
791                        "long-comment",
792                        &[
793                            (
794                                "long",
795                                &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
796                            ),
797                            ("master", &(doc("a fixture signal") + "@long\n")),
798                        ],
799                    );
800                    assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
801                    assert_refused(
802                        lint(&dir, Some("master")),
803                        LintCode::LongCommentBlock,
804                        "long",
805                    );
806                }) as Box<dyn Fn()>,
807            ),
808            (
809                "long-expression-body, 10000 chars",
810                dir_of(
811                    "boundary-expression-body",
812                    &[(
813                        "drone",
814                        &(String::from(
815                            "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
816                             amplitude | Tags: drone\n",
817                        ) + &"0".repeat(10_000)
818                            + "\n"),
819                    )],
820                ),
821                Box::new(|| {
822                    let body = "0".repeat(10_001);
823                    let dir = dir_of(
824                        "long-expression-body",
825                        &[
826                            (
827                                "drone",
828                                &(String::from(
829                                    "; Models: a sustained drone | Neglects: envelope, detune | \
830                                     IO: t -> amplitude | Tags: drone\n",
831                                ) + &body
832                                    + "\n"),
833                            ),
834                            (
835                                "master",
836                                &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
837                            ),
838                        ],
839                    );
840                    assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
841                    assert_refused(
842                        lint(&dir, Some("master")),
843                        LintCode::LongExpressionBody,
844                        "drone",
845                    );
846                }),
847            ),
848            (
849                "tag-shape, 24 chars",
850                dir_of(
851                    "tag-at-threshold",
852                    &[("master", &(doc_with_tags(&"a".repeat(24)) + "sin(t)\n"))],
853                ),
854                Box::new(|| {
855                    let dir = dir_of(
856                        "tag-too-long",
857                        &[("master", &(doc_with_tags(&"a".repeat(25)) + "sin(t)\n"))],
858                    );
859                    assert_eq!(
860                        advised(lint(&dir, None), "master"),
861                        vec![LintCode::TagShape]
862                    );
863                }),
864            ),
865        ];
866        for (label, at, over) in cases {
867            assert!(
868                lint(&at, None).is_ok(),
869                "{label}: exactly at the threshold, not over it"
870            );
871            over();
872        }
873    }
874
875    /// Line 1's own run is exempt from this budget, however long its one required line is.
876    #[test]
877    fn a_long_but_well_formed_leading_doc_comment_is_not_a_long_comment_block() {
878        let filler = "x".repeat(2000);
879        let header = format!(
880            "; Models: {filler} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
881             fixture\n"
882        );
883        let dir = dir_of("long-header-exempt", &[("master", &(header + "sin(t)\n"))]);
884        assert!(
885            lint(&dir, None).is_ok(),
886            "a single well-formed doc-comment line is exempt from the long-comment-block \
887             budget regardless of its own length"
888        );
889    }
890
891    /// A 2-line run is also `multiline-comment`; `.any()` isolates the arithmetic under test.
892    #[test]
893    fn the_line_one_header_annotation_is_excluded_from_its_run() {
894        let header = ";header"; // 7 chars
895        let boundary_line = format!(";{}", "x".repeat(999)); // 1000 chars
896        let over_line = format!(";{}", "x".repeat(1000)); // 1001 chars
897
898        let under = dir_of(
899            "header-excluded-under",
900            &[("master", &format!("{header}\n{boundary_line}\nsin(t)\n"))],
901        );
902        let Err(CliError::LintRefused(violations)) = lint(&under, None) else {
903            panic!("a 2-line leading run is also multiline-comment, so this must still refuse")
904        };
905        assert!(
906            !violations
907                .iter()
908                .any(|v| v.code == LintCode::LongCommentBlock),
909            "1007 chars from line 1 is 1000 once the header is excluded: {:?}",
910            violations.iter().map(|v| &v.code).collect::<Vec<_>>()
911        );
912
913        let over = dir_of(
914            "header-excluded-over",
915            &[("master", &format!("{header}\n{over_line}\nsin(t)\n"))],
916        );
917        assert_refused(lint(&over, None), LintCode::LongCommentBlock, "master");
918    }
919
920    /// Well-formed, so it never adds noise to a scenario testing some other node's comment.
921    const WELL_FORMED_MASTER_COMMENT: &str = "; Models: a test harness's master node | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: fixture\n";
922
923    #[test]
924    fn a_node_with_no_comment_at_all_is_missing_comment_in_both_modes() {
925        let dir = dir_of(
926            "missing-comment",
927            &[
928                ("plucked", "sin(t)\n"),
929                (
930                    "master",
931                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
932                ),
933            ],
934        );
935        assert_refused(lint(&dir, None), LintCode::MissingComment, "plucked");
936        assert_refused(
937            lint(&dir, Some("master")),
938            LintCode::MissingComment,
939            "plucked",
940        );
941    }
942
943    #[test]
944    fn two_contiguous_comment_lines_are_multiline_comment_in_both_modes() {
945        let dir = dir_of(
946            "multiline-comment",
947            &[
948                (
949                    "stacked",
950                    "; Models: a thing\n; Neglects: nothing | IO: t -> out\nsin(t)\n",
951                ),
952                (
953                    "master",
954                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@stacked\n"),
955                ),
956            ],
957        );
958        assert_refused(lint(&dir, None), LintCode::MultilineComment, "stacked");
959        assert_refused(
960            lint(&dir, Some("master")),
961            LintCode::MultilineComment,
962            "stacked",
963        );
964    }
965
966    #[test]
967    fn one_correctly_shaped_comment_line_under_the_length_threshold_is_clean() {
968        let dir = dir_of(
969            "well-formed-comment",
970            &[
971                (
972                    "plucked",
973                    "; Models: a plucked string's fundamental decay | Neglects: pick-position \
974                     comb, body coupling | IO: note -> supersaw base | Tags: pluck\nsin(t)\n",
975                ),
976                (
977                    "master",
978                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
979                ),
980            ],
981        );
982        assert!(
983            lint(&dir, None).is_ok(),
984            "a single well-formed, short comment must not refuse"
985        );
986        assert!(
987            lint(&dir, Some("master")).is_ok(),
988            "a single well-formed, short comment must not refuse"
989        );
990    }
991
992    #[test]
993    fn a_single_free_text_comment_line_is_malformed_comment_in_both_modes() {
994        let dir = dir_of(
995            "malformed-comment",
996            &[
997                (
998                    "plucked",
999                    "; a plucked string, decays over time, no body resonance modeled\nsin(t)\n",
1000                ),
1001                (
1002                    "master",
1003                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
1004                ),
1005            ],
1006        );
1007        assert_refused(lint(&dir, None), LintCode::MalformedComment, "plucked");
1008        assert_refused(
1009            lint(&dir, Some("master")),
1010            LintCode::MalformedComment,
1011            "plucked",
1012        );
1013    }
1014
1015    /// `; lane`-style inline comments inside a TSV grid, well below the header, are a
1016    /// separate, already-legitimate feature (`sva-ast`'s `code_rows`) — not a second doc
1017    /// comment competing with the header for "exactly one".
1018    #[test]
1019    fn a_grids_own_inline_comment_below_a_well_formed_header_is_not_multiline_comment() {
1020        let dir = dir_of(
1021            "grid-inline-comment",
1022            &[
1023                ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
1024                (
1025                    "pattern-1b",
1026                    "; Models: a kick pattern | Neglects: dynamics, humanization | \
1027                     IO: (t) -> amplitude | Tags: kick\n@kick\n; lane\n@kick\n",
1028                ),
1029                (
1030                    "master",
1031                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@pattern-1b\n"),
1032                ),
1033            ],
1034        );
1035        assert!(
1036            lint(&dir, None).is_ok(),
1037            "a grid's own inline comment must not be mistaken for a second doc comment"
1038        );
1039    }
1040
1041    /// A well-formed header plus a body just under the cap must not refuse, even though
1042    /// their combined length exceeds it.
1043    #[test]
1044    fn the_leading_doc_comment_is_excluded_from_the_expression_body_count() {
1045        let body = "0".repeat(9_999);
1046        let dir = dir_of(
1047            "doc-comment-excluded",
1048            &[(
1049                "drone",
1050                &(String::from(
1051                    "; Models: a sustained drone with a longer than usual doc comment header \
1052                     | Neglects: envelope, detune | IO: t -> amplitude | Tags: drone\n",
1053                ) + &body
1054                    + "\n"),
1055            )],
1056        );
1057        assert!(
1058            lint(&dir, None).is_ok(),
1059            "the doc comment header must not count toward the body length"
1060        );
1061    }
1062
1063    /// Raw count is over budget only because the author picked a verbose local `@ref` name;
1064    /// once every occurrence collapses to `@x` the equation itself is nowhere near the cap.
1065    #[test]
1066    fn a_body_over_budget_only_from_verbose_ref_names_is_clean_once_they_collapse() {
1067        let long_name = "a".repeat(50);
1068        let n = 200; // (1 + 50) chars per `@<name>` occurrence, joined by " + " (3 chars).
1069        let refs: Vec<String> = std::iter::repeat_n(format!("@{long_name}"), n).collect();
1070        let body = refs.join(" + ");
1071        let raw = body.chars().count();
1072        assert!(
1073            raw > EXPRESSION_BODY_CHARS,
1074            "raw count must be over budget: {raw}"
1075        );
1076
1077        let reduced: usize = n * REF_PLACEHOLDER.chars().count() + (n - 1) * 3;
1078        assert!(
1079            reduced <= EXPRESSION_BODY_CHARS,
1080            "reduced count must be under budget: {reduced}"
1081        );
1082
1083        let dir = dir_of(
1084            "ref-collapse-clean",
1085            &[
1086                (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1087                (
1088                    "drone",
1089                    &(String::from(
1090                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1091                         amplitude | Tags: drone\n",
1092                    ) + &body
1093                        + "\n"),
1094                ),
1095                (
1096                    "master",
1097                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1098                ),
1099            ],
1100        );
1101        assert!(
1102            lint(&dir, None).is_ok(),
1103            "a body over budget only from a verbose ref name must clear once ref names collapse"
1104        );
1105    }
1106
1107    /// Even with ref names collapsed, enough literal filler keeps the reduced count over budget.
1108    #[test]
1109    fn a_body_still_over_budget_after_ref_collapse_is_still_flagged() {
1110        let long_name = "b".repeat(50);
1111        let filler = "0".repeat(10_500);
1112        let body = format!("@{long_name} + @{long_name} + {filler}");
1113        let raw = body.chars().count();
1114        assert!(
1115            raw > EXPRESSION_BODY_CHARS,
1116            "raw count must be over budget: {raw}"
1117        );
1118        let reduced = 2 * REF_PLACEHOLDER.chars().count() + 2 * 3 + filler.chars().count();
1119        assert!(
1120            reduced > EXPRESSION_BODY_CHARS,
1121            "reduced count must still be over budget: {reduced}"
1122        );
1123
1124        let dir = dir_of(
1125            "ref-collapse-still-over",
1126            &[
1127                (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1128                (
1129                    "drone",
1130                    &(String::from(
1131                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1132                         amplitude | Tags: drone\n",
1133                    ) + &body
1134                        + "\n"),
1135                ),
1136                (
1137                    "master",
1138                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1139                ),
1140            ],
1141        );
1142        assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
1143    }
1144
1145    /// A body with `@ref`s well under the raw cap is clean — refs or not, staying under
1146    /// budget never depends on collapsing them.
1147    #[test]
1148    fn a_body_with_refs_under_the_raw_cap_is_clean() {
1149        let dir = dir_of(
1150            "under-budget-with-refs",
1151            &[
1152                ("kick", &(doc("a fixture kick") + "sin(t)\n")),
1153                (
1154                    "drone",
1155                    &(String::from(
1156                        "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1157                         amplitude | Tags: drone\n",
1158                    ) + &"@kick + ".repeat(20)
1159                        + "0.5\n"),
1160                ),
1161                (
1162                    "master",
1163                    &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1164                ),
1165            ],
1166        );
1167        assert!(
1168            lint(&dir, None).is_ok(),
1169            "this body is well under the raw cap"
1170        );
1171    }
1172
1173    /// A well-formed doc comment with a caller-chosen `Tags:` value, for tests exercising
1174    /// `Tags:` validation specifically.
1175    fn doc_with_tags(tags: &str) -> String {
1176        format!(
1177            "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> mix | \
1178             Tags: {tags}\n"
1179        )
1180    }
1181
1182    #[test]
1183    fn a_comment_with_the_old_three_fields_and_no_tags_is_refused_as_missing_a_fourth_field() {
1184        let dir = dir_of(
1185            "old-three-field-comment",
1186            &[(
1187                "master",
1188                "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> \
1189                 mix\nsin(t)\n",
1190            )],
1191        );
1192        let Err(CliError::LintRefused(violations)) = lint(&dir, None) else {
1193            panic!("a comment with no `Tags:` field must refuse")
1194        };
1195        let violation = violations
1196            .iter()
1197            .find(|v| v.code == LintCode::MalformedComment && v.subject == "master")
1198            .unwrap_or_else(|| {
1199                panic!(
1200                    "expected a malformed-comment refusal, got: {:?}",
1201                    violations.iter().map(|v| v.code).collect::<Vec<_>>()
1202                )
1203            });
1204        assert!(
1205            violation.message.contains("four"),
1206            "expected the refusal to name the missing fourth field: {}",
1207            violation.message
1208        );
1209    }
1210
1211    #[test]
1212    fn a_tags_field_with_no_tags_is_refused() {
1213        let dir = dir_of(
1214            "empty-tags-field",
1215            &[("master", &(doc_with_tags("") + "sin(t)\n"))],
1216        );
1217        assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1218    }
1219
1220    #[test]
1221    fn a_tags_field_with_four_tags_is_advised() {
1222        let dir = dir_of(
1223            "four-tags",
1224            &[(
1225                "master",
1226                &(doc_with_tags("piano, sustained-pad, mellow, extra") + "sin(t)\n"),
1227            )],
1228        );
1229        assert_eq!(
1230            advised(lint(&dir, None), "master"),
1231            vec![LintCode::TagShape]
1232        );
1233    }
1234
1235    #[test]
1236    fn a_tag_containing_a_space_is_advised() {
1237        let dir = dir_of(
1238            "tag-with-space",
1239            &[("master", &(doc_with_tags("sustained pad") + "sin(t)\n"))],
1240        );
1241        assert_eq!(
1242            advised(lint(&dir, None), "master"),
1243            vec![LintCode::TagShape]
1244        );
1245    }
1246
1247    #[test]
1248    fn an_empty_tag_between_commas_is_refused() {
1249        let dir = dir_of(
1250            "empty-tag-between-commas",
1251            &[("master", &(doc_with_tags("piano,,pad") + "sin(t)\n"))],
1252        );
1253        assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1254    }
1255
1256    #[test]
1257    fn a_single_valid_tag_is_clean() {
1258        let dir = dir_of(
1259            "one-valid-tag",
1260            &[("master", &(doc_with_tags("piano") + "sin(t)\n"))],
1261        );
1262        assert!(
1263            lint(&dir, None).is_ok(),
1264            "a single valid tag is not a finding"
1265        );
1266    }
1267
1268    #[test]
1269    fn three_valid_comma_separated_tags_are_clean() {
1270        let dir = dir_of(
1271            "three-valid-tags",
1272            &[(
1273                "master",
1274                &(doc_with_tags("piano, sustained-pad, mellow") + "sin(t)\n"),
1275            )],
1276        );
1277        assert!(
1278            lint(&dir, None).is_ok(),
1279            "three valid comma-separated tags are not a finding"
1280        );
1281    }
1282
1283    /// A well-formed 4-field header (valid `Tags:` included) ahead of a separate, non-leading
1284    /// comment block must not shield that block from `LONG_COMMENT_BLOCK_CHARS` — the two
1285    /// checks stay independent even once the header carries a fourth field.
1286    #[test]
1287    fn a_well_formed_tagged_header_does_not_exempt_a_later_long_comment_block() {
1288        let trailing = format!(";{}", "x".repeat(1000)); // 1001 chars
1289        let dir = dir_of(
1290            "tagged-header-long-block",
1291            &[
1292                (
1293                    "long",
1294                    &(doc_with_tags("fixture") + "sin(t)\n" + &trailing + "\n"),
1295                ),
1296                ("master", &(doc_with_tags("fixture") + "@long\n")),
1297            ],
1298        );
1299        assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
1300    }
1301}