1use 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
10pub struct Finding {
12 pub code: LintCode,
13 pub severity: Severity,
14 pub subject: String,
15 pub message: String,
16 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 pub song: Vec<sva_ast::song::Warning>,
37 pub interval: Option<(f64, f64)>,
39}
40
41pub fn lint(dir: &Path, target: Option<&str>) -> Result<LintReport, CliError> {
44 lint_bounded(dir, target, BODY_CHARS)
45}
46
47pub 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
60fn 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
78fn 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
85fn 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
111fn 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
142fn 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
154const LONG_COMMENT_BLOCK_CHARS: usize = 1000;
156
157fn 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 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
182fn 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
210fn 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
248fn 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
301pub const BODY_CHARS: usize = 10_000;
304
305const REF_PLACEHOLDER: &str = "@x";
307
308fn 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
356fn 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
376fn 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 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 #[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 #[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 #[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 #[test]
637 fn the_line_one_header_annotation_is_excluded_from_its_run() {
638 let header = ";header"; let boundary_line = format!(";{}", "x".repeat(999)); let over_line = format!(";{}", "x".repeat(1000)); 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 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 #[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 #[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 #[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; 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 #[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 #[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 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 #[test]
994 fn a_well_formed_tagged_header_does_not_exempt_a_later_long_comment_block() {
995 let trailing = format!(";{}", "x".repeat(1000)); 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}