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