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