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