1use std::{borrow::Cow, fmt};
9
10use crate::SourceLocation;
11
12#[derive(Debug, PartialEq)]
18#[non_exhaustive]
19pub struct Warning {
20 pub kind: WarningKind,
22 pub location: Option<SourceLocation>,
25}
26
27impl Warning {
28 pub(crate) fn emit_trace(&self) {
30 let kind = match self.kind {
31 WarningKind::SectionLevelOutOfSequence { .. } => "section-level-out-of-sequence",
32 WarningKind::NestedSectionInBibliography => "nested-section-in-bibliography",
33 WarningKind::NestedSectionInIndex => "nested-section-in-index",
34 WarningKind::UnterminatedTable { .. } => "unterminated-table",
35 WarningKind::UnterminatedDelimitedBlock { .. } => "unterminated-delimited-block",
36 WarningKind::TableUnknownFormat { .. } => "table-unknown-format",
37 WarningKind::TableIncompleteRow => "table-incomplete-row",
38 WarningKind::TableCellOverflow { .. } => "table-cell-overflow",
39 WarningKind::TableColumnCount { .. } => "table-column-count",
40 WarningKind::NonStandardAuthorLine { .. } => "non-standard-author-line",
41 WarningKind::UnresolvedReference { .. } => "unresolved-reference",
42 WarningKind::DuplicateId { .. } => "duplicate-id",
43 WarningKind::ConflictingFootnote { .. } => "conflicting-footnote",
44 WarningKind::LegacyFloatDiscreteHeading => "legacy-float-discrete-heading",
45 WarningKind::InvalidDocumentAttribute { .. } => "invalid-document-attribute",
46 WarningKind::ContentRecovery { .. } => "content-recovery",
47 WarningKind::Other(_) => "other",
48 };
49 tracing::warn!(
50 kind,
51 line = self
52 .location
53 .as_ref()
54 .map(|source| source.location.start.line),
55 column = self
56 .location
57 .as_ref()
58 .map(|source| source.location.start.column),
59 "parser warning; see returned diagnostics"
60 );
61 }
62
63 #[must_use]
65 pub(crate) fn new(kind: WarningKind, location: Option<SourceLocation>) -> Self {
66 Self { kind, location }
67 }
68
69 #[must_use]
71 pub fn source_location(&self) -> Option<&SourceLocation> {
72 self.location.as_ref()
73 }
74
75 #[must_use]
78 pub fn advice(&self) -> Option<&'static str> {
79 match &self.kind {
80 WarningKind::SectionLevelOutOfSequence { .. } => Some(
81 "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).",
82 ),
83 WarningKind::NestedSectionInIndex => Some(
84 "Move the nested section outside the index or replace its heading with a non-section block.",
85 ),
86 WarningKind::NestedSectionInBibliography => Some(
87 "Move the nested section outside the bibliography section or replace its heading with a non-section block.",
88 ),
89 WarningKind::UnterminatedTable { .. }
90 | WarningKind::UnterminatedDelimitedBlock { .. } => Some(
91 "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.",
92 ),
93 WarningKind::TableUnknownFormat { .. } => Some(
94 "Use a supported table format (`csv`, `dsv`, or `tsv`) or remove the `format` attribute to use the table delimiter's default separator.",
95 ),
96 WarningKind::TableIncompleteRow => Some(
97 "Complete the final table row or remove the trailing cells that do not fill the configured column count.",
98 ),
99 WarningKind::TableCellOverflow { .. } | WarningKind::TableColumnCount { .. } => Some(
100 "Adjust the `cols` attribute or the row's cells/spans so each row occupies the configured number of columns.",
101 ),
102 WarningKind::NonStandardAuthorLine { .. } => Some(
103 "Author lines use `firstname [middlename] [lastname] [<email>]`, with multiple authors separated by `;`. Keeping the whole line as a single author name.",
104 ),
105 WarningKind::UnresolvedReference { .. } => Some(
106 "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.",
107 ),
108 WarningKind::DuplicateId { .. } => Some(
109 "Assign a unique id to each target. Cross-references use the first definition.",
110 ),
111 WarningKind::ConflictingFootnote { .. } => Some(
112 "Use footnote:id[] to reuse the first definition, or choose a different id for a new footnote.",
113 ),
114 WarningKind::LegacyFloatDiscreteHeading => Some(
115 "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.",
116 ),
117 WarningKind::InvalidDocumentAttribute { .. } => {
118 Some("Use a value in the attribute's documented domain")
119 }
120 WarningKind::ContentRecovery { .. } | WarningKind::Other(_) => None,
121 }
122 }
123}
124
125impl fmt::Display for Warning {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 let Some(loc) = &self.location else {
128 return write!(f, "{}", self.kind);
129 };
130 if let Some(name) = loc
131 .file
132 .as_ref()
133 .and_then(|p| p.file_name())
134 .and_then(|s| s.to_str())
135 {
136 write!(f, "{name}: {}: {}", loc, self.kind)
137 } else {
138 write!(f, "{}: {}", loc, self.kind)
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, thiserror::Error)]
151#[non_exhaustive]
152pub enum WarningKind {
153 #[error("section title out of sequence: expected level {expected}, got level {got}")]
159 SectionLevelOutOfSequence {
160 expected: u8,
163 got: u8,
165 },
166
167 #[error("bibliography sections do not support nested sections")]
170 NestedSectionInBibliography,
171
172 #[error("index sections do not support nested sections")]
175 NestedSectionInIndex,
176
177 #[error("unterminated table block (opened by `{delimiter}`)")]
184 UnterminatedTable {
185 delimiter: String,
187 },
188
189 #[error("unterminated {kind} block (opened by `{delimiter}`)")]
200 UnterminatedDelimitedBlock {
201 kind: &'static str,
204 delimiter: String,
206 },
207
208 #[error("unknown table format `{format}`, using default separator")]
211 TableUnknownFormat {
212 format: String,
214 },
215
216 #[error("dropping cells from incomplete row detected end of table")]
219 TableIncompleteRow,
220
221 #[error(
224 "dropping cell because it exceeds specified number of columns: actual={actual}, expected={expected}"
225 )]
226 TableCellOverflow {
227 actual: usize,
229 expected: usize,
231 },
232
233 #[error(
236 "table row has incorrect column count: actual={actual}, expected={expected}, occupied_from_rowspans={occupied_from_rowspans}"
237 )]
238 TableColumnCount {
239 actual: usize,
241 expected: usize,
243 occupied_from_rowspans: usize,
245 },
246
247 #[error(
252 "author line `{line}` is not in `firstname [middlename] [lastname] [<email>]` form; using the whole line as a single author name"
253 )]
254 NonStandardAuthorLine {
255 line: String,
257 },
258
259 #[error("invalid reference: {target}")]
263 UnresolvedReference {
264 target: String,
266 },
267
268 #[error("id already in use: {id} (first defined at {})", first_definition(.first))]
270 DuplicateId {
271 id: String,
273 first: Box<SourceLocation>,
275 },
276
277 #[error("footnote '{id}' has different text from its first definition at {}; keeping the first definition", first_definition(.first))]
279 ConflictingFootnote {
280 id: String,
282 first: Box<SourceLocation>,
284 },
285
286 #[error("`float` is a legacy alias for a discrete heading; prefer `discrete`")]
291 LegacyFloatDiscreteHeading,
292
293 #[error("invalid document attribute `{name}` value `{value}`: expected {expected}")]
296 InvalidDocumentAttribute {
297 name: String,
299 value: String,
301 expected: &'static str,
303 },
304
305 #[error("{message}")]
310 ContentRecovery {
311 message: Cow<'static, str>,
313 },
314
315 #[error("{0}")]
317 Other(Cow<'static, str>),
318}
319
320fn first_definition(location: &SourceLocation) -> String {
321 let file = location
322 .file
323 .as_deref()
324 .map_or_else(|| "<input>".into(), std::path::Path::to_string_lossy);
325 format!(
326 "{file}:{}:{}",
327 location.location.start.line, location.location.start.column
328 )
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::Position;
335
336 #[test]
337 fn display_without_location() {
338 let w = Warning::new(WarningKind::Other("something happened".into()), None);
339 assert_eq!(format!("{w}"), "something happened");
340 }
341
342 #[test]
343 fn display_with_location_no_file() {
344 let loc = SourceLocation {
345 file: None,
346 location: crate::Location::point(Position::new(5, 1)),
347 };
348 let w = Warning::new(
349 WarningKind::SectionLevelOutOfSequence {
350 expected: 2,
351 got: 3,
352 },
353 Some(loc),
354 );
355 assert_eq!(
356 format!("{w}"),
357 "line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
358 );
359 }
360
361 #[test]
362 fn display_with_location_and_file() {
363 let loc = SourceLocation {
364 file: Some(std::path::PathBuf::from("/docs/guide.adoc")),
365 location: crate::Location::point(Position::new(5, 1)),
366 };
367 let w = Warning::new(
368 WarningKind::SectionLevelOutOfSequence {
369 expected: 2,
370 got: 3,
371 },
372 Some(loc),
373 );
374 assert_eq!(
375 format!("{w}"),
376 "guide.adoc: line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
377 );
378 }
379
380 #[test]
381 fn equality_holds_on_kind_and_location() {
382 let a = Warning::new(WarningKind::Other("x".into()), None);
383 let b = Warning::new(WarningKind::Other("x".into()), None);
384 let c = Warning::new(WarningKind::Other("y".into()), None);
385 assert_eq!(a, b);
386 assert_ne!(a, c);
387 }
388
389 #[test]
390 fn unterminated_table_display_renders_original_token() {
391 let w = Warning::new(
392 WarningKind::UnterminatedTable {
393 delimiter: "|===".into(),
394 },
395 None,
396 );
397 assert_eq!(
398 format!("{w}"),
399 "unterminated table block (opened by `|===`)",
400 );
401 }
402
403 #[test]
404 fn unterminated_table_display_preserves_longer_tokens() {
405 let w = Warning::new(
406 WarningKind::UnterminatedTable {
407 delimiter: "!=====".into(),
408 },
409 None,
410 );
411 assert_eq!(
412 format!("{w}"),
413 "unterminated table block (opened by `!=====`)",
414 );
415 }
416}