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