Skip to main content

sva_cli/
lint.rs

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