1use std::{borrow::Cow, fmt};
10
11use crate::SourceLocation;
12
13#[derive(Debug, PartialEq)]
19#[non_exhaustive]
20pub struct Warning {
21 pub kind: WarningKind,
23 pub location: Option<SourceLocation>,
26}
27
28impl Warning {
29 #[must_use]
31 pub(crate) fn new(kind: WarningKind, location: Option<SourceLocation>) -> Self {
32 Self { kind, location }
33 }
34
35 #[must_use]
37 pub fn source_location(&self) -> Option<&SourceLocation> {
38 self.location.as_ref()
39 }
40
41 #[must_use]
44 pub fn advice(&self) -> Option<&'static str> {
45 match &self.kind {
46 WarningKind::SectionLevelOutOfSequence { .. } => Some(
47 "Section levels must increment by at most one. Renumber the heading so it is one level deeper than its parent (the document title counts as level 0).",
48 ),
49 WarningKind::NestedSectionInIndex => Some(
50 "Move the nested section outside the index or replace its heading with a non-section block.",
51 ),
52 WarningKind::NestedSectionInBibliography => Some(
53 "Move the nested section outside the bibliography section or replace its heading with a non-section block.",
54 ),
55 WarningKind::UnterminatedTable { .. }
56 | WarningKind::UnterminatedDelimitedBlock { .. } => Some(
57 "The opening delimiter was found but no matching closing delimiter was seen before end of document. Add the closing delimiter on its own line, or remove the opening delimiter if not intended.",
58 ),
59 WarningKind::TableUnknownFormat { .. } => Some(
60 "Use a supported table format (`csv`, `dsv`, or `tsv`) or remove the `format` attribute to use the table delimiter's default separator.",
61 ),
62 WarningKind::TableIncompleteRow => Some(
63 "Complete the final table row or remove the trailing cells that do not fill the configured column count.",
64 ),
65 WarningKind::TableCellOverflow { .. } | WarningKind::TableColumnCount { .. } => Some(
66 "Adjust the `cols` attribute or the row's cells/spans so each row occupies the configured number of columns.",
67 ),
68 WarningKind::NonStandardAuthorLine { .. } => Some(
69 "Author lines use `firstname [middlename] [lastname] [<email>]`, with multiple authors separated by `;`. Keeping the whole line as a single author name.",
70 ),
71 WarningKind::UnresolvedReference { .. } => Some(
72 "Define an anchor with this id (e.g. `[[id]]` or `[#id]` on a block or section), or fix the reference to point at an existing id.",
73 ),
74 WarningKind::DuplicateId { .. } => Some(
75 "Assign a unique id to each target. Cross-references use the first definition.",
76 ),
77 WarningKind::ConflictingFootnote { .. } => Some(
78 "Use footnote:id[] to reuse the first definition, or choose a different id for a new footnote.",
79 ),
80 WarningKind::LegacyFloatDiscreteHeading => Some(
81 "Replace the `float` attribute with `discrete` (e.g. `[discrete]`). `float` here does not control layout; it is an older name for a discrete (free-floating) heading.",
82 ),
83 WarningKind::InvalidDocumentAttribute { .. } => {
84 Some("Use a value in the attribute's documented domain")
85 }
86 WarningKind::ContentRecovery { .. } | WarningKind::Other(_) => None,
87 }
88 }
89}
90
91impl fmt::Display for Warning {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 let Some(loc) = &self.location else {
94 return write!(f, "{}", self.kind);
95 };
96 if let Some(name) = loc
97 .file
98 .as_ref()
99 .and_then(|p| p.file_name())
100 .and_then(|s| s.to_str())
101 {
102 write!(f, "{name}: {}: {}", loc, self.kind)
103 } else {
104 write!(f, "{}: {}", loc, self.kind)
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, thiserror::Error)]
117#[non_exhaustive]
118pub enum WarningKind {
119 #[error("section title out of sequence: expected level {expected}, got level {got}")]
125 SectionLevelOutOfSequence {
126 expected: u8,
129 got: u8,
131 },
132
133 #[error("bibliography sections do not support nested sections")]
136 NestedSectionInBibliography,
137
138 #[error("index sections do not support nested sections")]
141 NestedSectionInIndex,
142
143 #[error("unterminated table block (opened by `{delimiter}`)")]
150 UnterminatedTable {
151 delimiter: String,
153 },
154
155 #[error("unterminated {kind} block (opened by `{delimiter}`)")]
166 UnterminatedDelimitedBlock {
167 kind: &'static str,
170 delimiter: String,
172 },
173
174 #[error("unknown table format `{format}`, using default separator")]
177 TableUnknownFormat {
178 format: String,
180 },
181
182 #[error("dropping cells from incomplete row detected end of table")]
185 TableIncompleteRow,
186
187 #[error(
190 "dropping cell because it exceeds specified number of columns: actual={actual}, expected={expected}"
191 )]
192 TableCellOverflow {
193 actual: usize,
195 expected: usize,
197 },
198
199 #[error(
202 "table row has incorrect column count: actual={actual}, expected={expected}, occupied_from_rowspans={occupied_from_rowspans}"
203 )]
204 TableColumnCount {
205 actual: usize,
207 expected: usize,
209 occupied_from_rowspans: usize,
211 },
212
213 #[error(
218 "author line `{line}` is not in `firstname [middlename] [lastname] [<email>]` form; using the whole line as a single author name"
219 )]
220 NonStandardAuthorLine {
221 line: String,
223 },
224
225 #[error("invalid reference: {target}")]
229 UnresolvedReference {
230 target: String,
232 },
233
234 #[error("id already in use: {id} (first defined at {})", first_definition(.first))]
236 DuplicateId {
237 id: String,
239 first: Box<SourceLocation>,
241 },
242
243 #[error("footnote '{id}' has different text from its first definition at {}; keeping the first definition", first_definition(.first))]
245 ConflictingFootnote {
246 id: String,
248 first: Box<SourceLocation>,
250 },
251
252 #[error("`float` is a legacy alias for a discrete heading; prefer `discrete`")]
257 LegacyFloatDiscreteHeading,
258
259 #[error("invalid document attribute `{name}` value `{value}`: expected {expected}")]
262 InvalidDocumentAttribute {
263 name: String,
265 value: String,
267 expected: &'static str,
269 },
270
271 #[error("{message}")]
276 ContentRecovery {
277 message: Cow<'static, str>,
279 },
280
281 #[error("{0}")]
283 Other(Cow<'static, str>),
284}
285
286fn first_definition(location: &SourceLocation) -> String {
287 let file = location
288 .file
289 .as_deref()
290 .map_or_else(|| "<input>".into(), std::path::Path::to_string_lossy);
291 format!(
292 "{file}:{}:{}",
293 location.location.start.line, location.location.start.column
294 )
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::Position;
301
302 #[test]
303 fn display_without_location() {
304 let w = Warning::new(WarningKind::Other("something happened".into()), None);
305 assert_eq!(format!("{w}"), "something happened");
306 }
307
308 #[test]
309 fn display_with_location_no_file() {
310 let loc = SourceLocation {
311 file: None,
312 location: crate::Location::point(Position::new(5, 1)),
313 };
314 let w = Warning::new(
315 WarningKind::SectionLevelOutOfSequence {
316 expected: 2,
317 got: 3,
318 },
319 Some(loc),
320 );
321 assert_eq!(
322 format!("{w}"),
323 "line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
324 );
325 }
326
327 #[test]
328 fn display_with_location_and_file() {
329 let loc = SourceLocation {
330 file: Some(std::path::PathBuf::from("/docs/guide.adoc")),
331 location: crate::Location::point(Position::new(5, 1)),
332 };
333 let w = Warning::new(
334 WarningKind::SectionLevelOutOfSequence {
335 expected: 2,
336 got: 3,
337 },
338 Some(loc),
339 );
340 assert_eq!(
341 format!("{w}"),
342 "guide.adoc: line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
343 );
344 }
345
346 #[test]
347 fn equality_holds_on_kind_and_location() {
348 let a = Warning::new(WarningKind::Other("x".into()), None);
349 let b = Warning::new(WarningKind::Other("x".into()), None);
350 let c = Warning::new(WarningKind::Other("y".into()), None);
351 assert_eq!(a, b);
352 assert_ne!(a, c);
353 }
354
355 #[test]
356 fn unterminated_table_display_renders_original_token() {
357 let w = Warning::new(
358 WarningKind::UnterminatedTable {
359 delimiter: "|===".into(),
360 },
361 None,
362 );
363 assert_eq!(
364 format!("{w}"),
365 "unterminated table block (opened by `|===`)",
366 );
367 }
368
369 #[test]
370 fn unterminated_table_display_preserves_longer_tokens() {
371 let w = Warning::new(
372 WarningKind::UnterminatedTable {
373 delimiter: "!=====".into(),
374 },
375 None,
376 );
377 assert_eq!(
378 format!("{w}"),
379 "unterminated table block (opened by `!=====`)",
380 );
381 }
382}