1use crate::linter::{Lint, LintPolicy, Linter};
2use foundry_common::{
3 comments::{
4 Comments,
5 inline_config::{InlineConfig, InlineConfigItem},
6 },
7 errors::convert_solar_errors,
8 sh_warn,
9};
10use foundry_compilers::{ProjectPathsConfig, solc::SolcLanguage};
11use foundry_config::{
12 DenyLevel,
13 lint::{LintSpecificConfig, Severity},
14};
15use solar::{
16 ast,
17 interface::{
18 ColorChoice, Session,
19 diagnostics::{HumanEmitter, JsonEmitter, Level, SilentEmitter},
20 },
21 sema::Compiler,
22};
23use solar_lint::{LintRegistry, LintRunContext, LintRunError, LintSource, LintSuite, run_lints};
24use std::{
25 collections::HashSet,
26 path::{Path, PathBuf},
27 str::FromStr,
28 sync::{Arc, LazyLock},
29};
30use thiserror::Error;
31
32#[macro_use]
33pub mod macros;
34
35pub mod analysis;
36pub mod codesize;
37pub mod gas;
38pub mod high;
39pub mod info;
40pub mod low;
41pub mod med;
42pub mod naming;
43
44fn all_lints() -> impl Iterator<Item = &'static SolLint> {
46 [
47 high::REGISTERED_LINTS,
48 med::REGISTERED_LINTS,
49 low::REGISTERED_LINTS,
50 info::REGISTERED_LINTS,
51 gas::REGISTERED_LINTS,
52 codesize::REGISTERED_LINTS,
53 ]
54 .into_iter()
55 .flatten()
56}
57
58static ALL_REGISTERED_LINTS: LazyLock<Vec<&'static str>> =
59 LazyLock::new(|| all_lints().map(|lint| lint.id).collect());
60
61static DEFAULT_LINT_SPECIFIC_CONFIG: LazyLock<LintSpecificConfig> =
62 LazyLock::new(LintSpecificConfig::default);
63
64struct OwnedLintPolicy {
65 inline: Option<Arc<InlineConfig<Vec<String>>>>,
66 active: Arc<Vec<&'static str>>,
67 sources: Option<Arc<Vec<SourceLintPolicy>>>,
68}
69
70struct SourceLintPolicy {
71 file: Arc<solar::interface::source_map::SourceFile>,
72 inline: Arc<InlineConfig<Vec<String>>>,
73 active: Vec<&'static str>,
74}
75
76impl LintPolicy for OwnedLintPolicy {
77 fn is_lint_enabled(&self, id: &str) -> bool {
78 self.active.contains(&id)
79 }
80
81 fn is_lint_suppressed(&self, id: &str, span: solar::interface::Span) -> bool {
82 if !span.is_dummy()
83 && let Some(sources) = &self.sources
84 {
85 let source = sources
88 .partition_point(|source| source.file.start_pos <= span.lo())
89 .checked_sub(1)
90 .map(|idx| &sources[idx])
91 .filter(|source| source.file.contains(span.lo()));
92 return source.is_none_or(|source| {
93 !source.active.contains(&id) || source.inline.is_id_disabled(span, id)
94 });
95 }
96 self.inline.as_ref().is_some_and(|inline| inline.is_id_disabled(span, id))
97 }
98}
99
100#[derive(Clone)]
102pub struct ForgeLintSuite {
103 path_config: ProjectPathsConfig,
104 severity: Option<Vec<Severity>>,
105 lints_included: Option<Vec<SolLint>>,
106 lints_excluded: Option<Vec<SolLint>>,
107 registry: Arc<LintRegistry>,
108 sources: Option<Arc<Vec<SourceLintPolicy>>>,
109 run_active: Option<Arc<Vec<&'static str>>>,
110}
111
112impl std::fmt::Debug for ForgeLintSuite {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("ForgeLintSuite")
115 .field("path_config", &self.path_config)
116 .field("severity", &self.severity)
117 .field("lints_included", &self.lints_included)
118 .field("lints_excluded", &self.lints_excluded)
119 .finish_non_exhaustive()
120 }
121}
122
123impl ForgeLintSuite {
124 fn include_lint(&self, lint: SolLint) -> bool {
125 self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity()))
126 && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint))
127 && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint))
128 }
129
130 fn active_lints(&self, path: Option<&Path>) -> Vec<&'static str> {
131 all_lints()
132 .filter(|lint| {
133 self.include_lint(**lint)
134 && path.is_none_or(|path| {
135 !self.path_config.is_test_or_script(path)
136 || matches!(
137 lint.id,
138 "unsafe-cheatcode" | "environment-read-across-mutation"
139 )
140 })
141 })
142 .map(|lint| lint.id)
143 .collect()
144 }
145}
146
147impl LintSuite for ForgeLintSuite {
148 fn registry(&self) -> &LintRegistry {
149 &self.registry
150 }
151
152 fn source_policy(&self, source: LintSource<'_, '_>) -> Arc<dyn LintPolicy> {
153 let inline = self
154 .sources
155 .as_ref()
156 .and_then(|sources| {
157 sources
158 .binary_search_by_key(&source.file.start_pos, |source| source.file.start_pos)
159 .ok()
160 .map(|idx| sources[idx].inline.clone())
161 })
162 .unwrap_or_else(|| {
163 let comments =
164 Comments::new(source.file, source.session.source_map(), false, false, None);
165 Arc::new(parse_inline_config(source.session, &comments, source.ast))
166 });
167 Arc::new(OwnedLintPolicy {
168 inline: Some(inline),
169 active: self
170 .run_active
171 .clone()
172 .unwrap_or_else(|| Arc::new(self.active_lints(Some(source.path)))),
173 sources: self.sources.clone(),
174 })
175 }
176
177 fn project_policy(&self) -> Arc<dyn LintPolicy> {
178 Arc::new(OwnedLintPolicy {
179 inline: None,
180 active: Arc::new(self.active_lints(None)),
181 sources: None,
182 })
183 }
184}
185
186#[derive(Debug)]
189pub struct SolidityLinter<'a> {
190 path_config: ProjectPathsConfig,
191 severity: Option<Vec<Severity>>,
192 lints_included: Option<Vec<SolLint>>,
193 lints_excluded: Option<Vec<SolLint>>,
194 with_description: bool,
195 with_json_emitter: bool,
196 json_emitter_stdout: bool,
197 report_unused_suppressions: bool,
198 lint_specific: &'a LintSpecificConfig,
200}
201
202impl<'a> SolidityLinter<'a> {
203 pub fn new(path_config: ProjectPathsConfig) -> Self {
204 Self {
205 path_config,
206 with_description: true,
207 severity: None,
208 lints_included: None,
209 lints_excluded: None,
210 with_json_emitter: false,
211 json_emitter_stdout: false,
212 report_unused_suppressions: false,
213 lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG,
214 }
215 }
216
217 pub fn with_severity(mut self, severity: Option<Vec<Severity>>) -> Self {
218 self.severity = severity;
219 self
220 }
221
222 pub fn with_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
223 self.lints_included = lints;
224 self
225 }
226
227 pub fn without_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
228 self.lints_excluded = lints;
229 self
230 }
231
232 pub const fn with_description(mut self, with: bool) -> Self {
233 self.with_description = with;
234 self
235 }
236
237 pub const fn with_json_emitter(mut self, with: bool) -> Self {
238 self.with_json_emitter = with;
239 self
240 }
241
242 pub const fn with_json_emitter_stdout(mut self, with: bool) -> Self {
243 self.json_emitter_stdout = with;
244 self
245 }
246
247 pub const fn with_report_unused_suppressions(mut self, with: bool) -> Self {
248 self.report_unused_suppressions = with;
249 self
250 }
251
252 pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self {
253 self.lint_specific = lint_specific;
254 self
255 }
256
257 pub fn to_suite(&self) -> ForgeLintSuite {
259 let lint_specific = Arc::new(self.lint_specific.clone());
260 let mut registry = LintRegistry::new();
261 high::register_lints(&mut registry, &lint_specific);
262 med::register_lints(&mut registry, &lint_specific);
263 low::register_lints(&mut registry, &lint_specific);
264 info::register_lints(&mut registry, &lint_specific);
265 gas::register_lints(&mut registry, &lint_specific);
266 codesize::register_lints(&mut registry, &lint_specific);
267
268 ForgeLintSuite {
269 path_config: self.path_config.clone(),
270 severity: self.severity.clone(),
271 lints_included: self.lints_included.clone(),
272 lints_excluded: self.lints_excluded.clone(),
273 registry: Arc::new(registry),
274 sources: None,
275 run_active: None,
276 }
277 }
278}
279
280impl<'a> Linter for SolidityLinter<'a> {
281 type Language = SolcLanguage;
282 type Lint = SolLint;
283
284 fn lint(
285 &self,
286 input: &[PathBuf],
287 deny: DenyLevel,
288 compiler: &mut Compiler,
289 ) -> eyre::Result<()> {
290 convert_solar_errors(compiler.dcx())?;
291
292 let mut warn_count_before = compiler.dcx().warn_count();
294 let mut note_count_before = compiler.dcx().note_count();
295
296 let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some();
297
298 let sm = compiler.sess().clone_source_map();
299 let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter {
300 let writer: Box<dyn std::io::Write + Send> = if self.json_emitter_stdout && !ui_testing
301 {
302 Box::new(std::io::BufWriter::new(std::io::stdout()))
303 } else {
304 Box::new(std::io::BufWriter::new(std::io::stderr()))
305 };
306 let json_emitter = JsonEmitter::new(writer, sm, ColorChoice::Never)
307 .rustc_like(true)
308 .ui_testing(ui_testing);
309 Box::new(json_emitter)
310 } else {
311 Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm)))
312 });
313 let sess = compiler.sess_mut();
314 sess.dcx.set_flags_mut(|f| f.track_diagnostics = false);
315 if ui_testing {
316 sess.opts.unstable.ui_testing = true;
317 sess.reconfigure();
318 }
319
320 compiler.dcx().set_allowed_diagnostic_codes(["5333".into()]);
322 compiler.enter_mut(|compiler| -> eyre::Result<()> {
323 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
324 let _ = compiler.lower_asts();
325 }
326 convert_solar_errors(compiler.dcx())?;
327 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
328 let prev_emitter =
331 compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
332 let _ = compiler.analysis();
333 compiler.dcx().set_emitter(prev_emitter);
334 }
335 warn_count_before = compiler.dcx().warn_count();
336 note_count_before = compiler.dcx().note_count();
337
338 let gcx = compiler.gcx();
339 let mut targets = Vec::with_capacity(input.len());
340 let mut seen_sources = HashSet::new();
341 for path in input {
342 let path = self.path_config.root.join(path);
343 let Some((_, source)) = gcx.get_ast_source(&path) else {
344 _ = sh_warn!("AST source not found for {}", path.display());
347 continue;
348 };
349 if seen_sources.insert(source.file.start_pos) {
350 targets.push(path);
351 }
352 }
353
354 let mut suite = self.to_suite();
355 let mut sources = targets
356 .iter()
357 .map(|path| {
358 let (_, source) =
359 gcx.get_ast_source(path).expect("lint target was validated above");
360 let ast = source.ast.as_ref().expect("lint target AST was validated above");
361 let comments =
362 Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
363 SourceLintPolicy {
364 file: source.file.clone(),
365 inline: Arc::new(parse_inline_config(gcx.sess, &comments, ast)),
366 active: suite.active_lints(Some(path)),
367 }
368 })
369 .collect::<Vec<_>>();
370 sources.sort_unstable_by_key(|source| source.file.start_pos);
371 suite.run_active = Some(Arc::new(
372 suite
373 .active_lints(None)
374 .into_iter()
375 .filter(|id| sources.iter().any(|source| source.active.contains(id)))
376 .collect(),
377 ));
378 suite.sources = Some(Arc::new(sources));
379 run_lints(
380 &suite,
381 LintRunContext {
382 gcx,
383 targets: &targets,
384 with_description: self.with_description,
385 with_ansi_help: !self.with_json_emitter,
386 },
387 )
388 .unwrap_or_else(|error| match error {
389 LintRunError::MissingAstSource(path) => {
390 unreachable!("prevalidated AST source missing for {}", path.display())
391 }
392 LintRunError::MissingAst(path) => {
393 panic!("AST missing for {}", path.display())
394 }
395 LintRunError::MissingHir(path) => {
396 panic!("HIR source not found for {}", path.display())
397 }
398 error => panic!("lint run failed: {error}"),
399 });
400
401 if self.report_unused_suppressions
402 && let Some(sources) = &suite.sources
403 {
404 for source in sources.iter() {
405 for (span, id) in source.inline.unused_suppressions(&source.active) {
406 gcx.sess
407 .dcx
408 .warn(format!("unused lint suppression for '{id}'"))
409 .span(span)
410 .emit();
411 }
412 }
413 }
414
415 Ok(())
416 })?;
417
418 let sess = compiler.sess_mut();
419 sess.dcx.set_emitter(prev_emitter);
420 if ui_testing {
421 sess.opts.unstable.ui_testing = false;
422 sess.reconfigure();
423 }
424
425 let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
426 let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
427
428 let (w, n) = (lint_warn_count, lint_note_count);
429 let denied = match deny {
430 DenyLevel::Warnings if w > 0 && n > 0 => {
431 format!("{w} linter warning(s); {n} note(s) were also emitted")
432 }
433 DenyLevel::Warnings if w > 0 => format!("{w} linter warning(s)"),
434 DenyLevel::Notes if w > 0 && n > 0 => format!("{w} linter warning(s) and {n} note(s)"),
435 DenyLevel::Notes if w > 0 => format!("{w} linter warning(s)"),
436 DenyLevel::Notes if n > 0 => format!("{n} linter note(s)"),
437 _ => return Ok(()),
438 };
439 Err(DeniedLintDiagnostics(format!(
440 "aborting due to {denied}
441"
442 ))
443 .into())
444 }
445}
446
447fn parse_inline_config<'ast>(
448 sess: &Session,
449 comments: &Comments,
450 ast: &'ast ast::SourceUnit<'ast>,
451) -> InlineConfig<Vec<String>> {
452 let items = comments.iter().filter_map(|comment| {
453 let mut item = comment.lines.first()?.as_str();
454 if let Some(prefix) = comment.prefix() {
455 item = item.strip_prefix(prefix).unwrap_or(item);
456 }
457 if let Some(suffix) = comment.suffix() {
458 item = item.strip_suffix(suffix).unwrap_or(item);
459 }
460 let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
461 let span = comment.span;
462 match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
463 Ok(item) => Some((span, item)),
464 Err(e) => {
465 sess.dcx.warn(e.to_string()).span(span).emit();
466 None
467 }
468 }
469 });
470
471 InlineConfig::from_ast(items, ast, sess.source_map())
472}
473
474#[derive(Error, Debug)]
475pub enum SolLintError {
476 #[error("Unknown lint ID: {0}")]
477 InvalidId(String),
478}
479
480#[derive(Error, Debug)]
481#[error("{0}")]
482pub struct DeniedLintDiagnostics(String);
483
484#[derive(Debug, Clone, Copy, Eq, PartialEq)]
485pub struct SolLint {
486 id: &'static str,
487 description: &'static str,
488 help: &'static str,
489 severity: Severity,
490}
491
492impl SolLint {
493 pub const fn severity(self) -> Severity {
494 self.severity
495 }
496}
497
498impl Lint for SolLint {
499 fn id(&self) -> &'static str {
500 self.id
501 }
502 fn level(&self) -> Level {
503 self.severity.into()
504 }
505 fn description(&self) -> &'static str {
506 self.description
507 }
508 fn help(&self) -> &'static str {
509 self.help
510 }
511}
512
513impl FromStr for SolLint {
514 type Err = SolLintError;
515
516 fn from_str(value: &str) -> Result<Self, Self::Err> {
517 all_lints()
518 .find(|lint| lint.id == value)
519 .copied()
520 .ok_or_else(|| SolLintError::InvalidId(value.to_string()))
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::{Severity, all_lints};
529 use crate::linter::Lint;
530 use eyre::{Result, ensure};
531 use std::{collections::BTreeSet, fs, path::Path};
532
533 #[test]
534 fn registered_lints_have_docs() {
535 let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
536 let mut registered = BTreeSet::new();
537 let mut errors = Vec::new();
538 for lint in all_lints() {
539 assert!(registered.insert(lint.id().to_owned()), "duplicate lint ID: {}", lint.id());
540 let path = docs.join(format!("{}.md", lint.id()));
541 let result = fs::read_to_string(&path)
542 .map_err(eyre::Report::from)
543 .and_then(|text| validate_doc(&text, lint.id(), lint.severity()));
544 if let Err(error) = result {
545 errors.push(format!("{}: {error}", path.display()));
546 }
547 }
548 assert!(!registered.is_empty(), "no registered lints");
549 let documented = fs::read_dir(&docs)
550 .unwrap()
551 .map(|entry| entry.unwrap().path())
552 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
553 .map(|path| path.file_stem().unwrap().to_str().unwrap().to_owned())
554 .filter(|id| !matches!(id.as_str(), "README" | "_template"))
555 .collect::<BTreeSet<_>>();
556 for id in documented.difference(®istered) {
557 errors.push(format!("{id}.md: no registered lint"));
558 }
559 assert!(errors.is_empty(), "invalid lint documentation:\n{}", errors.join("\n"));
560 }
561
562 #[test]
563 fn registered_lints_have_canonical_help_url() {
564 for lint in all_lints() {
565 let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
566 assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
567 }
568 }
569
570 #[derive(Debug, PartialEq)]
571 enum Kind<'a> {
572 Heading(usize),
573 Text,
574 Code(&'a str),
575 }
576
577 #[derive(Debug)]
578 struct Token<'a> {
579 kind: Kind<'a>,
580 text: String,
581 line: usize,
582 }
583
584 fn fence(line: &str) -> Option<(u8, usize, &str)> {
585 let trimmed = line.trim_start_matches(' ');
586 if line.len() - trimmed.len() > 3 {
587 return None;
588 }
589 let marker = *trimmed.as_bytes().first()?;
590 if !matches!(marker, b'`' | b'~') {
591 return None;
592 }
593 let len = trimmed.bytes().take_while(|&byte| byte == marker).count();
594 (len >= 3).then(|| (marker, len, trimmed[len..].trim()))
595 }
596
597 fn tokens(text: &str) -> Result<Vec<Token<'_>>> {
599 let mut result = Vec::new();
600 let mut lines = text.lines().enumerate();
601 while let Some((index, line)) = lines.next() {
602 let (kind, text) = if let Some((marker, len, language)) = fence(line) {
603 let mut code = String::new();
604 let mut closed = false;
605 for (_, line) in lines.by_ref() {
606 if fence(line).is_some_and(|(end, width, tail)| {
607 end == marker && width >= len && tail.is_empty()
608 }) {
609 closed = true;
610 break;
611 }
612 code.push_str(line);
613 code.push('\n');
614 }
615 ensure!(closed, "line {}: unclosed code fence", index + 1);
616 (Kind::Code(language), code)
617 } else {
618 let level = line.bytes().take_while(|&byte| byte == b'#').count();
619 if (1..=6).contains(&level) && line[level..].starts_with(' ') {
620 (Kind::Heading(level), line[level..].trim().to_owned())
621 } else if line.trim().is_empty() {
622 continue;
623 } else {
624 (Kind::Text, line.trim().to_owned())
625 }
626 };
627 result.push(Token { kind, text, line: index + 1 });
628 }
629 Ok(result)
630 }
631
632 fn validate_doc(text: &str, id: &str, severity: Severity) -> Result<()> {
633 ensure!(
634 id.starts_with(|c: char| c.is_ascii_lowercase())
635 && id.split('-').all(|part| !part.is_empty()
636 && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())),
637 "lint ID must be kebab-case"
638 );
639 let items = tokens(text)?;
640 let [title, level, identity, body @ ..] = items.as_slice() else {
641 eyre::bail!("expected title, severity, ID, and sections");
642 };
643 ensure!(title.kind == Kind::Heading(1) && !title.text.is_empty(), "start with one # title");
644 ensure!(
645 level.kind == Kind::Text && level.text == format!("**Severity**: `{severity:?}`"),
646 "line {}: severity must match the registered lint ({severity:?})",
647 level.line
648 );
649 ensure!(
650 identity.kind == Kind::Text && identity.text == format!("**ID**: `{id}`"),
651 "line {}: ID must match the registered lint and filename ({id})",
652 identity.line
653 );
654 ensure!(
655 body.first().is_some_and(|t| t.kind == Kind::Heading(2) && t.text == "What it does"),
656 "start the body with ## What it does (no introductory summary)"
657 );
658 let mut core = Vec::new();
659 let mut seen = BTreeSet::new();
660 let mut remaining = body;
661 while let Some((heading, tail)) = remaining.split_first() {
662 let end = tail.iter().position(|t| matches!(t.kind, Kind::Heading(1 | 2)));
663 let (content, rest) = tail.split_at(end.unwrap_or(tail.len()));
664 remaining = rest;
665 ensure!(heading.kind == Kind::Heading(2), "line {}: only one # title", heading.line);
666 let name = heading.text.as_str();
667 ensure!(seen.insert(name), "line {}: duplicate section {name}", heading.line);
668 match name {
669 "What it does" | "Why is this bad?" | "Why restrict this?" | "Example" => {
670 core.push(name);
671 }
672 "Configuration" | "Notes" | "Limitations" | "Known limitations" => {}
673 _ => eyre::bail!("line {}: unexpected section {name}", heading.line),
674 }
675 ensure!(
676 content
677 .iter()
678 .any(|t| !matches!(t.kind, Kind::Heading(_)) && !t.text.trim().is_empty()),
679 "line {}: {name} must not be empty",
680 heading.line
681 );
682 for token in content {
683 ensure!(
684 !(matches!(token.kind, Kind::Heading(_))
685 && matches!(token.text.as_str(), "Bad" | "Good")),
686 "line {}: use Use instead: rather than Bad/Good headings",
687 token.line
688 );
689 ensure!(
690 !(token.kind == Kind::Text
691 && (token.text.starts_with("**Severity**:")
692 || token.text.starts_with("**ID**:"))),
693 "line {}: metadata belongs only below the title",
694 token.line
695 );
696 }
697 if matches!(name, "What it does" | "Why is this bad?" | "Why restrict this?") {
698 ensure!(
699 content.iter().any(|t| t.kind == Kind::Text),
700 "{name} needs explanatory prose"
701 );
702 }
703 if name == "Example" {
704 let separators = content
705 .iter()
706 .enumerate()
707 .filter(|(_, t)| t.kind == Kind::Text && t.text == "Use instead:")
708 .map(|(index, _)| index)
709 .collect::<Vec<_>>();
710 ensure!(separators.len() == 1, "Example needs exactly one Use instead: separator");
711 let split = separators[0];
712 for side in [&content[..split], &content[split + 1..]] {
713 ensure!(
714 side.iter()
715 .any(|t| t.kind == Kind::Code("solidity") && !t.text.trim().is_empty()),
716 "Example needs a nonempty solidity code block on each side of Use instead:"
717 );
718 }
719 }
720 }
721 ensure!(
722 matches!(
723 core.as_slice(),
724 ["What it does", "Why is this bad?" | "Why restrict this?", "Example"]
725 ),
726 "expected What it does, exactly one Why section, then Example"
727 );
728 Ok(())
729 }
730
731 const VALID: &str = "# Example\n\n**Severity**: `Info`\n**ID**: `example`\n\n\
732 ## What it does\n\nReports a pattern.\n\n## Why is this bad?\n\nExplains the consequence.\n\n\
733 ## Example\n\n```solidity\nbad();\n```\n\nUse instead:\n\n```solidity\ngood();\n```\n";
734
735 #[test]
736 fn accepts_documentation_variants() {
737 for severity in [
738 Severity::High,
739 Severity::Med,
740 Severity::Low,
741 Severity::Info,
742 Severity::Gas,
743 Severity::CodeSize,
744 ] {
745 validate_doc(&VALID.replace("`Info`", &format!("`{severity:?}`")), "example", severity)
746 .unwrap();
747 }
748 for text in [
749 VALID.replace("Why is this bad?", "Why restrict this?"),
750 VALID.replace('\n', "\r\n"),
751 VALID.replace("## Why", "## Known limitations\n\nAn exclusion.\n\n## Why")
752 + "\n## Configuration\n\n```toml\nsetting = true\n```\n\n## Notes\n\nA note.\n\n## Limitations\n\nA limitation.\n",
753 include_str!("../../docs/_template.md")
754 .replace("`<High | Med | Low | Info | Gas | CodeSize>`", "`Info`")
755 .replace("`<str_id>`", "`example`"),
756 ] {
757 validate_doc(&text, "example", Severity::Info).unwrap();
758 }
759 for marker in ["````", "~~~"] {
760 let text = VALID
761 .replace("```", marker)
762 .replace("bad();", "## Example\n**ID**: `other`\nUse instead:\n### Bad\n```");
763 validate_doc(&text, "example", Severity::Info).unwrap();
764 }
765 }
766
767 #[test]
768 fn rejects_invalid_documentation() {
769 for (from, to, error) in [
770 ("# Example", "Example", "one # title"),
771 ("**Severity**: `Info`", "", "severity"),
772 ("`Info`", "`High`", "severity"),
773 ("`example`", "`other`", "ID must match"),
774 ("## What", "Summary.\n\n## What", "no introductory summary"),
775 ("## Why is this bad?\n\nExplains the consequence.\n\n", "", "exactly one Why"),
776 ("Reports a pattern.", "", "must not be empty"),
777 ("Reports a pattern.", "```solidity\nf();\n```", "explanatory prose"),
778 ("Use instead:", "", "exactly one Use instead:"),
779 ("Use instead:", "### Bad", "Bad/Good headings"),
780 ("Use instead:", "### Good", "Bad/Good headings"),
781 ("bad();", "", "nonempty solidity"),
782 ("good();", " ", "nonempty solidity"),
783 ("```solidity", "```text", "nonempty solidity"),
784 ("```solidity", "~~~solidity", "unclosed code fence"),
785 ("```solidity", "````solidity", "unclosed code fence"),
786 ] {
787 let text = VALID.replacen(from, to, 1);
788 let result = validate_doc(&text, "example", Severity::Info).unwrap_err().to_string();
789 assert!(result.contains(error), "{from:?} -> {to:?}: {result}");
790 }
791 for (suffix, error) in [
792 ("# Extra\n", "only one # title"),
793 ("## Example\n", "duplicate section"),
794 ("## Why restrict this?\n\nPolicy.\n", "exactly one Why"),
795 ("## Scope and controls\n\nText.\n", "unexpected section"),
796 ("## Notes\n\n### Detail\n", "must not be empty"),
797 ("**ID**: `example`\n", "metadata belongs only"),
798 ("Use instead:\n", "exactly one Use instead:"),
799 ] {
800 let result = validate_doc(&(VALID.to_owned() + suffix), "example", Severity::Info)
801 .unwrap_err()
802 .to_string();
803 assert!(result.contains(error), "{suffix:?}: {result}");
804 }
805 let reordered = VALID
806 .replace("## Why is this bad?", "## Temporary")
807 .replace("## Example", "## Why is this bad?")
808 .replace("## Temporary", "## Example");
809 assert!(validate_doc(&reordered, "example", Severity::Info).is_err());
810 assert!(
811 validate_doc(VALID.trim_end().trim_end_matches("```"), "example", Severity::Info)
812 .is_err()
813 );
814 for id in ["Not_kebab", "example-", "example--lint", "1example"] {
815 let text = VALID.replace("`example`", &format!("`{id}`"));
816 assert!(validate_doc(&text, id, Severity::Info).is_err(), "{id}");
817 }
818 }
819}