doctest_file/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3extern crate proc_macro;
4
5mod logic;
6mod tokenmanip;
7mod imports {
8	pub(crate) use proc_macro::{
9		Delimiter, Group, Ident as I, Literal, Punct, Spacing, Span, TokenStream as TS,
10		TokenTree as TT,
11	};
12}
13
14use std::{
15	borrow::Cow,
16	fs::File,
17	io::{BufRead, BufReader},
18	path::PathBuf,
19};
20use {imports::*, logic::*, tokenmanip::*};
21
22type MResult<T = TS> = Result<T, Error>;
23
24struct Error {
25	msg: Cow<'static, str>,
26	span: Span,
27}
28impl Error {
29	fn new_static(msg: &'static str, span: Span) -> Self {
30		Self {
31			msg: Cow::Borrowed(msg),
32			span,
33		}
34	}
35	fn new_owned(msg: String, span: Span) -> Self {
36		Self {
37			msg: Cow::Owned(msg),
38			span,
39		}
40	}
41}
42
43/// Includes a documentation test from a separate file, **without** inserting the surrounding
44/// \`\`\` markers.
45///
46/// See the [crate-level documentation](crate) for more.
47#[proc_macro]
48pub fn include_doctest(input: TS) -> TS {
49	macro_main(input).unwrap_or_else(compile_error)
50}
51
52struct Input {
53	filename: PathBuf,
54	filename_span: Span,
55}
56
57fn parse_input(input: TS) -> MResult<Input> {
58	let mut input = input.into_iter();
59	let Some(literal) = input.next() else {
60		return Err(Error::new_static(
61			"expected filename, found empty parameter list",
62			Span::call_site(),
63		));
64	};
65	let lspan = literal.span();
66	let TT::Literal(literal) = literal else {
67		return Err(Error::new_owned(
68			format!("expected literal, found \"{literal}\""),
69			lspan,
70		));
71	};
72
73	Ok(Input {
74		filename: PathBuf::from(parse_literal(literal)?),
75		filename_span: lspan,
76	})
77}
78
79fn macro_main(input: TS) -> MResult {
80	let input = parse_input(input)?;
81	// PathBuf::push() with absolute paths replaces the original value.
82	let mut path = if input.filename.is_relative() {
83		std::env::var_os("CARGO_MANIFEST_DIR")
84			.map(PathBuf::from)
85			.ok_or_else(|| {
86				Error::new_static(
87					"the CARGO_MANIFEST_DIR environment variable is not set",
88					Span::call_site(),
89				)
90			})?
91	} else {
92		PathBuf::new()
93	};
94	path.push(&input.filename);
95
96	let fln = input.filename.display();
97	let ioe = |m, e| {
98		Error::new_owned(
99			format!("I/O error (file {fln}) {m}: {e}"),
100			input.filename_span,
101		)
102	};
103	let file = File::open(path).map_err(|e| ioe("could not open", e))?;
104
105	let lines = BufReader::new(file)
106		.lines()
107		.map(|rslt| rslt.map_err(|e| ioe("read failed", e)));
108
109	let mut pass1 = Pass1::new(lines);
110	let mut lines_pass2 = Vec::with_capacity(256);
111	for rslt in &mut pass1 {
112		let t = rslt?;
113		lines_pass2.push(t);
114	}
115
116	let mut docstring = String::with_capacity(pass1.total_length());
117	let dedent = pass1.min_indent();
118	for (line, visible) in lines_pass2 {
119		if visible {
120			// The space at the beginning is the space immediately after the /// that gets eaten by
121			// Rustdoc to make doc comments look nicer.
122			docstring.push(' ');
123			let indent = indent_of(&line);
124			for _ in 0..indent.saturating_sub(dedent) {
125				docstring.push(' ');
126			}
127			docstring.push_str(line.trim_start());
128		} else {
129			docstring.push_str("# ");
130			docstring.push_str(&line);
131		}
132		docstring.push('\n');
133	}
134
135	while docstring.ends_with('\n') {
136		docstring.pop();
137	}
138
139	Ok(TT::Literal(Literal::string(&docstring)).into())
140}