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