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