From 09b82cfd4af38b477b2ed03c1abcf39ff5c16331 Mon Sep 17 00:00:00 2001 From: qraqras Date: Tue, 3 Mar 2026 23:32:13 +0000 Subject: [PATCH 1/8] refactor: refactor --- examples/parse_google.rs | 6 +- examples/test_ret.rs | 2 +- src/cursor.rs | 69 ----- src/styles/google/ast.rs | 79 +----- src/styles/google/parser.rs | 497 ++++++++++++++++++++++-------------- src/styles/numpy/parser.rs | 6 +- src/styles/utils.rs | 61 +++++ tests/google_tests.rs | 166 +++++++++--- 8 files changed, 518 insertions(+), 368 deletions(-) diff --git a/examples/parse_google.rs b/examples/parse_google.rs index 45fbef2..0122362 100644 --- a/examples/parse_google.rs +++ b/examples/parse_google.rs @@ -57,7 +57,7 @@ Raises: " {} ({}): {}", arg.name.source_text(&doc.source), type_str, - arg.description.source_text(&doc.source) + arg.description.as_ref().unwrap().source_text(&doc.source) ); } @@ -77,7 +77,7 @@ Raises: println!( "\nReturns: {}: {}", type_str, - ret.description.source_text(&doc.source) + ret.description.as_ref().unwrap().source_text(&doc.source) ); } @@ -98,7 +98,7 @@ Raises: println!( " {}: {}", exc.r#type.source_text(&doc.source), - exc.description.source_text(&doc.source) + exc.description.as_ref().unwrap().source_text(&doc.source) ); } diff --git a/examples/test_ret.rs b/examples/test_ret.rs index 802436f..278b2f9 100644 --- a/examples/test_ret.rs +++ b/examples/test_ret.rs @@ -34,7 +34,7 @@ Returns: ); if let GoogleSectionBody::Returns(ref ret) = s.body { let type_str = ret.return_type.as_ref().map(|t| t.source_text(&doc.source)); - let d = &ret.description.source_text(&doc.source); + let d = &ret.description.as_ref().unwrap().source_text(&doc.source); println!(" type: {:?}", type_str); println!(" desc: {:?}", if d.len() > 80 { &d[..80] } else { d }); } diff --git a/src/cursor.rs b/src/cursor.rs index 2e5546b..c3e32b5 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -206,37 +206,6 @@ impl<'a> LineCursor<'a> { pub fn substr_offset(&self, inner: &str) -> usize { inner.as_ptr() as usize - self.source.as_ptr() as usize } - - /// Find the matching closing bracket for an opening bracket at `open_pos`. - /// - /// Tracks bracket *kind* so that `(` is only closed by `)`, `[` by `]`, - /// `{` by `}`, and `<` by `>`. Mismatched closing brackets are ignored. - pub fn find_matching_close(&self, open_pos: usize) -> Option { - let mut stack: Vec = Vec::new(); - for (i, c) in self.source[open_pos..].char_indices() { - match c { - '(' | '[' | '{' | '<' => stack.push(c), - ')' | ']' | '}' | '>' => { - let expected_open = match c { - ')' => '(', - ']' => '[', - '}' => '{', - '>' => '<', - _ => unreachable!(), - }; - if stack.last() == Some(&expected_open) { - stack.pop(); - if stack.is_empty() { - return Some(open_pos + i); - } - } - // Mismatched close bracket — skip it - } - _ => {} - } - } - None - } } // ============================================================================= @@ -334,42 +303,4 @@ mod tests { assert_eq!(indent_len(" hello"), 4); assert_eq!(indent_len(" \thello"), 3); } - - #[test] - fn test_find_matching_close_basic() { - let c = LineCursor::new("(abc)"); - assert_eq!(c.find_matching_close(0), Some(4)); - } - - #[test] - fn test_find_matching_close_nested_same() { - let c = LineCursor::new("(a(b)c)"); - assert_eq!(c.find_matching_close(0), Some(6)); - } - - #[test] - fn test_find_matching_close_nested_mixed() { - let c = LineCursor::new("(a[b]c)"); - assert_eq!(c.find_matching_close(0), Some(6)); - } - - #[test] - fn test_find_matching_close_mismatched_ignored() { - // `(` should NOT be closed by `]` — the `]` is ignored and `)` closes it. - let c = LineCursor::new("(a]b)"); - assert_eq!(c.find_matching_close(0), Some(4)); - } - - #[test] - fn test_find_matching_close_no_match() { - // Only mismatched closers — never finds a match - let c = LineCursor::new("(a]b}c"); - assert_eq!(c.find_matching_close(0), None); - } - - #[test] - fn test_find_matching_close_angle_brackets() { - let c = LineCursor::new(""); - assert_eq!(c.find_matching_close(0), Some(4)); - } } diff --git a/src/styles/google/ast.rs b/src/styles/google/ast.rs index dc0f3d7..3b1159d 100644 --- a/src/styles/google/ast.rs +++ b/src/styles/google/ast.rs @@ -304,8 +304,8 @@ impl GoogleSectionBody { GoogleSectionKind::Attributes => Self::Attributes(Vec::new()), GoogleSectionKind::Methods => Self::Methods(Vec::new()), GoogleSectionKind::SeeAlso => Self::SeeAlso(Vec::new()), - GoogleSectionKind::Returns => Self::Returns(GoogleReturns { range: TextRange::empty(), return_type: None, colon: None, description: TextRange::empty() }), - GoogleSectionKind::Yields => Self::Yields(GoogleReturns { range: TextRange::empty(), return_type: None, colon: None, description: TextRange::empty() }), + GoogleSectionKind::Returns => Self::Returns(GoogleReturns { range: TextRange::empty(), return_type: None, colon: None, description: None }), + GoogleSectionKind::Yields => Self::Yields(GoogleReturns { range: TextRange::empty(), return_type: None, colon: None, description: None }), GoogleSectionKind::Notes => Self::Notes(TextRange::empty()), GoogleSectionKind::Examples => Self::Examples(TextRange::empty()), GoogleSectionKind::Todo => Self::Todo(TextRange::empty()), @@ -321,57 +321,6 @@ impl GoogleSectionBody { GoogleSectionKind::Unknown => Self::Unknown(TextRange::empty()), } } - - /// Extend the last entry's description and range with a continuation range. - /// - /// # Panics - /// - /// Panics if called on a non-entry-based section body. - pub fn extend_last_description(&mut self, range: TextRange) { - match self { - Self::Args(v) | Self::KeywordArgs(v) | Self::OtherParameters(v) | Self::Receives(v) => { - if let Some(last) = v.last_mut() { - last.description.extend(range); - last.range = TextRange::new(last.range.start(), range.end()); - } - } - Self::Raises(v) => { - if let Some(last) = v.last_mut() { - last.description.extend(range); - last.range = TextRange::new(last.range.start(), range.end()); - } - } - Self::Warns(v) => { - if let Some(last) = v.last_mut() { - last.description.extend(range); - last.range = TextRange::new(last.range.start(), range.end()); - } - } - Self::Attributes(v) => { - if let Some(last) = v.last_mut() { - last.description.extend(range); - last.range = TextRange::new(last.range.start(), range.end()); - } - } - Self::Methods(v) => { - if let Some(last) = v.last_mut() { - last.description.extend(range); - last.range = TextRange::new(last.range.start(), range.end()); - } - } - Self::SeeAlso(v) => { - if let Some(last) = v.last_mut() { - if let Some(ref mut desc) = last.description { - desc.extend(range); - } else { - last.description = Some(range); - } - last.range = TextRange::new(last.range.start(), range.end()); - } - } - _ => unreachable!(), - } - } } /// Google-style docstring. @@ -414,8 +363,8 @@ pub struct GoogleArg { pub close_bracket: Option, /// The colon (`:`) separating name/type from description, with its span, if present. pub colon: Option, - /// Argument description with its span. - pub description: TextRange, + /// Argument description with its span, if present. + pub description: Option, /// The `optional` marker, if present. /// `None` means not marked as optional. pub optional: Option, @@ -430,8 +379,8 @@ pub struct GoogleReturns { pub return_type: Option, /// The colon (`:`) separating type and description, with its span, if present. pub colon: Option, - /// Description with its span. - pub description: TextRange, + /// Description with its span, if present. + pub description: Option, } /// Google-style exception. @@ -443,8 +392,8 @@ pub struct GoogleException { pub r#type: TextRange, /// The colon (`:`) separating type from description, with its span, if present. pub colon: Option, - /// Description with its span. - pub description: TextRange, + /// Description with its span, if present. + pub description: Option, } /// Google-style warning (from Warns section). @@ -458,8 +407,8 @@ pub struct GoogleWarning { pub warning_type: TextRange, /// The colon (`:`) separating type from description, with its span, if present. pub colon: Option, - /// Description of when the warning is issued, with its span. - pub description: TextRange, + /// Description of when the warning is issued, with its span, if present. + pub description: Option, } /// Google-style See Also item. @@ -498,8 +447,8 @@ pub struct GoogleAttribute { pub close_bracket: Option, /// The colon (`:`) separating name/type from description, with its span, if present. pub colon: Option, - /// Description with its span. - pub description: TextRange, + /// Description with its span, if present. + pub description: Option, } /// Google-style method entry (from Methods section). @@ -517,8 +466,8 @@ pub struct GoogleMethod { pub close_bracket: Option, /// The colon (`:`) separating name from description, with its span, if present. pub colon: Option, - /// Brief description with its span. - pub description: TextRange, + /// Brief description with its span, if present. + pub description: Option, } impl GoogleDocstring { diff --git a/src/styles/google/parser.rs b/src/styles/google/parser.rs index 7f16dd2..3a76d16 100644 --- a/src/styles/google/parser.rs +++ b/src/styles/google/parser.rs @@ -24,7 +24,7 @@ use crate::styles::google::ast::{ GoogleMethod, GoogleReturns, GoogleSection, GoogleSectionBody, GoogleSectionHeader, GoogleSectionKind, GoogleSeeAlsoItem, GoogleWarning, }; -use crate::styles::utils::{find_entry_colon, split_comma_parts}; +use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; // ============================================================================= // Section detection @@ -109,36 +109,8 @@ struct EntryHeader { type_info: Option, /// The entry-separating colon (`:`) span, if present. colon: Option, - /// First-line description fragment (may be empty). - first_description: TextRange, -} - -/// Find the matching close bracket within a single string. -/// -/// `open_pos` is the byte index of the opening bracket in `s`. -/// Returns `Some(close_pos)` on success, `None` if unmatched. -fn find_matching_close_in_str(s: &str, open_pos: usize) -> Option { - let bytes = s.as_bytes(); - let open = bytes[open_pos]; - let close = match open { - b'(' => b')', - b'[' => b']', - b'{' => b'}', - b'<' => b'>', - _ => return None, - }; - let mut depth: u32 = 1; - for (i, &b) in bytes[open_pos + 1..].iter().enumerate() { - if b == open { - depth += 1; - } else if b == close { - depth -= 1; - if depth == 0 { - return Some(open_pos + 1 + i); - } - } - } - None + /// First-line description fragment, if present. + first_description: Option, } /// Parse a Google-style entry header at `cursor.line`. @@ -174,7 +146,7 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { if let Some(rel_paren) = bracket_pos { // Line-local bracket matching - if let Some(rel_close) = find_matching_close_in_str(trimmed, rel_paren) { + if let Some(rel_close) = find_matching_close(trimmed, rel_paren) { let abs_paren = entry_start + rel_paren; let abs_close = entry_start + rel_close; @@ -211,8 +183,8 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { let after_close = &trimmed[rel_close + 1..]; let (first_description, colon) = extract_desc_after_colon(after_close, abs_close + 1); - let range_end = if !first_description.is_empty() { - first_description.end() + let range_end = if let Some(ref desc) = first_description { + desc.end() } else if let Some(ref c) = colon { c.end() } else { @@ -238,12 +210,12 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { let desc_start = entry_start + colon_rel + 1 + ws_after; let colon_span = TextRange::from_offset_len(entry_start + colon_rel, 1); let first_description = if desc.is_empty() { - TextRange::empty() + None } else { - TextRange::from_offset_len(desc_start, desc.len()) + Some(TextRange::from_offset_len(desc_start, desc.len())) }; - let range_end = if !first_description.is_empty() { - first_description.end() + let range_end = if let Some(ref d) = first_description { + d.end() } else { colon_span.end() }; @@ -264,7 +236,7 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { name: name_span, type_info: None, colon: None, - first_description: TextRange::empty(), + first_description: None, } } @@ -277,7 +249,7 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { fn extract_desc_after_colon( after_paren: &str, base_offset: usize, -) -> (TextRange, Option) { +) -> (Option, Option) { let stripped = after_paren.trim_start(); if let Some(after_colon) = stripped.strip_prefix(':') { let desc = after_colon.trim_start(); @@ -286,13 +258,13 @@ fn extract_desc_after_colon( let colon_abs = base_offset + leading_to_stripped; let desc_start = colon_abs + 1 + leading_after_colon; let desc_range = if desc.is_empty() { - TextRange::empty() + None } else { - TextRange::from_offset_len(desc_start, desc.len()) + Some(TextRange::from_offset_len(desc_start, desc.len())) }; (desc_range, Some(TextRange::from_offset_len(colon_abs, 1))) } else { - (TextRange::empty(), None) + (None, None) } } @@ -368,109 +340,20 @@ fn try_parse_section_header(cursor: &LineCursor) -> Option // Section body helpers // ============================================================================= -/// Push a new entry into `body` from the parsed header at the current line. -fn push_entry(cursor: &LineCursor, body: &mut GoogleSectionBody) { +/// Parse an entry header and compute its range span. +/// +/// Shared setup for all entry-based sections — extracts the entry header +/// and constructs the initial entry range from the current cursor line. +fn parse_entry(cursor: &LineCursor) -> (EntryHeader, TextRange) { let header = parse_entry_header(cursor); let entry_col = cursor.current_indent(); - let range_end = if !header.first_description.is_empty() { - header.first_description.end() - } else { - header.range.end() - }; + let range_end = header + .first_description + .as_ref() + .map_or(header.range.end(), |d| d.end()); let (end_line, end_col_pos) = cursor.offset_to_line_col(range_end.raw() as usize); let entry_range = cursor.make_range(cursor.line, entry_col, end_line, end_col_pos); - - let (r#type, optional, open_bracket, close_bracket) = match &header.type_info { - Some(ti) => ( - ti.r#type, - ti.optional, - Some(ti.open_bracket), - Some(ti.close_bracket), - ), - None => (None, None, None, None), - }; - - match body { - GoogleSectionBody::Args(v) - | GoogleSectionBody::KeywordArgs(v) - | GoogleSectionBody::OtherParameters(v) - | GoogleSectionBody::Receives(v) => { - v.push(GoogleArg { - range: entry_range, - name: header.name, - open_bracket, - r#type, - close_bracket, - colon: header.colon, - description: header.first_description, - optional, - }); - } - GoogleSectionBody::Raises(v) => { - v.push(GoogleException { - range: entry_range, - r#type: header.name, - colon: header.colon, - description: header.first_description, - }); - } - GoogleSectionBody::Warns(v) => { - v.push(GoogleWarning { - range: entry_range, - warning_type: header.name, - colon: header.colon, - description: header.first_description, - }); - } - GoogleSectionBody::Attributes(v) => { - v.push(GoogleAttribute { - range: entry_range, - name: header.name, - open_bracket, - r#type, - close_bracket, - colon: header.colon, - description: header.first_description, - }); - } - GoogleSectionBody::Methods(v) => { - v.push(GoogleMethod { - range: entry_range, - name: header.name, - open_bracket, - r#type, - close_bracket, - colon: header.colon, - description: header.first_description, - }); - } - GoogleSectionBody::SeeAlso(v) => { - // Split the name span by comma into individual name spans. - let name_text = header.name.source_text(cursor.source()); - let base = header.name.start().raw() as usize; - let mut names = Vec::new(); - let mut offset = 0; - for part in name_text.split(',') { - let name = part.trim(); - if !name.is_empty() { - let lead = part.len() - part.trim_start().len(); - names.push(TextRange::from_offset_len(base + offset + lead, name.len())); - } - offset += part.len() + 1; // +1 for the comma - } - v.push(GoogleSeeAlsoItem { - range: entry_range, - names, - colon: header.colon, - description: if header.first_description.is_empty() { - None - } else { - Some(header.first_description) - }, - }); - } - _ => unreachable!(), - } + (header, entry_range) } /// Build a [`TextRange`] spanning from the first to the last content line. @@ -490,29 +373,235 @@ fn build_content_range(cursor: &LineCursor, first: Option, last: usize) - // Per-line section body processors // ============================================================================= -/// Process one content line for an entry-based section (Args, Raises, etc.). -/// -/// Blank lines must be filtered by the caller before invoking this function. -fn process_entry_line( +/// Extend the description of the last entry in a `Vec`, used for continuation lines. +fn extend_last_description( + description: &mut Option, + range: &mut TextRange, + cont: TextRange, +) { + match description { + Some(desc) => desc.extend(cont), + None => *description = Some(cont), + } + *range = TextRange::new(range.start(), cont.end()); +} + +/// Process one content line for an Args / KeywordArgs / OtherParameters / Receives section. +fn process_arg_line( cursor: &LineCursor, - body: &mut GoogleSectionBody, + args: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = args.last_mut() { + extend_last_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } - // Continuation line for the current entry? + let (header, entry_range) = parse_entry(cursor); + let ti = header.type_info.as_ref(); + args.push(GoogleArg { + range: entry_range, + name: header.name, + open_bracket: ti.map(|t| t.open_bracket), + r#type: ti.and_then(|t| t.r#type), + close_bracket: ti.map(|t| t.close_bracket), + colon: header.colon, + description: header.first_description, + optional: ti.and_then(|t| t.optional), + }); +} + +/// Process one content line for a Raises section. +fn process_exception_line( + cursor: &LineCursor, + exceptions: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - body.extend_last_description(cursor.current_trimmed_range()); + if let Some(last) = exceptions.last_mut() { + extend_last_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } return; } } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } - // New entry (or first entry): push immediately + let (header, entry_range) = parse_entry(cursor); + exceptions.push(GoogleException { + range: entry_range, + r#type: header.name, + colon: header.colon, + description: header.first_description, + }); +} + +/// Process one content line for a Warns section. +fn process_warning_line( + cursor: &LineCursor, + warnings: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = warnings.last_mut() { + extend_last_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } + + let (header, entry_range) = parse_entry(cursor); + warnings.push(GoogleWarning { + range: entry_range, + warning_type: header.name, + colon: header.colon, + description: header.first_description, + }); +} + +/// Process one content line for an Attributes section. +fn process_attribute_line( + cursor: &LineCursor, + attrs: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = attrs.last_mut() { + extend_last_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } + + let (header, entry_range) = parse_entry(cursor); + let ti = header.type_info.as_ref(); + attrs.push(GoogleAttribute { + range: entry_range, + name: header.name, + open_bracket: ti.map(|t| t.open_bracket), + r#type: ti.and_then(|t| t.r#type), + close_bracket: ti.map(|t| t.close_bracket), + colon: header.colon, + description: header.first_description, + }); +} + +/// Process one content line for a Methods section. +fn process_method_line( + cursor: &LineCursor, + methods: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = methods.last_mut() { + extend_last_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } + + let (header, entry_range) = parse_entry(cursor); + let ti = header.type_info.as_ref(); + methods.push(GoogleMethod { + range: entry_range, + name: header.name, + open_bracket: ti.map(|t| t.open_bracket), + r#type: ti.and_then(|t| t.r#type), + close_bracket: ti.map(|t| t.close_bracket), + colon: header.colon, + description: header.first_description, + }); +} + +/// Process one content line for a See Also section. +fn process_see_also_line( + cursor: &LineCursor, + items: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = items.last_mut() { + extend_last_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } if entry_indent.is_none() { *entry_indent = Some(indent_cols); } - push_entry(cursor, body); + + let (header, entry_range) = parse_entry(cursor); + // Split the name span by comma into individual name spans. + let name_text = header.name.source_text(cursor.source()); + let base = header.name.start().raw() as usize; + let mut names = Vec::new(); + let mut offset = 0; + for part in name_text.split(',') { + let name = part.trim(); + if !name.is_empty() { + let lead = part.len() - part.trim_start().len(); + names.push(TextRange::from_offset_len(base + offset + lead, name.len())); + } + offset += part.len() + 1; // +1 for the comma + } + items.push(GoogleSeeAlsoItem { + range: entry_range, + names, + colon: header.colon, + description: header.first_description, + }); } /// Process one content line for a Returns / Yields section. @@ -537,16 +626,19 @@ fn process_returns_line(cursor: &LineCursor, ret: &mut GoogleReturns) { ret.colon = Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)); let desc_start = col + colon_pos + 1 + ws_after; ret.description = if desc_str.is_empty() { - TextRange::empty() + None } else { - cursor.make_line_range(cursor.line, desc_start, desc_str.len()) + Some(cursor.make_line_range(cursor.line, desc_start, desc_str.len())) }; } else { - ret.description = trimmed_range; + ret.description = Some(trimmed_range); } } else { // Continuation line — extend description and range - ret.description.extend(trimmed_range); + match ret.description { + Some(ref mut desc) => desc.extend(trimmed_range), + None => ret.description = Some(trimmed_range), + } ret.range = TextRange::new(ret.range.start(), trimmed_range.end()); } } @@ -695,30 +787,30 @@ pub fn parse_google(input: &str) -> GoogleDocstring { if let Some(ref mut body) = current_body { #[rustfmt::skip] match body { - GoogleSectionBody::Args(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::KeywordArgs(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::OtherParameters(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::Receives(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::Raises(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::Warns(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::Attributes(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::Methods(_) => process_entry_line(&line_cursor, body, &mut entry_indent), - GoogleSectionBody::SeeAlso(_) => process_entry_line(&line_cursor, body, &mut entry_indent), + GoogleSectionBody::Args(v) => process_arg_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::KeywordArgs(v) => process_arg_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::OtherParameters(v) => process_arg_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::Receives(v) => process_arg_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::Raises(v) => process_exception_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::Warns(v) => process_warning_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::Attributes(v) => process_attribute_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::Methods(v) => process_method_line(&line_cursor, v, &mut entry_indent), + GoogleSectionBody::SeeAlso(v) => process_see_also_line(&line_cursor, v, &mut entry_indent), GoogleSectionBody::Returns(ret) => process_returns_line(&line_cursor, ret), - GoogleSectionBody::Yields(ret) => process_returns_line(&line_cursor, ret), - GoogleSectionBody::Notes(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Examples(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Todo(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::References(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Warnings(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Attention(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Caution(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Danger(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Error(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Hint(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Important(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Tip(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Unknown(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Yields(ret) => process_returns_line(&line_cursor, ret), + GoogleSectionBody::Notes(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Examples(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Todo(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::References(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Warnings(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Attention(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Caution(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Danger(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Error(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Hint(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Important(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Tip(r) => process_freetext_line(&line_cursor, r), + GoogleSectionBody::Unknown(r) => process_freetext_line(&line_cursor, r), }; } else if !summary_done { // Summary content line @@ -753,19 +845,16 @@ pub fn parse_google(input: &str) -> GoogleDocstring { } // Finalise at EOF - if !summary_done - && summary_first.is_some() { - docstring.summary = Some(build_content_range( - &line_cursor, - summary_first, - summary_last, - )); - } - if !extended_done - && ext_first.is_some() { - docstring.extended_summary = - Some(build_content_range(&line_cursor, ext_first, ext_last)); - } + if !summary_done && summary_first.is_some() { + docstring.summary = Some(build_content_range( + &line_cursor, + summary_first, + summary_last, + )); + } + if !extended_done && ext_first.is_some() { + docstring.extended_summary = Some(build_content_range(&line_cursor, ext_first, ext_last)); + } // --- Docstring span --- docstring.range = line_cursor.full_range(); @@ -839,7 +928,10 @@ mod tests { assert!(header.type_info.is_some()); let ti = header.type_info.unwrap(); assert_eq!(ti.r#type.unwrap().source_text(src), "int"); - assert_eq!(header.first_description.source_text(src), "Description"); + assert_eq!( + header.first_description.unwrap().source_text(src), + "Description" + ); } #[test] @@ -859,7 +951,10 @@ mod tests { let header = header_from(src); assert_eq!(header.name.source_text(src), "name"); assert!(header.type_info.is_none()); - assert_eq!(header.first_description.source_text(src), "Description"); + assert_eq!( + header.first_description.unwrap().source_text(src), + "Description" + ); } #[test] @@ -869,7 +964,7 @@ mod tests { assert_eq!(header.name.source_text(src), "data"); let ti = header.type_info.unwrap(); assert_eq!(ti.r#type.unwrap().source_text(src), "Dict[str, List[int]]"); - assert_eq!(header.first_description.source_text(src), "Values"); + assert_eq!(header.first_description.unwrap().source_text(src), "Values"); } #[test] @@ -878,7 +973,7 @@ mod tests { let header = header_from(src); assert_eq!(header.name.source_text(src), "x"); assert!(header.type_info.is_none()); - assert!(header.first_description.is_empty()); + assert!(header.first_description.is_none()); } #[test] @@ -887,7 +982,7 @@ mod tests { let header = header_from(src1); assert_eq!(header.name.source_text(src1), "*args"); assert_eq!( - header.first_description.source_text(src1), + header.first_description.unwrap().source_text(src1), "Positional arguments" ); @@ -904,7 +999,10 @@ mod tests { let header = header_from(src); assert_eq!(header.name.source_text(src), "name"); assert!(header.type_info.is_none()); - assert_eq!(header.first_description.source_text(src), "Description"); + assert_eq!( + header.first_description.unwrap().source_text(src), + "Description" + ); } #[test] @@ -913,7 +1011,10 @@ mod tests { let header = header_from(src); assert_eq!(header.name.source_text(src), "name"); assert!(header.type_info.is_none()); - assert_eq!(header.first_description.source_text(src), "Description"); + assert_eq!( + header.first_description.unwrap().source_text(src), + "Description" + ); } // -- strip_optional -- diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index 004f7d9..420c964 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -26,7 +26,7 @@ use crate::styles::numpy::ast::{ NumPyMethod, NumPyParameter, NumPyReturns, NumPySection, NumPySectionBody, NumPySectionHeader, NumPySectionKind, NumPyWarning, }; -use crate::styles::utils::{find_entry_colon, split_comma_parts}; +use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; // ============================================================================= // Section detection @@ -533,7 +533,7 @@ fn parse_name_and_type( .position(|b| matches!(b, b'(' | b'[' | b'{' | b'<')) .unwrap(); let abs_open = type_abs_start + first_open_rel; - if let Some(abs_close) = cursor.find_matching_close(abs_open) { + if let Some(abs_close) = find_matching_close(cursor.source(), abs_open) { let close_line_idx = cursor.offset_to_line_col(abs_close).0; // Include everything from type start through end of close bracket's line let close_line_text = cursor.line_text(close_line_idx); @@ -961,7 +961,7 @@ fn parse_references(cursor: &mut LineCursor) -> Vec Vec<(usize, &str)> { parts } +/// Find the matching closing bracket for an opening bracket at `open_pos`. +/// +/// Only tracks the *same* bracket kind: `(` is matched by `)`, `[` by `]`, +/// `{` by `}`, and `<` by `>`. Other bracket kinds are ignored. +/// +/// Returns `Some(close_pos)` on success, `None` if unmatched. +pub(crate) fn find_matching_close(s: &str, open_pos: usize) -> Option { + let bytes = s.as_bytes(); + let open = bytes[open_pos]; + let close = match open { + b'(' => b')', + b'[' => b']', + b'{' => b'}', + b'<' => b'>', + _ => return None, + }; + let mut depth: u32 = 1; + for (i, &b) in bytes[open_pos + 1..].iter().enumerate() { + if b == open { + depth += 1; + } else if b == close { + depth -= 1; + if depth == 0 { + return Some(open_pos + 1 + i); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -82,4 +112,35 @@ mod tests { assert_eq!(parts[0].0, 0); assert_eq!(parts[1].0, 4); } + + #[test] + fn test_find_matching_close_basic() { + assert_eq!(find_matching_close("(abc)", 0), Some(4)); + } + + #[test] + fn test_find_matching_close_nested_same() { + assert_eq!(find_matching_close("(a(b)c)", 0), Some(6)); + } + + #[test] + fn test_find_matching_close_nested_mixed() { + assert_eq!(find_matching_close("(a[b]c)", 0), Some(6)); + } + + #[test] + fn test_find_matching_close_mismatched_ignored() { + // `]` is not `)`, so it is ignored — `)` closes the `(`. + assert_eq!(find_matching_close("(a]b)", 0), Some(4)); + } + + #[test] + fn test_find_matching_close_no_match() { + assert_eq!(find_matching_close("(abc", 0), None); + } + + #[test] + fn test_find_matching_close_angle_brackets() { + assert_eq!(find_matching_close("", 0), Some(4)); + } } diff --git a/tests/google_tests.rs b/tests/google_tests.rs index 2f33d5e..72adf8a 100644 --- a/tests/google_tests.rs +++ b/tests/google_tests.rs @@ -358,7 +358,13 @@ fn test_args_basic() { a[0].r#type.as_ref().unwrap().source_text(&result.source), "int" ); - assert_eq!(a[0].description.source_text(&result.source), "The value."); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); } #[test] @@ -386,7 +392,13 @@ fn test_args_no_type() { let a = args(&result); assert_eq!(a[0].name.source_text(&result.source), "x"); assert!(a[0].r#type.is_none()); - assert_eq!(a[0].description.source_text(&result.source), "The value."); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); } /// Colon with no space after it: `name:description` @@ -396,7 +408,13 @@ fn test_args_no_space_after_colon() { let result = parse_google(docstring); let a = args(&result); assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!(a[0].description.source_text(&result.source), "The value."); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); } /// Colon with extra spaces: `name: description` @@ -406,7 +424,13 @@ fn test_args_extra_spaces_after_colon() { let result = parse_google(docstring); let a = args(&result); assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!(a[0].description.source_text(&result.source), "The value."); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); } /// Returns entry with no space after colon. @@ -419,7 +443,10 @@ fn test_returns_no_space_after_colon() { r.return_type.as_ref().unwrap().source_text(&result.source), "int" ); - assert_eq!(r.description.source_text(&result.source), "The result."); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); } /// Returns entry with extra spaces after colon. @@ -432,7 +459,10 @@ fn test_returns_extra_spaces_after_colon() { r.return_type.as_ref().unwrap().source_text(&result.source), "int" ); - assert_eq!(r.description.source_text(&result.source), "The result."); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); } /// Raises entry with no space after colon. @@ -442,7 +472,13 @@ fn test_raises_no_space_after_colon() { let result = parse_google(docstring); let r = raises(&result); assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!(r[0].description.source_text(&result.source), "If invalid."); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If invalid." + ); } /// Raises entry with extra spaces after colon. @@ -452,7 +488,13 @@ fn test_raises_extra_spaces_after_colon() { let result = parse_google(docstring); let r = raises(&result); assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!(r[0].description.source_text(&result.source), "If invalid."); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If invalid." + ); } #[test] @@ -502,7 +544,11 @@ fn test_args_multiline_description() { "Summary.\n\nArgs:\n x (int): First line.\n Second line.\n Third line."; let result = parse_google(docstring); assert_eq!( - args(&result)[0].description.source_text(&result.source), + args(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "First line.\n Second line.\n Third line." ); } @@ -518,7 +564,10 @@ fn test_args_description_on_next_line() { "int" ); assert_eq!( - a[0].description.source_text(&result.source), + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), "The description." ); } @@ -531,12 +580,18 @@ fn test_args_varargs() { assert_eq!(a.len(), 2); assert_eq!(a[0].name.source_text(&result.source), "*args"); assert_eq!( - a[0].description.source_text(&result.source), + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), "Positional args." ); assert_eq!(a[1].name.source_text(&result.source), "**kwargs"); assert_eq!( - a[1].description.source_text(&result.source), + a[1].description + .as_ref() + .unwrap() + .source_text(&result.source), "Keyword args." ); } @@ -620,7 +675,10 @@ fn test_args_square_bracket_type() { .source_text(&result.source), "]" ); - assert_eq!(a.description.source_text(&result.source), "The value."); + assert_eq!( + a.description.as_ref().unwrap().source_text(&result.source), + "The value." + ); } #[test] @@ -644,7 +702,10 @@ fn test_args_curly_bracket_type() { .source_text(&result.source), "}" ); - assert_eq!(a.description.source_text(&result.source), "The value."); + assert_eq!( + a.description.as_ref().unwrap().source_text(&result.source), + "The value." + ); } #[test] @@ -755,7 +816,10 @@ fn test_args_angle_bracket_type() { .source_text(&result.source), ">" ); - assert_eq!(a.description.source_text(&result.source), "The value."); + assert_eq!( + a.description.as_ref().unwrap().source_text(&result.source), + "The value." + ); } // ============================================================================= @@ -771,7 +835,10 @@ fn test_returns_with_type() { r.return_type.as_ref().unwrap().source_text(&result.source), "int" ); - assert_eq!(r.description.source_text(&result.source), "The result."); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); } #[test] @@ -785,7 +852,7 @@ fn test_returns_multiple_lines() { "int" ); assert_eq!( - r.description.source_text(&result.source), + r.description.as_ref().unwrap().source_text(&result.source), "The count.\n str: The message." ); } @@ -797,7 +864,7 @@ fn test_returns_without_type() { let r = returns(&result).unwrap(); assert!(r.return_type.is_none()); assert_eq!( - r.description.source_text(&result.source), + r.description.as_ref().unwrap().source_text(&result.source), "The computed result." ); } @@ -810,6 +877,8 @@ fn test_returns_multiline_description() { returns(&result) .unwrap() .description + .as_ref() + .unwrap() .source_text(&result.source), "The result\n of the computation." ); @@ -835,7 +904,10 @@ fn test_yields() { y.return_type.as_ref().unwrap().source_text(&result.source), "int" ); - assert_eq!(y.description.source_text(&result.source), "The next value."); + assert_eq!( + y.description.as_ref().unwrap().source_text(&result.source), + "The next value." + ); } #[test] @@ -857,7 +929,10 @@ fn test_raises_single() { assert_eq!(r.len(), 1); assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); assert_eq!( - r[0].description.source_text(&result.source), + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), "If the input is invalid." ); } @@ -878,7 +953,11 @@ fn test_raises_multiline_description() { let docstring = "Summary.\n\nRaises:\n ValueError: If the\n input is invalid."; let result = parse_google(docstring); assert_eq!( - raises(&result)[0].description.source_text(&result.source), + raises(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "If the\n input is invalid." ); } @@ -1514,7 +1593,10 @@ fn test_warns_basic() { "DeprecationWarning" ); assert_eq!( - w[0].description.source_text(&result.source), + w[0].description + .as_ref() + .unwrap() + .source_text(&result.source), "If using old API." ); } @@ -1555,7 +1637,11 @@ fn test_warns_multiline_description() { let docstring = "Summary.\n\nWarns:\n UserWarning: First line.\n Second line."; let result = parse_google(docstring); assert_eq!( - warns(&result)[0].description.source_text(&result.source), + warns(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "First line.\n Second line." ); } @@ -1629,7 +1715,10 @@ fn test_methods_basic() { assert_eq!(m.len(), 2); assert_eq!(m[0].name.source_text(&result.source), "reset()"); assert_eq!( - m[0].description.source_text(&result.source), + m[0].description + .as_ref() + .unwrap() + .source_text(&result.source), "Reset the state." ); assert_eq!(m[1].name.source_text(&result.source), "update(data)"); @@ -1643,7 +1732,10 @@ fn test_methods_without_parens() { assert_eq!(m.len(), 1); assert_eq!(m[0].name.source_text(&result.source), "do_stuff"); assert_eq!( - m[0].description.source_text(&result.source), + m[0].description + .as_ref() + .unwrap() + .source_text(&result.source), "Performs the operation." ); } @@ -2035,10 +2127,19 @@ fn test_tab_indented_args() { let a = args(&result); assert_eq!(a.len(), 2); assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!(a[0].description.source_text(&result.source), "The value."); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); assert_eq!(a[1].name.source_text(&result.source), "y"); assert_eq!( - a[1].description.source_text(&result.source), + a[1].description + .as_ref() + .unwrap() + .source_text(&result.source), "Another value." ); } @@ -2051,7 +2152,11 @@ fn test_tab_args_with_continuation() { let a = args(&result); assert_eq!(a.len(), 2); assert_eq!(a[0].name.source_text(&result.source), "x"); - let desc = a[0].description.source_text(&result.source); + let desc = a[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); assert!(desc.contains("First line."), "desc = {:?}", desc); assert!(desc.contains("Continuation."), "desc = {:?}", desc); } @@ -2065,7 +2170,10 @@ fn test_tab_indented_returns() { assert!(r.is_some()); let r = r.unwrap(); assert_eq!(r.return_type.unwrap().source_text(&result.source), "int"); - assert_eq!(r.description.source_text(&result.source), "The result."); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); } /// Raises section with tab-indented entries. From 2593f135cda035a9eba442476a018c611515930f Mon Sep 17 00:00:00 2001 From: qraqras Date: Wed, 4 Mar 2026 04:31:13 +0000 Subject: [PATCH 2/8] refactor: refactor --- examples/parse_numpy.rs | 19 +- src/styles/numpy/ast.rs | 37 +- src/styles/numpy/parser.rs | 1075 ++++++++++++++++-------------------- tests/numpy_tests.rs | 161 +++--- 4 files changed, 611 insertions(+), 681 deletions(-) diff --git a/examples/parse_numpy.rs b/examples/parse_numpy.rs index 2760a3b..e4a759c 100644 --- a/examples/parse_numpy.rs +++ b/examples/parse_numpy.rs @@ -72,7 +72,13 @@ Examples names, param.r#type.as_ref().map(|t| t.source_text(&doc.source)) ); - println!(" {}", param.description.source_text(&doc.source)); + println!( + " {}", + param + .description + .as_ref() + .map_or("", |d| d.source_text(&doc.source)) + ); } } NumPySectionBody::Returns(rets) => { @@ -82,7 +88,12 @@ Examples " Type: {:?}", ret.return_type.as_ref().map(|t| t.source_text(&doc.source)) ); - println!(" {}", ret.description.source_text(&doc.source)); + println!( + " {}", + ret.description + .as_ref() + .map_or("", |d| d.source_text(&doc.source)) + ); } } NumPySectionBody::Raises(excs) => { @@ -91,7 +102,9 @@ Examples println!( " - {}: {}", exc.r#type.source_text(&doc.source), - exc.description.source_text(&doc.source) + exc.description + .as_ref() + .map_or("", |d| d.source_text(&doc.source)) ); } } diff --git a/src/styles/numpy/ast.rs b/src/styles/numpy/ast.rs index d071af1..d941d68 100644 --- a/src/styles/numpy/ast.rs +++ b/src/styles/numpy/ast.rs @@ -277,7 +277,7 @@ pub struct NumPyParameter { /// Type is optional for parameters but required for returns. pub r#type: Option, /// Parameter description with its span. - pub description: TextRange, + pub description: Option, /// The `optional` marker, if present. /// `None` means not marked as optional. pub optional: Option, @@ -303,7 +303,7 @@ pub struct NumPyReturns { /// Return type with its span. pub return_type: Option, /// Description with its span. - pub description: TextRange, + pub description: Option, } /// NumPy-style exception. @@ -316,7 +316,7 @@ pub struct NumPyException { /// The colon (`:`) separating type from description, with its span, if present. pub colon: Option, /// Description of when raised, with its span. - pub description: TextRange, + pub description: Option, } /// NumPy-style warning (from Warns section). @@ -329,7 +329,7 @@ pub struct NumPyWarning { /// The colon (`:`) separating type from description, with its span, if present. pub colon: Option, /// When the warning is issued, with its span. - pub description: TextRange, + pub description: Option, } /// See Also item. @@ -367,7 +367,7 @@ pub struct NumPyReference { /// Closing bracket (`]`) enclosing the reference number, with its span, if present. pub close_bracket: Option, /// Reference content (author, title, etc) with its span. - pub content: TextRange, + pub content: Option, } /// NumPy-style attribute. @@ -382,7 +382,7 @@ pub struct NumPyAttribute { /// Attribute type with its span. pub r#type: Option, /// Description with its span. - pub description: TextRange, + pub description: Option, } /// NumPy-style method (for classes). @@ -395,7 +395,30 @@ pub struct NumPyMethod { /// The colon (`:`) separating name from description, with its span, if present. pub colon: Option, /// Brief description with its span. - pub description: TextRange, + pub description: Option, +} + +impl NumPySectionBody { + /// Create a new empty section body for the given section kind. + pub fn new(kind: NumPySectionKind) -> Self { + match kind { + NumPySectionKind::Parameters => Self::Parameters(Vec::new()), + NumPySectionKind::Returns => Self::Returns(Vec::new()), + NumPySectionKind::Yields => Self::Yields(Vec::new()), + NumPySectionKind::Receives => Self::Receives(Vec::new()), + NumPySectionKind::OtherParameters => Self::OtherParameters(Vec::new()), + NumPySectionKind::Raises => Self::Raises(Vec::new()), + NumPySectionKind::Warns => Self::Warns(Vec::new()), + NumPySectionKind::Warnings => Self::Warnings(TextRange::empty()), + NumPySectionKind::SeeAlso => Self::SeeAlso(Vec::new()), + NumPySectionKind::Notes => Self::Notes(TextRange::empty()), + NumPySectionKind::References => Self::References(Vec::new()), + NumPySectionKind::Examples => Self::Examples(TextRange::empty()), + NumPySectionKind::Attributes => Self::Attributes(Vec::new()), + NumPySectionKind::Methods => Self::Methods(Vec::new()), + NumPySectionKind::Unknown => Self::Unknown(TextRange::empty()), + } + } } impl NumPyDocstring { diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index 420c964..3920ce8 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -23,8 +23,8 @@ use crate::ast::TextRange; use crate::cursor::{LineCursor, indent_columns, indent_len}; use crate::styles::numpy::ast::{ NumPyAttribute, NumPyDeprecation, NumPyDocstring, NumPyDocstringItem, NumPyException, - NumPyMethod, NumPyParameter, NumPyReturns, NumPySection, NumPySectionBody, NumPySectionHeader, - NumPySectionKind, NumPyWarning, + NumPyMethod, NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPySectionBody, + NumPySectionHeader, NumPySectionKind, NumPyWarning, SeeAlsoItem, }; use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; @@ -74,7 +74,7 @@ fn try_parse_numpy_header(cursor: &LineCursor) -> Option { } // ============================================================================= -// Description collector +// Description collector (used only for deprecation directive body) // ============================================================================= /// Collect indented description lines starting at `cursor.line`. @@ -83,18 +83,18 @@ fn try_parse_numpy_header(cursor: &LineCursor) -> Option { /// below `entry_indent`, section headers, or EOF. /// /// On return, `cursor.line` points to the first unconsumed line. -fn collect_description(cursor: &mut LineCursor, entry_indent: usize) -> TextRange { - let mut desc_parts: Vec<&str> = Vec::new(); +/// +/// NOTE: This is used **only** for the deprecation directive body, which needs +/// eager multi-line collection. Section body parsing uses per-line functions. +fn collect_description(cursor: &mut LineCursor, entry_indent: usize) -> Option { let mut first_content_line: Option = None; let mut last_content_line = cursor.line; while !cursor.is_eof() { let line = cursor.current_line_text(); - // Non-empty line at or below entry indentation signals end of description if !line.trim().is_empty() && indent_columns(line) <= entry_indent { break; } - desc_parts.push(line.trim()); if !line.trim().is_empty() { if first_content_line.is_none() { first_content_line = Some(cursor.line); @@ -104,25 +104,13 @@ fn collect_description(cursor: &mut LineCursor, entry_indent: usize) -> TextRang cursor.advance(); } - // Trim trailing empty entries - while desc_parts.last().is_some_and(|l| l.is_empty()) { - desc_parts.pop(); - } - // Trim leading empty entries - while desc_parts.first().is_some_and(|l| l.is_empty()) { - desc_parts.remove(0); - } - - if let Some(first) = first_content_line { + first_content_line.map(|first| { let first_line = cursor.line_text(first); let first_col = indent_len(first_line); let last_line = cursor.line_text(last_content_line); - let last_trimmed = last_line.trim(); - let last_col = indent_len(last_line) + last_trimmed.len(); + let last_col = indent_len(last_line) + last_line.trim().len(); cursor.make_range(first, first_col, last_content_line, last_col) - } else { - TextRange::empty() - } + }) } // ============================================================================= @@ -207,10 +195,9 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { let desc_spanned = collect_description(&mut cursor, col); // Compute deprecation span - let (dep_end_line, dep_end_col) = if desc_spanned.is_empty() { - (dep_start_line, col + trimmed.len()) - } else { - cursor.offset_to_line_col(desc_spanned.end().raw() as usize) + let (dep_end_line, dep_end_col) = match &desc_spanned { + None => (dep_start_line, col + trimmed.len()), + Some(d) => cursor.offset_to_line_col(d.end().raw() as usize), }; docstring.deprecation = Some(NumPyDeprecation { @@ -219,7 +206,7 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { keyword, double_colon, version: version_spanned, - description: desc_spanned, + description: desc_spanned.unwrap_or_else(TextRange::empty), }); // skip blanks @@ -261,58 +248,76 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { } } - // --- Sections --- + // --- Section state --- + let mut current_header: Option = None; + let mut current_body: Option = None; + let mut entry_indent: Option = None; + while !cursor.is_eof() { - // Skip blank lines between sections + // --- Blank lines --- if cursor.current_trimmed().is_empty() { cursor.advance(); continue; } - // Detect section header - let Some(header) = try_parse_numpy_header(&cursor) else { - // Stray line (not a section header) + // --- Detect section header --- + if let Some(header) = try_parse_numpy_header(&cursor) { + // Flush previous section + if let Some(prev_header) = current_header.take() { + flush_section( + &cursor, + &mut docstring, + prev_header, + current_body.take().unwrap(), + ); + } + + // Start new section + current_body = Some(NumPySectionBody::new(header.kind)); + current_header = Some(header); + entry_indent = None; + cursor.line += 2; // skip header + underline + continue; + } + + // --- Process line based on current state --- + if let Some(ref mut body) = current_body { + #[rustfmt::skip] + match body { + NumPySectionBody::Parameters(v) => process_parameter_line(&cursor, v, &mut entry_indent), + NumPySectionBody::OtherParameters(v) => process_parameter_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Receives(v) => process_parameter_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Returns(v) => process_returns_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Yields(v) => process_returns_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Raises(v) => process_raises_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Warns(v) => process_warning_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Attributes(v) => process_attribute_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Methods(v) => process_method_line(&cursor, v, &mut entry_indent), + NumPySectionBody::SeeAlso(v) => process_see_also_line(&cursor, v, &mut entry_indent), + NumPySectionBody::References(v) => process_reference_line(&cursor, v, &mut entry_indent), + NumPySectionBody::Notes(r) => process_freetext_line(&cursor, r), + NumPySectionBody::Examples(r) => process_freetext_line(&cursor, r), + NumPySectionBody::Warnings(r) => process_freetext_line(&cursor, r), + NumPySectionBody::Unknown(r) => process_freetext_line(&cursor, r), + }; + } else { + // Stray line (outside any section in post-section phase) docstring.items.push(NumPyDocstringItem::StrayLine( cursor.current_trimmed_range(), )); - cursor.advance(); - continue; - }; - - let header_indent = cursor.current_indent_columns(); - let section_kind = header.kind; - - cursor.line += 2; // skip header + underline - - // Parse section body (branching by kind) - #[rustfmt::skip] - let body = match section_kind { - NumPySectionKind::Parameters => NumPySectionBody::Parameters (parse_parameters (&mut cursor, header_indent)), - NumPySectionKind::Returns => NumPySectionBody::Returns (parse_returns (&mut cursor, header_indent)), - NumPySectionKind::Raises => NumPySectionBody::Raises (parse_raises (&mut cursor, header_indent)), - NumPySectionKind::Yields => NumPySectionBody::Yields (parse_returns (&mut cursor, header_indent)), - NumPySectionKind::Receives => NumPySectionBody::Receives (parse_parameters (&mut cursor, header_indent)), - NumPySectionKind::OtherParameters => NumPySectionBody::OtherParameters(parse_parameters (&mut cursor, header_indent)), - NumPySectionKind::Warns => NumPySectionBody::Warns (parse_warns (&mut cursor, header_indent)), - NumPySectionKind::Notes => NumPySectionBody::Notes (parse_section_content(&mut cursor)), - NumPySectionKind::Examples => NumPySectionBody::Examples (parse_section_content(&mut cursor)), - NumPySectionKind::Warnings => NumPySectionBody::Warnings (parse_section_content(&mut cursor)), - NumPySectionKind::SeeAlso => NumPySectionBody::SeeAlso (parse_see_also (&mut cursor)), - NumPySectionKind::References => NumPySectionBody::References (parse_references (&mut cursor)), - NumPySectionKind::Attributes => NumPySectionBody::Attributes (parse_attributes (&mut cursor, header_indent)), - NumPySectionKind::Methods => NumPySectionBody::Methods (parse_methods (&mut cursor, header_indent)), - NumPySectionKind::Unknown => NumPySectionBody::Unknown (parse_section_content(&mut cursor)), - }; + } - let section_range = cursor.span_back_from_cursor(header.range.start().raw() as usize); + cursor.advance(); + } - docstring - .items - .push(NumPyDocstringItem::Section(NumPySection { - range: section_range, - header, - body, - })); + // Flush final section + if let Some(header) = current_header.take() { + flush_section( + &cursor, + &mut docstring, + header, + current_body.take().unwrap(), + ); } // Docstring span @@ -322,128 +327,31 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { } // ============================================================================= -// Warns parsing -// ============================================================================= - -/// Parse the Warns section body. -/// -/// Reuses `parse_raises` and converts each `NumPyException` to `NumPyWarning`. -fn parse_warns(cursor: &mut LineCursor, entry_indent: usize) -> Vec { - parse_raises(cursor, entry_indent) - .into_iter() - .map(|e| NumPyWarning { - range: e.range, - r#type: e.r#type, - colon: e.colon, - description: e.description, - }) - .collect() -} - -// ============================================================================= -// Attributes parsing +// Section flush // ============================================================================= -/// Parse the Attributes section body. -/// -/// Reuses `parse_parameters` and converts each `NumPyParameter` to `NumPyAttribute`. -fn parse_attributes(cursor: &mut LineCursor, entry_indent: usize) -> Vec { - parse_parameters(cursor, entry_indent) - .into_iter() - .map(|p| NumPyAttribute { - range: p.range, - name: p.names.into_iter().next().unwrap_or_else(TextRange::empty), - colon: p.colon, - r#type: p.r#type, - description: p.description, - }) - .collect() -} - -// ============================================================================= -// Methods parsing -// ============================================================================= - -/// Parse the Methods section body. -/// -/// Reuses `parse_parameters` and converts each `NumPyParameter` to `NumPyMethod`. -fn parse_methods(cursor: &mut LineCursor, entry_indent: usize) -> Vec { - parse_parameters(cursor, entry_indent) - .into_iter() - .map(|p| NumPyMethod { - range: p.range, - name: p.names.into_iter().next().unwrap_or_else(TextRange::empty), - colon: p.colon, - description: p.description, - }) - .collect() +/// Flush a completed section into the docstring. +fn flush_section( + cursor: &LineCursor, + docstring: &mut NumPyDocstring, + header: NumPySectionHeader, + body: NumPySectionBody, +) { + let header_start = header.range.start().raw() as usize; + let range = cursor.span_back_from_cursor(header_start); + docstring + .items + .push(NumPyDocstringItem::Section(NumPySection { + range, + header, + body, + })); } // ============================================================================= -// Parameter parsing +// Entry header parsing // ============================================================================= -/// Parse the Parameters section body. -/// -/// On return, `cursor.line` points to the first line after the section. -fn parse_parameters(cursor: &mut LineCursor, entry_indent: usize) -> Vec { - let mut parameters = Vec::new(); - - while !cursor.is_eof() { - if try_parse_numpy_header(cursor).is_some() { - break; - } - let line = cursor.current_line_text(); - let trimmed = line.trim(); - - // A parameter header is a non-empty line at or below entry indentation. - // Lines with a colon are split into name/type; lines without a colon - // are parsed best-effort as a bare name (colon = None). - if !trimmed.is_empty() && indent_columns(line) <= entry_indent { - let col = cursor.current_indent(); - let entry_start = cursor.line; - let parts = parse_name_and_type(trimmed, cursor.line, col, cursor); - - // Advance past all header lines (may span multiple for multi-line types) - cursor.line = parts.header_end_line + 1; - let desc = collect_description(cursor, entry_indent); - - let (entry_end_line, entry_end_col) = if desc.is_empty() { - if parts.header_end_line > entry_start { - // Multi-line header: compute end from last header line - let last_line = cursor.line_text(parts.header_end_line); - let last_trimmed = last_line.trim(); - ( - parts.header_end_line, - indent_len(last_line) + last_trimmed.len(), - ) - } else { - (entry_start, col + trimmed.len()) - } - } else { - cursor.offset_to_line_col(desc.end().raw() as usize) - }; - - parameters.push(NumPyParameter { - range: cursor.make_range(entry_start, col, entry_end_line, entry_end_col), - names: parts.names, - colon: parts.colon, - r#type: parts.param_type, - description: desc, - optional: parts.optional, - default_keyword: parts.default_keyword, - default_separator: parts.default_separator, - default_value: parts.default_value, - }); - continue; - } - - cursor.advance(); - } - - parameters -} - /// Result of parsing a parameter header. struct ParamHeaderParts { names: Vec, @@ -453,15 +361,12 @@ struct ParamHeaderParts { default_keyword: Option, default_separator: Option, default_value: Option, - /// Line index where the header ends (may differ from start for multi-line types). - header_end_line: usize, } /// Parse `"name : type, optional"` into components with precise spans. /// /// Tolerant of any whitespace around the colon separator. -/// Supports multi-line type annotations with brackets spanning multiple lines -/// (e.g., `Dict[str,\n int]`). +/// Single-line only — multi-line type annotations are not supported. /// /// `line_idx` is the 0-based line index, `col_base` is the byte column where /// `text` starts in the raw line. @@ -488,7 +393,6 @@ fn parse_name_and_type( default_keyword: None, default_separator: None, default_value: None, - header_end_line: line_idx, }; }; @@ -507,54 +411,18 @@ fn parse_name_and_type( default_keyword: None, default_separator: None, default_value: None, - header_end_line: line_idx, }; } - // Determine the full type text, potentially spanning multiple lines. - // `after_trimmed` is a subslice of cursor.source(), so we can use - // substr_offset to get its absolute byte position. let type_abs_start = cursor.substr_offset(after_trimmed); + let type_text = after_trimmed; - // Check if brackets are balanced on the current line. - let opens: usize = after_trimmed - .bytes() - .filter(|&b| matches!(b, b'(' | b'[' | b'{' | b'<')) - .count(); - let closes: usize = after_trimmed - .bytes() - .filter(|&b| matches!(b, b')' | b']' | b'}' | b'>')) - .count(); - - let (type_text, header_end_line) = if opens > closes { - // Unclosed bracket — find the first opening bracket and its match - let first_open_rel = after_trimmed - .bytes() - .position(|b| matches!(b, b'(' | b'[' | b'{' | b'<')) - .unwrap(); - let abs_open = type_abs_start + first_open_rel; - if let Some(abs_close) = find_matching_close(cursor.source(), abs_open) { - let close_line_idx = cursor.offset_to_line_col(abs_close).0; - // Include everything from type start through end of close bracket's line - let close_line_text = cursor.line_text(close_line_idx); - let close_line_end = - cursor.substr_offset(close_line_text) + close_line_text.trim_end().len(); - let full = &cursor.source()[type_abs_start..close_line_end]; - (full, close_line_idx) - } else { - // No matching close found — treat as single-line - (after_trimmed, line_idx) - } - } else { - (after_trimmed, line_idx) - }; - - // Now classify segments within `type_text` using bracket-aware comma splitting. + // Classify segments using bracket-aware comma splitting. let mut optional: Option = None; let mut default_keyword: Option = None; let mut default_separator: Option = None; let mut default_value: Option = None; - let mut type_parts_end: usize = 0; // byte end offset of last type part in type_text + let mut type_parts_end: usize = 0; for (seg_offset, seg_raw) in split_comma_parts(type_text) { let seg = seg_raw.trim(); @@ -618,7 +486,6 @@ fn parse_name_and_type( default_keyword, default_separator, default_value, - header_end_line, } } @@ -646,410 +513,446 @@ fn parse_name_list( } // ============================================================================= -// Returns parsing +// Per-line section body processors // ============================================================================= -/// Parse the Returns / Yields section body. -/// -/// Supports both unnamed and named return values: -/// ```text -/// int # unnamed, type only -/// Description. -/// -/// result : int # named -/// Description. -/// ``` -/// -/// On return, `cursor.line` points to the first line after the section. -fn parse_returns(cursor: &mut LineCursor, entry_indent: usize) -> Vec { - let mut returns = Vec::new(); - - while !cursor.is_eof() { - if try_parse_numpy_header(cursor).is_some() { - break; - } - let line = cursor.current_line_text(); - let trimmed = line.trim(); - - if !trimmed.is_empty() && indent_columns(line) <= entry_indent { - let col = cursor.current_indent(); - let entry_start = cursor.line; - - let (name, colon, return_type) = if let Some(colon_pos) = find_entry_colon(trimmed) { - // Named return: "name : type" (tolerant of whitespace) - let n = trimmed[..colon_pos].trim_end(); - let after_colon = &trimmed[colon_pos + 1..]; - let t = after_colon.trim(); - let name_col = col; - let ws_after = after_colon.len() - after_colon.trim_start().len(); - let type_col = col + colon_pos + 1 + ws_after; - let colon_col = col + colon_pos; - ( - Some(cursor.make_line_range(cursor.line, name_col, n.len())), - Some(cursor.make_line_range(cursor.line, colon_col, 1)), - Some(cursor.make_line_range(cursor.line, type_col, t.len())), - ) - } else { - // Unnamed: type only - (None, None, Some(cursor.current_trimmed_range())) - }; - - cursor.advance(); - let desc = collect_description(cursor, entry_indent); - - let (entry_end_line, entry_end_col) = if desc.is_empty() { - (entry_start, col + trimmed.len()) - } else { - cursor.offset_to_line_col(desc.end().raw() as usize) - }; +/// Extend a description field with a continuation range. +fn extend_description(description: &mut Option, range: &mut TextRange, cont: TextRange) { + match description { + Some(desc) => desc.extend(cont), + None => *description = Some(cont), + } + *range = TextRange::new(range.start(), cont.end()); +} - returns.push(NumPyReturns { - range: cursor.make_range(entry_start, col, entry_end_line, entry_end_col), - name, - colon, - return_type, - description: desc, - }); - continue; +/// Process one content line for a Parameters / OtherParameters / Receives section. +fn process_parameter_line( + cursor: &LineCursor, + params: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = params.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; } - - cursor.advance(); + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); } - returns + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + let parts = parse_name_and_type(trimmed, cursor.line, col, cursor); + + let entry_range = cursor.current_trimmed_range(); + params.push(NumPyParameter { + range: entry_range, + names: parts.names, + colon: parts.colon, + r#type: parts.param_type, + description: None, + optional: parts.optional, + default_keyword: parts.default_keyword, + default_separator: parts.default_separator, + default_value: parts.default_value, + }); } -// ============================================================================= -// Raises parsing -// ============================================================================= - -/// Parse the Raises section body. -/// -/// Supports both bare exception types and `ExcType : description` format. -/// -/// On return, `cursor.line` points to the first line after the section. -fn parse_raises(cursor: &mut LineCursor, entry_indent: usize) -> Vec { - let mut raises = Vec::new(); - - while !cursor.is_eof() { - if try_parse_numpy_header(cursor).is_some() { - break; +/// Process one content line for a Returns / Yields section. +fn process_returns_line( + cursor: &LineCursor, + returns: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = returns.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; } - let line = cursor.current_line_text(); - let trimmed = line.trim(); - - if !trimmed.is_empty() && indent_columns(line) <= entry_indent { - let col = cursor.current_indent(); - let entry_start = cursor.line; - - let (exc_type, colon, first_desc) = if let Some(colon_pos) = find_entry_colon(trimmed) { - let type_str = trimmed[..colon_pos].trim_end(); - let after_colon = &trimmed[colon_pos + 1..]; - let desc_str = after_colon.trim(); - let ws_after = after_colon.len() - after_colon.trim_start().len(); - let colon_col = col + colon_pos; - let desc_col = col + colon_pos + 1 + ws_after; - - let et = cursor.make_line_range(cursor.line, col, type_str.len()); - let c = Some(cursor.make_line_range(cursor.line, colon_col, 1)); - let fd = if desc_str.is_empty() { - TextRange::empty() - } else { - cursor.make_line_range(cursor.line, desc_col, desc_str.len()) - }; - (et, c, fd) - } else { - // Bare type, no colon - let et = cursor.current_trimmed_range(); - (et, None, TextRange::empty()) - }; - - cursor.advance(); - let cont_desc = collect_description(cursor, entry_indent); + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } - // Merge first-line description with continuation - let desc = if first_desc.is_empty() { - cont_desc - } else if cont_desc.is_empty() { - first_desc + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + + let (name, colon, return_type) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let n = trimmed[..colon_pos].trim_end(); + let after_colon = &trimmed[colon_pos + 1..]; + let t = after_colon.trim(); + let ws_after = after_colon.len() - after_colon.trim_start().len(); + let type_col = col + colon_pos + 1 + ws_after; + ( + Some(cursor.make_line_range(cursor.line, col, n.len())), + Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), + if t.is_empty() { + None } else { - TextRange::new(first_desc.start(), cont_desc.end()) - }; + Some(cursor.make_line_range(cursor.line, type_col, t.len())) + }, + ) + } else { + // Unnamed: type only + (None, None, Some(cursor.current_trimmed_range())) + }; - let (entry_end_line, entry_end_col) = if desc.is_empty() { - (entry_start, col + trimmed.len()) - } else { - cursor.offset_to_line_col(desc.end().raw() as usize) - }; + returns.push(NumPyReturns { + range: cursor.current_trimmed_range(), + name, + colon, + return_type, + description: None, + }); +} - raises.push(NumPyException { - range: cursor.make_range(entry_start, col, entry_end_line, entry_end_col), - r#type: exc_type, - colon, - description: desc, - }); - continue; +/// Process one content line for a Raises section. +fn process_raises_line( + cursor: &LineCursor, + raises: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = raises.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; } - - cursor.advance(); + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); } - raises -} - -// ============================================================================= -// Free-text section content -// ============================================================================= + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + + let (exc_type, colon, first_desc) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let type_str = trimmed[..colon_pos].trim_end(); + let after_colon = &trimmed[colon_pos + 1..]; + let desc_str = after_colon.trim(); + let ws_after = after_colon.len() - after_colon.trim_start().len(); + let desc_col = col + colon_pos + 1 + ws_after; + ( + cursor.make_line_range(cursor.line, col, type_str.len()), + Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), + if desc_str.is_empty() { + None + } else { + Some(cursor.make_line_range(cursor.line, desc_col, desc_str.len())) + }, + ) + } else { + (cursor.current_trimmed_range(), None, None) + }; -/// Parse a free-text section body (Notes, Examples, Warnings, Unknown, etc.). -/// -/// Preserves blank lines between paragraphs. -/// -/// On return, `cursor.line` points to the first line after the section. -fn parse_section_content(cursor: &mut LineCursor) -> TextRange { - let mut content_lines: Vec<&str> = Vec::new(); - let mut first_content_line: Option = None; - let mut last_content_line = cursor.line; + raises.push(NumPyException { + range: cursor.current_trimmed_range(), + r#type: exc_type, + colon, + description: first_desc, + }); +} - while !cursor.is_eof() { - if try_parse_numpy_header(cursor).is_some() { - break; - } - let line = cursor.current_line_text(); - let trimmed = line.trim(); - content_lines.push(trimmed); - if !trimmed.is_empty() { - if first_content_line.is_none() { - first_content_line = Some(cursor.line); +/// Process one content line for a Warns section. +fn process_warning_line( + cursor: &LineCursor, + warnings: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = warnings.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); } - last_content_line = cursor.line; + return; } - cursor.advance(); } - - // Trim trailing empty - while content_lines.last().is_some_and(|l| l.is_empty()) { - content_lines.pop(); - } - while content_lines.first().is_some_and(|l| l.is_empty()) { - content_lines.remove(0); + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); } - if let Some(first) = first_content_line { - let first_line = cursor.line_text(first); - let first_col = indent_len(first_line); - let last_line = cursor.line_text(last_content_line); - let last_trimmed = last_line.trim(); - let last_col = indent_len(last_line) + last_trimmed.len(); - cursor.make_range(first, first_col, last_content_line, last_col) + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + + let (warn_type, colon, first_desc) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let type_str = trimmed[..colon_pos].trim_end(); + let after_colon = &trimmed[colon_pos + 1..]; + let desc_str = after_colon.trim(); + let ws_after = after_colon.len() - after_colon.trim_start().len(); + let desc_col = col + colon_pos + 1 + ws_after; + ( + cursor.make_line_range(cursor.line, col, type_str.len()), + Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), + if desc_str.is_empty() { + None + } else { + Some(cursor.make_line_range(cursor.line, desc_col, desc_str.len())) + }, + ) } else { - TextRange::empty() - } + (cursor.current_trimmed_range(), None, None) + }; + + warnings.push(NumPyWarning { + range: cursor.current_trimmed_range(), + r#type: warn_type, + colon, + description: first_desc, + }); } -// ============================================================================= -// See Also parsing -// ============================================================================= +/// Process one content line for an Attributes section. +fn process_attribute_line( + cursor: &LineCursor, + attrs: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = attrs.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } -/// Parse the See Also section body. -/// -/// ```text -/// func_a : Description of func_a. -/// func_b, func_c -/// ``` -/// -/// On return, `cursor.line` points to the first line after the section. -fn parse_see_also(cursor: &mut LineCursor) -> Vec { - let mut items = Vec::new(); + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + let parts = parse_name_and_type(trimmed, cursor.line, col, cursor); - while !cursor.is_eof() { - if try_parse_numpy_header(cursor).is_some() { - break; - } - let line = cursor.current_line_text(); - let trimmed = line.trim(); + let name = parts + .names + .into_iter() + .next() + .unwrap_or_else(TextRange::empty); + + attrs.push(NumPyAttribute { + range: cursor.current_trimmed_range(), + name, + colon: parts.colon, + r#type: parts.param_type, + description: None, + }); +} - if trimmed.is_empty() { - cursor.advance(); - continue; +/// Process one content line for a Methods section. +fn process_method_line( + cursor: &LineCursor, + methods: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = methods.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } - let col = cursor.current_indent(); - let entry_start = cursor.line; - - // Split on first colon for description (tolerant of whitespace) - let (names_str, colon, description) = if let Some(colon_pos) = find_entry_colon(trimmed) { - let after_colon = &trimmed[colon_pos + 1..]; - let desc_text = after_colon.trim(); - let ws_after = after_colon.len() - after_colon.trim_start().len(); - let desc_col = col + colon_pos + 1 + ws_after; - let colon_col = col + colon_pos; - ( - trimmed[..colon_pos].trim_end(), - Some(cursor.make_line_range(cursor.line, colon_col, 1)), - Some(cursor.make_line_range(cursor.line, desc_col, desc_text.len())), - ) - } else { - (trimmed, None, None) - }; + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); - let names = parse_name_list(names_str, cursor.line, col, cursor); + let (name, colon) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let n = trimmed[..colon_pos].trim_end(); + ( + cursor.make_line_range(cursor.line, col, n.len()), + Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), + ) + } else { + (cursor.current_trimmed_range(), None) + }; - items.push(crate::styles::numpy::ast::SeeAlsoItem { - range: cursor.make_line_range(entry_start, col, trimmed.len()), - names, - colon, - description, - }); + methods.push(NumPyMethod { + range: cursor.current_trimmed_range(), + name, + colon, + description: None, + }); +} - cursor.advance(); +/// Process one content line for a See Also section. +fn process_see_also_line( + cursor: &LineCursor, + items: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = items.last_mut() { + extend_description( + &mut last.description, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; + } + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); } - items -} + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + + let (names_str, colon, description) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let after_colon = &trimmed[colon_pos + 1..]; + let desc_text = after_colon.trim(); + let ws_after = after_colon.len() - after_colon.trim_start().len(); + let desc_col = col + colon_pos + 1 + ws_after; + ( + trimmed[..colon_pos].trim_end(), + Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), + if desc_text.is_empty() { + None + } else { + Some(cursor.make_line_range(cursor.line, desc_col, desc_text.len())) + }, + ) + } else { + (trimmed, None, None) + }; -// ============================================================================= -// References parsing -// ============================================================================= + let names = parse_name_list(names_str, cursor.line, col, cursor); -/// Parse the References section body. -/// -/// Supports RST citation references like `.. [1] Author, Title`. -/// -/// On return, `cursor.line` points to the first line after the section. -fn parse_references(cursor: &mut LineCursor) -> Vec { - let mut refs = Vec::new(); - let mut current_number: TextRange = TextRange::empty(); - let mut current_directive_marker: Option = None; - let mut current_open_bracket: Option = None; - let mut current_close_bracket: Option = None; - let mut current_content_lines: Vec<&str> = Vec::new(); - let mut current_start_line: Option = None; - let mut current_col = 0usize; + items.push(SeeAlsoItem { + range: cursor.make_line_range(cursor.line, col, trimmed.len()), + names, + colon, + description, + }); +} - while !cursor.is_eof() { - if try_parse_numpy_header(cursor).is_some() { - break; +/// Process one content line for a References section. +/// +/// Handles both RST citation references (`.. [N] content`) and plain text. +fn process_reference_line( + cursor: &LineCursor, + refs: &mut Vec, + entry_indent: &mut Option, +) { + let indent_cols = cursor.current_indent_columns(); + if let Some(base) = *entry_indent { + if indent_cols > base { + if let Some(last) = refs.last_mut() { + extend_description( + &mut last.content, + &mut last.range, + cursor.current_trimmed_range(), + ); + } + return; } - let line = cursor.current_line_text(); - let trimmed = line.trim(); + } + if entry_indent.is_none() { + *entry_indent = Some(indent_cols); + } - // Check for `.. [N]` pattern — tolerate extra whitespace between `..` and `[` - let is_directive_ref = - trimmed.starts_with("..") && trimmed[2..].trim_start().starts_with('['); - if is_directive_ref { - // Flush previous reference - if let Some(start_l) = current_start_line { - let content = current_content_lines.join("\n"); - let end_l = if current_content_lines.len() > 1 { - start_l + current_content_lines.len() - 1 - } else { - start_l - }; - let end_col = current_col + content.lines().last().unwrap_or("").len(); - refs.push(crate::styles::numpy::ast::NumPyReference { - range: cursor.make_range(start_l, current_col, end_l, end_col), - directive_marker: current_directive_marker, - open_bracket: current_open_bracket, - number: current_number, - close_bracket: current_close_bracket, - content: cursor.make_range(start_l, current_col, end_l, end_col), - }); - } + let col = cursor.current_indent(); + let trimmed = cursor.current_trimmed(); + let is_directive = trimmed.starts_with("..") && trimmed[2..].trim_start().starts_with('['); - let col = cursor.current_indent(); - // Find actual positions of `[` and `]` — use bracket-aware matching - let rel_open = trimmed.find('[').unwrap(); - let abs_open = cursor.substr_offset(trimmed) + rel_open; - if let Some(abs_close) = find_matching_close(cursor.source(), abs_open) { - // `..` directive marker - current_directive_marker = - Some(cursor.make_range(cursor.line, col, cursor.line, col + 2)); - // `[` - current_open_bracket = Some(TextRange::from_offset_len(abs_open, 1)); - // `]` - current_close_bracket = Some(TextRange::from_offset_len(abs_close, 1)); - // Number inside brackets, trimmed of whitespace - let num_raw = &cursor.source()[abs_open + 1..abs_close]; - let num_str = num_raw.trim(); - if !num_str.is_empty() { - let num_abs = cursor.substr_offset(num_str); - current_number = TextRange::from_offset_len(num_abs, num_str.len()); - } else { - current_number = TextRange::empty(); - } - let close_line_text = cursor.line_text(cursor.offset_to_line_col(abs_close).0); - let close_line_end = cursor.substr_offset(close_line_text) + close_line_text.len(); - let after_bracket = cursor.source()[abs_close + 1..close_line_end].trim(); - current_content_lines = vec![after_bracket]; - current_start_line = Some(cursor.line); - current_col = col; - } - cursor.advance(); - } else if trimmed.is_empty() { - // Empty line between references — flush current - if let Some(start_l) = current_start_line.take() { - let content = current_content_lines.join("\n"); - let end_l = if current_content_lines.len() > 1 { - start_l + current_content_lines.len() - 1 - } else { - start_l - }; - let end_col = current_col + content.lines().last().unwrap_or("").len(); - refs.push(crate::styles::numpy::ast::NumPyReference { - range: cursor.make_range(start_l, current_col, end_l, end_col), - directive_marker: current_directive_marker, - open_bracket: current_open_bracket, - number: current_number, - close_bracket: current_close_bracket, - content: cursor.make_range(start_l, current_col, end_l, end_col), - }); - current_content_lines.clear(); - current_directive_marker = None; - current_open_bracket = None; - current_close_bracket = None; - } - cursor.advance(); - } else if current_start_line.is_some() { - // Continuation of current reference - current_content_lines.push(trimmed); - cursor.advance(); - } else { - // Non-RST reference — treat as plain text content - current_content_lines.push(trimmed); - if current_start_line.is_none() { - current_start_line = Some(cursor.line); - let num_col = cursor.current_indent(); - current_number = cursor.make_range(cursor.line, num_col, cursor.line, num_col); - current_col = num_col; - current_directive_marker = None; - current_open_bracket = None; - current_close_bracket = None; - } - cursor.advance(); + if is_directive { + let rel_open = trimmed.find('[').unwrap(); + let abs_open = cursor.substr_offset(trimmed) + rel_open; + if let Some(abs_close) = find_matching_close(cursor.source(), abs_open) { + let directive_marker = Some(cursor.make_line_range(cursor.line, col, 2)); + let open_bracket = Some(TextRange::from_offset_len(abs_open, 1)); + let close_bracket = Some(TextRange::from_offset_len(abs_close, 1)); + let num_raw = &cursor.source()[abs_open + 1..abs_close]; + let num_str = num_raw.trim(); + let number = if !num_str.is_empty() { + let num_abs = cursor.substr_offset(num_str); + TextRange::from_offset_len(num_abs, num_str.len()) + } else { + TextRange::empty() + }; + // Content after `]` on this line + let line_end_offset = + cursor.substr_offset(cursor.current_line_text()) + cursor.current_line_text().len(); + let after_on_line = + &cursor.source()[abs_close + 1..line_end_offset.min(cursor.source().len())]; + let content_str = after_on_line.trim(); + let content = if !content_str.is_empty() { + Some(TextRange::from_offset_len( + cursor.substr_offset(content_str), + content_str.len(), + )) + } else { + None + }; + + refs.push(NumPyReference { + range: cursor.current_trimmed_range(), + directive_marker, + open_bracket, + number, + close_bracket, + content, + }); + return; } } - // Flush last reference - if let Some(start_l) = current_start_line { - let content = current_content_lines.join("\n"); - let end_l = if current_content_lines.len() > 1 { - start_l + current_content_lines.len() - 1 - } else { - start_l - }; - let end_col = current_col + content.lines().last().unwrap_or("").len(); - refs.push(crate::styles::numpy::ast::NumPyReference { - range: cursor.make_range(start_l, current_col, end_l, end_col), - directive_marker: current_directive_marker, - open_bracket: current_open_bracket, - number: current_number, - close_bracket: current_close_bracket, - content: cursor.make_range(start_l, current_col, end_l, end_col), - }); - } + // Plain text reference / non-RST + let num_col = col; + refs.push(NumPyReference { + range: cursor.current_trimmed_range(), + directive_marker: None, + open_bracket: None, + number: cursor.make_range(cursor.line, num_col, cursor.line, num_col), + close_bracket: None, + content: Some(cursor.current_trimmed_range()), + }); +} - refs +/// Process one content line for a free-text section (Notes, Examples, etc.). +fn process_freetext_line(cursor: &LineCursor, content: &mut TextRange) { + content.extend(cursor.current_trimmed_range()); } // ============================================================================= diff --git a/tests/numpy_tests.rs b/tests/numpy_tests.rs index 0c0f7e1..f04f970 100644 --- a/tests/numpy_tests.rs +++ b/tests/numpy_tests.rs @@ -289,6 +289,8 @@ int assert_eq!( parameters(&result)[0] .description + .as_ref() + .unwrap() .source_text(&result.source), "The first number." ); @@ -386,7 +388,13 @@ fn test_parameters_no_space_before_colon() { p[0].r#type.as_ref().unwrap().source_text(&result.source), "int" ); - assert_eq!(p[0].description.source_text(&result.source), "The value."); + assert_eq!( + p[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); } /// Parameters with no space after colon: `x :int` @@ -491,6 +499,8 @@ x : int assert!( parameters(&result)[0] .description + .as_ref() + .unwrap() .source_text(&result.source) .contains("key: value") ); @@ -510,6 +520,8 @@ x : int let result = parse_numpy(docstring); let desc = ¶meters(&result)[0] .description + .as_ref() + .unwrap() .source_text(&result.source); assert!(desc.contains("First paragraph of x.")); assert!(desc.contains("Second paragraph of x.")); @@ -548,7 +560,11 @@ y : float Some("int") ); assert_eq!( - returns(&result)[0].description.source_text(&result.source), + returns(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "The first value." ); assert_eq!( @@ -675,6 +691,8 @@ References assert!( refs[0] .content + .as_ref() + .unwrap() .source_text(&result.source) .contains("Author A") ); @@ -682,6 +700,8 @@ References assert!( refs[1] .content + .as_ref() + .unwrap() .source_text(&result.source) .contains("Author B") ); @@ -780,7 +800,10 @@ x : int let p = ¶meters(&result)[0]; assert_eq!(p.names[0].source_text(src), "x"); assert_eq!(p.r#type.as_ref().unwrap().source_text(src), "int"); - assert_eq!(p.description.source_text(src), "Description of x."); + assert_eq!( + p.description.as_ref().unwrap().source_text(src), + "Description of x." + ); } // ============================================================================= @@ -939,6 +962,8 @@ fn test_mixed_indent_first_line() { assert_eq!( parameters(&result)[0] .description + .as_ref() + .unwrap() .source_text(&result.source), "Description." ); @@ -962,7 +987,10 @@ fn test_enum_type_as_string() { p.r#type.as_ref().unwrap().source_text(&result.source), "{'C', 'F', 'A'}" ); - assert_eq!(p.description.source_text(&result.source), "Memory layout."); + assert_eq!( + p.description.as_ref().unwrap().source_text(&result.source), + "Memory layout." + ); } #[test] @@ -1021,12 +1049,20 @@ fn test_tab_indented_parameters() { assert_eq!(params.len(), 2); assert_eq!(params[0].names[0].source_text(&result.source), "x"); assert_eq!( - params[0].description.source_text(&result.source), + params[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "Description of x." ); assert_eq!(params[1].names[0].source_text(&result.source), "y"); assert_eq!( - params[1].description.source_text(&result.source), + params[1] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "Description of y." ); } @@ -1041,7 +1077,11 @@ fn test_mixed_tab_space_parameters() { assert_eq!(params.len(), 1); assert_eq!(params[0].names[0].source_text(&result.source), "x"); // Description should include "The value." (the first desc line) - let desc = params[0].description.source_text(&result.source); + let desc = params[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); assert!(desc.contains("The value."), "desc = {:?}", desc); } @@ -1053,7 +1093,11 @@ fn test_tab_indented_returns() { let rets = returns(&result); assert_eq!(rets.len(), 1); assert_eq!( - rets[0].description.source_text(&result.source), + rets[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "The result value." ); } @@ -1067,7 +1111,11 @@ fn test_tab_indented_raises() { assert_eq!(exc.len(), 1); assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); assert_eq!( - exc[0].description.source_text(&result.source), + exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "If the input is invalid." ); } @@ -1086,13 +1134,21 @@ fn test_raises_colon_split() { assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); assert!(exc[0].colon.is_some()); assert_eq!( - exc[0].description.source_text(&result.source), + exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "If the input is invalid." ); assert_eq!(exc[1].r#type.source_text(&result.source), "TypeError"); assert!(exc[1].colon.is_some()); assert_eq!( - exc[1].description.source_text(&result.source), + exc[1] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "If the type is wrong." ); } @@ -1107,7 +1163,11 @@ fn test_raises_no_colon() { assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); assert!(exc[0].colon.is_none()); assert_eq!( - exc[0].description.source_text(&result.source), + exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), "If the input is invalid." ); } @@ -1121,80 +1181,11 @@ fn test_raises_colon_with_continuation() { assert_eq!(exc.len(), 1); assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); assert!(exc[0].colon.is_some()); - let desc = exc[0].description.source_text(&result.source); - assert!(desc.contains("If bad."), "desc = {:?}", desc); - assert!(desc.contains("More detail here."), "desc = {:?}", desc); -} - -// ============================================================================= -// Multi-line type annotation tests -// ============================================================================= - -/// Parameter with multi-line type annotation (brackets spanning lines). -#[test] -fn test_multiline_type_annotation() { - let docstring = "Summary.\n\nParameters\n----------\nx : Dict[str,\n int]\n The mapping."; - let result = parse_numpy(docstring); - let params = parameters(&result); - assert_eq!(params.len(), 1); - assert_eq!(params[0].names[0].source_text(&result.source), "x"); - let type_text = params[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source); - assert_eq!(type_text, "Dict[str,\n int]"); - assert_eq!( - params[0].description.source_text(&result.source), - "The mapping." - ); -} - -/// Parameter with multi-line type and optional marker after closing bracket. -#[test] -fn test_multiline_type_with_optional() { - let docstring = - "Summary.\n\nParameters\n----------\nx : Dict[str,\n int], optional\n The mapping."; - let result = parse_numpy(docstring); - let params = parameters(&result); - assert_eq!(params.len(), 1); - let type_text = params[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source); - assert_eq!(type_text, "Dict[str,\n int]"); - assert!(params[0].optional.is_some()); - assert_eq!( - params[0] - .optional - .as_ref() - .unwrap() - .source_text(&result.source), - "optional" - ); -} - -/// Multiple parameters where the first has a multi-line type. -#[test] -fn test_multiline_type_followed_by_another_param() { - let docstring = "Summary.\n\nParameters\n----------\nx : Dict[str,\n int]\n The mapping.\ny : str\n The name."; - let result = parse_numpy(docstring); - let params = parameters(&result); - assert_eq!(params.len(), 2); - let type_text = params[0] - .r#type + let desc = exc[0] + .description .as_ref() .unwrap() .source_text(&result.source); - assert_eq!(type_text, "Dict[str,\n int]"); - assert_eq!(params[1].names[0].source_text(&result.source), "y"); - assert_eq!( - params[1] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "str" - ); + assert!(desc.contains("If bad."), "desc = {:?}", desc); + assert!(desc.contains("More detail here."), "desc = {:?}", desc); } From f995712b5f656aed0bc749b3cbeed236256236f6 Mon Sep 17 00:00:00 2001 From: qraqras Date: Wed, 4 Mar 2026 08:03:49 +0000 Subject: [PATCH 3/8] refactor: refactor --- examples/parse_numpy.rs | 4 ++-- src/styles/numpy/ast.rs | 28 +++++++++++++++++-------- src/styles/numpy/parser.rs | 43 +++++++++++++++++++++----------------- tests/numpy_tests.rs | 14 +++++++++---- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/examples/parse_numpy.rs b/examples/parse_numpy.rs index e4a759c..66ca78e 100644 --- a/examples/parse_numpy.rs +++ b/examples/parse_numpy.rs @@ -108,11 +108,11 @@ Examples ); } } - NumPySectionBody::Notes(text) => { + NumPySectionBody::Notes(Some(text)) => { println!("\nNotes:"); println!(" {}", text.source_text(&doc.source)); } - NumPySectionBody::Examples(text) => { + NumPySectionBody::Examples(Some(text)) => { println!("\nExamples:"); println!("{}", text.source_text(&doc.source)); } diff --git a/src/styles/numpy/ast.rs b/src/styles/numpy/ast.rs index d941d68..413f887 100644 --- a/src/styles/numpy/ast.rs +++ b/src/styles/numpy/ast.rs @@ -198,21 +198,29 @@ pub enum NumPySectionBody { /// Warns section. Warns(Vec), /// Warnings section (free text). - Warnings(TextRange), + /// + /// `None` when the section body is empty (no content lines). + Warnings(Option), /// See Also section. SeeAlso(Vec), /// Notes section (free text). - Notes(TextRange), + /// + /// `None` when the section body is empty (no content lines). + Notes(Option), /// References section. References(Vec), /// Examples section (free text, doctest format). - Examples(TextRange), + /// + /// `None` when the section body is empty (no content lines). + Examples(Option), /// Attributes section. Attributes(Vec), /// Methods section. Methods(Vec), /// Unknown / unrecognized section (free text). - Unknown(TextRange), + /// + /// `None` when the section body is empty (no content lines). + Unknown(Option), } /// NumPy-style docstring. @@ -363,7 +371,9 @@ pub struct NumPyReference { /// Opening bracket (`[`) enclosing the reference number, with its span, if present. pub open_bracket: Option, /// Reference number (e.g., "1", "2", "3") with its span. - pub number: TextRange, + /// + /// `None` for non-RST (plain text) references or empty brackets. + pub number: Option, /// Closing bracket (`]`) enclosing the reference number, with its span, if present. pub close_bracket: Option, /// Reference content (author, title, etc) with its span. @@ -409,14 +419,14 @@ impl NumPySectionBody { NumPySectionKind::OtherParameters => Self::OtherParameters(Vec::new()), NumPySectionKind::Raises => Self::Raises(Vec::new()), NumPySectionKind::Warns => Self::Warns(Vec::new()), - NumPySectionKind::Warnings => Self::Warnings(TextRange::empty()), + NumPySectionKind::Warnings => Self::Warnings(None), NumPySectionKind::SeeAlso => Self::SeeAlso(Vec::new()), - NumPySectionKind::Notes => Self::Notes(TextRange::empty()), + NumPySectionKind::Notes => Self::Notes(None), NumPySectionKind::References => Self::References(Vec::new()), - NumPySectionKind::Examples => Self::Examples(TextRange::empty()), + NumPySectionKind::Examples => Self::Examples(None), NumPySectionKind::Attributes => Self::Attributes(Vec::new()), NumPySectionKind::Methods => Self::Methods(Vec::new()), - NumPySectionKind::Unknown => Self::Unknown(TextRange::empty()), + NumPySectionKind::Unknown => Self::Unknown(None), } } } diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index 3920ce8..9920857 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -80,19 +80,19 @@ fn try_parse_numpy_header(cursor: &LineCursor) -> Option { /// Collect indented description lines starting at `cursor.line`. /// /// Preserves blank lines between paragraphs. Stops at non-empty lines at or -/// below `entry_indent`, section headers, or EOF. +/// below `entry_indent_cols` visual columns, section headers, or EOF. /// /// On return, `cursor.line` points to the first unconsumed line. /// /// NOTE: This is used **only** for the deprecation directive body, which needs /// eager multi-line collection. Section body parsing uses per-line functions. -fn collect_description(cursor: &mut LineCursor, entry_indent: usize) -> Option { +fn collect_description(cursor: &mut LineCursor, entry_indent_cols: usize) -> Option { let mut first_content_line: Option = None; let mut last_content_line = cursor.line; while !cursor.is_eof() { let line = cursor.current_line_text(); - if !line.trim().is_empty() && indent_columns(line) <= entry_indent { + if !line.trim().is_empty() && indent_columns(line) <= entry_indent_cols { break; } if !line.trim().is_empty() { @@ -192,7 +192,7 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { cursor.advance(); // Collect indented body lines - let desc_spanned = collect_description(&mut cursor, col); + let desc_spanned = collect_description(&mut cursor, indent_columns(line)); // Compute deprecation span let (dep_end_line, dep_end_col) = match &desc_spanned { @@ -377,12 +377,7 @@ fn parse_name_and_type( cursor: &LineCursor, ) -> ParamHeaderParts { // Find the first colon not inside brackets - let (name_str, colon_span, colon_rel) = if let Some(colon_pos) = find_entry_colon(text) { - let before = text[..colon_pos].trim_end(); - let colon_col = col_base + colon_pos; - let colon = Some(cursor.make_line_range(line_idx, colon_col, 1)); - (before, colon, Some(colon_pos)) - } else { + let Some(colon_pos) = find_entry_colon(text) else { // No separator — whole text is the name let names = parse_name_list(text, line_idx, col_base, cursor); return ParamHeaderParts { @@ -396,10 +391,12 @@ fn parse_name_and_type( }; }; + let name_str = text[..colon_pos].trim_end(); + let colon_col = col_base + colon_pos; + let colon_span = Some(cursor.make_line_range(line_idx, colon_col, 1)); let names = parse_name_list(name_str, line_idx, col_base, cursor); - let colon_rel = colon_rel.unwrap(); - let after_colon = &text[colon_rel + 1..]; + let after_colon = &text[colon_pos + 1..]; let after_trimmed = after_colon.trim(); if after_trimmed.is_empty() { @@ -907,9 +904,9 @@ fn process_reference_line( let num_str = num_raw.trim(); let number = if !num_str.is_empty() { let num_abs = cursor.substr_offset(num_str); - TextRange::from_offset_len(num_abs, num_str.len()) + Some(TextRange::from_offset_len(num_abs, num_str.len())) } else { - TextRange::empty() + None }; // Content after `]` on this line let line_end_offset = @@ -939,20 +936,27 @@ fn process_reference_line( } // Plain text reference / non-RST - let num_col = col; refs.push(NumPyReference { range: cursor.current_trimmed_range(), directive_marker: None, open_bracket: None, - number: cursor.make_range(cursor.line, num_col, cursor.line, num_col), + number: None, close_bracket: None, content: Some(cursor.current_trimmed_range()), }); } /// Process one content line for a free-text section (Notes, Examples, etc.). -fn process_freetext_line(cursor: &LineCursor, content: &mut TextRange) { - content.extend(cursor.current_trimmed_range()); +/// +/// Only called for non-blank lines (blanks are skipped by the main loop). +/// Blank lines between content lines are implicitly included in the +/// resulting range because `extend` spans across them. +fn process_freetext_line(cursor: &LineCursor, content: &mut Option) { + let range = cursor.current_trimmed_range(); + match content { + Some(c) => c.extend(range), + None => *content = Some(range), + } } // ============================================================================= @@ -1006,7 +1010,8 @@ mod tests { // -- param header detection -- - /// Check whether `trimmed` looks like a parameter header line.\n /// A parameter header contains a colon (not inside brackets). + /// Check whether `trimmed` looks like a parameter header line. + /// A parameter header contains a colon (not inside brackets). fn is_param_header(trimmed: &str) -> bool { find_entry_colon(trimmed).is_some() } diff --git a/tests/numpy_tests.rs b/tests/numpy_tests.rs index f04f970..2e0c934 100644 --- a/tests/numpy_tests.rs +++ b/tests/numpy_tests.rs @@ -92,7 +92,7 @@ fn references(doc: &NumPyDocstring) -> Vec<&NumPyReference> { fn notes(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Notes(v) => Some(v), + NumPySectionBody::Notes(v) => v.as_ref(), _ => None, }) } @@ -100,7 +100,7 @@ fn notes(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { #[allow(dead_code)] fn examples(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Examples(v) => Some(v), + NumPySectionBody::Examples(v) => v.as_ref(), _ => None, }) } @@ -687,7 +687,10 @@ References let result = parse_numpy(docstring); let refs = references(&result); assert_eq!(refs.len(), 2); - assert_eq!(refs[0].number.source_text(&result.source), "1"); + assert_eq!( + refs[0].number.as_ref().unwrap().source_text(&result.source), + "1" + ); assert!( refs[0] .content @@ -696,7 +699,10 @@ References .source_text(&result.source) .contains("Author A") ); - assert_eq!(refs[1].number.source_text(&result.source), "2"); + assert_eq!( + refs[1].number.as_ref().unwrap().source_text(&result.source), + "2" + ); assert!( refs[1] .content From cfdf0f55e2bff6aaa06ca1241215707366c300e3 Mon Sep 17 00:00:00 2001 From: qraqras Date: Wed, 4 Mar 2026 10:50:36 +0000 Subject: [PATCH 4/8] refactor: tests --- tests/google/args.rs | 614 ++++++++++ tests/google/edge_cases.rs | 242 ++++ tests/google/freetext.rs | 210 ++++ tests/google/main.rs | 243 ++++ tests/google/raises.rs | 220 ++++ tests/google/returns.rs | 138 +++ tests/google/sections.rs | 251 ++++ tests/google/structured.rs | 153 +++ tests/google/summary.rs | 139 +++ tests/google_tests.rs | 2199 ------------------------------------ tests/numpy/edge_cases.rs | 216 ++++ tests/numpy/freetext.rs | 278 +++++ tests/numpy/main.rs | 183 +++ tests/numpy/parameters.rs | 463 ++++++++ tests/numpy/raises.rs | 218 ++++ tests/numpy/returns.rs | 212 ++++ tests/numpy/sections.rs | 229 ++++ tests/numpy/structured.rs | 210 ++++ tests/numpy/summary.rs | 142 +++ tests/numpy_tests.rs | 1197 -------------------- 20 files changed, 4361 insertions(+), 3396 deletions(-) create mode 100644 tests/google/args.rs create mode 100644 tests/google/edge_cases.rs create mode 100644 tests/google/freetext.rs create mode 100644 tests/google/main.rs create mode 100644 tests/google/raises.rs create mode 100644 tests/google/returns.rs create mode 100644 tests/google/sections.rs create mode 100644 tests/google/structured.rs create mode 100644 tests/google/summary.rs delete mode 100644 tests/google_tests.rs create mode 100644 tests/numpy/edge_cases.rs create mode 100644 tests/numpy/freetext.rs create mode 100644 tests/numpy/main.rs create mode 100644 tests/numpy/parameters.rs create mode 100644 tests/numpy/raises.rs create mode 100644 tests/numpy/returns.rs create mode 100644 tests/numpy/sections.rs create mode 100644 tests/numpy/structured.rs create mode 100644 tests/numpy/summary.rs delete mode 100644 tests/numpy_tests.rs diff --git a/tests/google/args.rs b/tests/google/args.rs new file mode 100644 index 0000000..2a53c08 --- /dev/null +++ b/tests/google/args.rs @@ -0,0 +1,614 @@ +use super::*; + +// ============================================================================= +// Args section — basic +// ============================================================================= + +#[test] +fn test_args_basic() { + let docstring = "Summary.\n\nArgs:\n x (int): The value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a.len(), 1); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); +} + +#[test] +fn test_args_multiple() { + let docstring = "Summary.\n\nArgs:\n x (int): First.\n y (str): Second."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a.len(), 2); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!(a[1].name.source_text(&result.source), "y"); + assert_eq!( + a[1].r#type.as_ref().unwrap().source_text(&result.source), + "str" + ); +} + +#[test] +fn test_args_no_type() { + let docstring = "Summary.\n\nArgs:\n x: The value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert!(a[0].r#type.is_none()); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); +} + +/// Colon with no space after it: `name:description` +#[test] +fn test_args_no_space_after_colon() { + let docstring = "Summary.\n\nArgs:\n x:The value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); +} + +/// Colon with extra spaces: `name: description` +#[test] +fn test_args_extra_spaces_after_colon() { + let docstring = "Summary.\n\nArgs:\n x: The value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); +} + +#[test] +fn test_args_optional() { + let docstring = "Summary.\n\nArgs:\n x (int, optional): The value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert!(a[0].optional.is_some()); +} + +#[test] +fn test_args_complex_type() { + let docstring = "Summary.\n\nArgs:\n data (Dict[str, List[int]]): The data."; + let result = parse_google(docstring); + assert_eq!( + args(&result)[0] + .r#type + .as_ref() + .unwrap() + .source_text(&result.source), + "Dict[str, List[int]]" + ); +} + +#[test] +fn test_args_tuple_type() { + let docstring = "Summary.\n\nArgs:\n pair (Tuple[int, str]): A pair of values."; + let result = parse_google(docstring); + assert_eq!( + args(&result)[0] + .r#type + .as_ref() + .unwrap() + .source_text(&result.source), + "Tuple[int, str]" + ); +} + +#[test] +fn test_args_multiline_description() { + let docstring = + "Summary.\n\nArgs:\n x (int): First line.\n Second line.\n Third line."; + let result = parse_google(docstring); + assert_eq!( + args(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "First line.\n Second line.\n Third line." + ); +} + +#[test] +fn test_args_description_on_next_line() { + let docstring = "Summary.\n\nArgs:\n x (int):\n The description."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The description." + ); +} + +#[test] +fn test_args_varargs() { + let docstring = "Summary.\n\nArgs:\n *args: Positional args.\n **kwargs: Keyword args."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a.len(), 2); + assert_eq!(a[0].name.source_text(&result.source), "*args"); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Positional args." + ); + assert_eq!(a[1].name.source_text(&result.source), "**kwargs"); + assert_eq!( + a[1].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Keyword args." + ); +} + +#[test] +fn test_args_kwargs_with_type() { + let docstring = "Summary.\n\nArgs:\n **kwargs (dict): Keyword arguments."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "**kwargs"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "dict" + ); +} + +// ============================================================================= +// Args — aliases +// ============================================================================= + +#[test] +fn test_arguments_alias() { + let docstring = "Summary.\n\nArguments:\n x (int): The value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a.len(), 1); + assert_eq!(a[0].name.source_text(&result.source), "x"); +} + +#[test] +fn test_parameters_alias() { + let docstring = "Summary.\n\nParameters:\n x (int): The value."; + let result = parse_google(docstring); + assert_eq!(args(&result).len(), 1); + assert_eq!(args(&result)[0].name.source_text(&result.source), "x"); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Parameters" + ); +} + +#[test] +fn test_params_alias() { + let docstring = "Summary.\n\nParams:\n x (int): The value."; + let result = parse_google(docstring); + assert_eq!(args(&result).len(), 1); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Params" + ); +} + +// ============================================================================= +// Args — span accuracy +// ============================================================================= + +#[test] +fn test_args_name_span() { + let docstring = "Summary.\n\nArgs:\n x (int): Value."; + let result = parse_google(docstring); + let arg = &args(&result)[0]; + let index = LineIndex::from_source(&result.source); + let (line, col) = index.line_col(arg.name.start()); + assert_eq!(line, 3); + assert_eq!(col, 4); + assert_eq!(arg.name.end(), TextSize::new(arg.name.start().raw() + 1)); + assert_eq!(arg.name.source_text(&result.source), "x"); +} + +#[test] +fn test_args_type_span() { + let docstring = "Summary.\n\nArgs:\n x (int): Value."; + let result = parse_google(docstring); + let arg = &args(&result)[0]; + let type_span = arg.r#type.as_ref().unwrap(); + let index = LineIndex::from_source(&result.source); + let (line, _col) = index.line_col(type_span.start()); + assert_eq!(line, 3); + assert_eq!(type_span.source_text(&result.source), "int"); +} + +#[test] +fn test_args_optional_span() { + let docstring = "Summary.\n\nArgs:\n x (int, optional): Value."; + let result = parse_google(docstring); + let opt_span = args(&result)[0].optional.as_ref().unwrap(); + assert_eq!(opt_span.source_text(&result.source), "optional"); +} + +// ============================================================================= +// Args — bracket types +// ============================================================================= + +#[test] +fn test_args_square_bracket_type() { + let docstring = "Summary.\n\nArgs:\n x [int]: The value."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert_eq!(a.name.source_text(&result.source), "x"); + assert_eq!( + a.r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "[" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + "]" + ); + assert_eq!( + a.description.as_ref().unwrap().source_text(&result.source), + "The value." + ); +} + +#[test] +fn test_args_curly_bracket_type() { + let docstring = "Summary.\n\nArgs:\n x {int}: The value."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert_eq!(a.name.source_text(&result.source), "x"); + assert_eq!( + a.r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "{" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + "}" + ); + assert_eq!( + a.description.as_ref().unwrap().source_text(&result.source), + "The value." + ); +} + +#[test] +fn test_args_paren_bracket_spans() { + let docstring = "Summary.\n\nArgs:\n x (int): The value."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "(" + ); + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "(" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + ")" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + ")" + ); +} + +#[test] +fn test_args_no_bracket_fields_when_no_type() { + let docstring = "Summary.\n\nArgs:\n x: The value."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert!(a.open_bracket.is_none()); + assert!(a.close_bracket.is_none()); + assert!(a.r#type.is_none()); +} + +#[test] +fn test_args_square_bracket_optional() { + let docstring = "Summary.\n\nArgs:\n x [int, optional]: The value."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert_eq!(a.name.source_text(&result.source), "x"); + assert_eq!( + a.r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "[" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + "]" + ); + assert!(a.optional.is_some()); +} + +#[test] +fn test_args_square_bracket_complex_type() { + let docstring = "Summary.\n\nArgs:\n items [List[int]]: The items."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert_eq!(a.name.source_text(&result.source), "items"); + assert_eq!( + a.r#type.as_ref().unwrap().source_text(&result.source), + "List[int]" + ); + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "[" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + "]" + ); +} + +#[test] +fn test_args_angle_bracket_type() { + let docstring = "Summary.\n\nArgs:\n x : The value."; + let result = parse_google(docstring); + let a = &args(&result)[0]; + assert_eq!(a.name.source_text(&result.source), "x"); + assert_eq!( + a.r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + a.open_bracket.as_ref().unwrap().source_text(&result.source), + "<" + ); + assert_eq!( + a.close_bracket + .as_ref() + .unwrap() + .source_text(&result.source), + ">" + ); + assert_eq!( + a.description.as_ref().unwrap().source_text(&result.source), + "The value." + ); +} + +// ============================================================================= +// Args — optional edge cases +// ============================================================================= + +#[test] +fn test_optional_only_in_parens() { + let docstring = "Summary.\n\nArgs:\n x (optional): Value."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert!(a[0].r#type.is_none()); + assert!(a[0].optional.is_some()); +} + +#[test] +fn test_complex_optional_type() { + let docstring = "Summary.\n\nArgs:\n x (List[int], optional): Values."; + let result = parse_google(docstring); + let a = args(&result); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "List[int]" + ); + assert!(a[0].optional.is_some()); +} + +// ============================================================================= +// Keyword Args section +// ============================================================================= + +#[test] +fn test_keyword_args_basic() { + let docstring = "Summary.\n\nKeyword Args:\n timeout (int): Timeout in seconds.\n retries (int): Number of retries."; + let result = parse_google(docstring); + let ka = keyword_args(&result); + assert_eq!(ka.len(), 2); + assert_eq!(ka[0].name.source_text(&result.source), "timeout"); + assert_eq!( + ka[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!(ka[1].name.source_text(&result.source), "retries"); +} + +#[test] +fn test_keyword_arguments_alias() { + let docstring = "Summary.\n\nKeyword Arguments:\n key (str): The key."; + let result = parse_google(docstring); + assert_eq!(keyword_args(&result).len(), 1); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Keyword Arguments" + ); +} + +#[test] +fn test_keyword_args_section_body_variant() { + let docstring = "Summary.\n\nKeyword Args:\n k (str): Key."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::KeywordArgs(args) => { + assert_eq!(args.len(), 1); + } + _ => panic!("Expected KeywordArgs section body"), + } +} + +// ============================================================================= +// Other Parameters section +// ============================================================================= + +#[test] +fn test_other_parameters() { + let docstring = "Summary.\n\nOther Parameters:\n debug (bool): Enable debug mode.\n verbose (bool, optional): Verbose output."; + let result = parse_google(docstring); + let op = other_parameters(&result); + assert_eq!(op.len(), 2); + assert_eq!(op[0].name.source_text(&result.source), "debug"); + assert_eq!(op[1].name.source_text(&result.source), "verbose"); + assert!(op[1].optional.is_some()); +} + +#[test] +fn test_other_parameters_section_body_variant() { + let docstring = "Summary.\n\nOther Parameters:\n x (int): Extra."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::OtherParameters(args) => { + assert_eq!(args.len(), 1); + } + _ => panic!("Expected OtherParameters section body"), + } +} + +// ============================================================================= +// Receives section +// ============================================================================= + +#[test] +fn test_receives() { + let docstring = "Summary.\n\nReceives:\n data (bytes): The received data."; + let result = parse_google(docstring); + let r = receives(&result); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name.source_text(&result.source), "data"); + assert_eq!( + r[0].r#type.as_ref().unwrap().source_text(&result.source), + "bytes" + ); +} + +#[test] +fn test_receive_alias() { + let docstring = "Summary.\n\nReceive:\n msg (str): The message."; + let result = parse_google(docstring); + assert_eq!(receives(&result).len(), 1); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Receive" + ); +} + +// ============================================================================= +// Convenience accessors +// ============================================================================= + +#[test] +fn test_docstring_like_parameters() { + let docstring = "Summary.\n\nArgs:\n x (int): Value.\n y (str): Name."; + let result = parse_google(docstring); + let params = args(&result); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name.source_text(&result.source), "x"); + assert_eq!( + params[0] + .r#type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); + assert_eq!(params[1].name.source_text(&result.source), "y"); +} diff --git a/tests/google/edge_cases.rs b/tests/google/edge_cases.rs new file mode 100644 index 0000000..f2c1fe8 --- /dev/null +++ b/tests/google/edge_cases.rs @@ -0,0 +1,242 @@ +use super::*; + +// ============================================================================= +// Indented docstrings +// ============================================================================= + +#[test] +fn test_indented_docstring() { + let docstring = " Summary.\n\n Args:\n x (int): Value."; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); + let a = args(&result); + assert_eq!(a.len(), 1); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +#[test] +fn test_indented_summary_span() { + let docstring = " Summary."; + let result = parse_google(docstring); + assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(4)); + assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(12)); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); +} + +// ============================================================================= +// Space-before-colon and colonless header tests +// ============================================================================= + +/// `Args :` (space before colon) should be dispatched as Args, not Unknown. +#[test] +fn test_section_header_space_before_colon() { + let input = "Summary.\n\nArgs :\n x (int): The value."; + let result = parse_google(input); + let doc = &result; + let a = args(doc); + assert_eq!(a.len(), 1, "expected 1 arg from 'Args :'"); + assert_eq!(a[0].name.source_text(&result.source), "x"); + + assert_eq!( + all_sections(doc) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Args" + ); + assert!( + all_sections(doc) + .into_iter() + .next() + .unwrap() + .header + .colon + .is_some() + ); +} + +/// `Returns :` with space before colon. +#[test] +fn test_returns_space_before_colon() { + let input = "Summary.\n\nReturns :\n int: The result."; + let result = parse_google(input); + let doc = &result; + let r = returns(doc).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +/// Colonless `Args` should be parsed as Args section. +#[test] +fn test_section_header_no_colon() { + let input = "Summary.\n\nArgs\n x (int): The value."; + let result = parse_google(input); + let doc = &result; + let a = args(doc); + assert_eq!(a.len(), 1, "expected 1 arg from colonless 'Args'"); + assert_eq!(a[0].name.source_text(&result.source), "x"); + + assert_eq!( + all_sections(doc) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Args" + ); + assert!( + all_sections(doc) + .into_iter() + .next() + .unwrap() + .header + .colon + .is_none() + ); +} + +/// Colonless `Returns` should be parsed as Returns section. +#[test] +fn test_returns_no_colon() { + let input = "Summary.\n\nReturns\n int: The result."; + let result = parse_google(input); + let doc = &result; + let r = returns(doc).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +/// Colonless `Raises` should be parsed as Raises section. +#[test] +fn test_raises_no_colon() { + let input = "Summary.\n\nRaises\n ValueError: If invalid."; + let result = parse_google(input); + let doc = &result; + let r = raises(doc); + assert_eq!(r.len(), 1); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); +} + +/// Unknown names without colon should NOT be treated as headers. +#[test] +fn test_unknown_name_without_colon_not_header() { + let input = "Summary.\n\nSomeWord\n x (int): value."; + let result = parse_google(input); + let doc = &result; + assert!( + all_sections(doc).is_empty(), + "unknown colonless name should not become a section" + ); +} + +/// Multiple sections with mixed colon styles. +#[test] +fn test_mixed_colon_styles() { + let input = "Summary.\n\nArgs:\n x: value.\n\nReturns\n int: result.\n\nRaises :\n ValueError: If bad."; + let result = parse_google(input); + let doc = &result; + assert_eq!(args(doc).len(), 1); + assert!(returns(doc).is_some()); + assert_eq!(raises(doc).len(), 1); +} + +// ============================================================================= +// Tab indentation tests +// ============================================================================= + +/// Args section with tab-indented entries. +#[test] +fn test_tab_indented_args() { + let input = "Summary.\n\nArgs:\n\tx: The value.\n\ty: Another value."; + let result = parse_google(input); + let a = args(&result); + assert_eq!(a.len(), 2); + assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); + assert_eq!(a[1].name.source_text(&result.source), "y"); + assert_eq!( + a[1].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Another value." + ); +} + +/// Args entries with tab indent and descriptions with deeper tab+space indent. +#[test] +fn test_tab_args_with_continuation() { + let input = "Summary.\n\nArgs:\n\tx: First line.\n\t Continuation.\n\ty: Second."; + let result = parse_google(input); + let a = args(&result); + assert_eq!(a.len(), 2); + assert_eq!(a[0].name.source_text(&result.source), "x"); + let desc = a[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); + assert!(desc.contains("First line."), "desc = {:?}", desc); + assert!(desc.contains("Continuation."), "desc = {:?}", desc); +} + +/// Returns section with tab-indented entry. +#[test] +fn test_tab_indented_returns() { + let input = "Summary.\n\nReturns:\n\tint: The result."; + let result = parse_google(input); + let r = returns(&result); + assert!(r.is_some()); + let r = r.unwrap(); + assert_eq!(r.return_type.unwrap().source_text(&result.source), "int"); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); +} + +/// Raises section with tab-indented entries. +#[test] +fn test_tab_indented_raises() { + let input = "Summary.\n\nRaises:\n\tValueError: If bad.\n\tTypeError: If wrong type."; + let result = parse_google(input); + let r = raises(&result); + assert_eq!(r.len(), 2); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[1].r#type.source_text(&result.source), "TypeError"); +} + +/// Section header detection with tab indentation matches. +#[test] +fn test_tab_indented_section_header() { + let input = "\tSummary.\n\n\tArgs:\n\t\tx: The value."; + let result = parse_google(input); + let a = args(&result); + assert_eq!(a.len(), 1); + assert_eq!(a[0].name.source_text(&result.source), "x"); +} diff --git a/tests/google/freetext.rs b/tests/google/freetext.rs new file mode 100644 index 0000000..635c88b --- /dev/null +++ b/tests/google/freetext.rs @@ -0,0 +1,210 @@ +use super::*; + +// ============================================================================= +// Notes section +// ============================================================================= + +#[test] +fn test_note_section() { + let docstring = "Summary.\n\nNote:\n This is a note."; + let result = parse_google(docstring); + assert_eq!( + notes(&result).unwrap().source_text(&result.source), + "This is a note." + ); +} + +#[test] +fn test_notes_alias() { + let docstring = "Summary.\n\nNotes:\n This is also a note."; + let result = parse_google(docstring); + assert_eq!( + notes(&result).unwrap().source_text(&result.source), + "This is also a note." + ); +} + +// ============================================================================= +// Examples section +// ============================================================================= + +#[test] +fn test_example_section() { + let docstring = "Summary.\n\nExample:\n >>> func(1)\n 1"; + let result = parse_google(docstring); + assert_eq!( + examples(&result).unwrap().source_text(&result.source), + ">>> func(1)\n 1" + ); +} + +#[test] +fn test_examples_alias() { + let docstring = "Summary.\n\nExamples:\n >>> 1 + 1\n 2"; + let result = parse_google(docstring); + assert!(examples(&result).is_some()); +} + +// ============================================================================= +// References section +// ============================================================================= + +#[test] +fn test_references_section() { + let docstring = "Summary.\n\nReferences:\n Author, Title, 2024."; + let result = parse_google(docstring); + assert!(references(&result).is_some()); +} + +// ============================================================================= +// Warnings section (free-text) +// ============================================================================= + +#[test] +fn test_warnings_section() { + let docstring = "Summary.\n\nWarnings:\n This function is deprecated."; + let result = parse_google(docstring); + assert_eq!( + warnings(&result).unwrap().source_text(&result.source), + "This function is deprecated." + ); +} + +// ============================================================================= +// Todo section +// ============================================================================= + +#[test] +fn test_todo_freetext() { + let docstring = "Summary.\n\nTodo:\n * Item one.\n * Item two."; + let result = parse_google(docstring); + let t = todo(&result).unwrap(); + assert!(t.source_text(&result.source).contains("Item one.")); + assert!(t.source_text(&result.source).contains("Item two.")); +} + +#[test] +fn test_todo_without_bullets() { + let docstring = "Summary.\n\nTodo:\n Implement feature X.\n Fix bug Y."; + let result = parse_google(docstring); + let t = todo(&result).unwrap(); + assert!( + t.source_text(&result.source) + .contains("Implement feature X.") + ); + assert!(t.source_text(&result.source).contains("Fix bug Y.")); +} + +#[test] +fn test_todo_multiline() { + let docstring = + "Summary.\n\nTodo:\n * Item one that\n continues here.\n * Item two."; + let result = parse_google(docstring); + let t = todo(&result).unwrap(); + assert!(t.source_text(&result.source).contains("Item one that")); + assert!(t.source_text(&result.source).contains("continues here.")); + assert!(t.source_text(&result.source).contains("Item two.")); +} + +// ============================================================================= +// Admonition sections +// ============================================================================= + +#[test] +fn test_attention_section() { + let docstring = "Summary.\n\nAttention:\n This requires careful handling."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Attention(text) => { + assert_eq!( + text.source_text(&result.source), + "This requires careful handling." + ); + } + _ => panic!("Expected Attention section body"), + } +} + +#[test] +fn test_caution_section() { + let docstring = "Summary.\n\nCaution:\n Use with care."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Caution(text) => { + assert_eq!(text.source_text(&result.source), "Use with care."); + } + _ => panic!("Expected Caution section body"), + } +} + +#[test] +fn test_danger_section() { + let docstring = "Summary.\n\nDanger:\n May cause data loss."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Danger(text) => { + assert_eq!(text.source_text(&result.source), "May cause data loss."); + } + _ => panic!("Expected Danger section body"), + } +} + +#[test] +fn test_error_section() { + let docstring = "Summary.\n\nError:\n Known issue with large inputs."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Error(text) => { + assert_eq!( + text.source_text(&result.source), + "Known issue with large inputs." + ); + } + _ => panic!("Expected Error section body"), + } +} + +#[test] +fn test_hint_section() { + let docstring = "Summary.\n\nHint:\n Try using a smaller batch size."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Hint(text) => { + assert_eq!( + text.source_text(&result.source), + "Try using a smaller batch size." + ); + } + _ => panic!("Expected Hint section body"), + } +} + +#[test] +fn test_important_section() { + let docstring = "Summary.\n\nImportant:\n Must be called before init()."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Important(text) => { + assert_eq!( + text.source_text(&result.source), + "Must be called before init()." + ); + } + _ => panic!("Expected Important section body"), + } +} + +#[test] +fn test_tip_section() { + let docstring = "Summary.\n\nTip:\n Use vectorized operations for speed."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Tip(text) => { + assert_eq!( + text.source_text(&result.source), + "Use vectorized operations for speed." + ); + } + _ => panic!("Expected Tip section body"), + } +} diff --git a/tests/google/main.rs b/tests/google/main.rs new file mode 100644 index 0000000..99c13bf --- /dev/null +++ b/tests/google/main.rs @@ -0,0 +1,243 @@ +//! Integration tests for Google-style docstring parser. + +pub use pydocstring::GoogleSectionBody; +pub use pydocstring::google::parse_google; +pub use pydocstring::google::{ + GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, + GoogleMethod, GoogleReturns, GoogleSection, GoogleSeeAlsoItem, GoogleWarning, +}; +pub use pydocstring::{LineIndex, TextSize}; + +mod args; +mod edge_cases; +mod freetext; +mod raises; +mod returns; +mod sections; +mod structured; +mod summary; + +// ============================================================================= +// Shared helpers +// ============================================================================= + +pub fn all_sections(doc: &GoogleDocstring) -> Vec<&GoogleSection> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => Some(s), + _ => None, + }) + .collect() +} + +pub fn args(doc: &GoogleDocstring) -> Vec<&GoogleArg> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Args(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn returns(doc: &GoogleDocstring) -> Option<&GoogleReturns> { + doc.items.iter().find_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Returns(r) => Some(r), + _ => None, + }, + _ => None, + }) +} + +pub fn yields(doc: &GoogleDocstring) -> Option<&GoogleReturns> { + doc.items.iter().find_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Yields(r) => Some(r), + _ => None, + }, + _ => None, + }) +} + +pub fn raises(doc: &GoogleDocstring) -> Vec<&GoogleException> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Raises(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn attributes(doc: &GoogleDocstring) -> Vec<&GoogleAttribute> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Attributes(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn keyword_args(doc: &GoogleDocstring) -> Vec<&GoogleArg> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::KeywordArgs(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn other_parameters(doc: &GoogleDocstring) -> Vec<&GoogleArg> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::OtherParameters(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn receives(doc: &GoogleDocstring) -> Vec<&GoogleArg> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Receives(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn warns(doc: &GoogleDocstring) -> Vec<&GoogleWarning> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Warns(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn see_also(doc: &GoogleDocstring) -> Vec<&GoogleSeeAlsoItem> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::SeeAlso(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn methods(doc: &GoogleDocstring) -> Vec<&GoogleMethod> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => match &s.body { + GoogleSectionBody::Methods(v) => Some(v.iter()), + _ => None, + }, + _ => None, + }) + .flatten() + .collect() +} + +pub fn notes(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => Some(s), + _ => None, + }) + .find_map(|s| match &s.body { + GoogleSectionBody::Notes(v) => Some(v), + _ => None, + }) +} + +pub fn examples(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => Some(s), + _ => None, + }) + .find_map(|s| match &s.body { + GoogleSectionBody::Examples(v) => Some(v), + _ => None, + }) +} + +pub fn todo(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => Some(s), + _ => None, + }) + .find_map(|s| match &s.body { + GoogleSectionBody::Todo(v) => Some(v), + _ => None, + }) +} + +pub fn references(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => Some(s), + _ => None, + }) + .find_map(|s| match &s.body { + GoogleSectionBody::References(v) => Some(v), + _ => None, + }) +} + +pub fn warnings(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { + doc.items + .iter() + .filter_map(|item| match item { + GoogleDocstringItem::Section(s) => Some(s), + _ => None, + }) + .find_map(|s| match &s.body { + GoogleSectionBody::Warnings(v) => Some(v), + _ => None, + }) +} diff --git a/tests/google/raises.rs b/tests/google/raises.rs new file mode 100644 index 0000000..ca972b7 --- /dev/null +++ b/tests/google/raises.rs @@ -0,0 +1,220 @@ +use super::*; + +// ============================================================================= +// Raises section +// ============================================================================= + +#[test] +fn test_raises_single() { + let docstring = "Summary.\n\nRaises:\n ValueError: If the input is invalid."; + let result = parse_google(docstring); + let r = raises(&result); + assert_eq!(r.len(), 1); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the input is invalid." + ); +} + +#[test] +fn test_raises_multiple() { + let docstring = + "Summary.\n\nRaises:\n ValueError: If invalid.\n TypeError: If wrong type."; + let result = parse_google(docstring); + let r = raises(&result); + assert_eq!(r.len(), 2); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[1].r#type.source_text(&result.source), "TypeError"); +} + +#[test] +fn test_raises_multiline_description() { + let docstring = "Summary.\n\nRaises:\n ValueError: If the\n input is invalid."; + let result = parse_google(docstring); + assert_eq!( + raises(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the\n input is invalid." + ); +} + +#[test] +fn test_raises_exception_type_span() { + let docstring = "Summary.\n\nRaises:\n ValueError: If bad."; + let result = parse_google(docstring); + assert_eq!( + raises(&result)[0].r#type.source_text(&result.source), + "ValueError" + ); +} + +/// Raises entry with no space after colon. +#[test] +fn test_raises_no_space_after_colon() { + let docstring = "Summary.\n\nRaises:\n ValueError:If invalid."; + let result = parse_google(docstring); + let r = raises(&result); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If invalid." + ); +} + +/// Raises entry with extra spaces after colon. +#[test] +fn test_raises_extra_spaces_after_colon() { + let docstring = "Summary.\n\nRaises:\n ValueError: If invalid."; + let result = parse_google(docstring); + let r = raises(&result); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If invalid." + ); +} + +#[test] +fn test_raise_alias() { + let docstring = "Summary.\n\nRaise:\n ValueError: If invalid."; + let result = parse_google(docstring); + let r = raises(&result); + assert_eq!(r.len(), 1); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Raise" + ); +} + +#[test] +fn test_docstring_like_raises() { + let docstring = "Summary.\n\nRaises:\n ValueError: If bad."; + let result = parse_google(docstring); + let r = raises(&result); + assert_eq!(r.len(), 1); + assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); +} + +// ============================================================================= +// Warns section +// ============================================================================= + +#[test] +fn test_warns_basic() { + let docstring = "Summary.\n\nWarns:\n DeprecationWarning: If using old API."; + let result = parse_google(docstring); + let w = warns(&result); + assert_eq!(w.len(), 1); + assert_eq!( + w[0].warning_type.source_text(&result.source), + "DeprecationWarning" + ); + assert_eq!( + w[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If using old API." + ); +} + +#[test] +fn test_warns_multiple() { + let docstring = + "Summary.\n\nWarns:\n DeprecationWarning: Old API.\n UserWarning: Bad config."; + let result = parse_google(docstring); + let w = warns(&result); + assert_eq!(w.len(), 2); + assert_eq!( + w[0].warning_type.source_text(&result.source), + "DeprecationWarning" + ); + assert_eq!(w[1].warning_type.source_text(&result.source), "UserWarning"); +} + +#[test] +fn test_warn_alias() { + let docstring = "Summary.\n\nWarn:\n FutureWarning: Will change."; + let result = parse_google(docstring); + assert_eq!(warns(&result).len(), 1); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Warn" + ); +} + +#[test] +fn test_warns_multiline_description() { + let docstring = "Summary.\n\nWarns:\n UserWarning: First line.\n Second line."; + let result = parse_google(docstring); + assert_eq!( + warns(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "First line.\n Second line." + ); +} + +#[test] +fn test_warns_section_body_variant() { + let docstring = "Summary.\n\nWarns:\n UserWarning: Desc."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Warns(warns) => { + assert_eq!(warns.len(), 1); + } + _ => panic!("Expected Warns section body"), + } +} + +// ============================================================================= +// Warning alias (free-text, not Warns) +// ============================================================================= + +#[test] +fn test_warning_singular_alias() { + let docstring = "Summary.\n\nWarning:\n This is deprecated."; + let result = parse_google(docstring); + assert_eq!( + warnings(&result).unwrap().source_text(&result.source), + "This is deprecated." + ); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Warning" + ); +} diff --git a/tests/google/returns.rs b/tests/google/returns.rs new file mode 100644 index 0000000..0793a0b --- /dev/null +++ b/tests/google/returns.rs @@ -0,0 +1,138 @@ +use super::*; + +// ============================================================================= +// Returns section +// ============================================================================= + +#[test] +fn test_returns_with_type() { + let docstring = "Summary.\n\nReturns:\n int: The result."; + let result = parse_google(docstring); + let r = returns(&result).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); +} + +#[test] +fn test_returns_multiple_lines() { + let docstring = "Summary.\n\nReturns:\n int: The count.\n str: The message."; + let result = parse_google(docstring); + let r = returns(&result).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The count.\n str: The message." + ); +} + +#[test] +fn test_returns_without_type() { + let docstring = "Summary.\n\nReturns:\n The computed result."; + let result = parse_google(docstring); + let r = returns(&result).unwrap(); + assert!(r.return_type.is_none()); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The computed result." + ); +} + +#[test] +fn test_returns_multiline_description() { + let docstring = "Summary.\n\nReturns:\n int: The result\n of the computation."; + let result = parse_google(docstring); + assert_eq!( + returns(&result) + .unwrap() + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "The result\n of the computation." + ); +} + +#[test] +fn test_return_alias() { + let docstring = "Summary.\n\nReturn:\n int: The value."; + let result = parse_google(docstring); + assert!(returns(&result).is_some()); +} + +/// Returns entry with no space after colon. +#[test] +fn test_returns_no_space_after_colon() { + let docstring = "Summary.\n\nReturns:\n int:The result."; + let result = parse_google(docstring); + let r = returns(&result).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); +} + +/// Returns entry with extra spaces after colon. +#[test] +fn test_returns_extra_spaces_after_colon() { + let docstring = "Summary.\n\nReturns:\n int: The result."; + let result = parse_google(docstring); + let r = returns(&result).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + r.description.as_ref().unwrap().source_text(&result.source), + "The result." + ); +} + +#[test] +fn test_docstring_like_returns() { + let docstring = "Summary.\n\nReturns:\n int: The result."; + let result = parse_google(docstring); + let r = returns(&result).unwrap(); + assert_eq!( + r.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +// ============================================================================= +// Yields section +// ============================================================================= + +#[test] +fn test_yields() { + let docstring = "Summary.\n\nYields:\n int: The next value."; + let result = parse_google(docstring); + let y = yields(&result).unwrap(); + assert_eq!( + y.return_type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + y.description.as_ref().unwrap().source_text(&result.source), + "The next value." + ); +} + +#[test] +fn test_yield_alias() { + let docstring = "Summary.\n\nYield:\n str: Next string."; + let result = parse_google(docstring); + assert!(yields(&result).is_some()); +} diff --git a/tests/google/sections.rs b/tests/google/sections.rs new file mode 100644 index 0000000..83ad7d6 --- /dev/null +++ b/tests/google/sections.rs @@ -0,0 +1,251 @@ +use super::*; + +// ============================================================================= +// Multiple sections +// ============================================================================= + +#[test] +fn test_all_sections() { + let docstring = r#"Calculate the sum. + +This function adds two numbers. + +Args: + a (int): The first number. + b (int): The second number. + +Returns: + int: The sum of a and b. + +Raises: + TypeError: If inputs are not numbers. + +Example: + >>> add(1, 2) + 3 + +Note: + This is a simple function."#; + + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Calculate the sum." + ); + assert!(result.extended_summary.is_some()); + assert_eq!(args(&result).len(), 2); + assert!(returns(&result).is_some()); + assert_eq!(raises(&result).len(), 1); + assert!(examples(&result).is_some()); + assert!(notes(&result).is_some()); +} + +#[test] +fn test_sections_with_blank_lines() { + let docstring = "Summary.\n\nArgs:\n x (int): Value.\n\n y (str): Name.\n\nReturns:\n bool: Success."; + let result = parse_google(docstring); + assert_eq!(args(&result).len(), 2); + assert!(returns(&result).is_some()); +} + +// ============================================================================= +// Section order preservation +// ============================================================================= + +#[test] +fn test_section_order() { + let docstring = "Summary.\n\nReturns:\n int: Value.\n\nArgs:\n x: Input."; + let result = parse_google(docstring); + let sections: Vec<_> = all_sections(&result); + assert_eq!(sections.len(), 2); + assert_eq!( + sections[0].header.name.source_text(&result.source), + "Returns" + ); + assert_eq!(sections[1].header.name.source_text(&result.source), "Args"); +} + +// ============================================================================= +// Section header / section spans +// ============================================================================= + +#[test] +fn test_section_header_span() { + let docstring = "Summary.\n\nArgs:\n x: Value."; + let result = parse_google(docstring); + let header = &all_sections(&result).into_iter().next().unwrap().header; + assert_eq!(header.name.source_text(&result.source), "Args"); + assert_eq!(header.name.source_text(&result.source), "Args"); + assert_eq!(header.range.source_text(&result.source), "Args:"); +} + +#[test] +fn test_section_span() { + let docstring = "Summary.\n\nArgs:\n x: Value."; + let result = parse_google(docstring); + let section = all_sections(&result).into_iter().next().unwrap(); + assert_eq!( + section.range.source_text(&result.source), + "Args:\n x: Value." + ); +} + +// ============================================================================= +// Unknown sections +// ============================================================================= + +#[test] +fn test_unknown_section_preserved() { + let docstring = "Summary.\n\nCustom:\n Some custom content."; + let result = parse_google(docstring); + let sections: Vec<_> = all_sections(&result); + assert_eq!(sections.len(), 1); + assert_eq!( + sections[0].header.name.source_text(&result.source), + "Custom" + ); + match §ions[0].body { + GoogleSectionBody::Unknown(text) => { + assert_eq!(text.source_text(&result.source), "Some custom content."); + } + _ => panic!("Expected Unknown section body"), + } +} + +#[test] +fn test_unknown_section_with_known() { + let docstring = + "Summary.\n\nArgs:\n x: Value.\n\nCustom:\n Content.\n\nReturns:\n int: Result."; + let result = parse_google(docstring); + let sections: Vec<_> = all_sections(&result); + assert_eq!(sections.len(), 3); + assert_eq!(sections[0].header.name.source_text(&result.source), "Args"); + assert_eq!( + sections[1].header.name.source_text(&result.source), + "Custom" + ); + assert_eq!( + sections[2].header.name.source_text(&result.source), + "Returns" + ); + assert_eq!(args(&result).len(), 1); + assert!(returns(&result).is_some()); +} + +#[test] +fn test_multiple_unknown_sections() { + let docstring = "Summary.\n\nCustom One:\n First.\n\nCustom Two:\n Second."; + let result = parse_google(docstring); + let sections: Vec<_> = all_sections(&result); + assert_eq!(sections.len(), 2); + assert_eq!( + sections[0].header.name.source_text(&result.source), + "Custom One" + ); + assert_eq!( + sections[1].header.name.source_text(&result.source), + "Custom Two" + ); +} + +// ============================================================================= +// Case-insensitive section headers +// ============================================================================= + +#[test] +fn test_napoleon_case_insensitive() { + let docstring = "Summary.\n\nkeyword args:\n x (int): Value."; + let result = parse_google(docstring); + assert_eq!(keyword_args(&result).len(), 1); +} + +#[test] +fn test_see_also_case_insensitive() { + let docstring = "Summary.\n\nsee also:\n func_a: Description."; + let result = parse_google(docstring); + assert_eq!(see_also(&result).len(), 1); +} + +// ============================================================================= +// Full docstring with all Napoleon sections +// ============================================================================= + +#[test] +fn test_napoleon_full_docstring() { + let docstring = r#"Calculate something. + +Extended description. + +Args: + x (int): First argument. + +Keyword Args: + timeout (float): Timeout value. + +Returns: + int: The result. + +Raises: + ValueError: If x is negative. + +Warns: + DeprecationWarning: If old API is used. + +See Also: + other_func: Related function. + +Note: + Implementation detail. + +Example: + >>> calculate(1) + 1"#; + + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Calculate something." + ); + assert!(result.extended_summary.is_some()); + assert_eq!(args(&result).len(), 1); + assert_eq!(keyword_args(&result).len(), 1); + assert!(returns(&result).is_some()); + assert_eq!(raises(&result).len(), 1); + assert_eq!(warns(&result).len(), 1); + assert_eq!(see_also(&result).len(), 1); + assert!(notes(&result).is_some()); + assert!(examples(&result).is_some()); +} + +// ============================================================================= +// Span round-trip +// ============================================================================= + +#[test] +fn test_span_source_text_round_trip() { + let docstring = "Summary.\n\nArgs:\n x (int): Value.\n\nReturns:\n bool: Success."; + let result = parse_google(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); + assert_eq!(args(&result)[0].name.source_text(&result.source), "x"); + assert_eq!( + args(&result)[0] + .r#type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); + assert_eq!( + returns(&result) + .unwrap() + .return_type + .as_ref() + .unwrap() + .source_text(&result.source), + "bool" + ); +} diff --git a/tests/google/structured.rs b/tests/google/structured.rs new file mode 100644 index 0000000..4d49161 --- /dev/null +++ b/tests/google/structured.rs @@ -0,0 +1,153 @@ +use super::*; + +// ============================================================================= +// Attributes section +// ============================================================================= + +#[test] +fn test_attributes() { + let docstring = "Summary.\n\nAttributes:\n name (str): The name.\n age (int): The age."; + let result = parse_google(docstring); + let a = attributes(&result); + assert_eq!(a.len(), 2); + assert_eq!(a[0].name.source_text(&result.source), "name"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "str" + ); + assert_eq!(a[1].name.source_text(&result.source), "age"); +} + +#[test] +fn test_attributes_no_type() { + let docstring = "Summary.\n\nAttributes:\n name: The name."; + let result = parse_google(docstring); + let a = attributes(&result); + assert_eq!(a[0].name.source_text(&result.source), "name"); + assert!(a[0].r#type.is_none()); +} + +#[test] +fn test_attribute_singular_alias() { + let docstring = "Summary.\n\nAttribute:\n name (str): The name."; + let result = parse_google(docstring); + assert_eq!(attributes(&result).len(), 1); + assert_eq!( + all_sections(&result) + .into_iter() + .next() + .unwrap() + .header + .name + .source_text(&result.source), + "Attribute" + ); +} + +// ============================================================================= +// Methods section +// ============================================================================= + +#[test] +fn test_methods_basic() { + let docstring = "Summary.\n\nMethods:\n reset(): Reset the state.\n update(data): Update with new data."; + let result = parse_google(docstring); + let m = methods(&result); + assert_eq!(m.len(), 2); + assert_eq!(m[0].name.source_text(&result.source), "reset()"); + assert_eq!( + m[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Reset the state." + ); + assert_eq!(m[1].name.source_text(&result.source), "update(data)"); +} + +#[test] +fn test_methods_without_parens() { + let docstring = "Summary.\n\nMethods:\n do_stuff: Performs the operation."; + let result = parse_google(docstring); + let m = methods(&result); + assert_eq!(m.len(), 1); + assert_eq!(m[0].name.source_text(&result.source), "do_stuff"); + assert_eq!( + m[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Performs the operation." + ); +} + +#[test] +fn test_methods_section_body_variant() { + let docstring = "Summary.\n\nMethods:\n foo(): Does bar."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::Methods(methods) => { + assert_eq!(methods.len(), 1); + } + _ => panic!("Expected Methods section body"), + } +} + +// ============================================================================= +// See Also section +// ============================================================================= + +#[test] +fn test_see_also_basic() { + let docstring = "Summary.\n\nSee Also:\n other_func: Does something else."; + let result = parse_google(docstring); + let sa = see_also(&result); + assert_eq!(sa.len(), 1); + assert_eq!(sa[0].names.len(), 1); + assert_eq!(sa[0].names[0].source_text(&result.source), "other_func"); + assert_eq!( + sa[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "Does something else." + ); +} + +#[test] +fn test_see_also_multiple_names() { + let docstring = "Summary.\n\nSee Also:\n func_a, func_b, func_c"; + let result = parse_google(docstring); + let sa = see_also(&result); + assert_eq!(sa.len(), 1); + assert_eq!(sa[0].names.len(), 3); + assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); + assert_eq!(sa[0].names[1].source_text(&result.source), "func_b"); + assert_eq!(sa[0].names[2].source_text(&result.source), "func_c"); + assert!(sa[0].description.is_none()); +} + +#[test] +fn test_see_also_mixed() { + let docstring = "Summary.\n\nSee Also:\n func_a: Description.\n func_b, func_c"; + let result = parse_google(docstring); + let sa = see_also(&result); + assert_eq!(sa.len(), 2); + assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); + assert!(sa[0].description.is_some()); + assert_eq!(sa[1].names.len(), 2); + assert!(sa[1].description.is_none()); +} + +#[test] +fn test_see_also_section_body_variant() { + let docstring = "Summary.\n\nSee Also:\n func_a: Desc."; + let result = parse_google(docstring); + match &all_sections(&result).into_iter().next().unwrap().body { + GoogleSectionBody::SeeAlso(items) => { + assert_eq!(items.len(), 1); + } + _ => panic!("Expected SeeAlso section body"), + } +} diff --git a/tests/google/summary.rs b/tests/google/summary.rs new file mode 100644 index 0000000..a0320f1 --- /dev/null +++ b/tests/google/summary.rs @@ -0,0 +1,139 @@ +use super::*; + +// ============================================================================= +// Summary / Extended Summary +// ============================================================================= + +#[test] +fn test_simple_summary() { + let docstring = "This is a brief summary."; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "This is a brief summary." + ); +} + +#[test] +fn test_summary_span() { + let docstring = "Brief description."; + let result = parse_google(docstring); + assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(0)); + assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(18)); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief description." + ); +} + +#[test] +fn test_empty_docstring() { + let result = parse_google(""); + assert!(result.summary.is_none()); +} + +#[test] +fn test_whitespace_only_docstring() { + let result = parse_google(" \n \n"); + assert!(result.summary.is_none()); +} + +#[test] +fn test_summary_with_description() { + let docstring = + "Brief summary.\n\nExtended description that provides\nmore details about the function."; + let result = parse_google(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief summary." + ); + let desc = result.extended_summary.as_ref().unwrap(); + assert_eq!( + desc.source_text(&result.source), + "Extended description that provides\nmore details about the function." + ); +} + +#[test] +fn test_summary_with_multiline_description() { + let docstring = r#"Brief summary. + +First paragraph of description. + +Second paragraph of description."#; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief summary." + ); + let desc = result.extended_summary.as_ref().unwrap(); + assert!(desc.source_text(&result.source).contains("First paragraph")); + assert!( + desc.source_text(&result.source) + .contains("Second paragraph") + ); +} + +#[test] +fn test_multiline_summary() { + let docstring = "This is a long summary\nthat spans two lines.\n\nExtended description."; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "This is a long summary\nthat spans two lines." + ); + let desc = result.extended_summary.as_ref().unwrap(); + assert_eq!(desc.source_text(&result.source), "Extended description."); +} + +#[test] +fn test_multiline_summary_no_extended() { + let docstring = "Summary line one\ncontinues here."; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary line one\ncontinues here." + ); + assert!(result.extended_summary.is_none()); +} + +#[test] +fn test_multiline_summary_then_section() { + let docstring = "Summary line one\ncontinues here.\nArgs:\n x (int): val"; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary line one\ncontinues here." + ); + assert!(result.extended_summary.is_none()); + assert_eq!(result.items.len(), 1); +} + +#[test] +fn test_section_only_no_summary() { + let docstring = "Args:\n x (int): Value."; + let result = parse_google(docstring); + assert_eq!(args(&result).len(), 1); +} + +#[test] +fn test_leading_blank_lines() { + let docstring = "\n\n\nSummary.\n\nArgs:\n x: Value."; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); + assert_eq!(args(&result).len(), 1); +} + +#[test] +fn test_docstring_like_summary() { + let docstring = "Summary."; + let result = parse_google(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); +} diff --git a/tests/google_tests.rs b/tests/google_tests.rs deleted file mode 100644 index 72adf8a..0000000 --- a/tests/google_tests.rs +++ /dev/null @@ -1,2199 +0,0 @@ -//! Integration tests for Google-style docstring parser. - -use pydocstring::GoogleSectionBody; -use pydocstring::google::parse_google; -use pydocstring::google::{ - GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, - GoogleMethod, GoogleReturns, GoogleSection, GoogleSeeAlsoItem, GoogleWarning, -}; -use pydocstring::{LineIndex, TextSize}; - -// ============================================================================= -// Test-local helpers -// ============================================================================= - -fn all_sections(doc: &GoogleDocstring) -> Vec<&GoogleSection> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .collect() -} - -fn args(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Args(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn returns(doc: &GoogleDocstring) -> Option<&GoogleReturns> { - doc.items.iter().find_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Returns(r) => Some(r), - _ => None, - }, - _ => None, - }) -} - -fn yields(doc: &GoogleDocstring) -> Option<&GoogleReturns> { - doc.items.iter().find_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Yields(r) => Some(r), - _ => None, - }, - _ => None, - }) -} - -fn raises(doc: &GoogleDocstring) -> Vec<&GoogleException> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Raises(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn attributes(doc: &GoogleDocstring) -> Vec<&GoogleAttribute> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Attributes(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn keyword_args(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::KeywordArgs(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn other_parameters(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::OtherParameters(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn receives(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Receives(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn warns(doc: &GoogleDocstring) -> Vec<&GoogleWarning> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Warns(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn see_also(doc: &GoogleDocstring) -> Vec<&GoogleSeeAlsoItem> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::SeeAlso(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn methods(doc: &GoogleDocstring) -> Vec<&GoogleMethod> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Methods(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect() -} - -fn notes(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Notes(v) => Some(v), - _ => None, - }) -} - -fn examples(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Examples(v) => Some(v), - _ => None, - }) -} - -fn todo(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Todo(v) => Some(v), - _ => None, - }) -} - -fn references(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::References(v) => Some(v), - _ => None, - }) -} - -fn warnings(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Warnings(v) => Some(v), - _ => None, - }) -} - -// ============================================================================= -// Basic parsing -// ============================================================================= - -#[test] -fn test_simple_summary() { - let docstring = "This is a brief summary."; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "This is a brief summary." - ); -} - -#[test] -fn test_summary_span() { - let docstring = "Brief description."; - let result = parse_google(docstring); - assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(0)); - assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(18)); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief description." - ); -} - -#[test] -fn test_empty_docstring() { - let result = parse_google(""); - assert!(result.summary.is_none()); -} - -#[test] -fn test_whitespace_only_docstring() { - let result = parse_google(" \n \n"); - assert!(result.summary.is_none()); -} - -#[test] -fn test_summary_with_description() { - let docstring = - "Brief summary.\n\nExtended description that provides\nmore details about the function."; - let result = parse_google(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief summary." - ); - let desc = result.extended_summary.as_ref().unwrap(); - assert_eq!( - desc.source_text(&result.source), - "Extended description that provides\nmore details about the function." - ); -} - -#[test] -fn test_summary_with_multiline_description() { - let docstring = r#"Brief summary. - -First paragraph of description. - -Second paragraph of description."#; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief summary." - ); - let desc = result.extended_summary.as_ref().unwrap(); - assert!(desc.source_text(&result.source).contains("First paragraph")); - assert!( - desc.source_text(&result.source) - .contains("Second paragraph") - ); -} - -#[test] -fn test_multiline_summary() { - let docstring = "This is a long summary\nthat spans two lines.\n\nExtended description."; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "This is a long summary\nthat spans two lines." - ); - let desc = result.extended_summary.as_ref().unwrap(); - assert_eq!(desc.source_text(&result.source), "Extended description."); -} - -#[test] -fn test_multiline_summary_no_extended() { - let docstring = "Summary line one\ncontinues here."; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary line one\ncontinues here." - ); - assert!(result.extended_summary.is_none()); -} - -#[test] -fn test_multiline_summary_then_section() { - let docstring = "Summary line one\ncontinues here.\nArgs:\n x (int): val"; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary line one\ncontinues here." - ); - assert!(result.extended_summary.is_none()); - assert_eq!(result.items.len(), 1); -} - -// ============================================================================= -// Args section -// ============================================================================= - -#[test] -fn test_args_basic() { - let docstring = "Summary.\n\nArgs:\n x (int): The value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The value." - ); -} - -#[test] -fn test_args_multiple() { - let docstring = "Summary.\n\nArgs:\n x (int): First.\n y (str): Second."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!(a[1].name.source_text(&result.source), "y"); - assert_eq!( - a[1].r#type.as_ref().unwrap().source_text(&result.source), - "str" - ); -} - -#[test] -fn test_args_no_type() { - let docstring = "Summary.\n\nArgs:\n x: The value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert!(a[0].r#type.is_none()); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The value." - ); -} - -/// Colon with no space after it: `name:description` -#[test] -fn test_args_no_space_after_colon() { - let docstring = "Summary.\n\nArgs:\n x:The value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The value." - ); -} - -/// Colon with extra spaces: `name: description` -#[test] -fn test_args_extra_spaces_after_colon() { - let docstring = "Summary.\n\nArgs:\n x: The value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The value." - ); -} - -/// Returns entry with no space after colon. -#[test] -fn test_returns_no_space_after_colon() { - let docstring = "Summary.\n\nReturns:\n int:The result."; - let result = parse_google(docstring); - let r = returns(&result).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), - "The result." - ); -} - -/// Returns entry with extra spaces after colon. -#[test] -fn test_returns_extra_spaces_after_colon() { - let docstring = "Summary.\n\nReturns:\n int: The result."; - let result = parse_google(docstring); - let r = returns(&result).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), - "The result." - ); -} - -/// Raises entry with no space after colon. -#[test] -fn test_raises_no_space_after_colon() { - let docstring = "Summary.\n\nRaises:\n ValueError:If invalid."; - let result = parse_google(docstring); - let r = raises(&result); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "If invalid." - ); -} - -/// Raises entry with extra spaces after colon. -#[test] -fn test_raises_extra_spaces_after_colon() { - let docstring = "Summary.\n\nRaises:\n ValueError: If invalid."; - let result = parse_google(docstring); - let r = raises(&result); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "If invalid." - ); -} - -#[test] -fn test_args_optional() { - let docstring = "Summary.\n\nArgs:\n x (int, optional): The value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert!(a[0].optional.is_some()); -} - -#[test] -fn test_args_complex_type() { - let docstring = "Summary.\n\nArgs:\n data (Dict[str, List[int]]): The data."; - let result = parse_google(docstring); - assert_eq!( - args(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "Dict[str, List[int]]" - ); -} - -#[test] -fn test_args_tuple_type() { - let docstring = "Summary.\n\nArgs:\n pair (Tuple[int, str]): A pair of values."; - let result = parse_google(docstring); - assert_eq!( - args(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "Tuple[int, str]" - ); -} - -#[test] -fn test_args_multiline_description() { - let docstring = - "Summary.\n\nArgs:\n x (int): First line.\n Second line.\n Third line."; - let result = parse_google(docstring); - assert_eq!( - args(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "First line.\n Second line.\n Third line." - ); -} - -#[test] -fn test_args_description_on_next_line() { - let docstring = "Summary.\n\nArgs:\n x (int):\n The description."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The description." - ); -} - -#[test] -fn test_args_varargs() { - let docstring = "Summary.\n\nArgs:\n *args: Positional args.\n **kwargs: Keyword args."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "*args"); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "Positional args." - ); - assert_eq!(a[1].name.source_text(&result.source), "**kwargs"); - assert_eq!( - a[1].description - .as_ref() - .unwrap() - .source_text(&result.source), - "Keyword args." - ); -} - -#[test] -fn test_args_kwargs_with_type() { - let docstring = "Summary.\n\nArgs:\n **kwargs (dict): Keyword arguments."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "**kwargs"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "dict" - ); -} - -#[test] -fn test_arguments_alias() { - let docstring = "Summary.\n\nArguments:\n x (int): The value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); -} - -// ============================================================================= -// Args span accuracy -// ============================================================================= - -#[test] -fn test_args_name_span() { - let docstring = "Summary.\n\nArgs:\n x (int): Value."; - let result = parse_google(docstring); - let arg = &args(&result)[0]; - let index = LineIndex::from_source(&result.source); - let (line, col) = index.line_col(arg.name.start()); - assert_eq!(line, 3); - assert_eq!(col, 4); - assert_eq!(arg.name.end(), TextSize::new(arg.name.start().raw() + 1)); - assert_eq!(arg.name.source_text(&result.source), "x"); -} - -#[test] -fn test_args_type_span() { - let docstring = "Summary.\n\nArgs:\n x (int): Value."; - let result = parse_google(docstring); - let arg = &args(&result)[0]; - let type_span = arg.r#type.as_ref().unwrap(); - let index = LineIndex::from_source(&result.source); - let (line, _col) = index.line_col(type_span.start()); - assert_eq!(line, 3); - assert_eq!(type_span.source_text(&result.source), "int"); -} - -#[test] -fn test_args_optional_span() { - let docstring = "Summary.\n\nArgs:\n x (int, optional): Value."; - let result = parse_google(docstring); - let opt_span = args(&result)[0].optional.as_ref().unwrap(); - assert_eq!(opt_span.source_text(&result.source), "optional"); -} - -#[test] -fn test_args_square_bracket_type() { - let docstring = "Summary.\n\nArgs:\n x [int]: The value."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "[" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "]" - ); - assert_eq!( - a.description.as_ref().unwrap().source_text(&result.source), - "The value." - ); -} - -#[test] -fn test_args_curly_bracket_type() { - let docstring = "Summary.\n\nArgs:\n x {int}: The value."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "{" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "}" - ); - assert_eq!( - a.description.as_ref().unwrap().source_text(&result.source), - "The value." - ); -} - -#[test] -fn test_args_paren_bracket_spans() { - // Verify that the standard () brackets are also tracked. - let docstring = "Summary.\n\nArgs:\n x (int): The value."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "(" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "(" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - ")" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - ")" - ); -} - -#[test] -fn test_args_no_bracket_fields_when_no_type() { - let docstring = "Summary.\n\nArgs:\n x: The value."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert!(a.open_bracket.is_none()); - assert!(a.close_bracket.is_none()); - assert!(a.r#type.is_none()); -} - -#[test] -fn test_args_square_bracket_optional() { - let docstring = "Summary.\n\nArgs:\n x [int, optional]: The value."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "[" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "]" - ); - assert!(a.optional.is_some()); -} - -#[test] -fn test_args_square_bracket_complex_type() { - let docstring = "Summary.\n\nArgs:\n items [List[int]]: The items."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "items"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "List[int]" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "[" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "]" - ); -} - -#[test] -fn test_args_angle_bracket_type() { - let docstring = "Summary.\n\nArgs:\n x : The value."; - let result = parse_google(docstring); - let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "<" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - ">" - ); - assert_eq!( - a.description.as_ref().unwrap().source_text(&result.source), - "The value." - ); -} - -// ============================================================================= -// Returns section -// ============================================================================= - -#[test] -fn test_returns_with_type() { - let docstring = "Summary.\n\nReturns:\n int: The result."; - let result = parse_google(docstring); - let r = returns(&result).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), - "The result." - ); -} - -#[test] -fn test_returns_multiple_lines() { - let docstring = "Summary.\n\nReturns:\n int: The count.\n str: The message."; - let result = parse_google(docstring); - let r = returns(&result).unwrap(); - // Only the first line is checked for type: the rest becomes description. - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), - "The count.\n str: The message." - ); -} - -#[test] -fn test_returns_without_type() { - let docstring = "Summary.\n\nReturns:\n The computed result."; - let result = parse_google(docstring); - let r = returns(&result).unwrap(); - assert!(r.return_type.is_none()); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), - "The computed result." - ); -} - -#[test] -fn test_returns_multiline_description() { - let docstring = "Summary.\n\nReturns:\n int: The result\n of the computation."; - let result = parse_google(docstring); - assert_eq!( - returns(&result) - .unwrap() - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "The result\n of the computation." - ); -} - -#[test] -fn test_return_alias() { - let docstring = "Summary.\n\nReturn:\n int: The value."; - let result = parse_google(docstring); - assert!(returns(&result).is_some()); -} - -// ============================================================================= -// Yields section -// ============================================================================= - -#[test] -fn test_yields() { - let docstring = "Summary.\n\nYields:\n int: The next value."; - let result = parse_google(docstring); - let y = yields(&result).unwrap(); - assert_eq!( - y.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - y.description.as_ref().unwrap().source_text(&result.source), - "The next value." - ); -} - -#[test] -fn test_yield_alias() { - let docstring = "Summary.\n\nYield:\n str: Next string."; - let result = parse_google(docstring); - assert!(yields(&result).is_some()); -} - -// ============================================================================= -// Raises section -// ============================================================================= - -#[test] -fn test_raises_single() { - let docstring = "Summary.\n\nRaises:\n ValueError: If the input is invalid."; - let result = parse_google(docstring); - let r = raises(&result); - assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "If the input is invalid." - ); -} - -#[test] -fn test_raises_multiple() { - let docstring = - "Summary.\n\nRaises:\n ValueError: If invalid.\n TypeError: If wrong type."; - let result = parse_google(docstring); - let r = raises(&result); - assert_eq!(r.len(), 2); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!(r[1].r#type.source_text(&result.source), "TypeError"); -} - -#[test] -fn test_raises_multiline_description() { - let docstring = "Summary.\n\nRaises:\n ValueError: If the\n input is invalid."; - let result = parse_google(docstring); - assert_eq!( - raises(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "If the\n input is invalid." - ); -} - -#[test] -fn test_raises_exception_type_span() { - let docstring = "Summary.\n\nRaises:\n ValueError: If bad."; - let result = parse_google(docstring); - assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), - "ValueError" - ); -} - -// ============================================================================= -// Attributes section -// ============================================================================= - -#[test] -fn test_attributes() { - let docstring = "Summary.\n\nAttributes:\n name (str): The name.\n age (int): The age."; - let result = parse_google(docstring); - let a = attributes(&result); - assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "name"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "str" - ); - assert_eq!(a[1].name.source_text(&result.source), "age"); -} - -#[test] -fn test_attributes_no_type() { - let docstring = "Summary.\n\nAttributes:\n name: The name."; - let result = parse_google(docstring); - let a = attributes(&result); - assert_eq!(a[0].name.source_text(&result.source), "name"); - assert!(a[0].r#type.is_none()); -} - -// ============================================================================= -// Free-text sections -// ============================================================================= - -#[test] -fn test_note_section() { - let docstring = "Summary.\n\nNote:\n This is a note."; - let result = parse_google(docstring); - assert_eq!( - notes(&result).unwrap().source_text(&result.source), - "This is a note." - ); -} - -#[test] -fn test_notes_alias() { - let docstring = "Summary.\n\nNotes:\n This is also a note."; - let result = parse_google(docstring); - assert_eq!( - notes(&result).unwrap().source_text(&result.source), - "This is also a note." - ); -} - -#[test] -fn test_example_section() { - let docstring = "Summary.\n\nExample:\n >>> func(1)\n 1"; - let result = parse_google(docstring); - assert_eq!( - examples(&result).unwrap().source_text(&result.source), - ">>> func(1)\n 1" - ); -} - -#[test] -fn test_examples_alias() { - let docstring = "Summary.\n\nExamples:\n >>> 1 + 1\n 2"; - let result = parse_google(docstring); - assert!(examples(&result).is_some()); -} - -#[test] -fn test_references_section() { - let docstring = "Summary.\n\nReferences:\n Author, Title, 2024."; - let result = parse_google(docstring); - assert!(references(&result).is_some()); -} - -#[test] -fn test_warnings_section() { - let docstring = "Summary.\n\nWarnings:\n This function is deprecated."; - let result = parse_google(docstring); - assert_eq!( - warnings(&result).unwrap().source_text(&result.source), - "This function is deprecated." - ); -} - -// ============================================================================= -// Todo section -// ============================================================================= - -#[test] -fn test_todo_freetext() { - let docstring = "Summary.\n\nTodo:\n * Item one.\n * Item two."; - let result = parse_google(docstring); - let t = todo(&result).unwrap(); - assert!(t.source_text(&result.source).contains("Item one.")); - assert!(t.source_text(&result.source).contains("Item two.")); -} - -#[test] -fn test_todo_without_bullets() { - let docstring = "Summary.\n\nTodo:\n Implement feature X.\n Fix bug Y."; - let result = parse_google(docstring); - let t = todo(&result).unwrap(); - assert!( - t.source_text(&result.source) - .contains("Implement feature X.") - ); - assert!(t.source_text(&result.source).contains("Fix bug Y.")); -} - -#[test] -fn test_todo_multiline() { - let docstring = - "Summary.\n\nTodo:\n * Item one that\n continues here.\n * Item two."; - let result = parse_google(docstring); - let t = todo(&result).unwrap(); - assert!(t.source_text(&result.source).contains("Item one that")); - assert!(t.source_text(&result.source).contains("continues here.")); - assert!(t.source_text(&result.source).contains("Item two.")); -} - -// ============================================================================= -// Multiple sections -// ============================================================================= - -#[test] -fn test_all_sections() { - let docstring = r#"Calculate the sum. - -This function adds two numbers. - -Args: - a (int): The first number. - b (int): The second number. - -Returns: - int: The sum of a and b. - -Raises: - TypeError: If inputs are not numbers. - -Example: - >>> add(1, 2) - 3 - -Note: - This is a simple function."#; - - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Calculate the sum." - ); - assert!(result.extended_summary.is_some()); - assert_eq!(args(&result).len(), 2); - assert!(returns(&result).is_some()); - assert_eq!(raises(&result).len(), 1); - assert!(examples(&result).is_some()); - assert!(notes(&result).is_some()); -} - -#[test] -fn test_sections_with_blank_lines() { - let docstring = "Summary.\n\nArgs:\n x (int): Value.\n\n y (str): Name.\n\nReturns:\n bool: Success."; - let result = parse_google(docstring); - assert_eq!(args(&result).len(), 2); - assert!(returns(&result).is_some()); -} - -// ============================================================================= -// Section order preservation -// ============================================================================= - -#[test] -fn test_section_order() { - let docstring = "Summary.\n\nReturns:\n int: Value.\n\nArgs:\n x: Input."; - let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); - assert_eq!(sections.len(), 2); - assert_eq!( - sections[0].header.name.source_text(&result.source), - "Returns" - ); - assert_eq!(sections[1].header.name.source_text(&result.source), "Args"); -} - -#[test] -fn test_section_header_span() { - let docstring = "Summary.\n\nArgs:\n x: Value."; - let result = parse_google(docstring); - let header = &all_sections(&result).into_iter().next().unwrap().header; - assert_eq!(header.name.source_text(&result.source), "Args"); - assert_eq!(header.name.source_text(&result.source), "Args"); - assert_eq!(header.range.source_text(&result.source), "Args:"); -} - -#[test] -fn test_section_span() { - let docstring = "Summary.\n\nArgs:\n x: Value."; - let result = parse_google(docstring); - let section = all_sections(&result).into_iter().next().unwrap(); - assert_eq!( - section.range.source_text(&result.source), - "Args:\n x: Value." - ); -} - -// ============================================================================= -// Unknown sections -// ============================================================================= - -#[test] -fn test_unknown_section_preserved() { - let docstring = "Summary.\n\nCustom:\n Some custom content."; - let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); - assert_eq!(sections.len(), 1); - assert_eq!( - sections[0].header.name.source_text(&result.source), - "Custom" - ); - match §ions[0].body { - GoogleSectionBody::Unknown(text) => { - assert_eq!(text.source_text(&result.source), "Some custom content."); - } - _ => panic!("Expected Unknown section body"), - } -} - -#[test] -fn test_unknown_section_with_known() { - let docstring = - "Summary.\n\nArgs:\n x: Value.\n\nCustom:\n Content.\n\nReturns:\n int: Result."; - let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); - assert_eq!(sections.len(), 3); - assert_eq!(sections[0].header.name.source_text(&result.source), "Args"); - assert_eq!( - sections[1].header.name.source_text(&result.source), - "Custom" - ); - assert_eq!( - sections[2].header.name.source_text(&result.source), - "Returns" - ); - // Known sections still accessible via helpers - assert_eq!(args(&result).len(), 1); - assert!(returns(&result).is_some()); -} - -#[test] -fn test_multiple_unknown_sections() { - let docstring = "Summary.\n\nCustom One:\n First.\n\nCustom Two:\n Second."; - let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); - assert_eq!(sections.len(), 2); - assert_eq!( - sections[0].header.name.source_text(&result.source), - "Custom One" - ); - assert_eq!( - sections[1].header.name.source_text(&result.source), - "Custom Two" - ); -} - -// ============================================================================= -// Indented docstring (non-zero base indent) -// ============================================================================= - -#[test] -fn test_indented_docstring() { - let docstring = " Summary.\n\n Args:\n x (int): Value."; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); - let a = args(&result); - assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); -} - -#[test] -fn test_indented_summary_span() { - let docstring = " Summary."; - let result = parse_google(docstring); - assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(4)); - assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(12)); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); -} - -// ============================================================================= -// Convenience accessors -// ============================================================================= - -#[test] -fn test_docstring_like_summary() { - let docstring = "Summary."; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); -} - -#[test] -fn test_docstring_like_parameters() { - let docstring = "Summary.\n\nArgs:\n x (int): Value.\n y (str): Name."; - let result = parse_google(docstring); - let params = args(&result); - assert_eq!(params.len(), 2); - assert_eq!(params[0].name.source_text(&result.source), "x"); - assert_eq!( - params[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); - assert_eq!(params[1].name.source_text(&result.source), "y"); -} - -#[test] -fn test_docstring_like_returns() { - let docstring = "Summary.\n\nReturns:\n int: The result."; - let result = parse_google(docstring); - let r = returns(&result).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); -} - -#[test] -fn test_docstring_like_raises() { - let docstring = "Summary.\n\nRaises:\n ValueError: If bad."; - let result = parse_google(docstring); - let r = raises(&result); - assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); -} - -// ============================================================================= -// Span round-trip -// ============================================================================= - -#[test] -fn test_span_source_text_round_trip() { - let docstring = "Summary.\n\nArgs:\n x (int): Value.\n\nReturns:\n bool: Success."; - let result = parse_google(docstring); - - // Summary - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); - - // Arg name - assert_eq!(args(&result)[0].name.source_text(&result.source), "x"); - - // Arg type - assert_eq!( - args(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); - - // Return type - assert_eq!( - returns(&result) - .unwrap() - .return_type - .as_ref() - .unwrap() - .source_text(&result.source), - "bool" - ); -} - -// ============================================================================= -// Edge cases -// ============================================================================= - -#[test] -fn test_section_only_no_summary() { - let docstring = "Args:\n x (int): Value."; - let result = parse_google(docstring); - // "Args:" at base_indent=0 is a section header, so summary remains empty - assert_eq!(args(&result).len(), 1); -} - -#[test] -fn test_leading_blank_lines() { - let docstring = "\n\n\nSummary.\n\nArgs:\n x: Value."; - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); - assert_eq!(args(&result).len(), 1); -} - -#[test] -fn test_optional_only_in_parens() { - let docstring = "Summary.\n\nArgs:\n x (optional): Value."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert!(a[0].r#type.is_none()); - assert!(a[0].optional.is_some()); -} - -#[test] -fn test_complex_optional_type() { - let docstring = "Summary.\n\nArgs:\n x (List[int], optional): Values."; - let result = parse_google(docstring); - let a = args(&result); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "List[int]" - ); - assert!(a[0].optional.is_some()); -} - -// ============================================================================= -// Napoleon: Parameters / Params aliases -// ============================================================================= - -#[test] -fn test_parameters_alias() { - let docstring = "Summary.\n\nParameters:\n x (int): The value."; - let result = parse_google(docstring); - assert_eq!(args(&result).len(), 1); - assert_eq!(args(&result)[0].name.source_text(&result.source), "x"); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Parameters" - ); -} - -#[test] -fn test_params_alias() { - let docstring = "Summary.\n\nParams:\n x (int): The value."; - let result = parse_google(docstring); - assert_eq!(args(&result).len(), 1); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Params" - ); -} - -// ============================================================================= -// Napoleon: Keyword Args section -// ============================================================================= - -#[test] -fn test_keyword_args_basic() { - let docstring = "Summary.\n\nKeyword Args:\n timeout (int): Timeout in seconds.\n retries (int): Number of retries."; - let result = parse_google(docstring); - let ka = keyword_args(&result); - assert_eq!(ka.len(), 2); - assert_eq!(ka[0].name.source_text(&result.source), "timeout"); - assert_eq!( - ka[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!(ka[1].name.source_text(&result.source), "retries"); -} - -#[test] -fn test_keyword_arguments_alias() { - let docstring = "Summary.\n\nKeyword Arguments:\n key (str): The key."; - let result = parse_google(docstring); - assert_eq!(keyword_args(&result).len(), 1); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Keyword Arguments" - ); -} - -#[test] -fn test_keyword_args_section_body_variant() { - let docstring = "Summary.\n\nKeyword Args:\n k (str): Key."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::KeywordArgs(args) => { - assert_eq!(args.len(), 1); - } - _ => panic!("Expected KeywordArgs section body"), - } -} - -// ============================================================================= -// Napoleon: Other Parameters section -// ============================================================================= - -#[test] -fn test_other_parameters() { - let docstring = "Summary.\n\nOther Parameters:\n debug (bool): Enable debug mode.\n verbose (bool, optional): Verbose output."; - let result = parse_google(docstring); - let op = other_parameters(&result); - assert_eq!(op.len(), 2); - assert_eq!(op[0].name.source_text(&result.source), "debug"); - assert_eq!(op[1].name.source_text(&result.source), "verbose"); - assert!(op[1].optional.is_some()); -} - -#[test] -fn test_other_parameters_section_body_variant() { - let docstring = "Summary.\n\nOther Parameters:\n x (int): Extra."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::OtherParameters(args) => { - assert_eq!(args.len(), 1); - } - _ => panic!("Expected OtherParameters section body"), - } -} - -// ============================================================================= -// Napoleon: Receives section -// ============================================================================= - -#[test] -fn test_receives() { - let docstring = "Summary.\n\nReceives:\n data (bytes): The received data."; - let result = parse_google(docstring); - let r = receives(&result); - assert_eq!(r.len(), 1); - assert_eq!(r[0].name.source_text(&result.source), "data"); - assert_eq!( - r[0].r#type.as_ref().unwrap().source_text(&result.source), - "bytes" - ); -} - -#[test] -fn test_receive_alias() { - let docstring = "Summary.\n\nReceive:\n msg (str): The message."; - let result = parse_google(docstring); - assert_eq!(receives(&result).len(), 1); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Receive" - ); -} - -// ============================================================================= -// Napoleon: Raise alias -// ============================================================================= - -#[test] -fn test_raise_alias() { - let docstring = "Summary.\n\nRaise:\n ValueError: If invalid."; - let result = parse_google(docstring); - let r = raises(&result); - assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Raise" - ); -} - -// ============================================================================= -// Napoleon: Warns section -// ============================================================================= - -#[test] -fn test_warns_basic() { - let docstring = "Summary.\n\nWarns:\n DeprecationWarning: If using old API."; - let result = parse_google(docstring); - let w = warns(&result); - assert_eq!(w.len(), 1); - assert_eq!( - w[0].warning_type.source_text(&result.source), - "DeprecationWarning" - ); - assert_eq!( - w[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "If using old API." - ); -} - -#[test] -fn test_warns_multiple() { - let docstring = - "Summary.\n\nWarns:\n DeprecationWarning: Old API.\n UserWarning: Bad config."; - let result = parse_google(docstring); - let w = warns(&result); - assert_eq!(w.len(), 2); - assert_eq!( - w[0].warning_type.source_text(&result.source), - "DeprecationWarning" - ); - assert_eq!(w[1].warning_type.source_text(&result.source), "UserWarning"); -} - -#[test] -fn test_warn_alias() { - let docstring = "Summary.\n\nWarn:\n FutureWarning: Will change."; - let result = parse_google(docstring); - assert_eq!(warns(&result).len(), 1); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Warn" - ); -} - -#[test] -fn test_warns_multiline_description() { - let docstring = "Summary.\n\nWarns:\n UserWarning: First line.\n Second line."; - let result = parse_google(docstring); - assert_eq!( - warns(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "First line.\n Second line." - ); -} - -#[test] -fn test_warns_section_body_variant() { - let docstring = "Summary.\n\nWarns:\n UserWarning: Desc."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Warns(warns) => { - assert_eq!(warns.len(), 1); - } - _ => panic!("Expected Warns section body"), - } -} - -// ============================================================================= -// Napoleon: Warning alias -// ============================================================================= - -#[test] -fn test_warning_singular_alias() { - let docstring = "Summary.\n\nWarning:\n This is deprecated."; - let result = parse_google(docstring); - assert_eq!( - warnings(&result).unwrap().source_text(&result.source), - "This is deprecated." - ); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Warning" - ); -} - -// ============================================================================= -// Napoleon: Attribute alias -// ============================================================================= - -#[test] -fn test_attribute_singular_alias() { - let docstring = "Summary.\n\nAttribute:\n name (str): The name."; - let result = parse_google(docstring); - assert_eq!(attributes(&result).len(), 1); - assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Attribute" - ); -} - -// ============================================================================= -// Napoleon: Methods section -// ============================================================================= - -#[test] -fn test_methods_basic() { - let docstring = "Summary.\n\nMethods:\n reset(): Reset the state.\n update(data): Update with new data."; - let result = parse_google(docstring); - let m = methods(&result); - assert_eq!(m.len(), 2); - assert_eq!(m[0].name.source_text(&result.source), "reset()"); - assert_eq!( - m[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "Reset the state." - ); - assert_eq!(m[1].name.source_text(&result.source), "update(data)"); -} - -#[test] -fn test_methods_without_parens() { - let docstring = "Summary.\n\nMethods:\n do_stuff: Performs the operation."; - let result = parse_google(docstring); - let m = methods(&result); - assert_eq!(m.len(), 1); - assert_eq!(m[0].name.source_text(&result.source), "do_stuff"); - assert_eq!( - m[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "Performs the operation." - ); -} - -#[test] -fn test_methods_section_body_variant() { - let docstring = "Summary.\n\nMethods:\n foo(): Does bar."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Methods(methods) => { - assert_eq!(methods.len(), 1); - } - _ => panic!("Expected Methods section body"), - } -} - -// ============================================================================= -// Napoleon: See Also section -// ============================================================================= - -#[test] -fn test_see_also_basic() { - let docstring = "Summary.\n\nSee Also:\n other_func: Does something else."; - let result = parse_google(docstring); - let sa = see_also(&result); - assert_eq!(sa.len(), 1); - assert_eq!(sa[0].names.len(), 1); - assert_eq!(sa[0].names[0].source_text(&result.source), "other_func"); - assert_eq!( - sa[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "Does something else." - ); -} - -#[test] -fn test_see_also_multiple_names() { - let docstring = "Summary.\n\nSee Also:\n func_a, func_b, func_c"; - let result = parse_google(docstring); - let sa = see_also(&result); - assert_eq!(sa.len(), 1); - assert_eq!(sa[0].names.len(), 3); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); - assert_eq!(sa[0].names[1].source_text(&result.source), "func_b"); - assert_eq!(sa[0].names[2].source_text(&result.source), "func_c"); - assert!(sa[0].description.is_none()); -} - -#[test] -fn test_see_also_mixed() { - let docstring = "Summary.\n\nSee Also:\n func_a: Description.\n func_b, func_c"; - let result = parse_google(docstring); - let sa = see_also(&result); - assert_eq!(sa.len(), 2); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); - assert!(sa[0].description.is_some()); - assert_eq!(sa[1].names.len(), 2); - assert!(sa[1].description.is_none()); -} - -#[test] -fn test_see_also_section_body_variant() { - let docstring = "Summary.\n\nSee Also:\n func_a: Desc."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::SeeAlso(items) => { - assert_eq!(items.len(), 1); - } - _ => panic!("Expected SeeAlso section body"), - } -} - -// ============================================================================= -// Napoleon: Admonition sections -// ============================================================================= - -#[test] -fn test_attention_section() { - let docstring = "Summary.\n\nAttention:\n This requires careful handling."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Attention(text) => { - assert_eq!( - text.source_text(&result.source), - "This requires careful handling." - ); - } - _ => panic!("Expected Attention section body"), - } -} - -#[test] -fn test_caution_section() { - let docstring = "Summary.\n\nCaution:\n Use with care."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Caution(text) => { - assert_eq!(text.source_text(&result.source), "Use with care."); - } - _ => panic!("Expected Caution section body"), - } -} - -#[test] -fn test_danger_section() { - let docstring = "Summary.\n\nDanger:\n May cause data loss."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Danger(text) => { - assert_eq!(text.source_text(&result.source), "May cause data loss."); - } - _ => panic!("Expected Danger section body"), - } -} - -#[test] -fn test_error_section() { - let docstring = "Summary.\n\nError:\n Known issue with large inputs."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Error(text) => { - assert_eq!( - text.source_text(&result.source), - "Known issue with large inputs." - ); - } - _ => panic!("Expected Error section body"), - } -} - -#[test] -fn test_hint_section() { - let docstring = "Summary.\n\nHint:\n Try using a smaller batch size."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Hint(text) => { - assert_eq!( - text.source_text(&result.source), - "Try using a smaller batch size." - ); - } - _ => panic!("Expected Hint section body"), - } -} - -#[test] -fn test_important_section() { - let docstring = "Summary.\n\nImportant:\n Must be called before init()."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Important(text) => { - assert_eq!( - text.source_text(&result.source), - "Must be called before init()." - ); - } - _ => panic!("Expected Important section body"), - } -} - -#[test] -fn test_tip_section() { - let docstring = "Summary.\n\nTip:\n Use vectorized operations for speed."; - let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Tip(text) => { - assert_eq!( - text.source_text(&result.source), - "Use vectorized operations for speed." - ); - } - _ => panic!("Expected Tip section body"), - } -} - -// ============================================================================= -// Napoleon: Case-insensitive section headers -// ============================================================================= - -#[test] -fn test_napoleon_case_insensitive() { - let docstring = "Summary.\n\nkeyword args:\n x (int): Value."; - let result = parse_google(docstring); - assert_eq!(keyword_args(&result).len(), 1); -} - -#[test] -fn test_see_also_case_insensitive() { - let docstring = "Summary.\n\nsee also:\n func_a: Description."; - let result = parse_google(docstring); - assert_eq!(see_also(&result).len(), 1); -} - -// ============================================================================= -// Napoleon: Full docstring with all sections -// ============================================================================= - -#[test] -fn test_napoleon_full_docstring() { - let docstring = r#"Calculate something. - -Extended description. - -Args: - x (int): First argument. - -Keyword Args: - timeout (float): Timeout value. - -Returns: - int: The result. - -Raises: - ValueError: If x is negative. - -Warns: - DeprecationWarning: If old API is used. - -See Also: - other_func: Related function. - -Note: - Implementation detail. - -Example: - >>> calculate(1) - 1"#; - - let result = parse_google(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Calculate something." - ); - assert!(result.extended_summary.is_some()); - assert_eq!(args(&result).len(), 1); - assert_eq!(keyword_args(&result).len(), 1); - assert!(returns(&result).is_some()); - assert_eq!(raises(&result).len(), 1); - assert_eq!(warns(&result).len(), 1); - assert_eq!(see_also(&result).len(), 1); - assert!(notes(&result).is_some()); - assert!(examples(&result).is_some()); -} - -// ============================================================================= -// Space-before-colon and colonless header tests -// ============================================================================= - -/// `Args :` (space before colon) should be dispatched as Args, not Unknown. -#[test] -fn test_section_header_space_before_colon() { - let input = "Summary.\n\nArgs :\n x (int): The value."; - let result = parse_google(input); - let doc = &result; - let a = args(doc); - assert_eq!(a.len(), 1, "expected 1 arg from 'Args :'"); - assert_eq!(a[0].name.source_text(&result.source), "x"); - - // Header name should be "Args" (trimmed), not "Args " - assert_eq!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Args" - ); - // Colon should still be present - assert!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .colon - .is_some() - ); -} - -/// `Returns :` with space before colon. -#[test] -fn test_returns_space_before_colon() { - let input = "Summary.\n\nReturns :\n int: The result."; - let result = parse_google(input); - let doc = &result; - let r = returns(doc).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); -} - -/// Colonless `Args` should be parsed as Args section. -#[test] -fn test_section_header_no_colon() { - let input = "Summary.\n\nArgs\n x (int): The value."; - let result = parse_google(input); - let doc = &result; - let a = args(doc); - assert_eq!(a.len(), 1, "expected 1 arg from colonless 'Args'"); - assert_eq!(a[0].name.source_text(&result.source), "x"); - - // Header name should be "Args" - assert_eq!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), - "Args" - ); - // Colon should be None - assert!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .colon - .is_none() - ); -} - -/// Missing colon on section header should emit a diagnostic. -/// Colonless `Returns` should be parsed as Returns section. -#[test] -fn test_returns_no_colon() { - let input = "Summary.\n\nReturns\n int: The result."; - let result = parse_google(input); - let doc = &result; - let r = returns(doc).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); -} - -/// Colonless `Raises` should be parsed as Raises section. -#[test] -fn test_raises_no_colon() { - let input = "Summary.\n\nRaises\n ValueError: If invalid."; - let result = parse_google(input); - let doc = &result; - let r = raises(doc); - assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); -} - -/// Unknown names without colon should NOT be treated as headers. -#[test] -fn test_unknown_name_without_colon_not_header() { - let input = "Summary.\n\nSomeWord\n x (int): value."; - let result = parse_google(input); - let doc = &result; - // "SomeWord" is not a known section name, so it becomes extended description - assert!( - all_sections(doc).is_empty(), - "unknown colonless name should not become a section" - ); -} - -/// Multiple sections with mixed colon styles. -#[test] -fn test_mixed_colon_styles() { - let input = "Summary.\n\nArgs:\n x: value.\n\nReturns\n int: result.\n\nRaises :\n ValueError: If bad."; - let result = parse_google(input); - let doc = &result; - assert_eq!(args(doc).len(), 1); - assert!(returns(doc).is_some()); - assert_eq!(raises(doc).len(), 1); -} - -// ============================================================================= -// Tab indentation tests -// ============================================================================= - -/// Args section with tab-indented entries. -#[test] -fn test_tab_indented_args() { - let input = "Summary.\n\nArgs:\n\tx: The value.\n\ty: Another value."; - let result = parse_google(input); - let a = args(&result); - assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The value." - ); - assert_eq!(a[1].name.source_text(&result.source), "y"); - assert_eq!( - a[1].description - .as_ref() - .unwrap() - .source_text(&result.source), - "Another value." - ); -} - -/// Args entries with tab indent and descriptions with deeper tab+space indent. -#[test] -fn test_tab_args_with_continuation() { - let input = "Summary.\n\nArgs:\n\tx: First line.\n\t Continuation.\n\ty: Second."; - let result = parse_google(input); - let a = args(&result); - assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "x"); - let desc = a[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); - assert!(desc.contains("First line."), "desc = {:?}", desc); - assert!(desc.contains("Continuation."), "desc = {:?}", desc); -} - -/// Returns section with tab-indented entry. -#[test] -fn test_tab_indented_returns() { - let input = "Summary.\n\nReturns:\n\tint: The result."; - let result = parse_google(input); - let r = returns(&result); - assert!(r.is_some()); - let r = r.unwrap(); - assert_eq!(r.return_type.unwrap().source_text(&result.source), "int"); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), - "The result." - ); -} - -/// Raises section with tab-indented entries. -#[test] -fn test_tab_indented_raises() { - let input = "Summary.\n\nRaises:\n\tValueError: If bad.\n\tTypeError: If wrong type."; - let result = parse_google(input); - let r = raises(&result); - assert_eq!(r.len(), 2); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!(r[1].r#type.source_text(&result.source), "TypeError"); -} - -/// Section header detection with tab indentation matches. -#[test] -fn test_tab_indented_section_header() { - // Section header at tab indent (4 cols), entry at tab+spaces (>4 cols) - let input = "\tSummary.\n\n\tArgs:\n\t\tx: The value."; - let result = parse_google(input); - let a = args(&result); - assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); -} diff --git a/tests/numpy/edge_cases.rs b/tests/numpy/edge_cases.rs new file mode 100644 index 0000000..e2018b3 --- /dev/null +++ b/tests/numpy/edge_cases.rs @@ -0,0 +1,216 @@ +use super::*; + +// ============================================================================= +// Indented docstrings (class/method bodies) +// ============================================================================= + +#[test] +fn test_indented_docstring() { + let docstring = " Summary line.\n\n Parameters\n ----------\n x : int\n Description of x.\n y : str, optional\n Description of y.\n\n Returns\n -------\n bool\n The result.\n"; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary line." + ); + assert_eq!(parameters(&result).len(), 2); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert_eq!( + parameters(&result)[0] + .r#type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("int") + ); + assert_eq!( + parameters(&result)[1].names[0].source_text(&result.source), + "y" + ); + assert!(parameters(&result)[1].optional.is_some()); + assert_eq!(returns(&result).len(), 1); + assert_eq!( + returns(&result)[0] + .return_type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("bool") + ); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary line." + ); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert_eq!( + parameters(&result)[0] + .r#type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); +} + +#[test] +fn test_deeply_indented_docstring() { + let docstring = " Brief.\n\n Parameters\n ----------\n a : float\n The value.\n\n Raises\n ------\n ValueError\n If bad.\n"; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief." + ); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "a" + ); + assert_eq!(raises(&result).len(), 1); + assert_eq!( + raises(&result)[0].r#type.source_text(&result.source), + "ValueError" + ); + assert_eq!( + raises(&result)[0].r#type.source_text(&result.source), + "ValueError" + ); +} + +#[test] +fn test_indented_with_deprecation() { + let docstring = " Summary.\n\n .. deprecated:: 2.0.0\n Use new_func instead.\n\n Parameters\n ----------\n x : int\n Desc.\n"; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); + let dep = result + .deprecation + .as_ref() + .expect("should have deprecation"); + assert_eq!(dep.version.source_text(&result.source), "2.0.0"); + assert!( + dep.description + .source_text(&result.source) + .contains("new_func") + ); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); +} + +#[test] +fn test_mixed_indent_first_line() { + let docstring = + "Summary.\n\n Parameters\n ----------\n x : int\n Description.\n"; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert_eq!( + parameters(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "Description." + ); +} + +// ============================================================================= +// Tab indentation tests +// ============================================================================= + +/// Parameters section with tab-indented descriptions. +#[test] +fn test_tab_indented_parameters() { + let docstring = "Summary.\n\nParameters\n----------\nx : int\n\tDescription of x.\ny : str\n\tDescription of y."; + let result = parse_numpy(docstring); + let params = parameters(&result); + assert_eq!(params.len(), 2); + assert_eq!(params[0].names[0].source_text(&result.source), "x"); + assert_eq!( + params[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "Description of x." + ); + assert_eq!(params[1].names[0].source_text(&result.source), "y"); + assert_eq!( + params[1] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "Description of y." + ); +} + +/// Mixed tabs and spaces: header at 0 indent, description indented with tab. +#[test] +fn test_mixed_tab_space_parameters() { + let docstring = "Summary.\n\nParameters\n----------\nx : int\n\tThe value.\n\t More detail."; + let result = parse_numpy(docstring); + let params = parameters(&result); + assert_eq!(params.len(), 1); + assert_eq!(params[0].names[0].source_text(&result.source), "x"); + let desc = params[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); + assert!(desc.contains("The value."), "desc = {:?}", desc); +} + +/// Returns section with tab-indented descriptions. +#[test] +fn test_tab_indented_returns() { + let docstring = "Summary.\n\nReturns\n-------\nint\n\tThe result value."; + let result = parse_numpy(docstring); + let rets = returns(&result); + assert_eq!(rets.len(), 1); + assert_eq!( + rets[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "The result value." + ); +} + +/// Raises section with tab-indented description. +#[test] +fn test_tab_indented_raises() { + let docstring = "Summary.\n\nRaises\n------\nValueError\n\tIf the input is invalid."; + let result = parse_numpy(docstring); + let exc = raises(&result); + assert_eq!(exc.len(), 1); + assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!( + exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the input is invalid." + ); +} diff --git a/tests/numpy/freetext.rs b/tests/numpy/freetext.rs new file mode 100644 index 0000000..7517b57 --- /dev/null +++ b/tests/numpy/freetext.rs @@ -0,0 +1,278 @@ +use super::*; + +// ============================================================================= +// Notes section +// ============================================================================= + +#[test] +fn test_with_notes_section() { + let docstring = r#"Function with notes. + +Notes +----- +This is an important note about the function. +"#; + let result = parse_numpy(docstring); + + assert!(notes(&result).is_some()); + assert!( + notes(&result) + .unwrap() + .source_text(&result.source) + .contains("important note") + ); +} + +/// `Note` alias for Notes. +#[test] +fn test_note_alias() { + let docstring = "Summary.\n\nNote\n----\nThis is a note.\n"; + let result = parse_numpy(docstring); + assert!(notes(&result).is_some()); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Note" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Notes); +} + +/// Notes with multi-paragraph content. +#[test] +fn test_notes_multi_paragraph() { + let docstring = "Summary.\n\nNotes\n-----\nFirst paragraph.\n\nSecond paragraph.\n"; + let result = parse_numpy(docstring); + let n = notes(&result).unwrap().source_text(&result.source); + assert!(n.contains("First paragraph.")); + assert!(n.contains("Second paragraph.")); +} + +// ============================================================================= +// Warnings section (free-text) +// ============================================================================= + +#[test] +fn test_warnings_section() { + let docstring = "Summary.\n\nWarnings\n--------\nThis function is deprecated.\n"; + let result = parse_numpy(docstring); + assert_eq!( + warnings_text(&result).unwrap().source_text(&result.source), + "This function is deprecated." + ); +} + +/// `Warning` alias for Warnings. +#[test] +fn test_warning_alias() { + let docstring = "Summary.\n\nWarning\n-------\nBe careful.\n"; + let result = parse_numpy(docstring); + assert!(warnings_text(&result).is_some()); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Warning" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Warnings); +} + +/// Warnings section body variant check. +#[test] +fn test_warnings_section_body_variant() { + let docstring = "Summary.\n\nWarnings\n--------\nDo not use.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Warnings(text) => { + assert!(text.is_some()); + } + other => panic!("Expected Warnings section body, got {:?}", other), + } +} + +// ============================================================================= +// Examples section +// ============================================================================= + +#[test] +fn test_examples_basic() { + let docstring = "Summary.\n\nExamples\n--------\n>>> func(1)\n1\n"; + let result = parse_numpy(docstring); + let ex = examples(&result).unwrap().source_text(&result.source); + assert!(ex.contains(">>> func(1)")); + assert!(ex.contains("1")); +} + +/// `Example` alias for Examples. +#[test] +fn test_example_alias() { + let docstring = "Summary.\n\nExample\n-------\n>>> 1 + 1\n2\n"; + let result = parse_numpy(docstring); + assert!(examples(&result).is_some()); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Example" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Examples); +} + +/// Examples with narrative text and doctest. +#[test] +fn test_examples_with_narrative() { + let docstring = "Summary.\n\nExamples\n--------\nHere is an example:\n\n>>> func(2)\n4\n"; + let result = parse_numpy(docstring); + let ex = examples(&result).unwrap().source_text(&result.source); + assert!(ex.contains("Here is an example:")); + assert!(ex.contains(">>> func(2)")); +} + +/// Examples section body variant check. +#[test] +fn test_examples_section_body_variant() { + let docstring = "Summary.\n\nExamples\n--------\n>>> pass\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Examples(text) => { + assert!(text.is_some()); + } + other => panic!("Expected Examples section body, got {:?}", other), + } +} + +// ============================================================================= +// See Also section +// ============================================================================= + +#[test] +fn test_see_also_parsing() { + let docstring = r#"Summary. + +See Also +-------- +func_a : Does something. +func_b, func_c +"#; + let result = parse_numpy(docstring); + let items = see_also(&result); + assert_eq!(items.len(), 2); + assert_eq!(items[0].names[0].source_text(&result.source), "func_a"); + assert_eq!( + items[0] + .description + .as_ref() + .map(|d| d.source_text(&result.source)), + Some("Does something.") + ); + assert_eq!(items[1].names.len(), 2); + assert_eq!(items[1].names[0].source_text(&result.source), "func_b"); + assert_eq!(items[1].names[1].source_text(&result.source), "func_c"); +} + +/// See Also with no space before colon. +#[test] +fn test_see_also_no_space_before_colon() { + let docstring = "Summary.\n\nSee Also\n--------\nfunc_a: Description of func_a.\n"; + let result = parse_numpy(docstring); + let sa = see_also(&result); + assert_eq!(sa.len(), 1); + assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); + assert!( + sa[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source) + .contains("Description") + ); +} + +/// See Also with multiple items with descriptions. +#[test] +fn test_see_also_multiple_with_descriptions() { + let docstring = + "Summary.\n\nSee Also\n--------\nfunc_a : First function.\nfunc_b : Second function.\n"; + let result = parse_numpy(docstring); + let sa = see_also(&result); + assert_eq!(sa.len(), 2); + assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); + assert_eq!( + sa[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "First function." + ); + assert_eq!(sa[1].names[0].source_text(&result.source), "func_b"); +} + +/// See Also section body variant check. +#[test] +fn test_see_also_section_body_variant() { + let docstring = "Summary.\n\nSee Also\n--------\nfunc_a : Desc.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::SeeAlso(items) => { + assert_eq!(items.len(), 1); + } + other => panic!("Expected SeeAlso section body, got {:?}", other), + } +} + +// ============================================================================= +// References section +// ============================================================================= + +#[test] +fn test_references_parsing() { + let docstring = r#"Summary. + +References +---------- +.. [1] Author A, "Title A", 2020. +.. [2] Author B, "Title B", 2021. +"#; + let result = parse_numpy(docstring); + let refs = references(&result); + assert_eq!(refs.len(), 2); + assert_eq!( + refs[0].number.as_ref().unwrap().source_text(&result.source), + "1" + ); + assert!( + refs[0] + .content + .as_ref() + .unwrap() + .source_text(&result.source) + .contains("Author A") + ); + assert_eq!( + refs[1].number.as_ref().unwrap().source_text(&result.source), + "2" + ); + assert!( + refs[1] + .content + .as_ref() + .unwrap() + .source_text(&result.source) + .contains("Author B") + ); +} + +/// References with directive markers. +#[test] +fn test_references_directive_markers() { + let docstring = "Summary.\n\nReferences\n----------\n.. [1] Some ref.\n"; + let result = parse_numpy(docstring); + let refs = references(&result); + assert_eq!(refs.len(), 1); + assert!(refs[0].directive_marker.is_some()); + assert_eq!( + refs[0] + .directive_marker + .as_ref() + .unwrap() + .source_text(&result.source), + ".." + ); + assert!(refs[0].open_bracket.is_some()); + assert!(refs[0].close_bracket.is_some()); +} diff --git a/tests/numpy/main.rs b/tests/numpy/main.rs new file mode 100644 index 0000000..2af0e7a --- /dev/null +++ b/tests/numpy/main.rs @@ -0,0 +1,183 @@ +//! Integration tests for NumPy-style docstring parser. + +pub use pydocstring::NumPySectionBody; +pub use pydocstring::NumPySectionKind; +pub use pydocstring::TextSize; +pub use pydocstring::numpy::parse_numpy; +pub use pydocstring::numpy::{ + NumPyAttribute, NumPyDocstring, NumPyDocstringItem, NumPyException, NumPyMethod, + NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPyWarning, SeeAlsoItem, +}; + +mod edge_cases; +mod freetext; +mod parameters; +mod raises; +mod returns; +mod sections; +mod structured; +mod summary; + +// ============================================================================= +// Shared helpers +// ============================================================================= + +/// Extract all sections from a docstring, ignoring stray lines. +pub fn sections(doc: &NumPyDocstring) -> Vec<&NumPySection> { + doc.items + .iter() + .filter_map(|item| match item { + NumPyDocstringItem::Section(s) => Some(s), + _ => None, + }) + .collect() +} + +pub fn parameters(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Parameters(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn returns(doc: &NumPyDocstring) -> Vec<&NumPyReturns> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Returns(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn raises(doc: &NumPyDocstring) -> Vec<&NumPyException> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Raises(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn warns(doc: &NumPyDocstring) -> Vec<&NumPyWarning> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Warns(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn see_also(doc: &NumPyDocstring) -> Vec<&SeeAlsoItem> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::SeeAlso(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn references(doc: &NumPyDocstring) -> Vec<&NumPyReference> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::References(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn notes(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { + sections(doc).iter().find_map(|s| match &s.body { + NumPySectionBody::Notes(v) => v.as_ref(), + _ => None, + }) +} + +pub fn examples(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { + sections(doc).iter().find_map(|s| match &s.body { + NumPySectionBody::Examples(v) => v.as_ref(), + _ => None, + }) +} + +pub fn yields(doc: &NumPyDocstring) -> Vec<&NumPyReturns> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Yields(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn receives(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Receives(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn other_parameters(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::OtherParameters(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn attributes(doc: &NumPyDocstring) -> Vec<&NumPyAttribute> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Attributes(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn methods(doc: &NumPyDocstring) -> Vec<&NumPyMethod> { + sections(doc) + .iter() + .filter_map(|s| match &s.body { + NumPySectionBody::Methods(v) => Some(v.iter()), + _ => None, + }) + .flatten() + .collect() +} + +pub fn warnings_text(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { + sections(doc).iter().find_map(|s| match &s.body { + NumPySectionBody::Warnings(v) => v.as_ref(), + _ => None, + }) +} + +pub fn unknown_text(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { + sections(doc).iter().find_map(|s| match &s.body { + NumPySectionBody::Unknown(v) => v.as_ref(), + _ => None, + }) +} diff --git a/tests/numpy/parameters.rs b/tests/numpy/parameters.rs new file mode 100644 index 0000000..34f4838 --- /dev/null +++ b/tests/numpy/parameters.rs @@ -0,0 +1,463 @@ +use super::*; + +// ============================================================================= +// Parameters section +// ============================================================================= + +#[test] +fn test_with_parameters() { + let docstring = r#"Calculate the sum of two numbers. + +Parameters +---------- +x : int + The first number. +y : int + The second number. + +Returns +------- +int + The sum of x and y. +"#; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Calculate the sum of two numbers." + ); + assert_eq!(parameters(&result).len(), 2); + + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert_eq!( + parameters(&result)[0] + .r#type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("int") + ); + assert_eq!( + parameters(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "The first number." + ); + + assert_eq!( + parameters(&result)[1].names[0].source_text(&result.source), + "y" + ); + assert_eq!( + parameters(&result)[1] + .r#type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("int") + ); + + assert!(!returns(&result).is_empty()); + assert_eq!( + returns(&result)[0] + .return_type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("int") + ); +} + +#[test] +fn test_optional_parameters() { + let docstring = r#"Function with optional parameters. + +Parameters +---------- +required : str + A required parameter. +optional : int, optional + An optional parameter. +"#; + let result = parse_numpy(docstring); + + assert_eq!(parameters(&result).len(), 2); + assert!(parameters(&result)[0].optional.is_none()); + assert!(parameters(&result)[1].optional.is_some()); + assert_eq!( + parameters(&result)[1] + .r#type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("int") + ); +} + +#[test] +fn test_parse_with_parameters_spans() { + let docstring = r#"Brief description. + +Parameters +---------- +x : int + The first parameter. +y : str, optional + The second parameter. +"#; + let result = parse_numpy(docstring); + assert_eq!(parameters(&result).len(), 2); + + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert_eq!( + parameters(&result)[1].names[0].source_text(&result.source), + "y" + ); + assert_eq!( + parameters(&result)[0] + .r#type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); +} + +/// Parameters with no space before colon: `x: int` +#[test] +fn test_parameters_no_space_before_colon() { + let docstring = "Summary.\n\nParameters\n----------\nx: int\n The value.\n"; + let result = parse_numpy(docstring); + let p = parameters(&result); + assert_eq!(p.len(), 1); + assert_eq!(p[0].names[0].source_text(&result.source), "x"); + assert_eq!( + p[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); + assert_eq!( + p[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The value." + ); +} + +/// Parameters with no space after colon: `x :int` +#[test] +fn test_parameters_no_space_after_colon() { + let docstring = "Summary.\n\nParameters\n----------\nx :int\n The value.\n"; + let result = parse_numpy(docstring); + let p = parameters(&result); + assert_eq!(p.len(), 1); + assert_eq!(p[0].names[0].source_text(&result.source), "x"); + assert_eq!( + p[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +/// Parameters with no spaces around colon: `x:int` +#[test] +fn test_parameters_no_spaces_around_colon() { + let docstring = "Summary.\n\nParameters\n----------\nx:int\n The value.\n"; + let result = parse_numpy(docstring); + let p = parameters(&result); + assert_eq!(p.len(), 1); + assert_eq!(p[0].names[0].source_text(&result.source), "x"); + assert_eq!( + p[0].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +#[test] +fn test_multiple_parameter_names() { + let docstring = r#"Summary. + +Parameters +---------- +x1, x2 : array_like + Input arrays. +"#; + let result = parse_numpy(docstring); + let p = ¶meters(&result)[0]; + assert_eq!(p.names.len(), 2); + assert_eq!(p.names[0].source_text(&result.source), "x1"); + assert_eq!(p.names[1].source_text(&result.source), "x2"); + assert_eq!(p.names[0].source_text(&result.source), "x1"); + assert_eq!(p.names[1].source_text(&result.source), "x2"); +} + +#[test] +fn test_description_with_colon_not_treated_as_param() { + let docstring = r#"Brief summary. + +Parameters +---------- +x : int + A value like key: value should not split. +"#; + let result = parse_numpy(docstring); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert!( + parameters(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source) + .contains("key: value") + ); +} + +#[test] +fn test_multi_paragraph_description() { + let docstring = r#"Summary. + +Parameters +---------- +x : int + First paragraph of x. + + Second paragraph of x. +"#; + let result = parse_numpy(docstring); + let desc = ¶meters(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); + assert!(desc.contains("First paragraph of x.")); + assert!(desc.contains("Second paragraph of x.")); + assert!(desc.contains('\n')); +} + +// ============================================================================= +// Enum / choices type +// ============================================================================= + +#[test] +fn test_enum_type_as_string() { + let docstring = + "Summary.\n\nParameters\n----------\norder : {'C', 'F', 'A'}\n Memory layout."; + let result = parse_numpy(docstring); + let params = parameters(&result); + assert_eq!(params.len(), 1); + + let p = ¶ms[0]; + assert_eq!(p.names[0].source_text(&result.source), "order"); + assert_eq!( + p.r#type.as_ref().unwrap().source_text(&result.source), + "{'C', 'F', 'A'}" + ); + assert_eq!( + p.description.as_ref().unwrap().source_text(&result.source), + "Memory layout." + ); +} + +#[test] +fn test_enum_type_with_optional() { + let docstring = + "Summary.\n\nParameters\n----------\norder : {'C', 'F'}, optional\n Memory layout."; + let result = parse_numpy(docstring); + let params = parameters(&result); + let p = ¶ms[0]; + + assert!(p.optional.is_some()); + assert_eq!( + p.r#type.as_ref().unwrap().source_text(&result.source), + "{'C', 'F'}" + ); +} + +#[test] +fn test_enum_type_with_default() { + let docstring = "Summary.\n\nParameters\n----------\norder : {'C', 'F', 'A'}, default 'C'\n Memory layout."; + let result = parse_numpy(docstring); + let params = parameters(&result); + let p = ¶ms[0]; + + assert_eq!( + p.r#type.as_ref().unwrap().source_text(&result.source), + "{'C', 'F', 'A'}" + ); + assert_eq!( + p.default_keyword + .as_ref() + .unwrap() + .source_text(&result.source), + "default" + ); + assert!(p.default_separator.is_none()); + assert_eq!( + p.default_value + .as_ref() + .unwrap() + .source_text(&result.source), + "'C'" + ); +} + +// ============================================================================= +// Parameters — aliases +// ============================================================================= + +/// `Params` alias for Parameters. +#[test] +fn test_params_alias() { + let docstring = "Summary.\n\nParams\n------\nx : int\n The value.\n"; + let result = parse_numpy(docstring); + let p = parameters(&result); + assert_eq!(p.len(), 1); + assert_eq!(p[0].names[0].source_text(&result.source), "x"); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Params" + ); + assert_eq!( + sections(&result)[0].header.kind, + NumPySectionKind::Parameters + ); +} + +/// `Param` alias for Parameters. +#[test] +fn test_param_alias() { + let docstring = "Summary.\n\nParam\n-----\nx : int\n The value.\n"; + let result = parse_numpy(docstring); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Param" + ); +} + +/// `Parameter` alias for Parameters. +#[test] +fn test_parameter_alias() { + let docstring = "Summary.\n\nParameter\n---------\nx : int\n The value.\n"; + let result = parse_numpy(docstring); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Parameter" + ); +} + +// ============================================================================= +// Other Parameters section +// ============================================================================= + +#[test] +fn test_other_parameters_basic() { + let docstring = "Summary.\n\nOther Parameters\n----------------\ndebug : bool\n Enable debug mode.\nverbose : bool, optional\n Verbose output.\n"; + let result = parse_numpy(docstring); + let op = other_parameters(&result); + assert_eq!(op.len(), 2); + assert_eq!(op[0].names[0].source_text(&result.source), "debug"); + assert_eq!( + op[0].r#type.as_ref().unwrap().source_text(&result.source), + "bool" + ); + assert_eq!(op[1].names[0].source_text(&result.source), "verbose"); + assert!(op[1].optional.is_some()); +} + +/// `Other Params` alias. +#[test] +fn test_other_params_alias() { + let docstring = "Summary.\n\nOther Params\n------------\nx : int\n Extra.\n"; + let result = parse_numpy(docstring); + assert_eq!(other_parameters(&result).len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Other Params" + ); + assert_eq!( + sections(&result)[0].header.kind, + NumPySectionKind::OtherParameters + ); +} + +/// Other Parameters section body variant check. +#[test] +fn test_other_parameters_section_body_variant() { + let docstring = "Summary.\n\nOther Parameters\n----------------\nx : int\n Extra.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::OtherParameters(params) => { + assert_eq!(params.len(), 1); + } + other => panic!("Expected OtherParameters section body, got {:?}", other), + } +} + +// ============================================================================= +// Receives section +// ============================================================================= + +#[test] +fn test_receives_basic() { + let docstring = "Summary.\n\nReceives\n--------\ndata : bytes\n The received data.\n"; + let result = parse_numpy(docstring); + let r = receives(&result); + assert_eq!(r.len(), 1); + assert_eq!(r[0].names[0].source_text(&result.source), "data"); + assert_eq!( + r[0].r#type.as_ref().unwrap().source_text(&result.source), + "bytes" + ); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The received data." + ); +} + +#[test] +fn test_receives_multiple() { + let docstring = "Summary.\n\nReceives\n--------\nmsg : str\n The message.\ndata : bytes\n The payload.\n"; + let result = parse_numpy(docstring); + let r = receives(&result); + assert_eq!(r.len(), 2); + assert_eq!(r[0].names[0].source_text(&result.source), "msg"); + assert_eq!(r[1].names[0].source_text(&result.source), "data"); +} + +/// `Receive` alias. +#[test] +fn test_receive_alias() { + let docstring = "Summary.\n\nReceive\n-------\ndata : bytes\n The data.\n"; + let result = parse_numpy(docstring); + assert_eq!(receives(&result).len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Receive" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Receives); +} + +/// Receives section body variant check. +#[test] +fn test_receives_section_body_variant() { + let docstring = "Summary.\n\nReceives\n--------\ndata : bytes\n Payload.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Receives(params) => { + assert_eq!(params.len(), 1); + } + other => panic!("Expected Receives section body, got {:?}", other), + } +} diff --git a/tests/numpy/raises.rs b/tests/numpy/raises.rs new file mode 100644 index 0000000..750ad65 --- /dev/null +++ b/tests/numpy/raises.rs @@ -0,0 +1,218 @@ +use super::*; + +// ============================================================================= +// Raises section +// ============================================================================= + +#[test] +fn test_with_raises() { + let docstring = r#"Function that may raise exceptions. + +Raises +------ +ValueError + If the input is invalid. +TypeError + If the type is wrong. +"#; + let result = parse_numpy(docstring); + + assert_eq!(raises(&result).len(), 2); + assert_eq!( + raises(&result)[0].r#type.source_text(&result.source), + "ValueError" + ); + assert_eq!( + raises(&result)[1].r#type.source_text(&result.source), + "TypeError" + ); +} + +#[test] +fn test_raises_with_spans() { + let docstring = r#"Summary. + +Raises +------ +ValueError + If input is bad. +TypeError + If type is wrong. +"#; + let result = parse_numpy(docstring); + assert_eq!(raises(&result).len(), 2); + assert_eq!( + raises(&result)[0].r#type.source_text(&result.source), + "ValueError" + ); + assert_eq!( + raises(&result)[1].r#type.source_text(&result.source), + "TypeError" + ); +} + +// ============================================================================= +// Raises — colon splitting +// ============================================================================= + +/// Raises with colon separating type and description on the same line. +#[test] +fn test_raises_colon_split() { + let docstring = "Summary.\n\nRaises\n------\nValueError : If the input is invalid.\nTypeError : If the type is wrong."; + let result = parse_numpy(docstring); + let exc = raises(&result); + assert_eq!(exc.len(), 2); + assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); + assert!(exc[0].colon.is_some()); + assert_eq!( + exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the input is invalid." + ); + assert_eq!(exc[1].r#type.source_text(&result.source), "TypeError"); + assert!(exc[1].colon.is_some()); + assert_eq!( + exc[1] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the type is wrong." + ); +} + +/// Raises without colon (bare type, description on next line). +#[test] +fn test_raises_no_colon() { + let docstring = "Summary.\n\nRaises\n------\nValueError\n If the input is invalid."; + let result = parse_numpy(docstring); + let exc = raises(&result); + assert_eq!(exc.len(), 1); + assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); + assert!(exc[0].colon.is_none()); + assert_eq!( + exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the input is invalid." + ); +} + +/// Raises with colon and continuation description on next lines. +#[test] +fn test_raises_colon_with_continuation() { + let docstring = "Summary.\n\nRaises\n------\nValueError : If bad.\n More detail here."; + let result = parse_numpy(docstring); + let exc = raises(&result); + assert_eq!(exc.len(), 1); + assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); + assert!(exc[0].colon.is_some()); + let desc = exc[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); + assert!(desc.contains("If bad."), "desc = {:?}", desc); + assert!(desc.contains("More detail here."), "desc = {:?}", desc); +} + +/// `Raise` alias for Raises. +#[test] +fn test_raise_alias() { + let docstring = "Summary.\n\nRaise\n-----\nValueError\n Bad input.\n"; + let result = parse_numpy(docstring); + let exc = raises(&result); + assert_eq!(exc.len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Raise" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Raises); +} + +// ============================================================================= +// Warns section +// ============================================================================= + +#[test] +fn test_warns_basic() { + let docstring = "Summary.\n\nWarns\n-----\nDeprecationWarning\n If the old API is used.\n"; + let result = parse_numpy(docstring); + let w = warns(&result); + assert_eq!(w.len(), 1); + assert_eq!( + w[0].r#type.source_text(&result.source), + "DeprecationWarning" + ); + assert_eq!( + w[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If the old API is used." + ); +} + +#[test] +fn test_warns_multiple() { + let docstring = + "Summary.\n\nWarns\n-----\nDeprecationWarning\n Old API.\nUserWarning\n Bad usage.\n"; + let result = parse_numpy(docstring); + let w = warns(&result); + assert_eq!(w.len(), 2); + assert_eq!( + w[0].r#type.source_text(&result.source), + "DeprecationWarning" + ); + assert_eq!(w[1].r#type.source_text(&result.source), "UserWarning"); +} + +/// Warns with colon separating type and description on the same line. +#[test] +fn test_warns_colon_split() { + let docstring = "Summary.\n\nWarns\n-----\nUserWarning : If input is unusual.\n"; + let result = parse_numpy(docstring); + let w = warns(&result); + assert_eq!(w.len(), 1); + assert_eq!(w[0].r#type.source_text(&result.source), "UserWarning"); + assert!(w[0].colon.is_some()); + assert_eq!( + w[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "If input is unusual." + ); +} + +/// `Warn` alias for Warns. +#[test] +fn test_warn_alias() { + let docstring = "Summary.\n\nWarn\n----\nUserWarning\n Bad usage.\n"; + let result = parse_numpy(docstring); + let w = warns(&result); + assert_eq!(w.len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Warn" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Warns); +} + +/// Warns section body variant check. +#[test] +fn test_warns_section_body_variant() { + let docstring = "Summary.\n\nWarns\n-----\nUserWarning\n Bad.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Warns(items) => { + assert_eq!(items.len(), 1); + } + other => panic!("Expected Warns section body, got {:?}", other), + } +} diff --git a/tests/numpy/returns.rs b/tests/numpy/returns.rs new file mode 100644 index 0000000..567cc7b --- /dev/null +++ b/tests/numpy/returns.rs @@ -0,0 +1,212 @@ +use super::*; + +// ============================================================================= +// Returns section +// ============================================================================= + +#[test] +fn test_parse_named_returns() { + let docstring = r#"Compute values. + +Returns +------- +x : int + The first value. +y : float + The second value. +"#; + let result = parse_numpy(docstring); + assert_eq!(returns(&result).len(), 2); + assert_eq!( + returns(&result)[0] + .name + .as_ref() + .map(|n| n.source_text(&result.source)), + Some("x") + ); + assert_eq!( + returns(&result)[0] + .return_type + .as_ref() + .map(|t| t.source_text(&result.source)), + Some("int") + ); + assert_eq!( + returns(&result)[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source), + "The first value." + ); + assert_eq!( + returns(&result)[1] + .name + .as_ref() + .map(|n| n.source_text(&result.source)), + Some("y") + ); +} + +/// Returns with no spaces around colon (named): `result:int` +#[test] +fn test_returns_no_spaces_around_colon() { + let docstring = "Summary.\n\nReturns\n-------\nresult:int\n The result.\n"; + let result = parse_numpy(docstring); + let r = returns(&result); + assert_eq!(r.len(), 1); + assert_eq!( + r[0].name.as_ref().unwrap().source_text(&result.source), + "result" + ); + assert_eq!( + r[0].return_type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); +} + +/// Returns with type only (no name). +#[test] +fn test_returns_type_only() { + let docstring = "Summary.\n\nReturns\n-------\nint\n The result.\n"; + let result = parse_numpy(docstring); + let r = returns(&result); + assert_eq!(r.len(), 1); + assert_eq!( + r[0].return_type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); + assert_eq!( + r[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The result." + ); +} + +/// Returns — `Return` alias. +#[test] +fn test_return_alias() { + let docstring = "Summary.\n\nReturn\n------\nint\n The value.\n"; + let result = parse_numpy(docstring); + let r = returns(&result); + assert_eq!(r.len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Return" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Returns); +} + +/// Returns with multiline description. +#[test] +fn test_returns_multiline_description() { + let docstring = + "Summary.\n\nReturns\n-------\nresult : int\n First line.\n\n Second paragraph.\n"; + let result = parse_numpy(docstring); + let r = returns(&result); + assert_eq!(r.len(), 1); + let desc = r[0] + .description + .as_ref() + .unwrap() + .source_text(&result.source); + assert!(desc.contains("First line.")); + assert!(desc.contains("Second paragraph.")); +} + +// ============================================================================= +// Yields section +// ============================================================================= + +#[test] +fn test_yields_basic() { + let docstring = "Summary.\n\nYields\n------\nint\n The next value.\n"; + let result = parse_numpy(docstring); + let y = yields(&result); + assert_eq!(y.len(), 1); + assert_eq!( + y[0].return_type + .as_ref() + .unwrap() + .source_text(&result.source), + "int" + ); + assert_eq!( + y[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The next value." + ); +} + +#[test] +fn test_yields_named() { + let docstring = "Summary.\n\nYields\n------\nvalue : str\n The generated string.\n"; + let result = parse_numpy(docstring); + let y = yields(&result); + assert_eq!(y.len(), 1); + assert_eq!( + y[0].name.as_ref().unwrap().source_text(&result.source), + "value" + ); + assert_eq!( + y[0].return_type + .as_ref() + .unwrap() + .source_text(&result.source), + "str" + ); +} + +#[test] +fn test_yields_multiple() { + let docstring = + "Summary.\n\nYields\n------\nindex : int\n The index.\nvalue : str\n The value.\n"; + let result = parse_numpy(docstring); + let y = yields(&result); + assert_eq!(y.len(), 2); + assert_eq!( + y[0].name.as_ref().unwrap().source_text(&result.source), + "index" + ); + assert_eq!( + y[1].name.as_ref().unwrap().source_text(&result.source), + "value" + ); +} + +/// Yields — `Yield` alias. +#[test] +fn test_yield_alias() { + let docstring = "Summary.\n\nYield\n-----\nint\n Next integer.\n"; + let result = parse_numpy(docstring); + let y = yields(&result); + assert_eq!(y.len(), 1); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "Yield" + ); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Yields); +} + +/// Yields section body variant check. +#[test] +fn test_yields_section_body_variant() { + let docstring = "Summary.\n\nYields\n------\nint\n Value.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Yields(items) => { + assert_eq!(items.len(), 1); + } + other => panic!("Expected Yields section body, got {:?}", other), + } +} diff --git a/tests/numpy/sections.rs b/tests/numpy/sections.rs new file mode 100644 index 0000000..8c8d958 --- /dev/null +++ b/tests/numpy/sections.rs @@ -0,0 +1,229 @@ +use super::*; + +// ============================================================================= +// Case insensitive sections +// ============================================================================= + +#[test] +fn test_case_insensitive_sections() { + let docstring = r#"Brief summary. + +parameters +---------- +x : int + First param. + +returns +------- +int + The result. + +NOTES +----- +Some notes here. +"#; + let result = parse_numpy(docstring); + assert_eq!(parameters(&result).len(), 1); + assert_eq!( + parameters(&result)[0].names[0].source_text(&result.source), + "x" + ); + assert_eq!(returns(&result).len(), 1); + assert!(notes(&result).is_some()); + assert_eq!( + sections(&result)[0].header.name.source_text(&result.source), + "parameters" + ); + assert_eq!( + sections(&result)[2].header.name.source_text(&result.source), + "NOTES" + ); +} + +// ============================================================================= +// Section header spans +// ============================================================================= + +#[test] +fn test_section_header_spans() { + let docstring = r#"Summary. + +Parameters +---------- +x : int + Desc. +"#; + let result = parse_numpy(docstring); + let hdr = §ions(&result)[0].header; + assert_eq!(hdr.name.source_text(&result.source), "Parameters"); + assert_eq!(hdr.underline.source_text(&result.source), "----------"); +} + +// ============================================================================= +// Span round-trip +// ============================================================================= + +#[test] +fn test_span_source_text_round_trip() { + let docstring = r#"Summary line. + +Parameters +---------- +x : int + Description of x. +"#; + let result = parse_numpy(docstring); + let src = &result.source; + + assert_eq!( + result.summary.as_ref().unwrap().source_text(src), + "Summary line." + ); + assert_eq!( + sections(&result)[0].header.name.source_text(src), + "Parameters" + ); + let underline = §ions(&result)[0] + .header + .underline + .source_text(&result.source); + assert!(underline.chars().all(|c| c == '-')); + + let p = ¶meters(&result)[0]; + assert_eq!(p.names[0].source_text(src), "x"); + assert_eq!(p.r#type.as_ref().unwrap().source_text(src), "int"); + assert_eq!( + p.description.as_ref().unwrap().source_text(src), + "Description of x." + ); +} + +// ============================================================================= +// Deprecation +// ============================================================================= + +#[test] +fn test_deprecation_directive() { + let docstring = r#"Summary. + +.. deprecated:: 1.6.0 + Use `new_func` instead. + +Parameters +---------- +x : int + Desc. +"#; + let result = parse_numpy(docstring); + let dep = result + .deprecation + .as_ref() + .expect("deprecation should be parsed"); + assert_eq!(dep.version.source_text(&result.source), "1.6.0"); + assert_eq!( + dep.description.source_text(&result.source), + "Use `new_func` instead." + ); + assert_eq!(dep.version.source_text(&result.source), "1.6.0"); +} + +// ============================================================================= +// Section ordering +// ============================================================================= + +#[test] +fn test_section_order_preserved() { + let docstring = r#"Summary. + +Parameters +---------- +x : int + Desc. + +Returns +------- +int + Result. + +Raises +------ +ValueError + Bad input. + +Notes +----- +Some notes. +"#; + let result = parse_numpy(docstring); + let s = sections(&result); + assert_eq!(s.len(), 4); + assert_eq!(s[0].header.kind, NumPySectionKind::Parameters); + assert_eq!(s[1].header.kind, NumPySectionKind::Returns); + assert_eq!(s[2].header.kind, NumPySectionKind::Raises); + assert_eq!(s[3].header.kind, NumPySectionKind::Notes); +} + +#[test] +fn test_all_section_kinds_exist() { + // Verify ALL is correct and contains no Unknown + assert_eq!(NumPySectionKind::ALL.len(), 14); + for kind in NumPySectionKind::ALL { + assert_ne!(*kind, NumPySectionKind::Unknown); + } +} + +#[test] +fn test_section_kind_from_name_unknown() { + assert_eq!( + NumPySectionKind::from_name("nonexistent"), + NumPySectionKind::Unknown + ); + assert!(!NumPySectionKind::is_known("nonexistent")); + assert!(NumPySectionKind::is_known("parameters")); +} + +#[test] +fn test_stray_lines() { + let docstring = + "Summary.\n\nThis line is not a section.\n\nParameters\n----------\nx : int\n Desc.\n"; + let result = parse_numpy(docstring); + // The non-section line might be treated as extended summary or stray line + // depending on parser behavior. Just verify parameters are still parsed. + assert_eq!(parameters(&result).len(), 1); +} + +// ============================================================================= +// Display impl +// ============================================================================= + +#[test] +fn test_section_kind_display() { + assert_eq!(format!("{}", NumPySectionKind::Parameters), "Parameters"); + assert_eq!(format!("{}", NumPySectionKind::Returns), "Returns"); + assert_eq!(format!("{}", NumPySectionKind::Yields), "Yields"); + assert_eq!(format!("{}", NumPySectionKind::Receives), "Receives"); + assert_eq!( + format!("{}", NumPySectionKind::OtherParameters), + "Other Parameters" + ); + assert_eq!(format!("{}", NumPySectionKind::Raises), "Raises"); + assert_eq!(format!("{}", NumPySectionKind::Warns), "Warns"); + assert_eq!(format!("{}", NumPySectionKind::Warnings), "Warnings"); + assert_eq!(format!("{}", NumPySectionKind::SeeAlso), "See Also"); + assert_eq!(format!("{}", NumPySectionKind::Notes), "Notes"); + assert_eq!(format!("{}", NumPySectionKind::References), "References"); + assert_eq!(format!("{}", NumPySectionKind::Examples), "Examples"); + assert_eq!(format!("{}", NumPySectionKind::Attributes), "Attributes"); + assert_eq!(format!("{}", NumPySectionKind::Methods), "Methods"); + assert_eq!(format!("{}", NumPySectionKind::Unknown), "Unknown"); +} + +#[test] +fn test_docstring_display() { + let docstring = "My summary."; + let result = parse_numpy(docstring); + assert_eq!( + format!("{}", result), + "NumPyDocstring(summary: My summary.)" + ); +} diff --git a/tests/numpy/structured.rs b/tests/numpy/structured.rs new file mode 100644 index 0000000..e4736f1 --- /dev/null +++ b/tests/numpy/structured.rs @@ -0,0 +1,210 @@ +use super::*; + +// ============================================================================= +// Attributes section +// ============================================================================= + +#[test] +fn test_attributes_basic() { + let docstring = + "Summary.\n\nAttributes\n----------\nname : str\n The name.\nage : int\n The age.\n"; + let result = parse_numpy(docstring); + let a = attributes(&result); + assert_eq!(a.len(), 2); + assert_eq!(a[0].name.source_text(&result.source), "name"); + assert_eq!( + a[0].r#type.as_ref().unwrap().source_text(&result.source), + "str" + ); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The name." + ); + assert_eq!(a[1].name.source_text(&result.source), "age"); + assert_eq!( + a[1].r#type.as_ref().unwrap().source_text(&result.source), + "int" + ); +} + +#[test] +fn test_attributes_no_type() { + let docstring = "Summary.\n\nAttributes\n----------\nname\n The name.\n"; + let result = parse_numpy(docstring); + let a = attributes(&result); + assert_eq!(a.len(), 1); + assert_eq!(a[0].name.source_text(&result.source), "name"); + assert!(a[0].r#type.is_none()); + assert_eq!( + a[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "The name." + ); +} + +#[test] +fn test_attributes_with_colon() { + let docstring = "Summary.\n\nAttributes\n----------\nname : str\n The name.\n"; + let result = parse_numpy(docstring); + let a = attributes(&result); + assert_eq!(a.len(), 1); + assert!(a[0].colon.is_some()); + assert_eq!( + a[0].colon.as_ref().unwrap().source_text(&result.source), + ":" + ); +} + +/// Attributes section body variant check. +#[test] +fn test_attributes_section_body_variant() { + let docstring = "Summary.\n\nAttributes\n----------\nx : int\n Value.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Attributes(attrs) => { + assert_eq!(attrs.len(), 1); + } + other => panic!("Expected Attributes section body, got {:?}", other), + } +} + +/// Attributes section kind check. +#[test] +fn test_attributes_section_kind() { + let docstring = "Summary.\n\nAttributes\n----------\nx : int\n Value.\n"; + let result = parse_numpy(docstring); + assert_eq!( + sections(&result)[0].header.kind, + NumPySectionKind::Attributes + ); +} + +// ============================================================================= +// Methods section +// ============================================================================= + +#[test] +fn test_methods_basic() { + let docstring = "Summary.\n\nMethods\n-------\nreset()\n Reset the state.\nupdate(data)\n Update with new data.\n"; + let result = parse_numpy(docstring); + let m = methods(&result); + assert_eq!(m.len(), 2); + assert_eq!(m[0].name.source_text(&result.source), "reset()"); + assert_eq!( + m[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Reset the state." + ); + assert_eq!(m[1].name.source_text(&result.source), "update(data)"); + assert_eq!( + m[1].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Update with new data." + ); +} + +#[test] +fn test_methods_with_colon() { + let docstring = "Summary.\n\nMethods\n-------\nreset() : Reset the state.\n"; + let result = parse_numpy(docstring); + let m = methods(&result); + assert_eq!(m.len(), 1); + assert_eq!(m[0].name.source_text(&result.source), "reset()"); + assert!(m[0].colon.is_some()); + // Description may be inline or on next line depending on parser + if let Some(desc) = &m[0].description { + assert!(desc.source_text(&result.source).contains("Reset")); + } +} + +#[test] +fn test_methods_without_parens() { + let docstring = "Summary.\n\nMethods\n-------\ndo_stuff\n Performs the operation.\n"; + let result = parse_numpy(docstring); + let m = methods(&result); + assert_eq!(m.len(), 1); + assert_eq!(m[0].name.source_text(&result.source), "do_stuff"); + assert_eq!( + m[0].description + .as_ref() + .unwrap() + .source_text(&result.source), + "Performs the operation." + ); +} + +/// Methods section body variant check. +#[test] +fn test_methods_section_body_variant() { + let docstring = "Summary.\n\nMethods\n-------\nfoo()\n Does bar.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Methods(methods) => { + assert_eq!(methods.len(), 1); + } + other => panic!("Expected Methods section body, got {:?}", other), + } +} + +/// Methods section kind check. +#[test] +fn test_methods_section_kind() { + let docstring = "Summary.\n\nMethods\n-------\nfoo()\n Does bar.\n"; + let result = parse_numpy(docstring); + assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Methods); +} + +// ============================================================================= +// Unknown section +// ============================================================================= + +#[test] +fn test_unknown_section() { + let docstring = "Summary.\n\nCustomSection\n-------------\nSome custom content.\n"; + let result = parse_numpy(docstring); + let s = sections(&result); + assert_eq!(s.len(), 1); + assert_eq!(s[0].header.kind, NumPySectionKind::Unknown); + assert_eq!( + s[0].header.name.source_text(&result.source), + "CustomSection" + ); +} + +#[test] +fn test_unknown_section_body_variant() { + let docstring = "Summary.\n\nCustomSection\n-------------\nSome content.\n"; + let result = parse_numpy(docstring); + match §ions(&result)[0].body { + NumPySectionBody::Unknown(text) => { + assert!(text.is_some()); + assert!( + text.as_ref() + .unwrap() + .source_text(&result.source) + .contains("Some content.") + ); + } + other => panic!("Expected Unknown section body, got {:?}", other), + } +} + +#[test] +fn test_unknown_section_with_known_sections() { + let docstring = + "Summary.\n\nParameters\n----------\nx : int\n Value.\n\nCustom\n------\nExtra info.\n"; + let result = parse_numpy(docstring); + let s = sections(&result); + assert_eq!(s.len(), 2); + assert_eq!(s[0].header.kind, NumPySectionKind::Parameters); + assert_eq!(s[1].header.kind, NumPySectionKind::Unknown); +} diff --git a/tests/numpy/summary.rs b/tests/numpy/summary.rs new file mode 100644 index 0000000..6b0fdee --- /dev/null +++ b/tests/numpy/summary.rs @@ -0,0 +1,142 @@ +use super::*; + +// ============================================================================= +// Basic parsing / Summary / Extended Summary +// ============================================================================= + +#[test] +fn test_simple_summary() { + let docstring = "This is a brief summary."; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "This is a brief summary." + ); + assert!(result.extended_summary.is_none()); + assert!(parameters(&result).is_empty()); +} + +#[test] +fn test_parse_simple_span() { + let docstring = "Brief description."; + let result = parse_numpy(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief description." + ); + assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(0)); + assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(18)); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief description." + ); +} + +#[test] +fn test_summary_with_description() { + let docstring = r#"Brief summary. + +This is a longer description that provides +more details about the function. +"#; + let result = parse_numpy(docstring); + + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Brief summary." + ); + assert!(result.extended_summary.is_some()); +} + +#[test] +fn test_multiline_summary() { + let docstring = "This is a long summary\nthat spans two lines.\n\nExtended description."; + let result = parse_numpy(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "This is a long summary\nthat spans two lines." + ); + let desc = result.extended_summary.as_ref().unwrap(); + assert_eq!(desc.source_text(&result.source), "Extended description."); +} + +#[test] +fn test_multiline_summary_no_extended() { + let docstring = "Summary line one\ncontinues here."; + let result = parse_numpy(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary line one\ncontinues here." + ); + assert!(result.extended_summary.is_none()); +} + +#[test] +fn test_empty_docstring() { + let result = parse_numpy(""); + assert!(result.summary.is_none()); +} + +#[test] +fn test_whitespace_only_docstring() { + let result = parse_numpy(" \n\n "); + assert!(result.summary.is_none()); +} + +#[test] +fn test_docstring_span_covers_entire_input() { + let docstring = "First line.\n\nSecond line."; + let result = parse_numpy(docstring); + assert_eq!(result.range.start(), TextSize::new(0)); + assert_eq!(result.range.end().raw() as usize, docstring.len()); +} + +// ============================================================================= +// Signature-like line is treated as summary +// ============================================================================= + +#[test] +fn test_parse_with_signature_line() { + let docstring = r#"add(a, b) + +The sum of two numbers. + +Parameters +---------- +a : int + First number. +b : int + Second number. +"#; + let result = parse_numpy(docstring); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "add(a, b)" + ); + assert_eq!(parameters(&result).len(), 2); +} + +// ============================================================================= +// Extended summary +// ============================================================================= + +#[test] +fn test_extended_summary_preserves_paragraphs() { + let docstring = r#"Summary. + +First paragraph of extended. + +Second paragraph of extended. + +Parameters +---------- +x : int + Desc. +"#; + let result = parse_numpy(docstring); + let ext = result.extended_summary.as_ref().unwrap(); + assert!(ext.source_text(&result.source).contains("First paragraph")); + assert!(ext.source_text(&result.source).contains("Second paragraph")); + assert!(ext.source_text(&result.source).contains('\n')); +} diff --git a/tests/numpy_tests.rs b/tests/numpy_tests.rs deleted file mode 100644 index 2e0c934..0000000 --- a/tests/numpy_tests.rs +++ /dev/null @@ -1,1197 +0,0 @@ -//! Integration tests for NumPy-style docstring parser. - -use pydocstring::NumPySectionBody; -use pydocstring::TextSize; -use pydocstring::numpy::parse_numpy; -use pydocstring::numpy::{ - NumPyDocstring, NumPyDocstringItem, NumPyException, NumPyParameter, NumPyReference, - NumPyReturns, NumPySection, NumPyWarning, SeeAlsoItem, -}; - -// ============================================================================= -// Test-local helpers -// ============================================================================= - -/// Extract all sections from a docstring, ignoring stray lines. -fn sections(doc: &NumPyDocstring) -> Vec<&NumPySection> { - doc.items - .iter() - .filter_map(|item| match item { - NumPyDocstringItem::Section(s) => Some(s), - _ => None, - }) - .collect() -} - -fn parameters(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Parameters(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() -} - -fn returns(doc: &NumPyDocstring) -> Vec<&NumPyReturns> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Returns(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() -} - -fn raises(doc: &NumPyDocstring) -> Vec<&NumPyException> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Raises(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() -} - -#[allow(dead_code)] -fn warns(doc: &NumPyDocstring) -> Vec<&NumPyWarning> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Warns(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() -} - -fn see_also(doc: &NumPyDocstring) -> Vec<&SeeAlsoItem> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::SeeAlso(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() -} - -fn references(doc: &NumPyDocstring) -> Vec<&NumPyReference> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::References(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() -} - -fn notes(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { - sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Notes(v) => v.as_ref(), - _ => None, - }) -} - -#[allow(dead_code)] -fn examples(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { - sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Examples(v) => v.as_ref(), - _ => None, - }) -} - -// ============================================================================= -// Basic parsing -// ============================================================================= - -#[test] -fn test_simple_summary() { - let docstring = "This is a brief summary."; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "This is a brief summary." - ); - assert!(result.extended_summary.is_none()); - assert!(parameters(&result).is_empty()); -} - -#[test] -fn test_parse_simple_span() { - let docstring = "Brief description."; - let result = parse_numpy(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief description." - ); - assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(0)); - assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(18)); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief description." - ); -} - -#[test] -fn test_summary_with_description() { - let docstring = r#"Brief summary. - -This is a longer description that provides -more details about the function. -"#; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief summary." - ); - assert!(result.extended_summary.is_some()); -} - -#[test] -fn test_multiline_summary() { - let docstring = "This is a long summary\nthat spans two lines.\n\nExtended description."; - let result = parse_numpy(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "This is a long summary\nthat spans two lines." - ); - let desc = result.extended_summary.as_ref().unwrap(); - assert_eq!(desc.source_text(&result.source), "Extended description."); -} - -#[test] -fn test_multiline_summary_no_extended() { - let docstring = "Summary line one\ncontinues here."; - let result = parse_numpy(docstring); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary line one\ncontinues here." - ); - assert!(result.extended_summary.is_none()); -} - -#[test] -fn test_empty_docstring() { - let result = parse_numpy(""); - assert!(result.summary.is_none()); -} - -#[test] -fn test_whitespace_only_docstring() { - let result = parse_numpy(" \n\n "); - assert!(result.summary.is_none()); -} - -#[test] -fn test_docstring_span_covers_entire_input() { - let docstring = "First line.\n\nSecond line."; - let result = parse_numpy(docstring); - assert_eq!(result.range.start(), TextSize::new(0)); - assert_eq!(result.range.end().raw() as usize, docstring.len()); -} - -// ============================================================================= -// Signature-like line is treated as summary -// ============================================================================= - -#[test] -fn test_parse_with_signature_line() { - let docstring = r#"add(a, b) - -The sum of two numbers. - -Parameters ----------- -a : int - First number. -b : int - Second number. -"#; - let result = parse_numpy(docstring); - // The signature-like line is now parsed as the summary - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "add(a, b)" - ); - assert_eq!(parameters(&result).len(), 2); -} - -// ============================================================================= -// Extended summary -// ============================================================================= - -#[test] -fn test_extended_summary_preserves_paragraphs() { - let docstring = r#"Summary. - -First paragraph of extended. - -Second paragraph of extended. - -Parameters ----------- -x : int - Desc. -"#; - let result = parse_numpy(docstring); - let ext = result.extended_summary.as_ref().unwrap(); - assert!(ext.source_text(&result.source).contains("First paragraph")); - assert!(ext.source_text(&result.source).contains("Second paragraph")); - assert!(ext.source_text(&result.source).contains('\n')); -} - -// ============================================================================= -// Parameters -// ============================================================================= - -#[test] -fn test_with_parameters() { - let docstring = r#"Calculate the sum of two numbers. - -Parameters ----------- -x : int - The first number. -y : int - The second number. - -Returns -------- -int - The sum of x and y. -"#; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Calculate the sum of two numbers." - ); - assert_eq!(parameters(&result).len(), 2); - - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!( - parameters(&result)[0] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("int") - ); - assert_eq!( - parameters(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "The first number." - ); - - assert_eq!( - parameters(&result)[1].names[0].source_text(&result.source), - "y" - ); - assert_eq!( - parameters(&result)[1] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("int") - ); - - assert!(!returns(&result).is_empty()); - assert_eq!( - returns(&result)[0] - .return_type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("int") - ); -} - -#[test] -fn test_optional_parameters() { - let docstring = r#"Function with optional parameters. - -Parameters ----------- -required : str - A required parameter. -optional : int, optional - An optional parameter. -"#; - let result = parse_numpy(docstring); - - assert_eq!(parameters(&result).len(), 2); - assert!(parameters(&result)[0].optional.is_none()); - assert!(parameters(&result)[1].optional.is_some()); - assert_eq!( - parameters(&result)[1] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("int") - ); -} - -#[test] -fn test_parse_with_parameters_spans() { - let docstring = r#"Brief description. - -Parameters ----------- -x : int - The first parameter. -y : str, optional - The second parameter. -"#; - let result = parse_numpy(docstring); - assert_eq!(parameters(&result).len(), 2); - - // Verify name spans point to correct source text - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!( - parameters(&result)[1].names[0].source_text(&result.source), - "y" - ); - // Verify type spans - assert_eq!( - parameters(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); -} - -/// Parameters with no space before colon: `x: int` -#[test] -fn test_parameters_no_space_before_colon() { - let docstring = "Summary.\n\nParameters\n----------\nx: int\n The value.\n"; - let result = parse_numpy(docstring); - let p = parameters(&result); - assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); - assert_eq!( - p[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - p[0].description - .as_ref() - .unwrap() - .source_text(&result.source), - "The value." - ); -} - -/// Parameters with no space after colon: `x :int` -#[test] -fn test_parameters_no_space_after_colon() { - let docstring = "Summary.\n\nParameters\n----------\nx :int\n The value.\n"; - let result = parse_numpy(docstring); - let p = parameters(&result); - assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); - assert_eq!( - p[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); -} - -/// Parameters with no spaces around colon: `x:int` -#[test] -fn test_parameters_no_spaces_around_colon() { - let docstring = "Summary.\n\nParameters\n----------\nx:int\n The value.\n"; - let result = parse_numpy(docstring); - let p = parameters(&result); - assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); - assert_eq!( - p[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); -} - -/// Returns with no spaces around colon (named): `result:int` -#[test] -fn test_returns_no_spaces_around_colon() { - let docstring = "Summary.\n\nReturns\n-------\nresult:int\n The result.\n"; - let result = parse_numpy(docstring); - let r = returns(&result); - assert_eq!(r.len(), 1); - assert_eq!( - r[0].name.as_ref().unwrap().source_text(&result.source), - "result" - ); - assert_eq!( - r[0].return_type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); -} - -/// See Also with no space before colon. -#[test] -fn test_see_also_no_space_before_colon() { - let docstring = "Summary.\n\nSee Also\n--------\nfunc_a: Description of func_a.\n"; - let result = parse_numpy(docstring); - let sa = see_also(&result); - assert_eq!(sa.len(), 1); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); - assert!( - sa[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source) - .contains("Description") - ); -} - -#[test] -fn test_multiple_parameter_names() { - let docstring = r#"Summary. - -Parameters ----------- -x1, x2 : array_like - Input arrays. -"#; - let result = parse_numpy(docstring); - let p = ¶meters(&result)[0]; - assert_eq!(p.names.len(), 2); - assert_eq!(p.names[0].source_text(&result.source), "x1"); - assert_eq!(p.names[1].source_text(&result.source), "x2"); - assert_eq!(p.names[0].source_text(&result.source), "x1"); - assert_eq!(p.names[1].source_text(&result.source), "x2"); -} - -#[test] -fn test_description_with_colon_not_treated_as_param() { - let docstring = r#"Brief summary. - -Parameters ----------- -x : int - A value like key: value should not split. -"#; - let result = parse_numpy(docstring); - assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert!( - parameters(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source) - .contains("key: value") - ); -} - -#[test] -fn test_multi_paragraph_description() { - let docstring = r#"Summary. - -Parameters ----------- -x : int - First paragraph of x. - - Second paragraph of x. -"#; - let result = parse_numpy(docstring); - let desc = ¶meters(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); - assert!(desc.contains("First paragraph of x.")); - assert!(desc.contains("Second paragraph of x.")); - assert!(desc.contains('\n')); -} - -// ============================================================================= -// Returns -// ============================================================================= - -#[test] -fn test_parse_named_returns() { - let docstring = r#"Compute values. - -Returns -------- -x : int - The first value. -y : float - The second value. -"#; - let result = parse_numpy(docstring); - assert_eq!(returns(&result).len(), 2); - assert_eq!( - returns(&result)[0] - .name - .as_ref() - .map(|n| n.source_text(&result.source)), - Some("x") - ); - assert_eq!( - returns(&result)[0] - .return_type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("int") - ); - assert_eq!( - returns(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "The first value." - ); - assert_eq!( - returns(&result)[1] - .name - .as_ref() - .map(|n| n.source_text(&result.source)), - Some("y") - ); -} - -// ============================================================================= -// Raises -// ============================================================================= - -#[test] -fn test_with_raises() { - let docstring = r#"Function that may raise exceptions. - -Raises ------- -ValueError - If the input is invalid. -TypeError - If the type is wrong. -"#; - let result = parse_numpy(docstring); - - assert_eq!(raises(&result).len(), 2); - assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), - "ValueError" - ); - assert_eq!( - raises(&result)[1].r#type.source_text(&result.source), - "TypeError" - ); -} - -#[test] -fn test_raises_with_spans() { - let docstring = r#"Summary. - -Raises ------- -ValueError - If input is bad. -TypeError - If type is wrong. -"#; - let result = parse_numpy(docstring); - assert_eq!(raises(&result).len(), 2); - assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), - "ValueError" - ); - assert_eq!( - raises(&result)[1].r#type.source_text(&result.source), - "TypeError" - ); -} - -// ============================================================================= -// Notes / See Also / References / Examples -// ============================================================================= - -#[test] -fn test_with_notes_section() { - let docstring = r#"Function with notes. - -Notes ------ -This is an important note about the function. -"#; - let result = parse_numpy(docstring); - - assert!(notes(&result).is_some()); - assert!( - notes(&result) - .unwrap() - .source_text(&result.source) - .contains("important note") - ); -} - -#[test] -fn test_see_also_parsing() { - let docstring = r#"Summary. - -See Also --------- -func_a : Does something. -func_b, func_c -"#; - let result = parse_numpy(docstring); - let items = see_also(&result); - assert_eq!(items.len(), 2); - assert_eq!(items[0].names[0].source_text(&result.source), "func_a"); - assert_eq!( - items[0] - .description - .as_ref() - .map(|d| d.source_text(&result.source)), - Some("Does something.") - ); - assert_eq!(items[1].names.len(), 2); - assert_eq!(items[1].names[0].source_text(&result.source), "func_b"); - assert_eq!(items[1].names[1].source_text(&result.source), "func_c"); -} - -#[test] -fn test_references_parsing() { - let docstring = r#"Summary. - -References ----------- -.. [1] Author A, "Title A", 2020. -.. [2] Author B, "Title B", 2021. -"#; - let result = parse_numpy(docstring); - let refs = references(&result); - assert_eq!(refs.len(), 2); - assert_eq!( - refs[0].number.as_ref().unwrap().source_text(&result.source), - "1" - ); - assert!( - refs[0] - .content - .as_ref() - .unwrap() - .source_text(&result.source) - .contains("Author A") - ); - assert_eq!( - refs[1].number.as_ref().unwrap().source_text(&result.source), - "2" - ); - assert!( - refs[1] - .content - .as_ref() - .unwrap() - .source_text(&result.source) - .contains("Author B") - ); -} - -// ============================================================================= -// Case insensitive sections -// ============================================================================= - -#[test] -fn test_case_insensitive_sections() { - let docstring = r#"Brief summary. - -parameters ----------- -x : int - First param. - -returns -------- -int - The result. - -NOTES ------ -Some notes here. -"#; - let result = parse_numpy(docstring); - assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!(returns(&result).len(), 1); - assert!(notes(&result).is_some()); - // Original text is preserved in header - assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), - "parameters" - ); - assert_eq!( - sections(&result)[2].header.name.source_text(&result.source), - "NOTES" - ); -} - -// ============================================================================= -// Section header spans -// ============================================================================= - -#[test] -fn test_section_header_spans() { - let docstring = r#"Summary. - -Parameters ----------- -x : int - Desc. -"#; - let result = parse_numpy(docstring); - let hdr = §ions(&result)[0].header; - assert_eq!(hdr.name.source_text(&result.source), "Parameters"); - assert_eq!(hdr.underline.source_text(&result.source), "----------"); -} - -// ============================================================================= -// Span round-trip -// ============================================================================= - -#[test] -fn test_span_source_text_round_trip() { - let docstring = r#"Summary line. - -Parameters ----------- -x : int - Description of x. -"#; - let result = parse_numpy(docstring); - let src = &result.source; - - assert_eq!( - result.summary.as_ref().unwrap().source_text(src), - "Summary line." - ); - assert_eq!( - sections(&result)[0].header.name.source_text(src), - "Parameters" - ); - let underline = §ions(&result)[0] - .header - .underline - .source_text(&result.source); - assert!(underline.chars().all(|c| c == '-')); - - let p = ¶meters(&result)[0]; - assert_eq!(p.names[0].source_text(src), "x"); - assert_eq!(p.r#type.as_ref().unwrap().source_text(src), "int"); - assert_eq!( - p.description.as_ref().unwrap().source_text(src), - "Description of x." - ); -} - -// ============================================================================= -// Deprecation -// ============================================================================= - -#[test] -fn test_deprecation_directive() { - let docstring = r#"Summary. - -.. deprecated:: 1.6.0 - Use `new_func` instead. - -Parameters ----------- -x : int - Desc. -"#; - let result = parse_numpy(docstring); - let dep = result - .deprecation - .as_ref() - .expect("deprecation should be parsed"); - assert_eq!(dep.version.source_text(&result.source), "1.6.0"); - assert_eq!( - dep.description.source_text(&result.source), - "Use `new_func` instead." - ); - assert_eq!(dep.version.source_text(&result.source), "1.6.0"); -} - -// ============================================================================= -// Indented docstrings (class/method bodies) -// ============================================================================= - -#[test] -fn test_indented_docstring() { - let docstring = " Summary line.\n\n Parameters\n ----------\n x : int\n Description of x.\n y : str, optional\n Description of y.\n\n Returns\n -------\n bool\n The result.\n"; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary line." - ); - assert_eq!(parameters(&result).len(), 2); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!( - parameters(&result)[0] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("int") - ); - assert_eq!( - parameters(&result)[1].names[0].source_text(&result.source), - "y" - ); - assert!(parameters(&result)[1].optional.is_some()); - assert_eq!(returns(&result).len(), 1); - assert_eq!( - returns(&result)[0] - .return_type - .as_ref() - .map(|t| t.source_text(&result.source)), - Some("bool") - ); - - // Spans point to correct positions in indented source - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary line." - ); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!( - parameters(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); -} - -#[test] -fn test_deeply_indented_docstring() { - let docstring = " Brief.\n\n Parameters\n ----------\n a : float\n The value.\n\n Raises\n ------\n ValueError\n If bad.\n"; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief." - ); - assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "a" - ); - assert_eq!(raises(&result).len(), 1); - assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), - "ValueError" - ); - assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), - "ValueError" - ); -} - -#[test] -fn test_indented_with_deprecation() { - let docstring = " Summary.\n\n .. deprecated:: 2.0.0\n Use new_func instead.\n\n Parameters\n ----------\n x : int\n Desc.\n"; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); - let dep = result - .deprecation - .as_ref() - .expect("should have deprecation"); - assert_eq!(dep.version.source_text(&result.source), "2.0.0"); - assert!( - dep.description - .source_text(&result.source) - .contains("new_func") - ); - assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); -} - -#[test] -fn test_mixed_indent_first_line() { - let docstring = - "Summary.\n\n Parameters\n ----------\n x : int\n Description.\n"; - let result = parse_numpy(docstring); - - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); - assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!( - parameters(&result)[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "Description." - ); -} - -// ============================================================================= -// Enum / choices type -// ============================================================================= - -#[test] -fn test_enum_type_as_string() { - let docstring = - "Summary.\n\nParameters\n----------\norder : {'C', 'F', 'A'}\n Memory layout."; - let result = parse_numpy(docstring); - let params = parameters(&result); - assert_eq!(params.len(), 1); - - let p = ¶ms[0]; - assert_eq!(p.names[0].source_text(&result.source), "order"); - assert_eq!( - p.r#type.as_ref().unwrap().source_text(&result.source), - "{'C', 'F', 'A'}" - ); - assert_eq!( - p.description.as_ref().unwrap().source_text(&result.source), - "Memory layout." - ); -} - -#[test] -fn test_enum_type_with_optional() { - let docstring = - "Summary.\n\nParameters\n----------\norder : {'C', 'F'}, optional\n Memory layout."; - let result = parse_numpy(docstring); - let params = parameters(&result); - let p = ¶ms[0]; - - assert!(p.optional.is_some()); - assert_eq!( - p.r#type.as_ref().unwrap().source_text(&result.source), - "{'C', 'F'}" - ); -} - -#[test] -fn test_enum_type_with_default() { - let docstring = "Summary.\n\nParameters\n----------\norder : {'C', 'F', 'A'}, default 'C'\n Memory layout."; - let result = parse_numpy(docstring); - let params = parameters(&result); - let p = ¶ms[0]; - - assert_eq!( - p.r#type.as_ref().unwrap().source_text(&result.source), - "{'C', 'F', 'A'}" - ); - assert_eq!( - p.default_keyword - .as_ref() - .unwrap() - .source_text(&result.source), - "default" - ); - assert!(p.default_separator.is_none()); // space-separated - assert_eq!( - p.default_value - .as_ref() - .unwrap() - .source_text(&result.source), - "'C'" - ); -} - -// ============================================================================= -// Tab indentation tests -// ============================================================================= - -/// Parameters section with tab-indented descriptions. -#[test] -fn test_tab_indented_parameters() { - let docstring = "Summary.\n\nParameters\n----------\nx : int\n\tDescription of x.\ny : str\n\tDescription of y."; - let result = parse_numpy(docstring); - let params = parameters(&result); - assert_eq!(params.len(), 2); - assert_eq!(params[0].names[0].source_text(&result.source), "x"); - assert_eq!( - params[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "Description of x." - ); - assert_eq!(params[1].names[0].source_text(&result.source), "y"); - assert_eq!( - params[1] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "Description of y." - ); -} - -/// Mixed tabs and spaces: header at 0 indent, description indented with tab. -#[test] -fn test_mixed_tab_space_parameters() { - // Header uses no indent, description uses tab (== 4 columns > 0) - let docstring = "Summary.\n\nParameters\n----------\nx : int\n\tThe value.\n\t More detail."; - let result = parse_numpy(docstring); - let params = parameters(&result); - assert_eq!(params.len(), 1); - assert_eq!(params[0].names[0].source_text(&result.source), "x"); - // Description should include "The value." (the first desc line) - let desc = params[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); - assert!(desc.contains("The value."), "desc = {:?}", desc); -} - -/// Returns section with tab-indented descriptions. -#[test] -fn test_tab_indented_returns() { - let docstring = "Summary.\n\nReturns\n-------\nint\n\tThe result value."; - let result = parse_numpy(docstring); - let rets = returns(&result); - assert_eq!(rets.len(), 1); - assert_eq!( - rets[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "The result value." - ); -} - -/// Raises section with tab-indented description. -#[test] -fn test_tab_indented_raises() { - let docstring = "Summary.\n\nRaises\n------\nValueError\n\tIf the input is invalid."; - let result = parse_numpy(docstring); - let exc = raises(&result); - assert_eq!(exc.len(), 1); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!( - exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "If the input is invalid." - ); -} - -// ============================================================================= -// Raises colon splitting tests -// ============================================================================= - -/// Raises with colon separating type and description on the same line. -#[test] -fn test_raises_colon_split() { - let docstring = "Summary.\n\nRaises\n------\nValueError : If the input is invalid.\nTypeError : If the type is wrong."; - let result = parse_numpy(docstring); - let exc = raises(&result); - assert_eq!(exc.len(), 2); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert!(exc[0].colon.is_some()); - assert_eq!( - exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "If the input is invalid." - ); - assert_eq!(exc[1].r#type.source_text(&result.source), "TypeError"); - assert!(exc[1].colon.is_some()); - assert_eq!( - exc[1] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "If the type is wrong." - ); -} - -/// Raises without colon (bare type, description on next line). -#[test] -fn test_raises_no_colon() { - let docstring = "Summary.\n\nRaises\n------\nValueError\n If the input is invalid."; - let result = parse_numpy(docstring); - let exc = raises(&result); - assert_eq!(exc.len(), 1); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert!(exc[0].colon.is_none()); - assert_eq!( - exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), - "If the input is invalid." - ); -} - -/// Raises with colon and continuation description on next lines. -#[test] -fn test_raises_colon_with_continuation() { - let docstring = "Summary.\n\nRaises\n------\nValueError : If bad.\n More detail here."; - let result = parse_numpy(docstring); - let exc = raises(&result); - assert_eq!(exc.len(), 1); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert!(exc[0].colon.is_some()); - let desc = exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); - assert!(desc.contains("If bad."), "desc = {:?}", desc); - assert!(desc.contains("More detail here."), "desc = {:?}", desc); -} From e9d61d079991e1f0619eddb1111c2b84c7cc855e Mon Sep 17 00:00:00 2001 From: qraqras Date: Wed, 4 Mar 2026 23:50:04 +0000 Subject: [PATCH 5/8] refactor: tests --- .devcontainer/devcontainer.json | 2 +- README.md | 103 ++++------ src/ast.rs | 324 -------------------------------- src/cursor.rs | 8 +- src/error.rs | 5 - src/lib.rs | 8 +- src/parser.rs | 109 ----------- src/styles.rs | 92 +++++++++ src/styles/google/ast.rs | 2 +- src/styles/google/parser.rs | 2 +- src/styles/numpy/ast.rs | 2 +- src/styles/numpy/parser.rs | 2 +- src/text.rs | 156 +++++++++++++++ tests/detect_style.rs | 18 ++ tests/google/args.rs | 11 +- tests/google/main.rs | 2 +- 16 files changed, 321 insertions(+), 525 deletions(-) delete mode 100644 src/ast.rs delete mode 100644 src/error.rs delete mode 100644 src/parser.rs create mode 100644 src/text.rs create mode 100644 tests/detect_style.rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ade7833..3581c3d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ "customizations": { "vscode": { "extensions": [ - "rust-lang.rust-analyzer", + "rust-lang.rust-analyzer" ] }, "settings": { diff --git a/README.md b/README.md index a22e594..998e7da 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ A fast, zero-dependency Rust parser for Python docstrings with full AST and sour - Google style — fully supported - NumPy style — fully supported - **Accurate source spans** (byte offsets) on every AST node -- **Diagnostic-based error reporting** — partial results + diagnostics, never panics +- **Always succeeds** — returns a best-effort AST for any input, never panics - **Style auto-detection** — automatically identifies NumPy or Google style -- **Comprehensive test coverage** (140+ tests) +- **Comprehensive test coverage** (260+ tests) ## Installation @@ -120,32 +120,12 @@ let google_doc = "Summary.\n\nArgs:\n x: Desc."; assert_eq!(detect_style(google_doc), Style::Google); ``` -## Diagnostic-Based Error Handling - -Parsers always return a result — even for malformed input. Diagnostics are collected alongside the best-effort AST: - -```rust -use pydocstring::google::parse_google; - -let result = parse_google("Summary.\n\nArgs:\n : missing name"); - -if result.has_errors() { - for diag in result.errors() { - eprintln!("{}", diag); // e.g. "error at 14..28: ..." - } -} - -// The AST is still available -println!("Summary: {}", result.summary.as_ref().map_or("", |s| s.source_text(&result.source))); -``` - ## Source Locations Every AST node carries a `TextRange` (byte offsets) so linters can report precise positions: ```rust use pydocstring::numpy::parse_numpy; -use pydocstring::LineIndex; let docstring = "Summary.\n\nParameters\n----------\nx : int\n Desc."; let result = parse_numpy(docstring); @@ -158,11 +138,6 @@ for item in &result.items { println!("Parameter '{}' at byte {}..{}", name_range.source_text(&result.source), name_range.start(), name_range.end()); - - // Convert to line/column if needed - let index = LineIndex::from_source(docstring); - let (line, col) = index.line_col(name_range.start()); - println!(" line {}, col {}", line, col); } } } @@ -173,47 +148,49 @@ for item in &result.items { ### NumPy Style -| Section | Method | Return Type | -|---------|--------|-------------| -| Parameters | `parameters()` | `Vec<&NumPyParameter>` | -| Other Parameters | `other_parameters()` | `Vec<&NumPyParameter>` | -| Returns | `returns()` | `Vec<&NumPyReturns>` | -| Yields | `yields()` | `Vec<&NumPyReturns>` | -| Receives | `receives()` | `Vec<&NumPyParameter>` | -| Raises | `raises()` | `Vec<&NumPyException>` | -| Warns | `warns()` | `Vec<&NumPyWarning>` | -| Warnings | `warnings()` | `Option<&TextRange>` | -| See Also | `see_also()` | `Vec<&SeeAlsoItem>` | -| Notes | `notes()` | `Option<&TextRange>` | -| References | `references()` | `Vec<&NumPyReference>` | -| Examples | `examples()` | `Option<&TextRange>` | -| Attributes | `attributes()` | `Vec<&NumPyAttribute>` | -| Methods | `methods()` | `Vec<&NumPyMethod>` | +| Section | Body variant | Body type | +|---------|-------------|-----------| +| Parameters | `Parameters(...)` | `Vec` | +| Other Parameters | `OtherParameters(...)` | `Vec` | +| Receives | `Receives(...)` | `Vec` | +| Returns | `Returns(...)` | `Vec` | +| Yields | `Yields(...)` | `Vec` | +| Raises | `Raises(...)` | `Vec` | +| Warns | `Warns(...)` | `Vec` | +| See Also | `SeeAlso(...)` | `Vec` | +| Attributes | `Attributes(...)` | `Vec` | +| Methods | `Methods(...)` | `Vec` | +| References | `References(...)` | `Vec` | +| Warnings | `Warnings(...)` | `Option` | +| Notes | `Notes(...)` | `Option` | +| Examples | `Examples(...)` | `Option` | Additionally, `NumPyDocstring` has fields: `summary`, `deprecation`, `extended_summary`. ### Google Style -| Section | Method | Return Type | -|---------|--------|-------------| -| Args | `args()` | `Vec<&GoogleArgument>` | -| Keyword Args | `keyword_args()` | `Vec<&GoogleArgument>` | -| Other Parameters | `other_parameters()` | `Vec<&GoogleArgument>` | -| Returns | `returns()` | `Vec<&GoogleReturns>` | -| Yields | `yields()` | `Vec<&GoogleReturns>` | -| Receives | `receives()` | `Vec<&GoogleArgument>` | -| Raises | `raises()` | `Vec<&GoogleException>` | -| Warns | `warns()` | `Vec<&GoogleWarning>` | -| Warnings | `warnings()` | `Option<&TextRange>` | -| See Also | `see_also()` | `Vec<&GoogleSeeAlsoItem>` | -| Notes | `notes()` | `Option<&TextRange>` | -| References | `references()` | `Option<&TextRange>` | -| Examples | `examples()` | `Option<&TextRange>` | -| Attributes | `attributes()` | `Vec<&GoogleAttribute>` | -| Methods | `methods()` | `Vec<&GoogleMethod>` | -| Todo | `todo()` | `Option<&TextRange>` | - -Additionally, `GoogleDocstring` has fields: `summary`, `description`, and admonition sections (Attention, Caution, Danger, etc.). +| Section | Body variant | Body type | +|---------|-------------|-----------| +| Args | `Args(...)` | `Vec` | +| Keyword Args | `KeywordArgs(...)` | `Vec` | +| Other Parameters | `OtherParameters(...)` | `Vec` | +| Receives | `Receives(...)` | `Vec` | +| Returns | `Returns(...)` | `GoogleReturns` | +| Yields | `Yields(...)` | `GoogleReturns` | +| Raises | `Raises(...)` | `Vec` | +| Warns | `Warns(...)` | `Vec` | +| See Also | `SeeAlso(...)` | `Vec` | +| Attributes | `Attributes(...)` | `Vec` | +| Methods | `Methods(...)` | `Vec` | +| Notes | `Notes(...)` | `TextRange` | +| Examples | `Examples(...)` | `TextRange` | +| Todo | `Todo(...)` | `TextRange` | +| References | `References(...)` | `TextRange` | +| Warnings | `Warnings(...)` | `TextRange` | + +Admonition sections (Attention, Caution, Danger, Error, Hint, Important, Tip) are also supported as `TextRange` bodies. + +Additionally, `GoogleDocstring` has fields: `summary`, `extended_summary`. ## Development diff --git a/src/ast.rs b/src/ast.rs deleted file mode 100644 index 74a9249..0000000 --- a/src/ast.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! Core AST types, source location primitives, and shared utilities. -//! -//! This module provides: -//! - [`TextSize`], [`TextRange`], [`Spanned`] — source location tracking (ruff-style, offset-only) -//! - [`LineIndex`] — line/column computation from byte offsets -//! - [`Style`] — docstring style identifier -//! - Common utilities used by parsers - -use core::fmt; -use core::ops; - -// ============================================================================= -// Source location types (ruff-style, offset-only) -// ============================================================================= - -/// A byte offset in the source text. -/// -/// Newtype over `u32` for type safety (prevents mixing with line numbers, etc.). -/// Inspired by ruff's `TextSize` (from the `text-size` crate). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -pub struct TextSize(u32); - -impl TextSize { - /// Creates a new text size from a raw byte offset. - pub const fn new(raw: u32) -> Self { - Self(raw) - } - - /// Returns the raw byte offset. - pub const fn raw(self) -> u32 { - self.0 - } -} - -impl From for TextSize { - fn from(raw: u32) -> Self { - Self(raw) - } -} - -impl From for u32 { - fn from(size: TextSize) -> Self { - size.0 - } -} - -impl From for usize { - fn from(size: TextSize) -> Self { - size.0 as usize - } -} - -impl From for TextSize { - fn from(raw: usize) -> Self { - Self(raw as u32) - } -} - -impl ops::Add for TextSize { - type Output = Self; - fn add(self, rhs: Self) -> Self { - Self(self.0 + rhs.0) - } -} - -impl ops::Sub for TextSize { - type Output = Self; - fn sub(self, rhs: Self) -> Self { - Self(self.0 - rhs.0) - } -} - -impl fmt::Display for TextSize { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -/// A range in the source text `[start, end)`, represented as byte offsets. -/// -/// Stores only offsets — line/column information is computed on demand -/// via [`LineIndex`]. Inspired by ruff's `TextRange`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct TextRange { - start: TextSize, - end: TextSize, -} - -impl TextRange { - /// Creates a new range from start (inclusive) and end (exclusive) offsets. - pub const fn new(start: TextSize, end: TextSize) -> Self { - Self { start, end } - } - - /// Creates an empty range at offset 0. - pub const fn empty() -> Self { - Self { - start: TextSize::new(0), - end: TextSize::new(0), - } - } - - /// Start offset (inclusive). - pub const fn start(self) -> TextSize { - self.start - } - - /// End offset (exclusive). - pub const fn end(self) -> TextSize { - self.end - } - - /// Length of the range in bytes. - pub const fn len(self) -> TextSize { - TextSize::new(self.end.0 - self.start.0) - } - - /// Whether the range is empty. - pub const fn is_empty(self) -> bool { - self.start.0 == self.end.0 - } - - /// Whether `offset` is contained in this range. - pub const fn contains(self, offset: TextSize) -> bool { - self.start.0 <= offset.0 && offset.0 < self.end.0 - } - - /// Creates a range from an absolute byte offset and a length. - pub const fn from_offset_len(offset: usize, len: usize) -> Self { - Self { - start: TextSize::new(offset as u32), - end: TextSize::new((offset + len) as u32), - } - } - - /// Extracts the corresponding slice from the source text. - /// - /// Returns an empty string if the range is empty or offsets are out of bounds. - pub fn source_text<'a>(&self, source: &'a str) -> &'a str { - let start = self.start.0 as usize; - let end = self.end.0 as usize; - if start <= end && end <= source.len() { - &source[start..end] - } else { - "" - } - } - - /// Extend this range to include `other`. - /// - /// If `self` is empty, it is set to `other`. Otherwise its end is - /// extended to `other.end()`. - pub fn extend(&mut self, other: TextRange) { - if self.is_empty() { - *self = other; - } else { - self.end = other.end; - } - } -} - -// ============================================================================= -// LineIndex — on-demand line/column computation -// ============================================================================= - -/// Index for mapping byte offsets to line/column positions. -/// -/// Built once per source text, then queried as needed (e.g. for error display). -/// -/// ```rust -/// use pydocstring::ast::{LineIndex, TextSize}; -/// -/// let source = "first\nsecond\nthird"; -/// let index = LineIndex::from_source(source); -/// let (line, col) = index.line_col(TextSize::new(6)); -/// assert_eq!(line, 1); // 0-indexed: second line -/// assert_eq!(col, 0); // start of line -/// ``` -pub struct LineIndex { - /// Byte offset of each line start. - line_starts: Vec, -} - -impl LineIndex { - /// Build a line index from source text. - pub fn from_source(source: &str) -> Self { - let mut line_starts = vec![TextSize::new(0)]; - for (i, byte) in source.bytes().enumerate() { - if byte == b'\n' { - line_starts.push(TextSize::new((i + 1) as u32)); - } - } - Self { line_starts } - } - - /// Returns 0-indexed (line, column) for a byte offset. - /// - /// Column is the byte offset from the start of the line. - pub fn line_col(&self, offset: TextSize) -> (u32, u32) { - let line = self - .line_starts - .partition_point(|&start| start <= offset) - .saturating_sub(1); - let col = offset.raw() - self.line_starts[line].raw(); - (line as u32, col) - } - - /// Returns the 0-indexed line number for a byte offset. - pub fn line(&self, offset: TextSize) -> u32 { - self.line_col(offset).0 - } - - /// Returns the byte offset of the start of a given line. - pub fn line_start(&self, line: u32) -> TextSize { - self.line_starts - .get(line as usize) - .copied() - .unwrap_or_default() - } - - /// Number of lines in the source. - pub fn line_count(&self) -> u32 { - self.line_starts.len() as u32 - } -} - -// ============================================================================= -// Spanned -// ============================================================================= - -/// A value annotated with source location information. -/// -/// Used to track the precise location of each semantic element in the docstring, -/// enabling linters to report errors at specific positions (e.g., a parameter name, -/// its type annotation, or its description individually). -/// -/// # Example -/// -/// ```rust -/// use pydocstring::ast::{TextRange, TextSize, Spanned}; -/// -/// let name = Spanned::new( -/// "x".to_string(), -/// TextRange::new(TextSize::new(30), TextSize::new(31)), -/// ); -/// assert_eq!(name.value, "x"); -/// assert_eq!(name.range.start().raw(), 30); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Spanned { - /// The value. - pub value: T, - /// Source range of this value. - pub range: TextRange, -} - -impl Spanned { - /// Creates a new spanned value. - pub fn new(value: T, range: TextRange) -> Self { - Self { value, range } - } - - /// Creates a spanned value with an empty range. - /// - /// Useful as a placeholder during construction or when - /// range information is not yet available. - pub fn dummy(value: T) -> Self { - Self { - value, - range: TextRange::empty(), - } - } - - /// Unwraps the spanned value, discarding the range. - pub fn into_inner(self) -> T { - self.value - } -} - -impl Spanned { - /// Creates an empty spanned string with an empty range. - pub fn empty_string() -> Self { - Self { - value: String::new(), - range: TextRange::empty(), - } - } - - /// Borrows as a `Spanned<&str>`, preserving the range. - pub fn as_spanned_str(&self) -> Spanned<&str> { - Spanned { - value: self.value.as_str(), - range: self.range, - } - } -} - -impl fmt::Display for Spanned { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.value.fmt(f) - } -} - -// ============================================================================= -// Style -// ============================================================================= - -/// Docstring style identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Style { - /// NumPy style (section headers with underlines). - NumPy, - /// Google style (section headers with colons). - Google, -} - -impl fmt::Display for Style { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Style::NumPy => write!(f, "numpy"), - Style::Google => write!(f, "google"), - } - } -} diff --git a/src/cursor.rs b/src/cursor.rs index c3e32b5..544617d 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -4,7 +4,7 @@ //! line position into a single struct, eliminating the need to thread //! `(source, &offsets, total_lines)` through every helper function. -use crate::ast::{TextRange, TextSize}; +use crate::text::{TextRange, TextSize}; // ============================================================================= // LineCursor @@ -137,12 +137,6 @@ impl<'a> LineCursor<'a> { &self.source[start..end] } - /// Leading-whitespace byte count of line `idx`. - #[allow(dead_code)] - pub fn line_indent(&self, idx: usize) -> usize { - indent_len(self.line_text(idx)) - } - // ── Span construction ────────────────────────────────────────── /// Build a [`TextRange`] from (line, col) pairs. diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 48d43b0..0000000 --- a/src/error.rs +++ /dev/null @@ -1,5 +0,0 @@ -// This module is intentionally empty. -// -// Diagnostic types that were previously defined here have been removed from the -// parser. Style and lint diagnostics should be produced by a separate linter -// layer that walks the parsed AST, not by the parser itself. diff --git a/src/lib.rs b/src/lib.rs index fedac3e..11ef7f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,13 +40,12 @@ //! - NumPy style: fully supported //! - Google style: fully supported -pub mod ast; pub(crate) mod cursor; -pub mod parser; pub mod styles; +pub mod text; -pub use ast::{LineIndex, Style, TextRange, TextSize}; -pub use parser::detect_style; +pub use styles::Style; +pub use styles::detect_style; pub use styles::google::{ self, GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, GoogleMethod, GoogleReturns, GoogleSection, GoogleSectionBody, GoogleSectionHeader, @@ -57,3 +56,4 @@ pub use styles::numpy::{ NumPyMethod, NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPySectionBody, NumPySectionHeader, NumPySectionKind, NumPyWarning, SeeAlsoItem, }; +pub use text::{TextRange, TextSize}; diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index 419b012..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Top-level style detection. -//! -//! This module provides [`detect_style`] for automatic style detection. - -use crate::ast::Style; - -// ============================================================================= -// Style detection -// ============================================================================= - -/// Detect the docstring style from its content. -/// -/// Uses heuristics to identify the style: -/// 1. **NumPy**: Section headers followed by `---` underlines -/// 2. **Google**: Section headers ending with `:` (e.g., `Args:`, `Returns:`) -/// 3. Falls back to `Google` if no style-specific patterns are found -/// -/// # Example -/// -/// ```rust -/// use pydocstring::detect_style; -/// use pydocstring::Style; -/// -/// let numpy = "Summary.\n\nParameters\n----------\nx : int\n Description."; -/// assert_eq!(detect_style(numpy), Style::NumPy); -/// -/// let google = "Summary.\n\nArgs:\n x: Description."; -/// assert_eq!(detect_style(google), Style::Google); -/// ``` -pub fn detect_style(input: &str) -> Style { - if has_numpy_sections(input) { - return Style::NumPy; - } - if has_google_sections(input) { - return Style::Google; - } - Style::Google -} - -// ============================================================================= -// Style detection helpers -// ============================================================================= - -fn has_numpy_sections(input: &str) -> bool { - let lines: Vec<&str> = input.lines().collect(); - for i in 0..lines.len().saturating_sub(1) { - let current = lines[i].trim(); - let next = lines[i + 1].trim(); - if !current.is_empty() - && !next.is_empty() - && next.len() >= 3 - && next.chars().all(|c| c == '-') - { - return true; - } - } - false -} - -const GOOGLE_SECTIONS: &[&str] = &[ - "Args:", - "Arguments:", - "Returns:", - "Return:", - "Raises:", - "Yields:", - "Yield:", - "Example:", - "Examples:", - "Note:", - "Notes:", - "Attributes:", - "Todo:", - "References:", - "Warnings:", -]; - -fn has_google_sections(input: &str) -> bool { - for line in input.lines() { - let trimmed = line.trim(); - if GOOGLE_SECTIONS.contains(&trimmed) { - return true; - } - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ast::Style; - - #[test] - fn test_detect_numpy() { - let input = "Summary.\n\nParameters\n----------\nx : int\n Desc."; - assert_eq!(detect_style(input), Style::NumPy); - } - - #[test] - fn test_detect_google() { - let input = "Summary.\n\nArgs:\n x: Desc."; - assert_eq!(detect_style(input), Style::Google); - } - - #[test] - fn test_detect_plain_defaults_to_google() { - assert_eq!(detect_style("Just a summary."), Style::Google); - } -} diff --git a/src/styles.rs b/src/styles.rs index aa84816..7be588f 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1,7 +1,99 @@ //! Docstring style implementations. //! //! Each sub-module provides an AST and parser for its respective style. +//! This module also provides [`detect_style`] for automatic style detection. + +use core::fmt; + +use google::GoogleSectionKind; pub mod google; pub mod numpy; pub(crate) mod utils; + +// ============================================================================= +// Style +// ============================================================================= + +/// Docstring style identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Style { + /// NumPy style (section headers with underlines). + NumPy, + /// Google style (section headers with colons). + Google, +} + +impl fmt::Display for Style { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Style::NumPy => write!(f, "numpy"), + Style::Google => write!(f, "google"), + } + } +} + +// ============================================================================= +// Style detection +// ============================================================================= + +/// Detect the docstring style from its content. +/// +/// Uses heuristics to identify the style: +/// 1. **NumPy**: Section headers followed by `---` underlines +/// 2. **Google**: Section headers ending with `:` (e.g., `Args:`, `Returns:`) +/// 3. Falls back to `Google` if no style-specific patterns are found +/// +/// # Example +/// +/// ```rust +/// use pydocstring::detect_style; +/// use pydocstring::Style; +/// +/// let numpy = "Summary.\n\nParameters\n----------\nx : int\n Description."; +/// assert_eq!(detect_style(numpy), Style::NumPy); +/// +/// let google = "Summary.\n\nArgs:\n x: Description."; +/// assert_eq!(detect_style(google), Style::Google); +/// ``` +pub fn detect_style(input: &str) -> Style { + if has_numpy_sections(input) { + return Style::NumPy; + } + if has_google_sections(input) { + return Style::Google; + } + Style::Google +} + +// ============================================================================= +// Style detection helpers +// ============================================================================= + +fn has_numpy_sections(input: &str) -> bool { + let lines: Vec<&str> = input.lines().collect(); + for i in 0..lines.len().saturating_sub(1) { + let current = lines[i].trim(); + let next = lines[i + 1].trim(); + if !current.is_empty() + && !next.is_empty() + && next.len() >= 3 + && next.chars().all(|c| c == '-') + { + return true; + } + } + false +} + +fn has_google_sections(input: &str) -> bool { + for line in input.lines() { + let trimmed = line.trim(); + if let Some(name) = trimmed.strip_suffix(':') { + if GoogleSectionKind::is_known(&name.to_ascii_lowercase()) { + return true; + } + } + } + false +} diff --git a/src/styles/google/ast.rs b/src/styles/google/ast.rs index 3b1159d..07588cd 100644 --- a/src/styles/google/ast.rs +++ b/src/styles/google/ast.rs @@ -1,6 +1,6 @@ use core::fmt; -use crate::ast::TextRange; +use crate::text::TextRange; // ============================================================================= // Google Style Types diff --git a/src/styles/google/parser.rs b/src/styles/google/parser.rs index 3a76d16..a6bb923 100644 --- a/src/styles/google/parser.rs +++ b/src/styles/google/parser.rs @@ -17,7 +17,6 @@ //! ValueError: If the input is invalid. //! ``` -use crate::ast::TextRange; use crate::cursor::{LineCursor, indent_len}; use crate::styles::google::ast::{ GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, @@ -25,6 +24,7 @@ use crate::styles::google::ast::{ GoogleSectionKind, GoogleSeeAlsoItem, GoogleWarning, }; use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; +use crate::text::TextRange; // ============================================================================= // Section detection diff --git a/src/styles/numpy/ast.rs b/src/styles/numpy/ast.rs index 413f887..2ad8e0b 100644 --- a/src/styles/numpy/ast.rs +++ b/src/styles/numpy/ast.rs @@ -1,6 +1,6 @@ use core::fmt; -use crate::ast::TextRange; +use crate::text::TextRange; // ============================================================================= // NumPy Style Types diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index 9920857..3d30135 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -19,7 +19,6 @@ //! Description of return value. //! ``` -use crate::ast::TextRange; use crate::cursor::{LineCursor, indent_columns, indent_len}; use crate::styles::numpy::ast::{ NumPyAttribute, NumPyDeprecation, NumPyDocstring, NumPyDocstringItem, NumPyException, @@ -27,6 +26,7 @@ use crate::styles::numpy::ast::{ NumPySectionHeader, NumPySectionKind, NumPyWarning, SeeAlsoItem, }; use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; +use crate::text::TextRange; // ============================================================================= // Section detection diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..8d8eaea --- /dev/null +++ b/src/text.rs @@ -0,0 +1,156 @@ +//! Source location primitives. +//! +//! This module provides [`TextSize`] and [`TextRange`] for offset-based +//! source location tracking (inspired by ruff's `text-size` crate). + +use core::fmt; +use core::ops; + +// ============================================================================= +// Source location types (ruff-style, offset-only) +// ============================================================================= + +/// A byte offset in the source text. +/// +/// Newtype over `u32` for type safety (prevents mixing with line numbers, etc.). +/// Inspired by ruff's `TextSize` (from the `text-size` crate). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct TextSize(u32); + +impl TextSize { + /// Creates a new text size from a raw byte offset. + pub const fn new(raw: u32) -> Self { + Self(raw) + } + + /// Returns the raw byte offset. + pub const fn raw(self) -> u32 { + self.0 + } +} + +impl From for TextSize { + fn from(raw: u32) -> Self { + Self(raw) + } +} + +impl From for u32 { + fn from(size: TextSize) -> Self { + size.0 + } +} + +impl From for usize { + fn from(size: TextSize) -> Self { + size.0 as usize + } +} + +impl From for TextSize { + fn from(raw: usize) -> Self { + Self(raw as u32) + } +} + +impl ops::Add for TextSize { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self(self.0 + rhs.0) + } +} + +impl ops::Sub for TextSize { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self(self.0 - rhs.0) + } +} + +impl fmt::Display for TextSize { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// A range in the source text `[start, end)`, represented as byte offsets. +/// +/// Stores only offsets. Inspired by ruff's `TextRange`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct TextRange { + start: TextSize, + end: TextSize, +} + +impl TextRange { + /// Creates a new range from start (inclusive) and end (exclusive) offsets. + pub const fn new(start: TextSize, end: TextSize) -> Self { + Self { start, end } + } + + /// Creates an empty range at offset 0. + pub const fn empty() -> Self { + Self { + start: TextSize::new(0), + end: TextSize::new(0), + } + } + + /// Start offset (inclusive). + pub const fn start(self) -> TextSize { + self.start + } + + /// End offset (exclusive). + pub const fn end(self) -> TextSize { + self.end + } + + /// Length of the range in bytes. + pub const fn len(self) -> TextSize { + TextSize::new(self.end.0 - self.start.0) + } + + /// Whether the range is empty. + pub const fn is_empty(self) -> bool { + self.start.0 == self.end.0 + } + + /// Whether `offset` is contained in this range. + pub const fn contains(self, offset: TextSize) -> bool { + self.start.0 <= offset.0 && offset.0 < self.end.0 + } + + /// Creates a range from an absolute byte offset and a length. + pub const fn from_offset_len(offset: usize, len: usize) -> Self { + Self { + start: TextSize::new(offset as u32), + end: TextSize::new((offset + len) as u32), + } + } + + /// Extracts the corresponding slice from the source text. + /// + /// Returns an empty string if the range is empty or offsets are out of bounds. + pub fn source_text<'a>(&self, source: &'a str) -> &'a str { + let start = self.start.0 as usize; + let end = self.end.0 as usize; + if start <= end && end <= source.len() { + &source[start..end] + } else { + "" + } + } + + /// Extend this range to include `other`. + /// + /// If `self` is empty, it is set to `other`. Otherwise its end is + /// extended to `other.end()`. + pub fn extend(&mut self, other: TextRange) { + if self.is_empty() { + *self = other; + } else { + self.end = other.end; + } + } +} diff --git a/tests/detect_style.rs b/tests/detect_style.rs new file mode 100644 index 0000000..b40e210 --- /dev/null +++ b/tests/detect_style.rs @@ -0,0 +1,18 @@ +use pydocstring::{Style, detect_style}; + +#[test] +fn test_detect_numpy() { + let input = "Summary.\n\nParameters\n----------\nx : int\n Desc."; + assert_eq!(detect_style(input), Style::NumPy); +} + +#[test] +fn test_detect_google() { + let input = "Summary.\n\nArgs:\n x: Desc."; + assert_eq!(detect_style(input), Style::Google); +} + +#[test] +fn test_detect_plain_defaults_to_google() { + assert_eq!(detect_style("Just a summary."), Style::Google); +} diff --git a/tests/google/args.rs b/tests/google/args.rs index 2a53c08..7610b1e 100644 --- a/tests/google/args.rs +++ b/tests/google/args.rs @@ -258,10 +258,8 @@ fn test_args_name_span() { let docstring = "Summary.\n\nArgs:\n x (int): Value."; let result = parse_google(docstring); let arg = &args(&result)[0]; - let index = LineIndex::from_source(&result.source); - let (line, col) = index.line_col(arg.name.start()); - assert_eq!(line, 3); - assert_eq!(col, 4); + // "x" starts at byte offset 20 (line 3, col 4) + assert_eq!(arg.name.start(), TextSize::new(20)); assert_eq!(arg.name.end(), TextSize::new(arg.name.start().raw() + 1)); assert_eq!(arg.name.source_text(&result.source), "x"); } @@ -272,9 +270,8 @@ fn test_args_type_span() { let result = parse_google(docstring); let arg = &args(&result)[0]; let type_span = arg.r#type.as_ref().unwrap(); - let index = LineIndex::from_source(&result.source); - let (line, _col) = index.line_col(type_span.start()); - assert_eq!(line, 3); + // "int" starts at byte offset 23 (line 3) + assert_eq!(type_span.start(), TextSize::new(23)); assert_eq!(type_span.source_text(&result.source), "int"); } diff --git a/tests/google/main.rs b/tests/google/main.rs index 99c13bf..9356ecd 100644 --- a/tests/google/main.rs +++ b/tests/google/main.rs @@ -1,12 +1,12 @@ //! Integration tests for Google-style docstring parser. pub use pydocstring::GoogleSectionBody; +pub use pydocstring::TextSize; pub use pydocstring::google::parse_google; pub use pydocstring::google::{ GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, GoogleMethod, GoogleReturns, GoogleSection, GoogleSeeAlsoItem, GoogleWarning, }; -pub use pydocstring::{LineIndex, TextSize}; mod args; mod edge_cases; From 2a8731ce3034665e1c16208c914f944d2c219555 Mon Sep 17 00:00:00 2001 From: qraqras Date: Fri, 6 Mar 2026 02:47:58 +0000 Subject: [PATCH 6/8] feat!: ast --- Cargo.toml | 2 +- README.md | 269 +++++--- examples/parse_google.rs | 109 +-- examples/parse_numpy.rs | 101 +-- examples/test_ret.rs | 50 +- src/lib.rs | 19 +- src/styles/google.rs | 11 +- src/styles/google/ast.rs | 502 -------------- src/styles/google/kind.rs | 186 +++++ src/styles/google/nodes.rs | 338 +++++++++ src/styles/google/parser.rs | 840 ++++++++++++----------- src/styles/numpy.rs | 9 +- src/styles/numpy/ast.rs | 464 ------------- src/styles/numpy/kind.rs | 137 ++++ src/styles/numpy/nodes.rs | 399 +++++++++++ src/styles/numpy/parser.rs | 1292 +++++++++++++++++++---------------- src/syntax.rs | 674 ++++++++++++++++++ src/text.rs | 22 +- tests/google/args.rs | 395 ++++------- tests/google/edge_cases.rs | 134 ++-- tests/google/freetext.rs | 145 ++-- tests/google/main.rs | 295 +++----- tests/google/raises.rs | 99 ++- tests/google/returns.rs | 49 +- tests/google/sections.rs | 83 +-- tests/google/structured.rs | 96 ++- tests/google/summary.rs | 53 +- tests/numpy/edge_cases.rs | 125 ++-- tests/numpy/freetext.rs | 135 ++-- tests/numpy/main.rs | 231 +++---- tests/numpy/parameters.rs | 245 +++---- tests/numpy/raises.rs | 104 ++- tests/numpy/returns.rs | 118 +--- tests/numpy/sections.rs | 81 +-- tests/numpy/structured.rs | 135 ++-- tests/numpy/summary.rs | 53 +- 36 files changed, 4151 insertions(+), 3849 deletions(-) delete mode 100644 src/styles/google/ast.rs create mode 100644 src/styles/google/kind.rs create mode 100644 src/styles/google/nodes.rs delete mode 100644 src/styles/numpy/ast.rs create mode 100644 src/styles/numpy/kind.rs create mode 100644 src/styles/numpy/nodes.rs create mode 100644 src/syntax.rs diff --git a/Cargo.toml b/Cargo.toml index c7802a9..4af6a7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "pydocstring" version = "0.0.1" edition = "2024" authors = ["Ryuma Asai"] -description = "A fast, zero-dependency Rust parser for Python docstrings (NumPy and Google styles) with full AST and source location tracking" +description = "A fast, zero-dependency Rust parser for Python docstrings (NumPy and Google styles) with a unified syntax tree and byte-precise source locations" license = "MIT" repository = "https://github.com/qraqras/pydocstring" homepage = "https://github.com/qraqras/pydocstring" diff --git a/README.md b/README.md index 998e7da..af0a8b0 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@ # pydocstring -A fast, zero-dependency Rust parser for Python docstrings with full AST and source location tracking. +A fast, zero-dependency Rust parser for Python docstrings. +Parses Google and NumPy style docstrings into a **unified syntax tree** with **byte-precise source locations** on every token. -## Features +## Why pydocstring? -- **Zero external dependencies** — pure Rust implementation -- **Docstring styles:** - - Google style — fully supported - - NumPy style — fully supported -- **Accurate source spans** (byte offsets) on every AST node -- **Always succeeds** — returns a best-effort AST for any input, never panics -- **Style auto-detection** — automatically identifies NumPy or Google style -- **Comprehensive test coverage** (260+ tests) +Existing Python docstring parsers (docstring_parser, griffe, etc.) return flat lists of extracted values with no positional information. pydocstring is designed as **infrastructure for linters and formatters**: + +- **Byte-precise source locations** — every `SyntaxToken` carries a `TextRange` (byte offset pair), so tools can emit diagnostics pointing to exact positions in the original text +- **Uniform syntax tree** — Google and NumPy styles produce the same `SyntaxNode` / `SyntaxToken` tree structure (inspired by [Biome](https://biomejs.dev/)), enabling style-agnostic tree traversal via `Visitor` + `walk` +- **Zero dependencies, never panics** — pure Rust with no external crates; always returns a best-effort tree for any input +- **Native performance** — suitable for embedding in Rust-based toolchains like Ruff ## Installation @@ -19,7 +18,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -pydocstring = "0.1.0" +pydocstring = "0.0.1" ``` ## Quick Start @@ -27,14 +26,12 @@ pydocstring = "0.1.0" ### NumPy Style ```rust -use pydocstring::numpy::parse_numpy; +use pydocstring::numpy::{parse_numpy, nodes::NumPyDocstring}; +use pydocstring::NumPySectionKind; -let docstring = r#" +let docstring = "\ Calculate the area of a rectangle. -This function takes the width and height of a rectangle -and returns its area. - Parameters ---------- width : float @@ -46,26 +43,33 @@ Returns ------- float The area of the rectangle. - -Raises ------- -ValueError - If width or height is negative. -"#; +"; let result = parse_numpy(docstring); - -println!("Summary: {}", result.summary.as_ref().map_or("", |s| s.source_text(&result.source))); -for item in &result.items { - if let pydocstring::NumPyDocstringItem::Section(s) = item { - if let pydocstring::NumPySectionBody::Parameters(params) = &s.body { - for param in params { - let names: Vec<&str> = param.names.iter() - .map(|n| n.source_text(&result.source)).collect(); - println!(" {:?}: {:?}", names, - param.r#type.as_ref().map(|t| t.source_text(&result.source))); +let doc = NumPyDocstring::cast(result.root()).unwrap(); + +// Summary +println!("{}", doc.summary().unwrap().text(result.source())); + +// Iterate sections +for section in doc.sections() { + match section.section_kind(result.source()) { + NumPySectionKind::Parameters => { + for param in section.parameters() { + let names: Vec<&str> = param.names() + .map(|n| n.text(result.source())) + .collect(); + let ty = param.r#type().map(|t| t.text(result.source())); + println!(" {:?}: {:?}", names, ty); + } + } + NumPySectionKind::Returns => { + for ret in section.returns() { + let ty = ret.return_type().map(|t| t.text(result.source())); + println!(" -> {:?}", ty); } } + _ => {} } } ``` @@ -73,14 +77,12 @@ for item in &result.items { ### Google Style ```rust -use pydocstring::google::parse_google; +use pydocstring::google::{parse_google, nodes::GoogleDocstring}; +use pydocstring::GoogleSectionKind; -let docstring = r#" +let docstring = "\ Calculate the area of a rectangle. -This function takes the width and height of a rectangle -and returns its area. - Args: width (float): The width of the rectangle. height (float): The height of the rectangle. @@ -90,20 +92,33 @@ Returns: Raises: ValueError: If width or height is negative. -"#; +"; let result = parse_google(docstring); - -println!("Summary: {}", result.summary.as_ref().map_or("", |s| s.source_text(&result.source))); -for item in &result.items { - if let pydocstring::GoogleDocstringItem::Section(s) = item { - if let pydocstring::GoogleSectionBody::Args(args) = &s.body { - for arg in args { - println!(" {} ({:?}): {}", arg.name.source_text(&result.source), - arg.r#type.as_ref().map(|t| t.source_text(&result.source)), - arg.description.source_text(&result.source)); +let doc = GoogleDocstring::cast(result.root()).unwrap(); + +// Summary +println!("{}", doc.summary().unwrap().text(result.source())); + +// Iterate sections +for section in doc.sections() { + match section.section_kind(result.source()) { + GoogleSectionKind::Args => { + for arg in section.args() { + println!(" {} ({:?}): {:?}", + arg.name().text(result.source()), + arg.r#type().map(|t| t.text(result.source())), + arg.description().map(|d| d.text(result.source()))); + } + } + GoogleSectionKind::Raises => { + for exc in section.exceptions() { + println!(" {}: {:?}", + exc.r#type().text(result.source()), + exc.description().map(|d| d.text(result.source()))); } } + _ => {} } } ``` @@ -122,86 +137,128 @@ assert_eq!(detect_style(google_doc), Style::Google); ## Source Locations -Every AST node carries a `TextRange` (byte offsets) so linters can report precise positions: +Every token carries a `TextRange` (byte offsets), so linters can report precise positions: ```rust -use pydocstring::numpy::parse_numpy; +use pydocstring::numpy::{parse_numpy, nodes::NumPyDocstring}; +use pydocstring::NumPySectionKind; -let docstring = "Summary.\n\nParameters\n----------\nx : int\n Desc."; +let docstring = "Summary.\n\nParameters\n----------\nx : int\n The value."; let result = parse_numpy(docstring); +let doc = NumPyDocstring::cast(result.root()).unwrap(); + +for section in doc.sections() { + if section.section_kind(result.source()) == NumPySectionKind::Parameters { + for param in section.parameters() { + let name = param.names().next().unwrap(); + println!("Parameter '{}' at byte {}..{}", + name.text(result.source()), + name.range().start(), + name.range().end()); + // => Parameter 'x' at byte 31..32 + } + } +} +``` -for item in &result.items { - if let pydocstring::NumPyDocstringItem::Section(s) = item { - if let pydocstring::NumPySectionBody::Parameters(params) = &s.body { - if let Some(param) = params.first() { - let name_range = ¶m.names[0]; - println!("Parameter '{}' at byte {}..{}", - name_range.source_text(&result.source), - name_range.start(), name_range.end()); - } +## Syntax Tree + +The parse result is a tree of `SyntaxNode` (branches) and `SyntaxToken` (leaves), each tagged with a `SyntaxKind`. Use `pretty_print()` to visualize: + +```rust +use pydocstring::google::parse_google; + +let result = parse_google("Summary.\n\nArgs:\n x (int): The value."); +println!("{}", result.pretty_print()); +``` + +Output: +``` +GOOGLE_DOCSTRING@0..42 { + SUMMARY: "Summary."@0..8 + GOOGLE_SECTION@10..42 { + GOOGLE_SECTION_HEADER@10..15 { + NAME: "Args"@10..14 + COLON: ":"@14..15 + } + GOOGLE_ARG@20..42 { + NAME: "x"@20..21 + OPEN_BRACKET: "("@22..23 + TYPE: "int"@23..26 + CLOSE_BRACKET: ")"@26..27 + COLON: ":"@27..28 + DESCRIPTION: "The value."@29..39 } } } ``` +### Visitor Pattern + +Walk the tree with the `Visitor` trait for style-agnostic analysis: + +```rust +use pydocstring::{Visitor, walk, SyntaxNode, SyntaxToken, SyntaxKind}; +use pydocstring::google::parse_google; + +struct NameCollector<'a> { + source: &'a str, + names: Vec, +} + +impl Visitor for NameCollector<'_> { + fn visit_token(&mut self, token: &SyntaxToken) { + if token.kind() == SyntaxKind::NAME { + self.names.push(token.text(self.source).to_string()); + } + } +} + +let result = parse_google("Summary.\n\nArgs:\n x: Desc.\n y: Desc."); +let mut collector = NameCollector { source: result.source(), names: vec![] }; +walk(result.root(), &mut collector); +assert_eq!(collector.names, vec!["Args", "x", "y"]); +``` + ## Supported Sections ### NumPy Style -| Section | Body variant | Body type | -|---------|-------------|-----------| -| Parameters | `Parameters(...)` | `Vec` | -| Other Parameters | `OtherParameters(...)` | `Vec` | -| Receives | `Receives(...)` | `Vec` | -| Returns | `Returns(...)` | `Vec` | -| Yields | `Yields(...)` | `Vec` | -| Raises | `Raises(...)` | `Vec` | -| Warns | `Warns(...)` | `Vec` | -| See Also | `SeeAlso(...)` | `Vec` | -| Attributes | `Attributes(...)` | `Vec` | -| Methods | `Methods(...)` | `Vec` | -| References | `References(...)` | `Vec` | -| Warnings | `Warnings(...)` | `Option` | -| Notes | `Notes(...)` | `Option` | -| Examples | `Examples(...)` | `Option` | - -Additionally, `NumPyDocstring` has fields: `summary`, `deprecation`, `extended_summary`. +| Section | Typed accessor | Entry type | +|---------|---------------|------------| +| Parameters / Other Parameters / Receives | `parameters()` | `NumPyParameter` | +| Returns / Yields | `returns()` | `NumPyReturns` | +| Raises | `exceptions()` | `NumPyException` | +| Warns | `warnings()` | `NumPyWarning` | +| See Also | `see_also_items()` | `NumPySeeAlsoItem` | +| References | `references()` | `NumPyReference` | +| Attributes | `attributes()` | `NumPyAttribute` | +| Methods | `methods()` | `NumPyMethod` | +| Notes / Examples / Warnings | `body_text()` | Free text | + +Additional root-level elements: `summary()`, `extended_summary()`, `deprecation()`. ### Google Style -| Section | Body variant | Body type | -|---------|-------------|-----------| -| Args | `Args(...)` | `Vec` | -| Keyword Args | `KeywordArgs(...)` | `Vec` | -| Other Parameters | `OtherParameters(...)` | `Vec` | -| Receives | `Receives(...)` | `Vec` | -| Returns | `Returns(...)` | `GoogleReturns` | -| Yields | `Yields(...)` | `GoogleReturns` | -| Raises | `Raises(...)` | `Vec` | -| Warns | `Warns(...)` | `Vec` | -| See Also | `SeeAlso(...)` | `Vec` | -| Attributes | `Attributes(...)` | `Vec` | -| Methods | `Methods(...)` | `Vec` | -| Notes | `Notes(...)` | `TextRange` | -| Examples | `Examples(...)` | `TextRange` | -| Todo | `Todo(...)` | `TextRange` | -| References | `References(...)` | `TextRange` | -| Warnings | `Warnings(...)` | `TextRange` | - -Admonition sections (Attention, Caution, Danger, Error, Hint, Important, Tip) are also supported as `TextRange` bodies. - -Additionally, `GoogleDocstring` has fields: `summary`, `extended_summary`. +| Section | Typed accessor | Entry type | +|---------|---------------|------------| +| Args / Keyword Args / Other Parameters / Receives | `args()` | `GoogleArg` | +| Returns / Yields | `returns()` | `GoogleReturns` | +| Raises | `exceptions()` | `GoogleException` | +| Warns | `warnings()` | `GoogleWarning` | +| See Also | `see_also_items()` | `GoogleSeeAlsoItem` | +| Attributes | `attributes()` | `GoogleAttribute` | +| Methods | `methods()` | `GoogleMethod` | +| Notes / Examples / Todo / References / Warnings | `body_text()` | Free text | +| Admonitions (Attention, Caution, Danger, ...) | `body_text()` | Free text | + +Additional root-level elements: `summary()`, `extended_summary()`. ## Development ```bash -# Build -cargo build - -# Run tests -cargo test - -# Run examples +cargo build # Build +cargo test # Run all 270+ tests cargo run --example parse_numpy cargo run --example parse_google ``` diff --git a/examples/parse_google.rs b/examples/parse_google.rs index 0122362..3ad53c0 100644 --- a/examples/parse_google.rs +++ b/examples/parse_google.rs @@ -1,7 +1,8 @@ //! Example: Parsing Google-style docstrings +//! +//! Shows the raw docstring text, then the detailed parsed AST. use pydocstring::google::parse_google; -use pydocstring::{GoogleDocstringItem, GoogleSectionBody}; fn main() { let docstring = r#" @@ -21,101 +22,17 @@ Raises: ValueError: If width or height is negative. "#; - let result = parse_google(docstring); - let doc = &result; + let parsed = parse_google(docstring); - println!( - "Summary: {}", - doc.summary - .as_ref() - .map_or("", |s| s.source_text(&doc.source)) - ); - if let Some(desc) = &doc.extended_summary { - println!("Description: {}", desc.source_text(&doc.source)); - } + // Display: raw source text + println!("╔══════════════════════════════════════════════════╗"); + println!("║ Google-style Docstring Example ║"); + println!("╚══════════════════════════════════════════════════╝"); + println!(); + println!("── Display (raw text) ─────────────────────────────"); + println!("{}", parsed.source()); - let args: Vec<_> = doc - .items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Args(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect(); - println!("\nArgs ({}):", args.len()); - for arg in &args { - let type_str = arg - .r#type - .as_ref() - .map(|t| t.source_text(&doc.source)) - .unwrap_or("?"); - println!( - " {} ({}): {}", - arg.name.source_text(&doc.source), - type_str, - arg.description.as_ref().unwrap().source_text(&doc.source) - ); - } - - let ret = doc.items.iter().find_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Returns(r) => Some(r), - _ => None, - }, - _ => None, - }); - if let Some(ret) = ret { - let type_str = ret - .return_type - .as_ref() - .map(|t| t.source_text(&doc.source)) - .unwrap_or("?"); - println!( - "\nReturns: {}: {}", - type_str, - ret.description.as_ref().unwrap().source_text(&doc.source) - ); - } - - let raises: Vec<_> = doc - .items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Raises(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() - .collect(); - println!("\nRaises ({}):", raises.len()); - for exc in &raises { - println!( - " {}: {}", - exc.r#type.source_text(&doc.source), - exc.description.as_ref().unwrap().source_text(&doc.source) - ); - } - - let all_sections: Vec<_> = doc - .items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .collect(); - println!("\nSections ({}):", all_sections.len()); - for section in &all_sections { - println!( - " {} (header: {:?})", - section.header.name.source_text(&doc.source), - section.header.range - ); - } + // pretty_print: structured AST + println!("── pretty_print (parsed AST) ──────────────────────"); + print!("{}", parsed.pretty_print()); } diff --git a/examples/parse_numpy.rs b/examples/parse_numpy.rs index 66ca78e..0bf8eb3 100644 --- a/examples/parse_numpy.rs +++ b/examples/parse_numpy.rs @@ -1,7 +1,8 @@ //! Example: Parsing NumPy-style docstrings +//! +//! Shows the raw docstring text, then the detailed parsed AST. -use pydocstring::NumPySectionBody; -use pydocstring::numpy::parse_numpy; +use pydocstring::numpy::{nodes::NumPyDocstring, parse_numpy}; fn main() { let docstring = r#" @@ -33,90 +34,18 @@ Examples 15.0 "#; - let result = parse_numpy(docstring); - let doc = &result; + let parsed = parse_numpy(docstring); + let doc = NumPyDocstring::cast(parsed.root()).unwrap(); - println!( - "Summary: {}", - doc.summary - .as_ref() - .map_or("", |s| s.source_text(&doc.source)) - ); - println!( - "\nExtended Summary: {}", - doc.extended_summary - .as_ref() - .map(|s| s.source_text(&doc.source)) - .unwrap_or_default() - ); + // Display: raw source text + println!("╔══════════════════════════════════════════════════╗"); + println!("║ NumPy-style Docstring Example ║"); + println!("╚══════════════════════════════════════════════════╝"); + println!(); + println!("── Display (raw text) ─────────────────────────────"); + println!("{}", doc.syntax().range().source_text(parsed.source())); - for item in &doc.items { - let section = match item { - pydocstring::NumPyDocstringItem::Section(s) => s, - pydocstring::NumPyDocstringItem::StrayLine(line) => { - println!("\nStray line: {}", line.source_text(&doc.source)); - continue; - } - }; - match §ion.body { - NumPySectionBody::Parameters(params) => { - println!("\nParameters:"); - for param in params { - let names: Vec<&str> = param - .names - .iter() - .map(|n| n.source_text(&doc.source)) - .collect(); - println!( - " - {:?}: {:?}", - names, - param.r#type.as_ref().map(|t| t.source_text(&doc.source)) - ); - println!( - " {}", - param - .description - .as_ref() - .map_or("", |d| d.source_text(&doc.source)) - ); - } - } - NumPySectionBody::Returns(rets) => { - println!("\nReturns:"); - for ret in rets { - println!( - " Type: {:?}", - ret.return_type.as_ref().map(|t| t.source_text(&doc.source)) - ); - println!( - " {}", - ret.description - .as_ref() - .map_or("", |d| d.source_text(&doc.source)) - ); - } - } - NumPySectionBody::Raises(excs) => { - println!("\nRaises:"); - for exc in excs { - println!( - " - {}: {}", - exc.r#type.source_text(&doc.source), - exc.description - .as_ref() - .map_or("", |d| d.source_text(&doc.source)) - ); - } - } - NumPySectionBody::Notes(Some(text)) => { - println!("\nNotes:"); - println!(" {}", text.source_text(&doc.source)); - } - NumPySectionBody::Examples(Some(text)) => { - println!("\nExamples:"); - println!("{}", text.source_text(&doc.source)); - } - _ => {} - } - } + // pretty_print: structured AST + println!("── pretty_print (parsed AST) ──────────────────────"); + print!("{}", parsed.pretty_print()); } diff --git a/examples/test_ret.rs b/examples/test_ret.rs index 278b2f9..205e3f7 100644 --- a/examples/test_ret.rs +++ b/examples/test_ret.rs @@ -1,8 +1,12 @@ -use pydocstring::GoogleSectionBody; +//! Example: Parsing a Returns-only Google-style docstring +//! +//! Demonstrates how pydocstring handles a docstring that starts +//! directly with a section header (no summary line). + use pydocstring::google::parse_google; fn main() { - let input = "\ + let docstring = "\ Returns: A dict mapping keys to the corresponding table row data fetched. Each row is represented as a tuple of strings. For @@ -16,32 +20,18 @@ Returns: missing from the dictionary, then that row was not found in the table (and require_all_keys must have been False). "; - let doc = parse_google(input); - println!( - "Summary: {:?}", - doc.summary - .as_ref() - .map_or("", |s| s.source_text(&doc.source)) - ); - println!("Items: {}", doc.items.len()); - for (idx, item) in doc.items.iter().enumerate() { - match item { - pydocstring::GoogleDocstringItem::Section(s) => { - println!( - "Item {}: Section {:?}", - idx, - s.header.name.source_text(&doc.source) - ); - if let GoogleSectionBody::Returns(ref ret) = s.body { - let type_str = ret.return_type.as_ref().map(|t| t.source_text(&doc.source)); - let d = &ret.description.as_ref().unwrap().source_text(&doc.source); - println!(" type: {:?}", type_str); - println!(" desc: {:?}", if d.len() > 80 { &d[..80] } else { d }); - } - } - pydocstring::GoogleDocstringItem::StrayLine(s) => { - println!("Item {}: StrayLine {:?}", idx, s.source_text(&doc.source)); - } - } - } + + let parsed = parse_google(docstring); + + // Display: raw source text + println!("╔══════════════════════════════════════════════════╗"); + println!("║ Returns-only Google Docstring Example ║"); + println!("╚══════════════════════════════════════════════════╝"); + println!(); + println!("── Display (raw text) ─────────────────────────────"); + println!("{}", parsed.source()); + + // pretty_print: structured AST + println!("── pretty_print (parsed AST) ──────────────────────"); + print!("{}", parsed.pretty_print()); } diff --git a/src/lib.rs b/src/lib.rs index 11ef7f1..14c8aad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ //! ## Quick Start //! //! ```rust -//! use pydocstring::numpy::parse_numpy; +//! use pydocstring::numpy::{parse_numpy, nodes::NumPyDocstring}; //! //! let docstring = r#" //! Brief description. @@ -18,7 +18,8 @@ //! "#; //! //! let result = parse_numpy(docstring); -//! assert_eq!(result.summary.as_ref().unwrap().source_text(&result.source), "Brief description."); +//! let doc = NumPyDocstring::cast(result.root()).unwrap(); +//! assert_eq!(doc.summary().unwrap().text(result.source()), "Brief description."); //! ``` //! //! ## Style Auto-Detection @@ -42,18 +43,12 @@ pub(crate) mod cursor; pub mod styles; +pub mod syntax; pub mod text; pub use styles::Style; pub use styles::detect_style; -pub use styles::google::{ - self, GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, - GoogleMethod, GoogleReturns, GoogleSection, GoogleSectionBody, GoogleSectionHeader, - GoogleSectionKind, GoogleSeeAlsoItem, GoogleWarning, -}; -pub use styles::numpy::{ - self, NumPyAttribute, NumPyDeprecation, NumPyDocstring, NumPyDocstringItem, NumPyException, - NumPyMethod, NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPySectionBody, - NumPySectionHeader, NumPySectionKind, NumPyWarning, SeeAlsoItem, -}; +pub use styles::google::{self, GoogleSectionKind}; +pub use styles::numpy::{self, NumPySectionKind}; +pub use syntax::{Parsed, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken, Visitor, walk}; pub use text::{TextRange, TextSize}; diff --git a/src/styles/google.rs b/src/styles/google.rs index d6967d9..559006a 100644 --- a/src/styles/google.rs +++ b/src/styles/google.rs @@ -2,12 +2,13 @@ //! //! This module contains the AST types and parser for Google-style docstrings. -pub mod ast; +pub mod kind; +pub mod nodes; pub mod parser; -pub use ast::{ - GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, - GoogleMethod, GoogleReturns, GoogleSection, GoogleSectionBody, GoogleSectionHeader, - GoogleSectionKind, GoogleSeeAlsoItem, GoogleWarning, +pub use kind::GoogleSectionKind; +pub use nodes::{ + GoogleArg, GoogleAttribute, GoogleDocstring, GoogleException, GoogleMethod, GoogleReturns, + GoogleSection, GoogleSectionHeader, GoogleSeeAlsoItem, GoogleWarning, }; pub use parser::parse_google; diff --git a/src/styles/google/ast.rs b/src/styles/google/ast.rs deleted file mode 100644 index 07588cd..0000000 --- a/src/styles/google/ast.rs +++ /dev/null @@ -1,502 +0,0 @@ -use core::fmt; - -use crate::text::TextRange; - -// ============================================================================= -// Google Style Types -// ============================================================================= - -/// Google-style section kinds. -/// -/// Each variant represents a recognised section name (or group of aliases), -/// or [`Unknown`](Self::Unknown) for unrecognised names. -/// Use [`GoogleSectionKind::from_name`] to convert a lowercased section name -/// to a variant. -/// -/// Having an enum instead of a plain string list gives compile-time -/// exhaustiveness checks: every variant must be handled when matching. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum GoogleSectionKind { - /// `Args` / `Arguments` / `Parameters` / `Params` - Args, - /// `Keyword Args` / `Keyword Arguments` - KeywordArgs, - /// `Other Parameters` - OtherParameters, - /// `Receive` / `Receives` - Receives, - /// `Returns` / `Return` - Returns, - /// `Yields` / `Yield` - Yields, - /// `Raises` / `Raise` - Raises, - /// `Warns` / `Warn` - Warns, - /// `Attributes` / `Attribute` - Attributes, - /// `Methods` - Methods, - /// `See Also` - SeeAlso, - /// `Note` / `Notes` - Notes, - /// `Example` / `Examples` - Examples, - /// `Todo` - Todo, - /// `References` - References, - /// `Warning` / `Warnings` - Warnings, - /// `Attention` - Attention, - /// `Caution` - Caution, - /// `Danger` - Danger, - /// `Error` - Error, - /// `Hint` - Hint, - /// `Important` - Important, - /// `Tip` - Tip, - /// Unrecognised section name. - Unknown, -} - -impl GoogleSectionKind { - /// All known section kinds (useful for iteration / testing). - pub const ALL: &[GoogleSectionKind] = &[ - Self::Args, - Self::KeywordArgs, - Self::OtherParameters, - Self::Receives, - Self::Returns, - Self::Yields, - Self::Raises, - Self::Warns, - Self::Attributes, - Self::Methods, - Self::SeeAlso, - Self::Notes, - Self::Examples, - Self::Todo, - Self::References, - Self::Warnings, - Self::Attention, - Self::Caution, - Self::Danger, - Self::Error, - Self::Hint, - Self::Important, - Self::Tip, - ]; - - /// Convert a **lowercased** section name to a [`GoogleSectionKind`]. - /// - /// Returns [`Unknown`](Self::Unknown) for unrecognised names (which are - /// dispatched as `GoogleSectionBody::Unknown` by the parser). - pub fn from_name(name: &str) -> Self { - match name { - "args" | "arguments" | "params" | "parameters" => Self::Args, - "keyword args" | "keyword arguments" | "keyword params" | "keyword parameters" => { - Self::KeywordArgs - } - "other args" | "other arguments" | "other params" | "other parameters" => { - Self::OtherParameters - } - "receives" | "receive" => Self::Receives, - "returns" | "return" => Self::Returns, - "yields" | "yield" => Self::Yields, - "raises" | "raise" => Self::Raises, - "warns" | "warn" => Self::Warns, - "see also" => Self::SeeAlso, - "attributes" | "attribute" => Self::Attributes, - "methods" => Self::Methods, - "notes" | "note" => Self::Notes, - "examples" | "example" => Self::Examples, - "todo" => Self::Todo, - "references" => Self::References, - "warnings" | "warning" => Self::Warnings, - "attention" => Self::Attention, - "caution" => Self::Caution, - "danger" => Self::Danger, - "error" => Self::Error, - "hint" => Self::Hint, - "important" => Self::Important, - "tip" => Self::Tip, - _ => Self::Unknown, - } - } - - /// Check if a lowercased name is a known (non-[`Unknown`](Self::Unknown)) section name. - pub fn is_known(name: &str) -> bool { - !matches!(Self::from_name(name), Self::Unknown) - } -} - -impl fmt::Display for GoogleSectionKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Self::Args => "Args", - Self::KeywordArgs => "Keyword Args", - Self::OtherParameters => "Other Parameters", - Self::Receives => "Receives", - Self::Returns => "Returns", - Self::Yields => "Yields", - Self::Raises => "Raises", - Self::Warns => "Warns", - Self::SeeAlso => "See Also", - Self::Attributes => "Attributes", - Self::Methods => "Methods", - Self::Notes => "Notes", - Self::Examples => "Examples", - Self::Todo => "Todo", - Self::References => "References", - Self::Warnings => "Warnings", - Self::Attention => "Attention", - Self::Caution => "Caution", - Self::Danger => "Danger", - Self::Error => "Error", - Self::Hint => "Hint", - Self::Important => "Important", - Self::Tip => "Tip", - Self::Unknown => "Unknown", - }; - write!(f, "{}", s) - } -} - -/// A single item in the body of a Google-style docstring (after the summary -/// and optional extended summary). -/// -/// Preserving both sections and stray lines in a single ordered `Vec` ensures -/// the original source order is never lost, which matters for linters that -/// want to report diagnostics in document order. -#[derive(Debug, Clone, PartialEq)] -pub enum GoogleDocstringItem { - /// A recognised (or unknown-name) section, e.g. `Args:` or `Custom:`. - Section(GoogleSection), - /// A non-blank line that appeared between sections but was neither blank - /// nor recognised as a section header. - /// - /// Typical causes include misplaced prose, a section name whose colon was - /// accidentally omitted, or an entry that was not indented correctly. - StrayLine(TextRange), -} - -/// A single Google-style section, combining header and body. -/// -/// ```text -/// Parameters: <-- header -/// x (int): Description <-- body -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleSection { - /// Source range of the entire section (header + body). - pub range: TextRange, - /// Section header (the `Parameters:` line). - pub header: GoogleSectionHeader, - /// Section body content. - pub body: GoogleSectionBody, -} - -/// Google-style section header. -/// -/// Represents a parsed section header like `Args:` or `Returns:`. -/// ```text -/// Parameters: <-- name, colon -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleSectionHeader { - /// Source range of the header line. - pub range: TextRange, - /// Resolved section kind. - pub kind: GoogleSectionKind, - /// Section name as written in source (e.g., "Args", "Parameters") with its span. - /// Stored without the trailing colon. - pub name: TextRange, - /// The trailing colon (`:`) with its span, if present. - pub colon: Option, -} - -/// Body content of a Google-style section. -/// -/// Each variant corresponds to a specific section kind. -/// Section names follow the Napoleon convention. -#[derive(Debug, Clone, PartialEq)] -pub enum GoogleSectionBody { - // ----- Parameter-like sections ----- - /// Args / Arguments / Parameters / Params section. - Args(Vec), - /// Keyword Args / Keyword Arguments section. - KeywordArgs(Vec), - /// Other Parameters section. - OtherParameters(Vec), - /// Receive / Receives section. - Receives(Vec), - - // ----- Return-like sections ----- - /// Returns / Return section. - Returns(GoogleReturns), - /// Yields / Yield section. - Yields(GoogleReturns), - - // ----- Exception / warning-like sections ----- - /// Raises / Raise section. - Raises(Vec), - /// Warns / Warn section. - Warns(Vec), - - // ----- Structured sections ----- - /// See Also section. - SeeAlso(Vec), - /// Attributes / Attribute section. - Attributes(Vec), - /// Methods section. - Methods(Vec), - - // ----- Free-text / admonition sections ----- - /// Note / Notes section (free text). - Notes(TextRange), - /// Example / Examples section (free text). - Examples(TextRange), - /// Todo section (free text, admonition in Napoleon). - Todo(TextRange), - /// References section (free text). - References(TextRange), - /// Warning / Warnings section (free text). - Warnings(TextRange), - /// Attention admonition (free text). - Attention(TextRange), - /// Caution admonition (free text). - Caution(TextRange), - /// Danger admonition (free text). - Danger(TextRange), - /// Error admonition (free text). - Error(TextRange), - /// Hint admonition (free text). - Hint(TextRange), - /// Important admonition (free text). - Important(TextRange), - /// Tip admonition (free text). - Tip(TextRange), - - // ----- Fallback ----- - /// Unknown / unrecognized section (free text). - Unknown(TextRange), -} - -impl GoogleSectionBody { - /// Create an empty section body for the given kind. - #[rustfmt::skip] - pub fn new(kind: GoogleSectionKind) -> Self { - match kind { - GoogleSectionKind::Args => Self::Args(Vec::new()), - GoogleSectionKind::KeywordArgs => Self::KeywordArgs(Vec::new()), - GoogleSectionKind::OtherParameters => Self::OtherParameters(Vec::new()), - GoogleSectionKind::Receives => Self::Receives(Vec::new()), - GoogleSectionKind::Raises => Self::Raises(Vec::new()), - GoogleSectionKind::Warns => Self::Warns(Vec::new()), - GoogleSectionKind::Attributes => Self::Attributes(Vec::new()), - GoogleSectionKind::Methods => Self::Methods(Vec::new()), - GoogleSectionKind::SeeAlso => Self::SeeAlso(Vec::new()), - GoogleSectionKind::Returns => Self::Returns(GoogleReturns { range: TextRange::empty(), return_type: None, colon: None, description: None }), - GoogleSectionKind::Yields => Self::Yields(GoogleReturns { range: TextRange::empty(), return_type: None, colon: None, description: None }), - GoogleSectionKind::Notes => Self::Notes(TextRange::empty()), - GoogleSectionKind::Examples => Self::Examples(TextRange::empty()), - GoogleSectionKind::Todo => Self::Todo(TextRange::empty()), - GoogleSectionKind::References => Self::References(TextRange::empty()), - GoogleSectionKind::Warnings => Self::Warnings(TextRange::empty()), - GoogleSectionKind::Attention => Self::Attention(TextRange::empty()), - GoogleSectionKind::Caution => Self::Caution(TextRange::empty()), - GoogleSectionKind::Danger => Self::Danger(TextRange::empty()), - GoogleSectionKind::Error => Self::Error(TextRange::empty()), - GoogleSectionKind::Hint => Self::Hint(TextRange::empty()), - GoogleSectionKind::Important => Self::Important(TextRange::empty()), - GoogleSectionKind::Tip => Self::Tip(TextRange::empty()), - GoogleSectionKind::Unknown => Self::Unknown(TextRange::empty()), - } - } -} - -/// Google-style docstring. -/// -/// Supports sections with colons like: -/// ```text -/// Args: -/// name (type): Description -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleDocstring { - /// Original source text of the docstring. - pub source: String, - /// Source range of the entire docstring. - pub range: TextRange, - /// Brief summary (first paragraph, up to the first blank line). - pub summary: Option, - /// Extended summary (multiple paragraphs before any section header). - pub extended_summary: Option, - /// All sections and stray lines in document order. - /// - /// Use [`sections()`](Self::sections) to iterate only over - /// [`GoogleSection`] items, or [`stray_lines()`](Self::stray_lines) to - /// iterate only over stray lines. - pub items: Vec, -} - -/// Google-style argument. -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleArg { - /// Source range. - pub range: TextRange, - /// Argument name with its span. - pub name: TextRange, - /// Opening bracket (`(`, `[`, `{`, or `<`) enclosing the type, with its span. - pub open_bracket: Option, - /// Argument type (inside brackets) with its span. - pub r#type: Option, - /// Closing bracket (`)`, `]`, `}`, or `>`) enclosing the type, with its span. - pub close_bracket: Option, - /// The colon (`:`) separating name/type from description, with its span, if present. - pub colon: Option, - /// Argument description with its span, if present. - pub description: Option, - /// The `optional` marker, if present. - /// `None` means not marked as optional. - pub optional: Option, -} - -/// Google-style return or yield value. -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleReturns { - /// Source range. - pub range: TextRange, - /// Return type with its span. - pub return_type: Option, - /// The colon (`:`) separating type and description, with its span, if present. - pub colon: Option, - /// Description with its span, if present. - pub description: Option, -} - -/// Google-style exception. -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleException { - /// Source range. - pub range: TextRange, - /// Exception type with its span. - pub r#type: TextRange, - /// The colon (`:`) separating type from description, with its span, if present. - pub colon: Option, - /// Description with its span, if present. - pub description: Option, -} - -/// Google-style warning (from Warns section). -/// -/// Same shape as [`GoogleException`] but represents a warning class. -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleWarning { - /// Source range. - pub range: TextRange, - /// Warning type (e.g., "DeprecationWarning") with its span. - pub warning_type: TextRange, - /// The colon (`:`) separating type from description, with its span, if present. - pub colon: Option, - /// Description of when the warning is issued, with its span, if present. - pub description: Option, -} - -/// Google-style See Also item. -/// -/// Supports both `:role:`name`` cross-references and plain names. -/// -/// ```text -/// See Also: -/// func_a: Description of func_a. -/// func_b, func_c -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleSeeAlsoItem { - /// Source range. - pub range: TextRange, - /// Reference names (can be multiple like `func_b, func_c`), each with its own span. - pub names: Vec, - /// The colon (`:`) separating names from description, with its span, if present. - pub colon: Option, - /// Optional description with its span. - pub description: Option, -} - -/// Google-style attribute. -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleAttribute { - /// Source range. - pub range: TextRange, - /// Attribute name with its span. - pub name: TextRange, - /// Opening bracket (`(`, `[`, `{`, or `<`) enclosing the type, with its span. - pub open_bracket: Option, - /// Attribute type (inside brackets) with its span. - pub r#type: Option, - /// Closing bracket (`)`, `]`, `}`, or `>`) enclosing the type, with its span. - pub close_bracket: Option, - /// The colon (`:`) separating name/type from description, with its span, if present. - pub colon: Option, - /// Description with its span, if present. - pub description: Option, -} - -/// Google-style method entry (from Methods section). -#[derive(Debug, Clone, PartialEq)] -pub struct GoogleMethod { - /// Source range. - pub range: TextRange, - /// Method name with its span. - pub name: TextRange, - /// Opening bracket (`(`, `[`, `{`, or `<`) enclosing the signature/type, with its span. - pub open_bracket: Option, - /// Method signature or type (inside brackets) with its span. - pub r#type: Option, - /// Closing bracket (`)`, `]`, `}`, or `>`) enclosing the signature/type, with its span. - pub close_bracket: Option, - /// The colon (`:`) separating name from description, with its span, if present. - pub colon: Option, - /// Brief description with its span, if present. - pub description: Option, -} - -impl GoogleDocstring { - /// Creates a new empty Google-style docstring with the given source. - pub fn new(input: &str) -> Self { - Self { - source: input.to_string(), - range: TextRange::empty(), - summary: None, - extended_summary: None, - items: Vec::new(), - } - } -} - -impl Default for GoogleDocstring { - fn default() -> Self { - Self::new("") - } -} - -impl fmt::Display for GoogleDocstring { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "GoogleDocstring(summary: {})", - self.summary - .as_ref() - .map_or("", |s| s.source_text(&self.source)) - ) - } -} diff --git a/src/styles/google/kind.rs b/src/styles/google/kind.rs new file mode 100644 index 0000000..971809c --- /dev/null +++ b/src/styles/google/kind.rs @@ -0,0 +1,186 @@ +//! Google-style section kind enumeration. + +use core::fmt; + +/// Google-style section kinds. +/// +/// Each variant represents a recognised section name (or group of aliases), +/// or [`Unknown`](Self::Unknown) for unrecognised names. +/// Use [`GoogleSectionKind::from_name`] to convert a lowercased section name +/// to a variant. +/// +/// Having an enum instead of a plain string list gives compile-time +/// exhaustiveness checks: every variant must be handled when matching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GoogleSectionKind { + /// `Args` / `Arguments` / `Parameters` / `Params` + Args, + /// `Keyword Args` / `Keyword Arguments` + KeywordArgs, + /// `Other Parameters` + OtherParameters, + /// `Receive` / `Receives` + Receives, + /// `Returns` / `Return` + Returns, + /// `Yields` / `Yield` + Yields, + /// `Raises` / `Raise` + Raises, + /// `Warns` / `Warn` + Warns, + /// `Attributes` / `Attribute` + Attributes, + /// `Methods` + Methods, + /// `See Also` + SeeAlso, + /// `Note` / `Notes` + Notes, + /// `Example` / `Examples` + Examples, + /// `Todo` + Todo, + /// `References` + References, + /// `Warning` / `Warnings` + Warnings, + /// `Attention` + Attention, + /// `Caution` + Caution, + /// `Danger` + Danger, + /// `Error` + Error, + /// `Hint` + Hint, + /// `Important` + Important, + /// `Tip` + Tip, + /// Unrecognised section name. + Unknown, +} + +impl GoogleSectionKind { + /// All known section kinds (useful for iteration / testing). + pub const ALL: &[GoogleSectionKind] = &[ + Self::Args, + Self::KeywordArgs, + Self::OtherParameters, + Self::Receives, + Self::Returns, + Self::Yields, + Self::Raises, + Self::Warns, + Self::Attributes, + Self::Methods, + Self::SeeAlso, + Self::Notes, + Self::Examples, + Self::Todo, + Self::References, + Self::Warnings, + Self::Attention, + Self::Caution, + Self::Danger, + Self::Error, + Self::Hint, + Self::Important, + Self::Tip, + ]; + + /// Convert a **lowercased** section name to a [`GoogleSectionKind`]. + /// + /// Returns [`Unknown`](Self::Unknown) for unrecognised names. + #[rustfmt::skip] + pub fn from_name(name: &str) -> Self { + match name { + "args" | "arguments" | "params" | "parameters" => Self::Args, + "keyword args" | "keyword arguments" | "keyword params" | "keyword parameters" => Self::KeywordArgs, + "other args" | "other arguments" | "other params" | "other parameters" => Self::OtherParameters, + "receives" | "receive" => Self::Receives, + "returns" | "return" => Self::Returns, + "yields" | "yield" => Self::Yields, + "raises" | "raise" => Self::Raises, + "warns" | "warn" => Self::Warns, + "see also" => Self::SeeAlso, + "attributes" | "attribute" => Self::Attributes, + "methods" => Self::Methods, + "notes" | "note" => Self::Notes, + "examples" | "example" => Self::Examples, + "todo" => Self::Todo, + "references" => Self::References, + "warnings" | "warning" => Self::Warnings, + "attention" => Self::Attention, + "caution" => Self::Caution, + "danger" => Self::Danger, + "error" => Self::Error, + "hint" => Self::Hint, + "important" => Self::Important, + "tip" => Self::Tip, + _ => Self::Unknown, + } + } + + /// Check if a lowercased name is a known (non-[`Unknown`](Self::Unknown)) section name. + pub fn is_known(name: &str) -> bool { + !matches!(Self::from_name(name), Self::Unknown) + } + + /// Whether this section kind uses structured (entry-based) body parsing. + pub fn is_structured(self) -> bool { + matches!( + self, + Self::Args + | Self::KeywordArgs + | Self::OtherParameters + | Self::Receives + | Self::Returns + | Self::Yields + | Self::Raises + | Self::Warns + | Self::Attributes + | Self::Methods + | Self::SeeAlso + ) + } + + /// Whether this section kind uses free-text body parsing. + pub fn is_freetext(self) -> bool { + !self.is_structured() + } +} + +impl fmt::Display for GoogleSectionKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Args => "Args", + Self::KeywordArgs => "Keyword Args", + Self::OtherParameters => "Other Parameters", + Self::Receives => "Receives", + Self::Returns => "Returns", + Self::Yields => "Yields", + Self::Raises => "Raises", + Self::Warns => "Warns", + Self::SeeAlso => "See Also", + Self::Attributes => "Attributes", + Self::Methods => "Methods", + Self::Notes => "Notes", + Self::Examples => "Examples", + Self::Todo => "Todo", + Self::References => "References", + Self::Warnings => "Warnings", + Self::Attention => "Attention", + Self::Caution => "Caution", + Self::Danger => "Danger", + Self::Error => "Error", + Self::Hint => "Hint", + Self::Important => "Important", + Self::Tip => "Tip", + Self::Unknown => "Unknown", + }; + write!(f, "{}", s) + } +} diff --git a/src/styles/google/nodes.rs b/src/styles/google/nodes.rs new file mode 100644 index 0000000..944be9c --- /dev/null +++ b/src/styles/google/nodes.rs @@ -0,0 +1,338 @@ +//! Typed wrappers for Google-style syntax nodes. +//! +//! Each wrapper is a newtype over `&SyntaxNode` that provides typed accessors +//! for the node's children (tokens and sub-nodes). + +use crate::styles::google::kind::GoogleSectionKind; +use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken}; + +// ============================================================================= +// Macro for defining typed node wrappers +// ============================================================================= + +/// Define a typed node wrapper that casts from `&SyntaxNode`. +macro_rules! define_node { + ($name:ident, $kind:ident) => { + #[derive(Debug)] + pub struct $name<'a>(pub(crate) &'a SyntaxNode); + + impl<'a> $name<'a> { + /// Try to cast a `SyntaxNode` reference into this typed wrapper. + pub fn cast(node: &'a SyntaxNode) -> Option { + (node.kind() == SyntaxKind::$kind).then(|| Self(node)) + } + + /// Access the underlying `SyntaxNode`. + pub fn syntax(&self) -> &'a SyntaxNode { + self.0 + } + } + }; +} + +// ============================================================================= +// GoogleDocstring +// ============================================================================= + +define_node!(GoogleDocstring, GOOGLE_DOCSTRING); + +impl<'a> GoogleDocstring<'a> { + /// Brief summary token, if present. + pub fn summary(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::SUMMARY) + } + + /// Extended summary token, if present. + pub fn extended_summary(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::EXTENDED_SUMMARY) + } + + /// Iterate over all section nodes. + pub fn sections(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_SECTION) + .filter_map(GoogleSection::cast) + } + + /// Iterate over stray line tokens. + pub fn stray_lines(&self) -> impl Iterator { + self.0.tokens(SyntaxKind::STRAY_LINE) + } +} + +// ============================================================================= +// GoogleSection +// ============================================================================= + +define_node!(GoogleSection, GOOGLE_SECTION); + +impl<'a> GoogleSection<'a> { + /// The section header node. + pub fn header(&self) -> GoogleSectionHeader<'a> { + GoogleSectionHeader::cast( + self.0 + .find_node(SyntaxKind::GOOGLE_SECTION_HEADER) + .expect("GOOGLE_SECTION must have a GOOGLE_SECTION_HEADER child"), + ) + .unwrap() + } + + /// Determine the section kind from the header name text. + pub fn section_kind(&self, source: &str) -> GoogleSectionKind { + let name_text = self.header().name().text(source); + GoogleSectionKind::from_name(&name_text.to_ascii_lowercase()) + } + + /// Iterate over arg entry nodes in this section. + pub fn args(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_ARG) + .filter_map(GoogleArg::cast) + } + + /// Returns entry node in this section, if present. + pub fn returns(&self) -> Option> { + self.0 + .find_node(SyntaxKind::GOOGLE_RETURNS) + .and_then(GoogleReturns::cast) + } + + /// Iterate over exception entry nodes. + pub fn exceptions(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_EXCEPTION) + .filter_map(GoogleException::cast) + } + + /// Iterate over warning entry nodes. + pub fn warnings(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_WARNING) + .filter_map(GoogleWarning::cast) + } + + /// Iterate over see-also item nodes. + pub fn see_also_items(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_SEE_ALSO_ITEM) + .filter_map(GoogleSeeAlsoItem::cast) + } + + /// Iterate over attribute entry nodes. + pub fn attributes(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_ATTRIBUTE) + .filter_map(GoogleAttribute::cast) + } + + /// Iterate over method entry nodes. + pub fn methods(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::GOOGLE_METHOD) + .filter_map(GoogleMethod::cast) + } + + /// Free-text body content, if this is a free-text section. + pub fn body_text(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::BODY_TEXT) + } +} + +// ============================================================================= +// GoogleSectionHeader +// ============================================================================= + +define_node!(GoogleSectionHeader, GOOGLE_SECTION_HEADER); + +impl<'a> GoogleSectionHeader<'a> { + /// Section name token (e.g. "Args", "Returns"). + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + /// Colon token, if present. + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } +} + +// ============================================================================= +// GoogleArg +// ============================================================================= + +define_node!(GoogleArg, GOOGLE_ARG); + +impl<'a> GoogleArg<'a> { + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + pub fn open_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::OPEN_BRACKET) + } + + pub fn r#type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::TYPE) + } + + pub fn close_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::CLOSE_BRACKET) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } + + pub fn optional(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::OPTIONAL) + } +} + +// ============================================================================= +// GoogleReturns +// ============================================================================= + +define_node!(GoogleReturns, GOOGLE_RETURNS); + +impl<'a> GoogleReturns<'a> { + pub fn return_type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::RETURN_TYPE) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// GoogleException +// ============================================================================= + +define_node!(GoogleException, GOOGLE_EXCEPTION); + +impl<'a> GoogleException<'a> { + pub fn r#type(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::TYPE) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// GoogleWarning +// ============================================================================= + +define_node!(GoogleWarning, GOOGLE_WARNING); + +impl<'a> GoogleWarning<'a> { + pub fn warning_type(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::WARNING_TYPE) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// GoogleSeeAlsoItem +// ============================================================================= + +define_node!(GoogleSeeAlsoItem, GOOGLE_SEE_ALSO_ITEM); + +impl<'a> GoogleSeeAlsoItem<'a> { + /// All name tokens (can be multiple, e.g. `func_a, func_b`). + pub fn names(&self) -> impl Iterator { + self.0.tokens(SyntaxKind::NAME) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// GoogleAttribute +// ============================================================================= + +define_node!(GoogleAttribute, GOOGLE_ATTRIBUTE); + +impl<'a> GoogleAttribute<'a> { + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + pub fn open_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::OPEN_BRACKET) + } + + pub fn r#type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::TYPE) + } + + pub fn close_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::CLOSE_BRACKET) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// GoogleMethod +// ============================================================================= + +define_node!(GoogleMethod, GOOGLE_METHOD); + +impl<'a> GoogleMethod<'a> { + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + pub fn open_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::OPEN_BRACKET) + } + + pub fn r#type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::TYPE) + } + + pub fn close_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::CLOSE_BRACKET) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} diff --git a/src/styles/google/parser.rs b/src/styles/google/parser.rs index a6bb923..a8342c4 100644 --- a/src/styles/google/parser.rs +++ b/src/styles/google/parser.rs @@ -1,29 +1,12 @@ -//! Google style docstring parser. +//! Google style docstring parser (SyntaxNode-based). //! -//! Parses docstrings in Google format: -//! ```text -//! Brief summary. -//! -//! Extended description. -//! -//! Args: -//! param1 (type): Description of param1. -//! param2 (type, optional): Description of param2. -//! -//! Returns: -//! type: Description of return value. -//! -//! Raises: -//! ValueError: If the input is invalid. -//! ``` +//! Parses docstrings in Google format and produces a [`Parsed`] result +//! containing a tree of [`SyntaxNode`]s and [`SyntaxToken`]s. use crate::cursor::{LineCursor, indent_len}; -use crate::styles::google::ast::{ - GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, - GoogleMethod, GoogleReturns, GoogleSection, GoogleSectionBody, GoogleSectionHeader, - GoogleSectionKind, GoogleSeeAlsoItem, GoogleWarning, -}; +use crate::styles::google::kind::GoogleSectionKind; use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; +use crate::syntax::{Parsed, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken}; use crate::text::TextRange; // ============================================================================= @@ -83,56 +66,29 @@ fn strip_optional(type_content: &str) -> (&str, Option) { // ============================================================================= /// Type information from a parsed entry header. -/// -/// All fields are `TextRange` (byte-offset spans in the source). struct TypeInfo { - /// Opening bracket (`(`, `[`, `{`, or `<`). open_bracket: TextRange, - /// Type annotation (without `optional` marker). `None` when brackets are empty. r#type: Option, - /// Closing bracket (`)`, `]`, `}`, or `>`). close_bracket: TextRange, - /// The `optional` marker, if present. optional: Option, } /// Parsed components of a Google-style entry header. -/// -/// All span fields are `TextRange` (byte-offset spans in the source). struct EntryHeader { - /// Span of the entire header (from name start to the end of the last - /// token on the header line — description fragment, colon, or bracket). range: TextRange, - /// Entry name (parameter name, exception type, etc.). name: TextRange, - /// Type annotation info (includes brackets, type, and optional marker). type_info: Option, - /// The entry-separating colon (`:`) span, if present. colon: Option, - /// First-line description fragment, if present. first_description: Option, } /// Parse a Google-style entry header at `cursor.line`. -/// -/// Recognised patterns: -/// - `name (type, optional): description` -/// - `name (type): description` -/// - `name: description` -/// - `*args: description` -/// - `**kwargs (dict): description` -/// -/// Bracket matching is line-local: the type annotation (including its -/// closing bracket) must appear on the same line as the opening bracket. -/// -/// Does **not** advance the cursor. fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { let line = cursor.current_line_text(); let trimmed = line.trim(); let entry_start = cursor.substr_offset(trimmed); - // --- Pattern 1: `name (type): desc` / `name [type]: desc` / `name {type}: desc` / `name : desc` --- - // Find the first opening bracket (`(`, `[`, `{`, or `<`) preceded by whitespace. + // --- Pattern 1: `name (type): desc` --- let bracket_pos = trimmed.bytes().enumerate().find_map(|(i, b)| { if (b == b'(' || b == b'[' || b == b'{' || b == b'<') && i > 0 @@ -145,7 +101,6 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { }); if let Some(rel_paren) = bracket_pos { - // Line-local bracket matching if let Some(rel_close) = find_matching_close(trimmed, rel_paren) { let abs_paren = entry_start + rel_paren; let abs_close = entry_start + rel_close; @@ -155,13 +110,11 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { let open_bracket = TextRange::from_offset_len(abs_paren, 1); let close_bracket = TextRange::from_offset_len(abs_close, 1); - // Type content between the brackets (single line) let type_raw = &trimmed[rel_paren + 1..rel_close]; let type_trimmed = type_raw.trim(); let leading_ws = type_raw.len() - type_raw.trim_start().len(); let type_start = abs_paren + 1 + leading_ws; - // Strip optional marker let (clean_type, opt_rel) = strip_optional(type_trimmed); let opt_span = opt_rel.map(|r| TextRange::from_offset_len(type_start + r, "optional".len())); @@ -179,7 +132,6 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { optional: opt_span, }); - // Description after closing bracket (same line) let after_close = &trimmed[rel_close + 1..]; let (first_description, colon) = extract_desc_after_colon(after_close, abs_close + 1); @@ -201,7 +153,7 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { } } - // --- Pattern 2: `name: desc` / `name:desc` / `name:` (no type) --- + // --- Pattern 2: `name: desc` --- if let Some(colon_rel) = find_entry_colon(trimmed) { let name = trimmed[..colon_rel].trim_end(); let after_colon = &trimmed[colon_rel + 1..]; @@ -229,7 +181,7 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { }; } - // --- Fallback: bare name or plain text --- + // --- Fallback: bare name --- let name_span = TextRange::from_offset_len(entry_start, trimmed.len()); EntryHeader { range: name_span, @@ -241,11 +193,6 @@ fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { } /// Extract description and colon spans after the closing bracket. -/// -/// `after_paren` is the portion of text after `)`, and `base_offset` is its -/// absolute byte offset within the source. -/// -/// Returns `(description_range, colon_range)`. fn extract_desc_after_colon( after_paren: &str, base_offset: usize, @@ -272,26 +219,15 @@ fn extract_desc_after_colon( // Section header parsing // ============================================================================= -/// Try to parse a Google-style section header at `cursor.line`. -/// -/// A section header is a line that matches one of: -/// - `Word:` / `Two Words:` — standard form with colon -/// - `Word :` — colon preceded by whitespace -/// - `Word` — colonless form, only for known section names -/// -/// For the colon forms, any name that starts with an ASCII letter and -/// contains only alphanumeric characters / whitespace (no embedded -/// colons) is accepted (dispatched as `Unknown` if unrecognised). -/// For the colonless form, only names in [`GoogleSectionKind`] are -/// accepted to avoid treating ordinary text lines as headers. -/// -/// Indentation is intentionally **not** checked here so that the parser -/// remains tolerant of irregular formatting. Indent-level validation is -/// left to a downstream lint pass that can inspect the parsed AST. -/// -/// Returns `Some(header)` if the current line is a valid section header, -/// `None` otherwise. Does **not** advance the cursor. -fn try_parse_section_header(cursor: &LineCursor) -> Option { +/// Parsed section header info (internal representation before building SyntaxNode). +struct SectionHeaderInfo { + range: TextRange, + kind: GoogleSectionKind, + name: TextRange, + colon: Option, +} + +fn try_parse_section_header(cursor: &LineCursor) -> Option { let trimmed = cursor.current_trimmed(); let (name, has_colon) = extract_section_name(trimmed); @@ -300,14 +236,11 @@ fn try_parse_section_header(cursor: &LineCursor) -> Option } let is_header = if has_colon { - // Standard / space-before-colon form: accept any name without - // embedded colons or entry-like characters (brackets, asterisks). !name.contains(':') && name .chars() .all(|c| c.is_alphanumeric() || c.is_ascii_whitespace()) } else { - // Colonless form: only known names. GoogleSectionKind::is_known(&name.to_ascii_lowercase()) }; @@ -328,7 +261,7 @@ fn try_parse_section_header(cursor: &LineCursor) -> Option let normalized = header_name.to_ascii_lowercase(); let kind = GoogleSectionKind::from_name(&normalized); - Some(GoogleSectionHeader { + Some(SectionHeaderInfo { range: cursor.current_trimmed_range(), kind, name: cursor.make_line_range(cursor.line, col, header_name.len()), @@ -336,14 +269,147 @@ fn try_parse_section_header(cursor: &LineCursor) -> Option }) } +// ============================================================================= +// SyntaxNode builders +// ============================================================================= + +fn build_section_header_node(info: &SectionHeaderInfo) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + info.name, + ))); + if let Some(colon) = info.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + SyntaxNode::new(SyntaxKind::GOOGLE_SECTION_HEADER, info.range, children) +} + +/// Build a SyntaxNode for an arg-like entry (GoogleArg, GoogleAttribute, GoogleMethod). +fn build_arg_node(kind: SyntaxKind, header: &EntryHeader, range: TextRange) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + header.name, + ))); + if let Some(ti) = &header.type_info { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::OPEN_BRACKET, + ti.open_bracket, + ))); + if let Some(t) = ti.r#type { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::TYPE, t))); + } + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::CLOSE_BRACKET, + ti.close_bracket, + ))); + if let Some(opt) = ti.optional { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::OPTIONAL, + opt, + ))); + } + } + if let Some(colon) = header.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(desc) = header.first_description { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + desc, + ))); + } + SyntaxNode::new(kind, range, children) +} + +/// Build a SyntaxNode for an exception entry. +fn build_exception_node(header: &EntryHeader, range: TextRange) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::TYPE, + header.name, + ))); + if let Some(colon) = header.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(desc) = header.first_description { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + desc, + ))); + } + SyntaxNode::new(SyntaxKind::GOOGLE_EXCEPTION, range, children) +} + +/// Build a SyntaxNode for a warning entry. +fn build_warning_node(header: &EntryHeader, range: TextRange) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::WARNING_TYPE, + header.name, + ))); + if let Some(colon) = header.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(desc) = header.first_description { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + desc, + ))); + } + SyntaxNode::new(SyntaxKind::GOOGLE_WARNING, range, children) +} + +/// Build a SyntaxNode for a see-also entry. +fn build_see_also_node(header: &EntryHeader, range: TextRange, source: &str) -> SyntaxNode { + let mut children = Vec::new(); + // Split name by comma into individual name tokens + let name_text = header.name.source_text(source); + let base = header.name.start().raw() as usize; + let mut offset = 0; + for part in name_text.split(',') { + let name = part.trim(); + if !name.is_empty() { + let lead = part.len() - part.trim_start().len(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + TextRange::from_offset_len(base + offset + lead, name.len()), + ))); + } + offset += part.len() + 1; + } + if let Some(colon) = header.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(desc) = header.first_description { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + desc, + ))); + } + SyntaxNode::new(SyntaxKind::GOOGLE_SEE_ALSO_ITEM, range, children) +} + // ============================================================================= // Section body helpers // ============================================================================= -/// Parse an entry header and compute its range span. -/// -/// Shared setup for all entry-based sections — extracts the entry header -/// and constructs the initial entry range from the current cursor line. fn parse_entry(cursor: &LineCursor) -> (EntryHeader, TextRange) { let header = parse_entry_header(cursor); let entry_col = cursor.current_indent(); @@ -356,7 +422,6 @@ fn parse_entry(cursor: &LineCursor) -> (EntryHeader, TextRange) { (header, entry_range) } -/// Build a [`TextRange`] spanning from the first to the last content line. fn build_content_range(cursor: &LineCursor, first: Option, last: usize) -> TextRange { if let Some(f) = first { let first_line = cursor.line_text(f); @@ -373,349 +438,324 @@ fn build_content_range(cursor: &LineCursor, first: Option, last: usize) - // Per-line section body processors // ============================================================================= -/// Extend the description of the last entry in a `Vec`, used for continuation lines. -fn extend_last_description( - description: &mut Option, - range: &mut TextRange, - cont: TextRange, -) { - match description { - Some(desc) => desc.extend(cont), - None => *description = Some(cont), +/// Extend the DESCRIPTION token of the last child node, or add one. +fn extend_last_node_description(nodes: &mut Vec, cont: TextRange) { + if let Some(SyntaxElement::Node(node)) = nodes.last_mut() { + // Find or add description token, extend range + let mut found_desc = false; + for child in node.children_mut() { + if let SyntaxElement::Token(t) = child { + if t.kind() == SyntaxKind::DESCRIPTION { + t.extend_range(cont); + found_desc = true; + break; + } + } + } + if !found_desc { + node.push_child(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + cont, + ))); + } + // Extend node range + node.extend_range_to(cont.end()); } - *range = TextRange::new(range.start(), cont.end()); } -/// Process one content line for an Args / KeywordArgs / OtherParameters / Receives section. fn process_arg_line( cursor: &LineCursor, - args: &mut Vec, + node_kind: SyntaxKind, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = args.last_mut() { - extend_last_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } if entry_indent.is_none() { *entry_indent = Some(indent_cols); } - let (header, entry_range) = parse_entry(cursor); - let ti = header.type_info.as_ref(); - args.push(GoogleArg { - range: entry_range, - name: header.name, - open_bracket: ti.map(|t| t.open_bracket), - r#type: ti.and_then(|t| t.r#type), - close_bracket: ti.map(|t| t.close_bracket), - colon: header.colon, - description: header.first_description, - optional: ti.and_then(|t| t.optional), - }); + nodes.push(SyntaxElement::Node(build_arg_node( + node_kind, + &header, + entry_range, + ))); } -/// Process one content line for a Raises section. fn process_exception_line( cursor: &LineCursor, - exceptions: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = exceptions.last_mut() { - extend_last_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } if entry_indent.is_none() { *entry_indent = Some(indent_cols); } - let (header, entry_range) = parse_entry(cursor); - exceptions.push(GoogleException { - range: entry_range, - r#type: header.name, - colon: header.colon, - description: header.first_description, - }); + nodes.push(SyntaxElement::Node(build_exception_node( + &header, + entry_range, + ))); } -/// Process one content line for a Warns section. fn process_warning_line( cursor: &LineCursor, - warnings: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = warnings.last_mut() { - extend_last_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } if entry_indent.is_none() { *entry_indent = Some(indent_cols); } - let (header, entry_range) = parse_entry(cursor); - warnings.push(GoogleWarning { - range: entry_range, - warning_type: header.name, - colon: header.colon, - description: header.first_description, - }); + nodes.push(SyntaxElement::Node(build_warning_node( + &header, + entry_range, + ))); } -/// Process one content line for an Attributes section. -fn process_attribute_line( +fn process_see_also_line( cursor: &LineCursor, - attrs: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = attrs.last_mut() { - extend_last_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } if entry_indent.is_none() { *entry_indent = Some(indent_cols); } - let (header, entry_range) = parse_entry(cursor); - let ti = header.type_info.as_ref(); - attrs.push(GoogleAttribute { - range: entry_range, - name: header.name, - open_bracket: ti.map(|t| t.open_bracket), - r#type: ti.and_then(|t| t.r#type), - close_bracket: ti.map(|t| t.close_bracket), - colon: header.colon, - description: header.first_description, - }); + nodes.push(SyntaxElement::Node(build_see_also_node( + &header, + entry_range, + cursor.source(), + ))); } -/// Process one content line for a Methods section. -fn process_method_line( - cursor: &LineCursor, - methods: &mut Vec, - entry_indent: &mut Option, -) { - let indent_cols = cursor.current_indent_columns(); - if let Some(base) = *entry_indent { - if indent_cols > base { - if let Some(last) = methods.last_mut() { - extend_last_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); +/// Returns/Yields section state during parsing. +struct ReturnsState { + range: TextRange, + return_type: Option, + colon: Option, + description: Option, +} + +impl ReturnsState { + fn new() -> Self { + Self { + range: TextRange::empty(), + return_type: None, + colon: None, + description: None, + } + } + + fn process_line(&mut self, cursor: &LineCursor) { + let trimmed_range = cursor.current_trimmed_range(); + if self.range.is_empty() { + self.range = trimmed_range; + let trimmed = cursor.current_trimmed(); + let col = cursor.current_indent(); + if let Some(colon_pos) = find_entry_colon(trimmed) { + let type_str = trimmed[..colon_pos].trim_end(); + let after_colon = &trimmed[colon_pos + 1..]; + let desc_str = after_colon.trim_start(); + let ws_after = after_colon.len() - desc_str.len(); + self.return_type = Some(cursor.make_line_range(cursor.line, col, type_str.len())); + self.colon = Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)); + let desc_start = col + colon_pos + 1 + ws_after; + self.description = if desc_str.is_empty() { + None + } else { + Some(cursor.make_line_range(cursor.line, desc_start, desc_str.len())) + }; + } else { + self.description = Some(trimmed_range); } - return; + } else { + match self.description { + Some(ref mut desc) => desc.extend(trimmed_range), + None => self.description = Some(trimmed_range), + } + self.range = TextRange::new(self.range.start(), trimmed_range.end()); } } - if entry_indent.is_none() { - *entry_indent = Some(indent_cols); + + fn into_node(self, kind: SyntaxKind) -> SyntaxNode { + let mut children = Vec::new(); + if let Some(rt) = self.return_type { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::RETURN_TYPE, + rt, + ))); + } + if let Some(colon) = self.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(desc) = self.description { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + desc, + ))); + } + SyntaxNode::new(kind, self.range, children) } +} - let (header, entry_range) = parse_entry(cursor); - let ti = header.type_info.as_ref(); - methods.push(GoogleMethod { - range: entry_range, - name: header.name, - open_bracket: ti.map(|t| t.open_bracket), - r#type: ti.and_then(|t| t.r#type), - close_bracket: ti.map(|t| t.close_bracket), - colon: header.colon, - description: header.first_description, - }); +// ============================================================================= +// Section body kind tracking +// ============================================================================= + +/// Tracks the current section being parsed and accumulates its body children. +enum SectionBody { + /// Args-like entries (Args, KeywordArgs, OtherParameters, Receives, Attributes, Methods) + Args(SyntaxKind, Vec), + /// Returns/Yields + Returns(SyntaxKind, ReturnsState), + /// Raises + Raises(Vec), + /// Warns + Warns(Vec), + /// SeeAlso + SeeAlso(Vec), + /// Free-text (Notes, Examples, etc.) + FreeText(TextRange), } -/// Process one content line for a See Also section. -fn process_see_also_line( - cursor: &LineCursor, - items: &mut Vec, - entry_indent: &mut Option, -) { - let indent_cols = cursor.current_indent_columns(); - if let Some(base) = *entry_indent { - if indent_cols > base { - if let Some(last) = items.last_mut() { - extend_last_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); +impl SectionBody { + fn new(kind: GoogleSectionKind) -> Self { + match kind { + GoogleSectionKind::Args + | GoogleSectionKind::KeywordArgs + | GoogleSectionKind::OtherParameters + | GoogleSectionKind::Receives => Self::Args(SyntaxKind::GOOGLE_ARG, Vec::new()), + GoogleSectionKind::Attributes => Self::Args(SyntaxKind::GOOGLE_ATTRIBUTE, Vec::new()), + GoogleSectionKind::Methods => Self::Args(SyntaxKind::GOOGLE_METHOD, Vec::new()), + GoogleSectionKind::Returns => { + Self::Returns(SyntaxKind::GOOGLE_RETURNS, ReturnsState::new()) } - return; + GoogleSectionKind::Yields => { + Self::Returns(SyntaxKind::GOOGLE_RETURNS, ReturnsState::new()) + } + GoogleSectionKind::Raises => Self::Raises(Vec::new()), + GoogleSectionKind::Warns => Self::Warns(Vec::new()), + GoogleSectionKind::SeeAlso => Self::SeeAlso(Vec::new()), + _ => Self::FreeText(TextRange::empty()), } } - if entry_indent.is_none() { - *entry_indent = Some(indent_cols); - } - let (header, entry_range) = parse_entry(cursor); - // Split the name span by comma into individual name spans. - let name_text = header.name.source_text(cursor.source()); - let base = header.name.start().raw() as usize; - let mut names = Vec::new(); - let mut offset = 0; - for part in name_text.split(',') { - let name = part.trim(); - if !name.is_empty() { - let lead = part.len() - part.trim_start().len(); - names.push(TextRange::from_offset_len(base + offset + lead, name.len())); + fn process_line(&mut self, cursor: &LineCursor, entry_indent: &mut Option) { + match self { + Self::Args(node_kind, nodes) => { + process_arg_line(cursor, *node_kind, nodes, entry_indent); + } + Self::Returns(_, state) => { + state.process_line(cursor); + } + Self::Raises(nodes) => { + process_exception_line(cursor, nodes, entry_indent); + } + Self::Warns(nodes) => { + process_warning_line(cursor, nodes, entry_indent); + } + Self::SeeAlso(nodes) => { + process_see_also_line(cursor, nodes, entry_indent); + } + Self::FreeText(range) => { + range.extend(cursor.current_trimmed_range()); + } } - offset += part.len() + 1; // +1 for the comma } - items.push(GoogleSeeAlsoItem { - range: entry_range, - names, - colon: header.colon, - description: header.first_description, - }); -} -/// Process one content line for a Returns / Yields section. -/// -/// The first non-blank content line is parsed as `type: description`; -/// subsequent lines extend the description range. -/// -/// Blank lines must be filtered by the caller before invoking this function. -fn process_returns_line(cursor: &LineCursor, ret: &mut GoogleReturns) { - let trimmed_range = cursor.current_trimmed_range(); - if ret.range.is_empty() { - // First content line — parse type and description - ret.range = trimmed_range; - let trimmed = cursor.current_trimmed(); - let col = cursor.current_indent(); - if let Some(colon_pos) = find_entry_colon(trimmed) { - let type_str = trimmed[..colon_pos].trim_end(); - let after_colon = &trimmed[colon_pos + 1..]; - let desc_str = after_colon.trim_start(); - let ws_after = after_colon.len() - desc_str.len(); - ret.return_type = Some(cursor.make_line_range(cursor.line, col, type_str.len())); - ret.colon = Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)); - let desc_start = col + colon_pos + 1 + ws_after; - ret.description = if desc_str.is_empty() { - None - } else { - Some(cursor.make_line_range(cursor.line, desc_start, desc_str.len())) - }; - } else { - ret.description = Some(trimmed_range); - } - } else { - // Continuation line — extend description and range - match ret.description { - Some(ref mut desc) => desc.extend(trimmed_range), - None => ret.description = Some(trimmed_range), + fn into_children(self) -> Vec { + match self { + Self::Args(_, nodes) => nodes, + Self::Returns(kind, state) => { + if state.range.is_empty() { + vec![] + } else { + vec![SyntaxElement::Node(state.into_node(kind))] + } + } + Self::Raises(nodes) | Self::Warns(nodes) | Self::SeeAlso(nodes) => nodes, + Self::FreeText(range) => { + if range.is_empty() { + vec![] + } else { + vec![SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::BODY_TEXT, + range, + ))] + } + } } - ret.range = TextRange::new(ret.range.start(), trimmed_range.end()); } } -/// Process one content line for a free-text section (Notes, Examples, etc.). -/// -/// Blank lines must be filtered by the caller before invoking this function. -fn process_freetext_line(cursor: &LineCursor, content: &mut TextRange) { - content.extend(cursor.current_trimmed_range()); -} - -/// Flush a completed section into the docstring. -fn flush_section( - cursor: &LineCursor, - docstring: &mut GoogleDocstring, - header: GoogleSectionHeader, - body: GoogleSectionBody, -) { - let header_start = header.range.start().raw() as usize; - let range = cursor.span_back_from_cursor(header_start); - docstring - .items - .push(GoogleDocstringItem::Section(GoogleSection { - range, - header, - body, - })); -} - // ============================================================================= // Main parser // ============================================================================= -/// Parse a Google-style docstring. +/// Parse a Google-style docstring into a [`Parsed`] result. /// /// # Example /// /// ```rust /// use pydocstring::google::parse_google; -/// use pydocstring::GoogleSectionBody; +/// use pydocstring::SyntaxKind; /// /// let input = "Summary.\n\nArgs:\n x (int): The value.\n\nReturns:\n int: The result."; -/// let doc = &parse_google(input); -/// -/// assert_eq!(doc.summary.as_ref().unwrap().source_text(&doc.source), "Summary."); +/// let parsed = parse_google(input); +/// let source = parsed.source(); +/// let root = parsed.root(); /// -/// let args: Vec<_> = doc.items.iter().filter_map(|item| match item { -/// pydocstring::GoogleDocstringItem::Section(s) => match &s.body { -/// GoogleSectionBody::Args(v) => Some(v.iter()), -/// _ => None, -/// }, -/// _ => None, -/// }).flatten().collect(); -/// assert_eq!(args.len(), 1); -/// assert_eq!(args[0].name.source_text(&doc.source), "x"); +/// // Access summary +/// let summary = root.find_token(SyntaxKind::SUMMARY).unwrap(); +/// assert_eq!(summary.text(source), "Summary."); /// -/// let ret = doc.items.iter().find_map(|item| match item { -/// pydocstring::GoogleDocstringItem::Section(s) => match &s.body { -/// GoogleSectionBody::Returns(r) => Some(r), -/// _ => None, -/// }, -/// _ => None, -/// }).unwrap(); -/// assert_eq!(ret.return_type.as_ref().unwrap().source_text(&doc.source), "int"); +/// // Access sections +/// let sections: Vec<_> = root.nodes(SyntaxKind::GOOGLE_SECTION).collect(); +/// assert_eq!(sections.len(), 2); /// ``` -pub fn parse_google(input: &str) -> GoogleDocstring { +pub fn parse_google(input: &str) -> Parsed { let mut line_cursor = LineCursor::new(input); - let mut docstring = GoogleDocstring::new(input); + let mut root_children: Vec = Vec::new(); line_cursor.skip_blanks(); if line_cursor.is_eof() { - return docstring; + let root = SyntaxNode::new( + SyntaxKind::GOOGLE_DOCSTRING, + line_cursor.full_range(), + root_children, + ); + return Parsed::new(input.to_string(), root); } - // Phase tracking for pre-section content. - // summary_done – true once a blank line or header terminates summary. - // extended_done – true once a header terminates extended summary. let mut summary_done = false; let mut extended_done = false; let mut summary_first: Option = None; @@ -723,21 +763,18 @@ pub fn parse_google(input: &str) -> GoogleDocstring { let mut ext_first: Option = None; let mut ext_last: usize = 0; - // Current section being parsed. - let mut current_header: Option = None; - let mut current_body: Option = None; + let mut current_header: Option = None; + let mut current_body: Option = None; let mut entry_indent: Option = None; while !line_cursor.is_eof() { // --- Blank lines --- if line_cursor.current_trimmed().is_empty() { - // Blank line after summary content → finalise summary if !summary_done && summary_first.is_some() { - docstring.summary = Some(build_content_range( - &line_cursor, - summary_first, - summary_last, - )); + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::SUMMARY, + build_content_range(&line_cursor, summary_first, summary_last), + ))); summary_done = true; } line_cursor.advance(); @@ -745,22 +782,23 @@ pub fn parse_google(input: &str) -> GoogleDocstring { } // --- Detect section header --- - if let Some(header) = try_parse_section_header(&line_cursor) { - // Finalise any pending pre-section content + if let Some(header_info) = try_parse_section_header(&line_cursor) { + // Finalise pending pre-section content if !summary_done { if summary_first.is_some() { - docstring.summary = Some(build_content_range( - &line_cursor, - summary_first, - summary_last, - )); + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::SUMMARY, + build_content_range(&line_cursor, summary_first, summary_last), + ))); } summary_done = true; } if !extended_done { if ext_first.is_some() { - docstring.extended_summary = - Some(build_content_range(&line_cursor, ext_first, ext_last)); + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::EXTENDED_SUMMARY, + build_content_range(&line_cursor, ext_first, ext_last), + ))); } extended_done = true; } @@ -769,66 +807,38 @@ pub fn parse_google(input: &str) -> GoogleDocstring { if let Some(prev_header) = current_header.take() { flush_section( &line_cursor, - &mut docstring, + &mut root_children, prev_header, current_body.take().unwrap(), ); } // Start new section - current_body = Some(GoogleSectionBody::new(header.kind)); - current_header = Some(header); + current_body = Some(SectionBody::new(header_info.kind)); + current_header = Some(header_info); entry_indent = None; - line_cursor.advance(); // skip header line + line_cursor.advance(); continue; } // --- Process line based on current state --- if let Some(ref mut body) = current_body { - #[rustfmt::skip] - match body { - GoogleSectionBody::Args(v) => process_arg_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::KeywordArgs(v) => process_arg_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::OtherParameters(v) => process_arg_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::Receives(v) => process_arg_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::Raises(v) => process_exception_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::Warns(v) => process_warning_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::Attributes(v) => process_attribute_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::Methods(v) => process_method_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::SeeAlso(v) => process_see_also_line(&line_cursor, v, &mut entry_indent), - GoogleSectionBody::Returns(ret) => process_returns_line(&line_cursor, ret), - GoogleSectionBody::Yields(ret) => process_returns_line(&line_cursor, ret), - GoogleSectionBody::Notes(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Examples(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Todo(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::References(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Warnings(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Attention(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Caution(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Danger(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Error(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Hint(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Important(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Tip(r) => process_freetext_line(&line_cursor, r), - GoogleSectionBody::Unknown(r) => process_freetext_line(&line_cursor, r), - }; + body.process_line(&line_cursor, &mut entry_indent); } else if !summary_done { - // Summary content line if summary_first.is_none() { summary_first = Some(line_cursor.line); } summary_last = line_cursor.line; } else if !extended_done { - // Extended summary content line if ext_first.is_none() { ext_first = Some(line_cursor.line); } ext_last = line_cursor.line; } else { - // Stray line (outside any section) - docstring.items.push(GoogleDocstringItem::StrayLine( + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::STRAY_LINE, line_cursor.current_trimmed_range(), - )); + ))); } line_cursor.advance(); @@ -838,7 +848,7 @@ pub fn parse_google(input: &str) -> GoogleDocstring { if let Some(header) = current_header.take() { flush_section( &line_cursor, - &mut docstring, + &mut root_children, header, current_body.take().unwrap(), ); @@ -846,20 +856,44 @@ pub fn parse_google(input: &str) -> GoogleDocstring { // Finalise at EOF if !summary_done && summary_first.is_some() { - docstring.summary = Some(build_content_range( - &line_cursor, - summary_first, - summary_last, - )); + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::SUMMARY, + build_content_range(&line_cursor, summary_first, summary_last), + ))); } if !extended_done && ext_first.is_some() { - docstring.extended_summary = Some(build_content_range(&line_cursor, ext_first, ext_last)); + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::EXTENDED_SUMMARY, + build_content_range(&line_cursor, ext_first, ext_last), + ))); } - // --- Docstring span --- - docstring.range = line_cursor.full_range(); + let root = SyntaxNode::new( + SyntaxKind::GOOGLE_DOCSTRING, + line_cursor.full_range(), + root_children, + ); + Parsed::new(input.to_string(), root) +} - docstring +fn flush_section( + cursor: &LineCursor, + root_children: &mut Vec, + header_info: SectionHeaderInfo, + body: SectionBody, +) { + let header_start = header_info.range.start().raw() as usize; + let section_range = cursor.span_back_from_cursor(header_start); + + let header_node = build_section_header_node(&header_info); + let mut section_children = vec![SyntaxElement::Node(header_node)]; + section_children.extend(body.into_children()); + + root_children.push(SyntaxElement::Node(SyntaxNode::new( + SyntaxKind::GOOGLE_SECTION, + section_range, + section_children, + ))); } // ============================================================================= @@ -870,9 +904,6 @@ pub fn parse_google(input: &str) -> GoogleDocstring { mod tests { use super::*; - // -- section detection -- - - /// Helper: returns true if the given line is detected as a section header. fn is_header(text: &str) -> bool { let cursor = LineCursor::new(text); try_parse_section_header(&cursor).is_some() @@ -880,41 +911,27 @@ mod tests { #[test] fn test_is_section_header() { - // Standard colon form (expects pre-trimmed input) assert!(is_header("Args:")); - // "NotASection:" is still detected as an unknown section header (has colon) assert!(is_header("NotASection:")); assert!(is_header("Returns:")); assert!(is_header("Custom:")); - // Case-insensitive assert!(is_header("args:")); assert!(is_header("RETURNS:")); - // Not a section header: contains embedded colon assert!(!is_header("key: value:")); - // Long names with colon are still accepted — length validation - // is left to a downstream lint pass. assert!(is_header( "This is a very long line that should not be a section header:" )); - - // Space-before-colon form assert!(is_header("Args :")); assert!(is_header("Returns :")); - - // Colonless form — only known section names assert!(is_header("Args")); assert!(is_header("Returns")); assert!(is_header("args")); assert!(is_header("RETURNS")); assert!(is_header("See Also")); - // Unknown names without colon are NOT headers assert!(!is_header("NotASection")); assert!(!is_header("SomeWord")); } - // -- entry header parsing -- - - /// Helper to parse an entry header from a single-line string. fn header_from(text: &str) -> EntryHeader { let cursor = LineCursor::new(text); parse_entry_header(&cursor) @@ -1017,8 +1034,6 @@ mod tests { ); } - // -- strip_optional -- - #[test] fn test_strip_optional_basic() { assert_eq!(strip_optional("int, optional"), ("int", Some(5))); @@ -1028,7 +1043,6 @@ mod tests { ("Dict[str, int]", Some(16)) ); assert_eq!(strip_optional("optional"), ("", Some(0))); - // Varying whitespace after comma assert_eq!(strip_optional("int,optional"), ("int", Some(4))); assert_eq!(strip_optional("int, optional"), ("int", Some(6))); assert_eq!(strip_optional("int, optional "), ("int", Some(5))); diff --git a/src/styles/numpy.rs b/src/styles/numpy.rs index f2f286f..44a01b6 100644 --- a/src/styles/numpy.rs +++ b/src/styles/numpy.rs @@ -2,12 +2,9 @@ //! //! This module contains the AST types and parser for NumPy-style docstrings. -pub mod ast; +pub mod kind; +pub mod nodes; pub mod parser; -pub use ast::{ - NumPyAttribute, NumPyDeprecation, NumPyDocstring, NumPyDocstringItem, NumPyException, - NumPyMethod, NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPySectionBody, - NumPySectionHeader, NumPySectionKind, NumPyWarning, SeeAlsoItem, -}; +pub use kind::NumPySectionKind; pub use parser::parse_numpy; diff --git a/src/styles/numpy/ast.rs b/src/styles/numpy/ast.rs deleted file mode 100644 index 2ad8e0b..0000000 --- a/src/styles/numpy/ast.rs +++ /dev/null @@ -1,464 +0,0 @@ -use core::fmt; - -use crate::text::TextRange; - -// ============================================================================= -// NumPy Style Types -// ============================================================================= - -/// NumPy-style section kinds. -/// -/// Each variant represents a recognised section name (or group of aliases), -/// or [`Unknown`](Self::Unknown) for unrecognised names. -/// Use [`NumPySectionKind::from_name`] to convert a lowercased section name -/// to a variant. -/// -/// Having an enum instead of a plain string list gives compile-time -/// exhaustiveness checks: every variant must be handled when matching. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum NumPySectionKind { - /// `Parameters` / `Params` - Parameters, - /// `Returns` / `Return` - Returns, - /// `Yields` / `Yield` - Yields, - /// `Receives` / `Receive` - Receives, - /// `Other Parameters` / `Other Params` - OtherParameters, - /// `Raises` / `Raise` - Raises, - /// `Warns` / `Warn` - Warns, - /// `Warnings` / `Warning` - Warnings, - /// `See Also` - SeeAlso, - /// `Notes` / `Note` - Notes, - /// `References` - References, - /// `Examples` / `Example` - Examples, - /// `Attributes` - Attributes, - /// `Methods` - Methods, - /// Unrecognised section name. - Unknown, -} - -impl NumPySectionKind { - /// All known section kinds (useful for iteration / testing). - pub const ALL: &[NumPySectionKind] = &[ - Self::Parameters, - Self::Returns, - Self::Yields, - Self::Receives, - Self::OtherParameters, - Self::Raises, - Self::Warns, - Self::Warnings, - Self::SeeAlso, - Self::Notes, - Self::References, - Self::Examples, - Self::Attributes, - Self::Methods, - ]; - - /// Convert a **lowercased** section name to a [`NumPySectionKind`]. - /// - /// Returns [`Unknown`](Self::Unknown) for unrecognised names (which are - /// dispatched as `NumPySectionBody::Unknown` by the parser). - pub fn from_name(name: &str) -> Self { - match name { - "parameters" | "parameter" | "params" | "param" => Self::Parameters, - "returns" | "return" => Self::Returns, - "yields" | "yield" => Self::Yields, - "receives" | "receive" => Self::Receives, - "other parameters" | "other parameter" | "other params" | "other param" => { - Self::OtherParameters - } - "raises" | "raise" => Self::Raises, - "warns" | "warn" => Self::Warns, - "warnings" | "warning" => Self::Warnings, - "see also" => Self::SeeAlso, - "notes" | "note" => Self::Notes, - "references" => Self::References, - "examples" | "example" => Self::Examples, - "attributes" => Self::Attributes, - "methods" => Self::Methods, - _ => Self::Unknown, - } - } - - /// Check if a lowercased name is a known (non-[`Unknown`](Self::Unknown)) section name. - pub fn is_known(name: &str) -> bool { - !matches!(Self::from_name(name), Self::Unknown) - } -} - -impl fmt::Display for NumPySectionKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Self::Parameters => "Parameters", - Self::Returns => "Returns", - Self::Yields => "Yields", - Self::Receives => "Receives", - Self::OtherParameters => "Other Parameters", - Self::Raises => "Raises", - Self::Warns => "Warns", - Self::Warnings => "Warnings", - Self::SeeAlso => "See Also", - Self::Notes => "Notes", - Self::References => "References", - Self::Examples => "Examples", - Self::Attributes => "Attributes", - Self::Methods => "Methods", - Self::Unknown => "Unknown", - }; - write!(f, "{}", s) - } -} - -/// A single item in the body of a NumPy-style docstring (after the summary -/// and optional extended summary). -/// -/// Preserving both sections and stray lines in a single ordered `Vec` ensures -/// the original source order is never lost, which matters for linters that -/// want to report diagnostics in document order. -#[derive(Debug, Clone, PartialEq)] -pub enum NumPyDocstringItem { - /// A recognised (or unknown-name) section with header + underline + body. - Section(NumPySection), - /// A non-blank line that appeared between sections but was neither blank - /// nor recognised as a section header (i.e. not followed by an underline). - /// - /// Typical causes include misplaced prose, a section name whose underline - /// was accidentally omitted, or text that belongs to a previous section. - StrayLine(TextRange), -} - -/// A single NumPy-style section, combining header and body. -/// -/// ```text -/// Parameters <-- header -/// ---------- <-- header -/// x : int <-- body -/// Description <-- body -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct NumPySection { - /// Source span of the entire section (header + body). - pub range: TextRange, - /// Section header (name + underline). - pub header: NumPySectionHeader, - /// Section body content. - pub body: NumPySectionBody, -} - -/// NumPy-style section header. -/// -/// Represents a parsed section header like: -/// ```text -/// Parameters <-- name -/// ---------- <-- underline -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct NumPySectionHeader { - /// Source span of the entire header (name line + underline line). - pub range: TextRange, - /// Resolved section kind. - pub kind: NumPySectionKind, - /// Section name as written in source (e.g., "Parameters", "Params") with its span. - pub name: TextRange, - /// Underline (dashes) line with its span. - pub underline: TextRange, -} - -/// Body content of a NumPy-style section. -/// -/// Each variant corresponds to a specific section kind. -#[derive(Debug, Clone, PartialEq)] -pub enum NumPySectionBody { - /// Parameters section. - Parameters(Vec), - /// Returns section. - Returns(Vec), - /// Yields section. - Yields(Vec), - /// Receives section. - Receives(Vec), - /// Other Parameters section. - OtherParameters(Vec), - /// Raises section. - Raises(Vec), - /// Warns section. - Warns(Vec), - /// Warnings section (free text). - /// - /// `None` when the section body is empty (no content lines). - Warnings(Option), - /// See Also section. - SeeAlso(Vec), - /// Notes section (free text). - /// - /// `None` when the section body is empty (no content lines). - Notes(Option), - /// References section. - References(Vec), - /// Examples section (free text, doctest format). - /// - /// `None` when the section body is empty (no content lines). - Examples(Option), - /// Attributes section. - Attributes(Vec), - /// Methods section. - Methods(Vec), - /// Unknown / unrecognized section (free text). - /// - /// `None` when the section body is empty (no content lines). - Unknown(Option), -} - -/// NumPy-style docstring. -/// -/// Supports sections with underlines like: -/// ```text -/// Parameters -/// ---------- -/// name : type -/// Description -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyDocstring { - /// Original source text of the docstring. - pub source: String, - /// Source span of the entire docstring. - pub range: TextRange, - /// Brief summary (first paragraph, up to the first blank line). - pub summary: Option, - /// Deprecation warning (if applicable). - pub deprecation: Option, - /// Extended summary (multiple sentences before any section header). - /// Clarifies functionality, may reference parameters. - pub extended_summary: Option, - /// All items (sections and stray lines) in order of appearance. - pub items: Vec, -} - -/// NumPy-style deprecation notice. -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyDeprecation { - /// Source span. - pub range: TextRange, - /// The `..` RST directive marker, with its span. - pub directive_marker: Option, - /// The `deprecated` keyword, with its span. - pub keyword: Option, - /// The `::` double-colon separator, with its span. - pub double_colon: Option, - /// Version when deprecated (e.g., "1.6.0") with its span. - pub version: TextRange, - /// Reason for deprecation and recommendation (free text body), with its span. - pub description: TextRange, -} - -/// NumPy-style parameter. -/// -/// Can represent a single parameter or multiple parameters with the same type: -/// `x : int` or `x1, x2 : array_like` -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyParameter { - /// Source span of this parameter definition. - pub range: TextRange, - /// Parameter names (supports multiple names like `x1, x2`), each with its own span. - pub names: Vec, - /// The colon separator (`:`) between name(s) and type, if present. - /// - /// `None` when the colon is missing (best-effort parse of a bare name). - /// A linter can use this to report a missing colon. - pub colon: Option, - /// Parameter type (e.g., "int", "str", "array_like") with its span. - /// Type is optional for parameters but required for returns. - pub r#type: Option, - /// Parameter description with its span. - pub description: Option, - /// The `optional` marker, if present. - /// `None` means not marked as optional. - pub optional: Option, - /// The `default` keyword, if present (e.g., `"default"`). - pub default_keyword: Option, - /// The separator after `default` (`=` or `:`), if present. - /// `None` when the value follows after whitespace only (e.g., `default True`). - pub default_separator: Option, - /// Default value (e.g., "True", "-1", "None") with its span. - /// `None` when `default` appears alone without a value. - pub default_value: Option, -} - -/// NumPy-style return or yield value. -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyReturns { - /// Source span. - pub range: TextRange, - /// Return value name (optional in NumPy style) with its span. - pub name: Option, - /// The colon (`:`) separating name from type, with its span, if present. - pub colon: Option, - /// Return type with its span. - pub return_type: Option, - /// Description with its span. - pub description: Option, -} - -/// NumPy-style exception. -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyException { - /// Source span. - pub range: TextRange, - /// Exception type with its span. - pub r#type: TextRange, - /// The colon (`:`) separating type from description, with its span, if present. - pub colon: Option, - /// Description of when raised, with its span. - pub description: Option, -} - -/// NumPy-style warning (from Warns section). -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyWarning { - /// Source span. - pub range: TextRange, - /// Warning type (e.g., "DeprecationWarning") with its span. - pub r#type: TextRange, - /// The colon (`:`) separating type from description, with its span, if present. - pub colon: Option, - /// When the warning is issued, with its span. - pub description: Option, -} - -/// See Also item. -/// -/// Supports multiple items and optional descriptions: -/// - `func_a : Description` -/// - `func_b, func_c` (multiple names, no description) -#[derive(Debug, Clone, PartialEq)] -pub struct SeeAlsoItem { - /// Source span. - pub range: TextRange, - /// Reference names (can be multiple like `func_b, func_c`), each with its own span. - pub names: Vec, - /// The colon (`:`) separating names from description, with its span, if present. - pub colon: Option, - /// Optional description with its span. - pub description: Option, -} - -/// Numbered reference (from References section). -/// -/// Represents an RST citation reference like `.. [1] Author, Title`. -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyReference { - /// Source span. - pub range: TextRange, - /// The RST directive marker (`..`) with its span, if present. - /// - /// `None` for non-RST (plain text) references. - pub directive_marker: Option, - /// Opening bracket (`[`) enclosing the reference number, with its span, if present. - pub open_bracket: Option, - /// Reference number (e.g., "1", "2", "3") with its span. - /// - /// `None` for non-RST (plain text) references or empty brackets. - pub number: Option, - /// Closing bracket (`]`) enclosing the reference number, with its span, if present. - pub close_bracket: Option, - /// Reference content (author, title, etc) with its span. - pub content: Option, -} - -/// NumPy-style attribute. -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyAttribute { - /// Source span. - pub range: TextRange, - /// Attribute name with its span. - pub name: TextRange, - /// The colon (`:`) separating name from type, with its span, if present. - pub colon: Option, - /// Attribute type with its span. - pub r#type: Option, - /// Description with its span. - pub description: Option, -} - -/// NumPy-style method (for classes). -#[derive(Debug, Clone, PartialEq)] -pub struct NumPyMethod { - /// Source span. - pub range: TextRange, - /// Method name with its span. - pub name: TextRange, - /// The colon (`:`) separating name from description, with its span, if present. - pub colon: Option, - /// Brief description with its span. - pub description: Option, -} - -impl NumPySectionBody { - /// Create a new empty section body for the given section kind. - pub fn new(kind: NumPySectionKind) -> Self { - match kind { - NumPySectionKind::Parameters => Self::Parameters(Vec::new()), - NumPySectionKind::Returns => Self::Returns(Vec::new()), - NumPySectionKind::Yields => Self::Yields(Vec::new()), - NumPySectionKind::Receives => Self::Receives(Vec::new()), - NumPySectionKind::OtherParameters => Self::OtherParameters(Vec::new()), - NumPySectionKind::Raises => Self::Raises(Vec::new()), - NumPySectionKind::Warns => Self::Warns(Vec::new()), - NumPySectionKind::Warnings => Self::Warnings(None), - NumPySectionKind::SeeAlso => Self::SeeAlso(Vec::new()), - NumPySectionKind::Notes => Self::Notes(None), - NumPySectionKind::References => Self::References(Vec::new()), - NumPySectionKind::Examples => Self::Examples(None), - NumPySectionKind::Attributes => Self::Attributes(Vec::new()), - NumPySectionKind::Methods => Self::Methods(Vec::new()), - NumPySectionKind::Unknown => Self::Unknown(None), - } - } -} - -impl NumPyDocstring { - /// Creates a new empty NumPy-style docstring with the given source. - pub fn new(input: &str) -> Self { - Self { - source: input.to_string(), - range: TextRange::empty(), - summary: None, - deprecation: None, - extended_summary: None, - items: Vec::new(), - } - } -} - -impl Default for NumPyDocstring { - fn default() -> Self { - Self::new("") - } -} - -impl fmt::Display for NumPyDocstring { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "NumPyDocstring(summary: {})", - self.summary - .as_ref() - .map_or("", |s| s.source_text(&self.source)) - ) - } -} diff --git a/src/styles/numpy/kind.rs b/src/styles/numpy/kind.rs new file mode 100644 index 0000000..1e7dc92 --- /dev/null +++ b/src/styles/numpy/kind.rs @@ -0,0 +1,137 @@ +//! NumPy-style section kind enumeration. + +use core::fmt; + +/// NumPy-style section kinds. +/// +/// Each variant represents a recognised section name (or group of aliases), +/// or [`Unknown`](Self::Unknown) for unrecognised names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NumPySectionKind { + /// `Parameters` / `Params` + Parameters, + /// `Returns` / `Return` + Returns, + /// `Yields` / `Yield` + Yields, + /// `Receives` / `Receive` + Receives, + /// `Other Parameters` / `Other Params` + OtherParameters, + /// `Raises` / `Raise` + Raises, + /// `Warns` / `Warn` + Warns, + /// `Warnings` / `Warning` + Warnings, + /// `See Also` + SeeAlso, + /// `Notes` / `Note` + Notes, + /// `References` + References, + /// `Examples` / `Example` + Examples, + /// `Attributes` + Attributes, + /// `Methods` + Methods, + /// Unrecognised section name. + Unknown, +} + +impl NumPySectionKind { + /// All known section kinds. + pub const ALL: &[NumPySectionKind] = &[ + Self::Parameters, + Self::Returns, + Self::Yields, + Self::Receives, + Self::OtherParameters, + Self::Raises, + Self::Warns, + Self::Warnings, + Self::SeeAlso, + Self::Notes, + Self::References, + Self::Examples, + Self::Attributes, + Self::Methods, + ]; + + /// Convert a **lowercased** section name to a [`NumPySectionKind`]. + #[rustfmt::skip] + pub fn from_name(name: &str) -> Self { + match name { + "parameters" | "parameter" | "params" | "param" => Self::Parameters, + "returns" | "return" => Self::Returns, + "yields" | "yield" => Self::Yields, + "receives" | "receive" => Self::Receives, + "other parameters" | "other parameter" | "other params" | "other param" => Self::OtherParameters, + "raises" | "raise" => Self::Raises, + "warns" | "warn" => Self::Warns, + "warnings" | "warning" => Self::Warnings, + "see also" => Self::SeeAlso, + "notes" | "note" => Self::Notes, + "references" => Self::References, + "examples" | "example" => Self::Examples, + "attributes" => Self::Attributes, + "methods" => Self::Methods, + _ => Self::Unknown, + } + } + + /// Check if a lowercased name is a known (non-[`Unknown`](Self::Unknown)) section name. + pub fn is_known(name: &str) -> bool { + !matches!(Self::from_name(name), Self::Unknown) + } + + /// Whether this section kind has structured entries (vs free text). + pub fn is_structured(&self) -> bool { + matches!( + self, + Self::Parameters + | Self::Returns + | Self::Yields + | Self::Receives + | Self::OtherParameters + | Self::Raises + | Self::Warns + | Self::SeeAlso + | Self::References + | Self::Attributes + | Self::Methods + ) + } + + /// Whether this section kind has free-text body. + pub fn is_freetext(&self) -> bool { + matches!( + self, + Self::Notes | Self::Examples | Self::Warnings | Self::Unknown + ) + } +} + +impl fmt::Display for NumPySectionKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Parameters => "Parameters", + Self::Returns => "Returns", + Self::Yields => "Yields", + Self::Receives => "Receives", + Self::OtherParameters => "Other Parameters", + Self::Raises => "Raises", + Self::Warns => "Warns", + Self::Warnings => "Warnings", + Self::SeeAlso => "See Also", + Self::Notes => "Notes", + Self::References => "References", + Self::Examples => "Examples", + Self::Attributes => "Attributes", + Self::Methods => "Methods", + Self::Unknown => "Unknown", + }; + write!(f, "{}", s) + } +} diff --git a/src/styles/numpy/nodes.rs b/src/styles/numpy/nodes.rs new file mode 100644 index 0000000..9b3a0cb --- /dev/null +++ b/src/styles/numpy/nodes.rs @@ -0,0 +1,399 @@ +//! Typed wrappers for NumPy-style syntax nodes. +//! +//! Each wrapper is a newtype over `&SyntaxNode` that provides typed accessors +//! for the node's children (tokens and sub-nodes). + +use crate::styles::numpy::kind::NumPySectionKind; +use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken}; + +// ============================================================================= +// Macro for defining typed node wrappers +// ============================================================================= + +macro_rules! define_node { + ($name:ident, $kind:ident) => { + #[derive(Debug)] + pub struct $name<'a>(pub(crate) &'a SyntaxNode); + + impl<'a> $name<'a> { + pub fn cast(node: &'a SyntaxNode) -> Option { + (node.kind() == SyntaxKind::$kind).then(|| Self(node)) + } + + pub fn syntax(&self) -> &'a SyntaxNode { + self.0 + } + } + }; +} + +// ============================================================================= +// NumPyDocstring +// ============================================================================= + +define_node!(NumPyDocstring, NUMPY_DOCSTRING); + +impl<'a> NumPyDocstring<'a> { + /// Brief summary token, if present. + pub fn summary(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::SUMMARY) + } + + /// Extended summary token, if present. + pub fn extended_summary(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::EXTENDED_SUMMARY) + } + + /// Deprecation node, if present. + pub fn deprecation(&self) -> Option> { + self.0 + .find_node(SyntaxKind::NUMPY_DEPRECATION) + .and_then(NumPyDeprecation::cast) + } + + /// Iterate over all section nodes. + pub fn sections(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_SECTION) + .filter_map(NumPySection::cast) + } + + /// Iterate over stray line tokens. + pub fn stray_lines(&self) -> impl Iterator { + self.0.tokens(SyntaxKind::STRAY_LINE) + } +} + +// ============================================================================= +// NumPySection +// ============================================================================= + +define_node!(NumPySection, NUMPY_SECTION); + +impl<'a> NumPySection<'a> { + /// The section header node. + pub fn header(&self) -> NumPySectionHeader<'a> { + NumPySectionHeader::cast( + self.0 + .find_node(SyntaxKind::NUMPY_SECTION_HEADER) + .expect("NUMPY_SECTION must have a NUMPY_SECTION_HEADER child"), + ) + .unwrap() + } + + /// Determine the section kind from the header name text. + pub fn section_kind(&self, source: &str) -> NumPySectionKind { + let name_text = self.header().name().text(source); + NumPySectionKind::from_name(&name_text.to_ascii_lowercase()) + } + + /// Iterate over parameter entry nodes. + pub fn parameters(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_PARAMETER) + .filter_map(NumPyParameter::cast) + } + + /// Iterate over returns entry nodes. + pub fn returns(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_RETURNS) + .filter_map(NumPyReturns::cast) + } + + /// Iterate over exception entry nodes. + pub fn exceptions(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_EXCEPTION) + .filter_map(NumPyException::cast) + } + + /// Iterate over warning entry nodes. + pub fn warnings(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_WARNING) + .filter_map(NumPyWarning::cast) + } + + /// Iterate over see-also item nodes. + pub fn see_also_items(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_SEE_ALSO_ITEM) + .filter_map(NumPySeeAlsoItem::cast) + } + + /// Iterate over reference nodes. + pub fn references(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_REFERENCE) + .filter_map(NumPyReference::cast) + } + + /// Iterate over attribute entry nodes. + pub fn attributes(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_ATTRIBUTE) + .filter_map(NumPyAttribute::cast) + } + + /// Iterate over method entry nodes. + pub fn methods(&self) -> impl Iterator> { + self.0 + .nodes(SyntaxKind::NUMPY_METHOD) + .filter_map(NumPyMethod::cast) + } + + /// Free-text body content, if this is a free-text section. + pub fn body_text(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::BODY_TEXT) + } +} + +// ============================================================================= +// NumPySectionHeader +// ============================================================================= + +define_node!(NumPySectionHeader, NUMPY_SECTION_HEADER); + +impl<'a> NumPySectionHeader<'a> { + /// Section name token (e.g. "Parameters", "Returns"). + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + /// Underline token (the `----------` line). + pub fn underline(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::UNDERLINE) + } +} + +// ============================================================================= +// NumPyDeprecation +// ============================================================================= + +define_node!(NumPyDeprecation, NUMPY_DEPRECATION); + +impl<'a> NumPyDeprecation<'a> { + /// The `..` RST directive marker. + pub fn directive_marker(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DIRECTIVE_MARKER) + } + + /// The `deprecated` keyword. + pub fn keyword(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::KEYWORD) + } + + /// The `::` double-colon separator. + pub fn double_colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DOUBLE_COLON) + } + + /// Version when deprecated. + pub fn version(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::VERSION) + } + + /// Description / reason for deprecation. + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// NumPyParameter +// ============================================================================= + +define_node!(NumPyParameter, NUMPY_PARAMETER); + +impl<'a> NumPyParameter<'a> { + /// Parameter name tokens (supports multiple names like `x1, x2`). + pub fn names(&self) -> impl Iterator { + self.0.tokens(SyntaxKind::NAME) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn r#type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::TYPE) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } + + pub fn optional(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::OPTIONAL) + } + + pub fn default_keyword(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DEFAULT_KEYWORD) + } + + pub fn default_separator(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DEFAULT_SEPARATOR) + } + + pub fn default_value(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DEFAULT_VALUE) + } +} + +// ============================================================================= +// NumPyReturns +// ============================================================================= + +define_node!(NumPyReturns, NUMPY_RETURNS); + +impl<'a> NumPyReturns<'a> { + pub fn name(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::NAME) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn return_type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::RETURN_TYPE) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// NumPyException +// ============================================================================= + +define_node!(NumPyException, NUMPY_EXCEPTION); + +impl<'a> NumPyException<'a> { + pub fn r#type(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::TYPE) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// NumPyWarning +// ============================================================================= + +define_node!(NumPyWarning, NUMPY_WARNING); + +impl<'a> NumPyWarning<'a> { + pub fn r#type(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::TYPE) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// NumPySeeAlsoItem +// ============================================================================= + +define_node!(NumPySeeAlsoItem, NUMPY_SEE_ALSO_ITEM); + +impl<'a> NumPySeeAlsoItem<'a> { + /// All name tokens (can be multiple, e.g. `func_a, func_b`). + pub fn names(&self) -> impl Iterator { + self.0.tokens(SyntaxKind::NAME) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// NumPyReference +// ============================================================================= + +define_node!(NumPyReference, NUMPY_REFERENCE); + +impl<'a> NumPyReference<'a> { + pub fn directive_marker(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DIRECTIVE_MARKER) + } + + pub fn open_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::OPEN_BRACKET) + } + + pub fn number(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::NUMBER) + } + + pub fn close_bracket(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::CLOSE_BRACKET) + } + + pub fn content(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::CONTENT) + } +} + +// ============================================================================= +// NumPyAttribute +// ============================================================================= + +define_node!(NumPyAttribute, NUMPY_ATTRIBUTE); + +impl<'a> NumPyAttribute<'a> { + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn r#type(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::TYPE) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} + +// ============================================================================= +// NumPyMethod +// ============================================================================= + +define_node!(NumPyMethod, NUMPY_METHOD); + +impl<'a> NumPyMethod<'a> { + pub fn name(&self) -> &'a SyntaxToken { + self.0.required_token(SyntaxKind::NAME) + } + + pub fn colon(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::COLON) + } + + pub fn description(&self) -> Option<&'a SyntaxToken> { + self.0.find_token(SyntaxKind::DESCRIPTION) + } +} diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index 3d30135..7421b01 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -1,31 +1,12 @@ -//! NumPy style docstring parser. +//! NumPy style docstring parser (SyntaxNode-based). //! -//! Parses docstrings in NumPy format: -//! ```text -//! Brief summary. -//! -//! Extended description. -//! -//! Parameters -//! ---------- -//! param1 : type -//! Description of param1. -//! param2 : type, optional -//! Description of param2. -//! -//! Returns -//! ------- -//! type -//! Description of return value. -//! ``` +//! Parses docstrings in NumPy format and produces a [`Parsed`] result +//! containing a tree of [`SyntaxNode`]s and [`SyntaxToken`]s. use crate::cursor::{LineCursor, indent_columns, indent_len}; -use crate::styles::numpy::ast::{ - NumPyAttribute, NumPyDeprecation, NumPyDocstring, NumPyDocstringItem, NumPyException, - NumPyMethod, NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPySectionBody, - NumPySectionHeader, NumPySectionKind, NumPyWarning, SeeAlsoItem, -}; +use crate::styles::numpy::kind::NumPySectionKind; use crate::styles::utils::{find_entry_colon, find_matching_close, split_comma_parts}; +use crate::syntax::{Parsed, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken}; use crate::text::TextRange; // ============================================================================= @@ -37,11 +18,11 @@ fn is_underline(trimmed: &str) -> bool { !trimmed.is_empty() && trimmed.bytes().all(|b| b == b'-') } -/// Try to parse a NumPy-style section header at `cursor.line`. +/// Try to detect a NumPy-style section header at `cursor.line`. /// /// A section header is a non-empty line immediately followed by a -/// line consisting only of dashes. Does **not** advance the cursor. -fn try_parse_numpy_header(cursor: &LineCursor) -> Option { +/// line consisting only of dashes. Does **not** advance the cursor. +fn try_detect_header(cursor: &LineCursor) -> Option { let header_trimmed = cursor.current_trimmed(); if header_trimmed.is_empty() { return None; @@ -60,7 +41,7 @@ fn try_parse_numpy_header(cursor: &LineCursor) -> Option { let normalized = header_trimmed.to_ascii_lowercase(); let kind = NumPySectionKind::from_name(&normalized); - Some(NumPySectionHeader { + Some(SectionHeaderInfo { range: cursor.make_range( cursor.line, header_col, @@ -73,19 +54,17 @@ fn try_parse_numpy_header(cursor: &LineCursor) -> Option { }) } +struct SectionHeaderInfo { + range: TextRange, + kind: NumPySectionKind, + name: TextRange, + underline: TextRange, +} + // ============================================================================= -// Description collector (used only for deprecation directive body) +// Description collector (for deprecation directive body) // ============================================================================= -/// Collect indented description lines starting at `cursor.line`. -/// -/// Preserves blank lines between paragraphs. Stops at non-empty lines at or -/// below `entry_indent_cols` visual columns, section headers, or EOF. -/// -/// On return, `cursor.line` points to the first unconsumed line. -/// -/// NOTE: This is used **only** for the deprecation directive body, which needs -/// eager multi-line collection. Section body parsing uses per-line functions. fn collect_description(cursor: &mut LineCursor, entry_indent_cols: usize) -> Option { let mut first_content_line: Option = None; let mut last_content_line = cursor.line; @@ -114,245 +93,9 @@ fn collect_description(cursor: &mut LineCursor, entry_indent_cols: usize) -> Opt } // ============================================================================= -// Main parser -// ============================================================================= - -/// Parse a NumPy-style docstring. -pub fn parse_numpy(input: &str) -> NumPyDocstring { - let mut cursor = LineCursor::new(input); - let mut docstring = NumPyDocstring::new(input); - - if cursor.total_lines() == 0 { - return docstring; - } - - // --- Skip leading blank lines --- - cursor.skip_blanks(); - if cursor.is_eof() { - return docstring; - } - - // --- Summary (all lines until blank line or section header) --- - if try_parse_numpy_header(&cursor).is_none() { - let trimmed = cursor.current_trimmed(); - if !trimmed.is_empty() { - let start_line = cursor.line; - let start_col = cursor.current_indent(); - let mut last_line = start_line; - - while !cursor.is_eof() { - if try_parse_numpy_header(&cursor).is_some() { - break; - } - let t = cursor.current_trimmed(); - if t.is_empty() { - break; - } - last_line = cursor.line; - cursor.advance(); - } - - let last_text = cursor.line_text(last_line); - let last_col = indent_len(last_text) + last_text.trim().len(); - let range = cursor.make_range(start_line, start_col, last_line, last_col); - if !range.is_empty() { - docstring.summary = Some(range); - } - } - } - - // skip blanks - cursor.skip_blanks(); - - // --- Deprecation directive --- - if !cursor.is_eof() && try_parse_numpy_header(&cursor).is_none() { - let line = cursor.current_line_text(); - let trimmed = line.trim(); - if trimmed.starts_with(".. deprecated::") { - let col = cursor.current_indent(); - let prefix = ".. deprecated::"; - let after_prefix = &trimmed[prefix.len()..]; - let ws_len = after_prefix.len() - after_prefix.trim_start().len(); - let version_str = after_prefix.trim(); - let version_col = col + prefix.len() + ws_len; - - // `..` at col..col+2 - let directive_marker = Some(cursor.make_line_range(cursor.line, col, 2)); - // `deprecated` at col+3..col+13 - let kw_col = col + 3; - let keyword = Some(cursor.make_line_range(cursor.line, kw_col, 10)); - // `::` at col+13..col+15 - let dc_col = col + 13; - let double_colon = Some(cursor.make_line_range(cursor.line, dc_col, 2)); - - let version_spanned = - cursor.make_line_range(cursor.line, version_col, version_str.len()); - - let dep_start_line = cursor.line; - cursor.advance(); - - // Collect indented body lines - let desc_spanned = collect_description(&mut cursor, indent_columns(line)); - - // Compute deprecation span - let (dep_end_line, dep_end_col) = match &desc_spanned { - None => (dep_start_line, col + trimmed.len()), - Some(d) => cursor.offset_to_line_col(d.end().raw() as usize), - }; - - docstring.deprecation = Some(NumPyDeprecation { - range: cursor.make_range(dep_start_line, col, dep_end_line, dep_end_col), - directive_marker, - keyword, - double_colon, - version: version_spanned, - description: desc_spanned.unwrap_or_else(TextRange::empty), - }); - - // skip blanks - cursor.skip_blanks(); - } - } - - // --- Extended summary --- - if !cursor.is_eof() && try_parse_numpy_header(&cursor).is_none() { - let start_line = cursor.line; - let mut desc_lines: Vec<&str> = Vec::new(); - let mut last_non_empty_line = cursor.line; - - while !cursor.is_eof() { - if try_parse_numpy_header(&cursor).is_some() { - break; - } - let trimmed = cursor.current_trimmed(); - desc_lines.push(trimmed); - if !trimmed.is_empty() { - last_non_empty_line = cursor.line; - } - cursor.advance(); - } - - // Trim trailing empty lines - let keep = last_non_empty_line - start_line + 1; - desc_lines.truncate(keep); - - let joined = desc_lines.join("\n"); - if !joined.trim().is_empty() { - let first_line = cursor.line_text(start_line); - let first_col = indent_len(first_line); - let last_line = cursor.line_text(last_non_empty_line); - let last_trimmed = last_line.trim(); - let last_col = indent_len(last_line) + last_trimmed.len(); - docstring.extended_summary = - Some(cursor.make_range(start_line, first_col, last_non_empty_line, last_col)); - } - } - - // --- Section state --- - let mut current_header: Option = None; - let mut current_body: Option = None; - let mut entry_indent: Option = None; - - while !cursor.is_eof() { - // --- Blank lines --- - if cursor.current_trimmed().is_empty() { - cursor.advance(); - continue; - } - - // --- Detect section header --- - if let Some(header) = try_parse_numpy_header(&cursor) { - // Flush previous section - if let Some(prev_header) = current_header.take() { - flush_section( - &cursor, - &mut docstring, - prev_header, - current_body.take().unwrap(), - ); - } - - // Start new section - current_body = Some(NumPySectionBody::new(header.kind)); - current_header = Some(header); - entry_indent = None; - cursor.line += 2; // skip header + underline - continue; - } - - // --- Process line based on current state --- - if let Some(ref mut body) = current_body { - #[rustfmt::skip] - match body { - NumPySectionBody::Parameters(v) => process_parameter_line(&cursor, v, &mut entry_indent), - NumPySectionBody::OtherParameters(v) => process_parameter_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Receives(v) => process_parameter_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Returns(v) => process_returns_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Yields(v) => process_returns_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Raises(v) => process_raises_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Warns(v) => process_warning_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Attributes(v) => process_attribute_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Methods(v) => process_method_line(&cursor, v, &mut entry_indent), - NumPySectionBody::SeeAlso(v) => process_see_also_line(&cursor, v, &mut entry_indent), - NumPySectionBody::References(v) => process_reference_line(&cursor, v, &mut entry_indent), - NumPySectionBody::Notes(r) => process_freetext_line(&cursor, r), - NumPySectionBody::Examples(r) => process_freetext_line(&cursor, r), - NumPySectionBody::Warnings(r) => process_freetext_line(&cursor, r), - NumPySectionBody::Unknown(r) => process_freetext_line(&cursor, r), - }; - } else { - // Stray line (outside any section in post-section phase) - docstring.items.push(NumPyDocstringItem::StrayLine( - cursor.current_trimmed_range(), - )); - } - - cursor.advance(); - } - - // Flush final section - if let Some(header) = current_header.take() { - flush_section( - &cursor, - &mut docstring, - header, - current_body.take().unwrap(), - ); - } - - // Docstring span - docstring.range = cursor.full_range(); - - docstring -} - -// ============================================================================= -// Section flush +// Entry header parsing (parameter name : type, optional) // ============================================================================= -/// Flush a completed section into the docstring. -fn flush_section( - cursor: &LineCursor, - docstring: &mut NumPyDocstring, - header: NumPySectionHeader, - body: NumPySectionBody, -) { - let header_start = header.range.start().raw() as usize; - let range = cursor.span_back_from_cursor(header_start); - docstring - .items - .push(NumPyDocstringItem::Section(NumPySection { - range, - header, - body, - })); -} - -// ============================================================================= -// Entry header parsing -// ============================================================================= - -/// Result of parsing a parameter header. struct ParamHeaderParts { names: Vec, colon: Option, @@ -363,22 +106,13 @@ struct ParamHeaderParts { default_value: Option, } -/// Parse `"name : type, optional"` into components with precise spans. -/// -/// Tolerant of any whitespace around the colon separator. -/// Single-line only — multi-line type annotations are not supported. -/// -/// `line_idx` is the 0-based line index, `col_base` is the byte column where -/// `text` starts in the raw line. fn parse_name_and_type( text: &str, line_idx: usize, col_base: usize, cursor: &LineCursor, ) -> ParamHeaderParts { - // Find the first colon not inside brackets let Some(colon_pos) = find_entry_colon(text) else { - // No separator — whole text is the name let names = parse_name_list(text, line_idx, col_base, cursor); return ParamHeaderParts { names, @@ -414,7 +148,6 @@ fn parse_name_and_type( let type_abs_start = cursor.substr_offset(after_trimmed); let type_text = after_trimmed; - // Classify segments using bracket-aware comma splitting. let mut optional: Option = None; let mut default_keyword: Option = None; let mut default_separator: Option = None; @@ -463,7 +196,6 @@ fn parse_name_and_type( } } } else { - // Real type segment type_parts_end = seg_offset + seg_raw.trim_end().len(); } } @@ -486,7 +218,6 @@ fn parse_name_and_type( } } -/// Parse a comma-separated name list like `"x1, x2"` into spanned names. fn parse_name_list( text: &str, line_idx: usize, @@ -503,41 +234,300 @@ fn parse_name_list( let name_col = col_base + byte_pos + leading; names.push(cursor.make_line_range(line_idx, name_col, trimmed.len())); } - byte_pos += part.len() + 1; // +1 for the comma + byte_pos += part.len() + 1; } names } +// ============================================================================= +// SyntaxNode builders +// ============================================================================= + +fn build_section_header_node(info: &SectionHeaderInfo) -> SyntaxNode { + let children = vec![ + SyntaxElement::Token(SyntaxToken::new(SyntaxKind::NAME, info.name)), + SyntaxElement::Token(SyntaxToken::new(SyntaxKind::UNDERLINE, info.underline)), + ]; + SyntaxNode::new(SyntaxKind::NUMPY_SECTION_HEADER, info.range, children) +} + +fn build_parameter_node(parts: &ParamHeaderParts, range: TextRange) -> SyntaxNode { + let mut children = Vec::new(); + for name in &parts.names { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + *name, + ))); + } + if let Some(colon) = parts.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(t) = parts.param_type { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::TYPE, t))); + } + if let Some(opt) = parts.optional { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::OPTIONAL, + opt, + ))); + } + if let Some(dk) = parts.default_keyword { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DEFAULT_KEYWORD, + dk, + ))); + } + if let Some(ds) = parts.default_separator { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DEFAULT_SEPARATOR, + ds, + ))); + } + if let Some(dv) = parts.default_value { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DEFAULT_VALUE, + dv, + ))); + } + SyntaxNode::new(SyntaxKind::NUMPY_PARAMETER, range, children) +} + +fn build_returns_node( + name: Option, + colon: Option, + return_type: Option, + range: TextRange, +) -> SyntaxNode { + let mut children = Vec::new(); + if let Some(n) = name { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::NAME, n))); + } + if let Some(c) = colon { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::COLON, c))); + } + if let Some(rt) = return_type { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::RETURN_TYPE, + rt, + ))); + } + SyntaxNode::new(SyntaxKind::NUMPY_RETURNS, range, children) +} + +fn build_exception_node( + exc_type: TextRange, + colon: Option, + first_desc: Option, + range: TextRange, +) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::TYPE, + exc_type, + ))); + if let Some(c) = colon { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::COLON, c))); + } + if let Some(d) = first_desc { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + d, + ))); + } + SyntaxNode::new(SyntaxKind::NUMPY_EXCEPTION, range, children) +} + +fn build_warning_node( + warn_type: TextRange, + colon: Option, + first_desc: Option, + range: TextRange, +) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::TYPE, + warn_type, + ))); + if let Some(c) = colon { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::COLON, c))); + } + if let Some(d) = first_desc { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + d, + ))); + } + SyntaxNode::new(SyntaxKind::NUMPY_WARNING, range, children) +} + +fn build_see_also_node( + names_str: &str, + names_line: usize, + names_col: usize, + colon: Option, + description: Option, + range: TextRange, + cursor: &LineCursor, +) -> SyntaxNode { + let mut children = Vec::new(); + let names = parse_name_list(names_str, names_line, names_col, cursor); + for name in &names { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + *name, + ))); + } + if let Some(c) = colon { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::COLON, c))); + } + if let Some(d) = description { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + d, + ))); + } + SyntaxNode::new(SyntaxKind::NUMPY_SEE_ALSO_ITEM, range, children) +} + +fn build_attribute_node(parts: &ParamHeaderParts, range: TextRange) -> SyntaxNode { + let mut children = Vec::new(); + // Attributes use the first name only. + if let Some(name) = parts.names.first() { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + *name, + ))); + } + if let Some(colon) = parts.colon { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + colon, + ))); + } + if let Some(t) = parts.param_type { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::TYPE, t))); + } + SyntaxNode::new(SyntaxKind::NUMPY_ATTRIBUTE, range, children) +} + +fn build_method_node(name: TextRange, colon: Option, range: TextRange) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + name, + ))); + if let Some(c) = colon { + children.push(SyntaxElement::Token(SyntaxToken::new(SyntaxKind::COLON, c))); + } + SyntaxNode::new(SyntaxKind::NUMPY_METHOD, range, children) +} + +fn build_reference_node_rst( + directive_marker: TextRange, + open_bracket: TextRange, + number: Option, + close_bracket: TextRange, + content: Option, + range: TextRange, +) -> SyntaxNode { + let mut children = Vec::new(); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DIRECTIVE_MARKER, + directive_marker, + ))); + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::OPEN_BRACKET, + open_bracket, + ))); + if let Some(n) = number { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NUMBER, + n, + ))); + } + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::CLOSE_BRACKET, + close_bracket, + ))); + if let Some(c) = content { + children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::CONTENT, + c, + ))); + } + SyntaxNode::new(SyntaxKind::NUMPY_REFERENCE, range, children) +} + +fn build_reference_node_plain(content: TextRange, range: TextRange) -> SyntaxNode { + let children = vec![SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::CONTENT, + content, + ))]; + SyntaxNode::new(SyntaxKind::NUMPY_REFERENCE, range, children) +} + // ============================================================================= // Per-line section body processors // ============================================================================= -/// Extend a description field with a continuation range. -fn extend_description(description: &mut Option, range: &mut TextRange, cont: TextRange) { - match description { - Some(desc) => desc.extend(cont), - None => *description = Some(cont), +fn extend_last_node_description(nodes: &mut Vec, cont: TextRange) { + if let Some(SyntaxElement::Node(node)) = nodes.last_mut() { + let mut found_desc = false; + for child in node.children_mut() { + if let SyntaxElement::Token(t) = child { + if t.kind() == SyntaxKind::DESCRIPTION { + t.extend_range(cont); + found_desc = true; + break; + } + } + } + if !found_desc { + node.push_child(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + cont, + ))); + } + node.extend_range_to(cont.end()); + } +} + +/// Extend `content` field on a reference node. +fn extend_last_ref_content(nodes: &mut Vec, cont: TextRange) { + if let Some(SyntaxElement::Node(node)) = nodes.last_mut() { + let mut found_content = false; + for child in node.children_mut() { + if let SyntaxElement::Token(t) = child { + if t.kind() == SyntaxKind::CONTENT { + t.extend_range(cont); + found_content = true; + break; + } + } + } + if !found_content { + node.push_child(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::CONTENT, + cont, + ))); + } + node.extend_range_to(cont.end()); } - *range = TextRange::new(range.start(), cont.end()); } -/// Process one content line for a Parameters / OtherParameters / Receives section. fn process_parameter_line( cursor: &LineCursor, - params: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = params.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -548,37 +538,22 @@ fn process_parameter_line( let col = cursor.current_indent(); let trimmed = cursor.current_trimmed(); let parts = parse_name_and_type(trimmed, cursor.line, col, cursor); - let entry_range = cursor.current_trimmed_range(); - params.push(NumPyParameter { - range: entry_range, - names: parts.names, - colon: parts.colon, - r#type: parts.param_type, - description: None, - optional: parts.optional, - default_keyword: parts.default_keyword, - default_separator: parts.default_separator, - default_value: parts.default_value, - }); + nodes.push(SyntaxElement::Node(build_parameter_node( + &parts, + entry_range, + ))); } -/// Process one content line for a Returns / Yields section. fn process_returns_line( cursor: &LineCursor, - returns: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = returns.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -605,35 +580,28 @@ fn process_returns_line( }, ) } else { - // Unnamed: type only + // Unnamed: type only (stored as RETURN_TYPE) (None, None, Some(cursor.current_trimmed_range())) }; - returns.push(NumPyReturns { - range: cursor.current_trimmed_range(), + let entry_range = cursor.current_trimmed_range(); + nodes.push(SyntaxElement::Node(build_returns_node( name, colon, return_type, - description: None, - }); + entry_range, + ))); } -/// Process one content line for a Raises section. fn process_raises_line( cursor: &LineCursor, - raises: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = raises.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -663,30 +631,24 @@ fn process_raises_line( (cursor.current_trimmed_range(), None, None) }; - raises.push(NumPyException { - range: cursor.current_trimmed_range(), - r#type: exc_type, + let entry_range = cursor.current_trimmed_range(); + nodes.push(SyntaxElement::Node(build_exception_node( + exc_type, colon, - description: first_desc, - }); + first_desc, + entry_range, + ))); } -/// Process one content line for a Warns section. fn process_warning_line( cursor: &LineCursor, - warnings: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = warnings.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -716,30 +678,24 @@ fn process_warning_line( (cursor.current_trimmed_range(), None, None) }; - warnings.push(NumPyWarning { - range: cursor.current_trimmed_range(), - r#type: warn_type, + let entry_range = cursor.current_trimmed_range(); + nodes.push(SyntaxElement::Node(build_warning_node( + warn_type, colon, - description: first_desc, - }); + first_desc, + entry_range, + ))); } -/// Process one content line for an Attributes section. -fn process_attribute_line( +fn process_see_also_line( cursor: &LineCursor, - attrs: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = attrs.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -749,39 +705,46 @@ fn process_attribute_line( let col = cursor.current_indent(); let trimmed = cursor.current_trimmed(); - let parts = parse_name_and_type(trimmed, cursor.line, col, cursor); - let name = parts - .names - .into_iter() - .next() - .unwrap_or_else(TextRange::empty); + let (names_str, colon, description) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let after_colon = &trimmed[colon_pos + 1..]; + let desc_text = after_colon.trim(); + let ws_after = after_colon.len() - after_colon.trim_start().len(); + let desc_col = col + colon_pos + 1 + ws_after; + ( + trimmed[..colon_pos].trim_end(), + Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), + if desc_text.is_empty() { + None + } else { + Some(cursor.make_line_range(cursor.line, desc_col, desc_text.len())) + }, + ) + } else { + (trimmed, None, None) + }; - attrs.push(NumPyAttribute { - range: cursor.current_trimmed_range(), - name, - colon: parts.colon, - r#type: parts.param_type, - description: None, - }); + let entry_range = cursor.make_line_range(cursor.line, col, trimmed.len()); + nodes.push(SyntaxElement::Node(build_see_also_node( + names_str, + cursor.line, + col, + colon, + description, + entry_range, + cursor, + ))); } -/// Process one content line for a Methods section. -fn process_method_line( +fn process_attribute_line( cursor: &LineCursor, - methods: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = methods.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -791,41 +754,23 @@ fn process_method_line( let col = cursor.current_indent(); let trimmed = cursor.current_trimmed(); - - let (name, colon) = if let Some(colon_pos) = find_entry_colon(trimmed) { - let n = trimmed[..colon_pos].trim_end(); - ( - cursor.make_line_range(cursor.line, col, n.len()), - Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), - ) - } else { - (cursor.current_trimmed_range(), None) - }; - - methods.push(NumPyMethod { - range: cursor.current_trimmed_range(), - name, - colon, - description: None, - }); + let parts = parse_name_and_type(trimmed, cursor.line, col, cursor); + let entry_range = cursor.current_trimmed_range(); + nodes.push(SyntaxElement::Node(build_attribute_node( + &parts, + entry_range, + ))); } -/// Process one content line for a See Also section. -fn process_see_also_line( +fn process_method_line( cursor: &LineCursor, - items: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = items.last_mut() { - extend_description( - &mut last.description, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_node_description(nodes, cursor.current_trimmed_range()); return; } } @@ -836,52 +781,33 @@ fn process_see_also_line( let col = cursor.current_indent(); let trimmed = cursor.current_trimmed(); - let (names_str, colon, description) = if let Some(colon_pos) = find_entry_colon(trimmed) { - let after_colon = &trimmed[colon_pos + 1..]; - let desc_text = after_colon.trim(); - let ws_after = after_colon.len() - after_colon.trim_start().len(); - let desc_col = col + colon_pos + 1 + ws_after; + let (name, colon) = if let Some(colon_pos) = find_entry_colon(trimmed) { + let n = trimmed[..colon_pos].trim_end(); ( - trimmed[..colon_pos].trim_end(), + cursor.make_line_range(cursor.line, col, n.len()), Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)), - if desc_text.is_empty() { - None - } else { - Some(cursor.make_line_range(cursor.line, desc_col, desc_text.len())) - }, ) } else { - (trimmed, None, None) + (cursor.current_trimmed_range(), None) }; - let names = parse_name_list(names_str, cursor.line, col, cursor); - - items.push(SeeAlsoItem { - range: cursor.make_line_range(cursor.line, col, trimmed.len()), - names, + let entry_range = cursor.current_trimmed_range(); + nodes.push(SyntaxElement::Node(build_method_node( + name, colon, - description, - }); + entry_range, + ))); } -/// Process one content line for a References section. -/// -/// Handles both RST citation references (`.. [N] content`) and plain text. fn process_reference_line( cursor: &LineCursor, - refs: &mut Vec, + nodes: &mut Vec, entry_indent: &mut Option, ) { let indent_cols = cursor.current_indent_columns(); if let Some(base) = *entry_indent { if indent_cols > base { - if let Some(last) = refs.last_mut() { - extend_description( - &mut last.content, - &mut last.range, - cursor.current_trimmed_range(), - ); - } + extend_last_ref_content(nodes, cursor.current_trimmed_range()); return; } } @@ -897,9 +823,9 @@ fn process_reference_line( let rel_open = trimmed.find('[').unwrap(); let abs_open = cursor.substr_offset(trimmed) + rel_open; if let Some(abs_close) = find_matching_close(cursor.source(), abs_open) { - let directive_marker = Some(cursor.make_line_range(cursor.line, col, 2)); - let open_bracket = Some(TextRange::from_offset_len(abs_open, 1)); - let close_bracket = Some(TextRange::from_offset_len(abs_close, 1)); + let directive_marker = cursor.make_line_range(cursor.line, col, 2); + let open_bracket = TextRange::from_offset_len(abs_open, 1); + let close_bracket = TextRange::from_offset_len(abs_close, 1); let num_raw = &cursor.source()[abs_open + 1..abs_close]; let num_str = num_raw.trim(); let number = if !num_str.is_empty() { @@ -908,7 +834,6 @@ fn process_reference_line( } else { None }; - // Content after `]` on this line let line_end_offset = cursor.substr_offset(cursor.current_line_text()) + cursor.current_line_text().len(); let after_on_line = @@ -923,40 +848,333 @@ fn process_reference_line( None }; - refs.push(NumPyReference { - range: cursor.current_trimmed_range(), + nodes.push(SyntaxElement::Node(build_reference_node_rst( directive_marker, open_bracket, number, close_bracket, content, - }); + cursor.current_trimmed_range(), + ))); return; } } - // Plain text reference / non-RST - refs.push(NumPyReference { - range: cursor.current_trimmed_range(), - directive_marker: None, - open_bracket: None, - number: None, - close_bracket: None, - content: Some(cursor.current_trimmed_range()), - }); + // Plain text reference + nodes.push(SyntaxElement::Node(build_reference_node_plain( + cursor.current_trimmed_range(), + cursor.current_trimmed_range(), + ))); +} + +// ============================================================================= +// Section body kind tracking +// ============================================================================= + +enum SectionBody { + Parameters(Vec), + Returns(Vec), + Raises(Vec), + Warns(Vec), + SeeAlso(Vec), + References(Vec), + Attributes(Vec), + Methods(Vec), + FreeText(Option), } -/// Process one content line for a free-text section (Notes, Examples, etc.). +impl SectionBody { + fn new(kind: NumPySectionKind) -> Self { + match kind { + NumPySectionKind::Parameters + | NumPySectionKind::OtherParameters + | NumPySectionKind::Receives => Self::Parameters(Vec::new()), + NumPySectionKind::Returns | NumPySectionKind::Yields => Self::Returns(Vec::new()), + NumPySectionKind::Raises => Self::Raises(Vec::new()), + NumPySectionKind::Warns => Self::Warns(Vec::new()), + NumPySectionKind::SeeAlso => Self::SeeAlso(Vec::new()), + NumPySectionKind::References => Self::References(Vec::new()), + NumPySectionKind::Attributes => Self::Attributes(Vec::new()), + NumPySectionKind::Methods => Self::Methods(Vec::new()), + _ => Self::FreeText(None), + } + } + + fn process_line(&mut self, cursor: &LineCursor, entry_indent: &mut Option) { + match self { + Self::Parameters(nodes) => process_parameter_line(cursor, nodes, entry_indent), + Self::Returns(nodes) => process_returns_line(cursor, nodes, entry_indent), + Self::Raises(nodes) => process_raises_line(cursor, nodes, entry_indent), + Self::Warns(nodes) => process_warning_line(cursor, nodes, entry_indent), + Self::SeeAlso(nodes) => process_see_also_line(cursor, nodes, entry_indent), + Self::References(nodes) => process_reference_line(cursor, nodes, entry_indent), + Self::Attributes(nodes) => process_attribute_line(cursor, nodes, entry_indent), + Self::Methods(nodes) => process_method_line(cursor, nodes, entry_indent), + Self::FreeText(range) => { + let r = cursor.current_trimmed_range(); + match range { + Some(existing) => existing.extend(r), + None => *range = Some(r), + } + } + } + } + + fn into_children(self) -> Vec { + match self { + Self::Parameters(nodes) + | Self::Returns(nodes) + | Self::Raises(nodes) + | Self::Warns(nodes) + | Self::SeeAlso(nodes) + | Self::References(nodes) + | Self::Attributes(nodes) + | Self::Methods(nodes) => nodes, + Self::FreeText(range) => { + if let Some(r) = range { + vec![SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::BODY_TEXT, + r, + ))] + } else { + vec![] + } + } + } + } +} + +// ============================================================================= +// Main parser +// ============================================================================= + +/// Parse a NumPy-style docstring into a [`Parsed`] result. +/// +/// # Example +/// +/// ```rust +/// use pydocstring::numpy::parse_numpy; +/// use pydocstring::SyntaxKind; +/// +/// let input = "Summary.\n\nParameters\n----------\nx : int\n The value.\n"; +/// let parsed = parse_numpy(input); +/// let source = parsed.source(); +/// let root = parsed.root(); /// -/// Only called for non-blank lines (blanks are skipped by the main loop). -/// Blank lines between content lines are implicitly included in the -/// resulting range because `extend` spans across them. -fn process_freetext_line(cursor: &LineCursor, content: &mut Option) { - let range = cursor.current_trimmed_range(); - match content { - Some(c) => c.extend(range), - None => *content = Some(range), +/// // Access summary +/// let summary = root.find_token(SyntaxKind::SUMMARY).unwrap(); +/// assert_eq!(summary.text(source), "Summary."); +/// +/// // Access sections +/// let sections: Vec<_> = root.nodes(SyntaxKind::NUMPY_SECTION).collect(); +/// assert_eq!(sections.len(), 1); +/// ``` +pub fn parse_numpy(input: &str) -> Parsed { + let mut cursor = LineCursor::new(input); + let mut root_children: Vec = Vec::new(); + + cursor.skip_blanks(); + if cursor.is_eof() { + let root = SyntaxNode::new( + SyntaxKind::NUMPY_DOCSTRING, + cursor.full_range(), + root_children, + ); + return Parsed::new(input.to_string(), root); + } + + // --- Summary (all lines until blank line or section header) --- + if try_detect_header(&cursor).is_none() { + let trimmed = cursor.current_trimmed(); + if !trimmed.is_empty() { + let start_line = cursor.line; + let start_col = cursor.current_indent(); + let mut last_line = start_line; + + while !cursor.is_eof() { + if try_detect_header(&cursor).is_some() { + break; + } + let t = cursor.current_trimmed(); + if t.is_empty() { + break; + } + last_line = cursor.line; + cursor.advance(); + } + + let last_text = cursor.line_text(last_line); + let last_col = indent_len(last_text) + last_text.trim().len(); + let range = cursor.make_range(start_line, start_col, last_line, last_col); + if !range.is_empty() { + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::SUMMARY, + range, + ))); + } + } } + + cursor.skip_blanks(); + + // --- Deprecation directive --- + if !cursor.is_eof() && try_detect_header(&cursor).is_none() { + let line = cursor.current_line_text(); + let trimmed = line.trim(); + if trimmed.starts_with(".. deprecated::") { + let col = cursor.current_indent(); + let prefix = ".. deprecated::"; + let after_prefix = &trimmed[prefix.len()..]; + let ws_len = after_prefix.len() - after_prefix.trim_start().len(); + let version_str = after_prefix.trim(); + let version_col = col + prefix.len() + ws_len; + + let mut dep_children: Vec = Vec::new(); + + // `..` at col..col+2 + dep_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DIRECTIVE_MARKER, + cursor.make_line_range(cursor.line, col, 2), + ))); + // `deprecated` at col+3..col+13 + dep_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::KEYWORD, + cursor.make_line_range(cursor.line, col + 3, 10), + ))); + // `::` at col+13..col+15 + dep_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DOUBLE_COLON, + cursor.make_line_range(cursor.line, col + 13, 2), + ))); + + let version_range = cursor.make_line_range(cursor.line, version_col, version_str.len()); + dep_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::VERSION, + version_range, + ))); + + let dep_start_line = cursor.line; + cursor.advance(); + + let desc_range = collect_description(&mut cursor, indent_columns(line)); + + if let Some(desc) = desc_range { + dep_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + desc, + ))); + } + + // Compute deprecation span + let (dep_end_line, dep_end_col) = match desc_range { + None => (dep_start_line, col + trimmed.len()), + Some(d) => cursor.offset_to_line_col(d.end().raw() as usize), + }; + + let dep_range = cursor.make_range(dep_start_line, col, dep_end_line, dep_end_col); + root_children.push(SyntaxElement::Node(SyntaxNode::new( + SyntaxKind::NUMPY_DEPRECATION, + dep_range, + dep_children, + ))); + + cursor.skip_blanks(); + } + } + + // --- Extended summary --- + if !cursor.is_eof() && try_detect_header(&cursor).is_none() { + let start_line = cursor.line; + let mut last_non_empty_line = cursor.line; + let mut has_content = false; + + while !cursor.is_eof() { + if try_detect_header(&cursor).is_some() { + break; + } + let t = cursor.current_trimmed(); + if !t.is_empty() { + last_non_empty_line = cursor.line; + has_content = true; + } + cursor.advance(); + } + + if has_content { + let first_line = cursor.line_text(start_line); + let first_col = indent_len(first_line); + let last_line = cursor.line_text(last_non_empty_line); + let last_col = indent_len(last_line) + last_line.trim().len(); + let range = cursor.make_range(start_line, first_col, last_non_empty_line, last_col); + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::EXTENDED_SUMMARY, + range, + ))); + } + } + + // --- Sections --- + let mut current_header: Option = None; + let mut current_body: Option = None; + let mut entry_indent: Option = None; + + while !cursor.is_eof() { + if cursor.current_trimmed().is_empty() { + cursor.advance(); + continue; + } + + if let Some(header_info) = try_detect_header(&cursor) { + // Flush previous section + if let Some(prev_header) = current_header.take() { + let section_node = + flush_section(&cursor, prev_header, current_body.take().unwrap()); + root_children.push(SyntaxElement::Node(section_node)); + } + + current_body = Some(SectionBody::new(header_info.kind)); + current_header = Some(header_info); + entry_indent = None; + cursor.line += 2; // skip header + underline + continue; + } + + if let Some(ref mut body) = current_body { + body.process_line(&cursor, &mut entry_indent); + } else { + // Stray line + root_children.push(SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::STRAY_LINE, + cursor.current_trimmed_range(), + ))); + } + + cursor.advance(); + } + + // Flush final section + if let Some(header) = current_header.take() { + let section_node = flush_section(&cursor, header, current_body.take().unwrap()); + root_children.push(SyntaxElement::Node(section_node)); + } + + let root = SyntaxNode::new( + SyntaxKind::NUMPY_DOCSTRING, + cursor.full_range(), + root_children, + ); + Parsed::new(input.to_string(), root) +} + +fn flush_section(cursor: &LineCursor, header: SectionHeaderInfo, body: SectionBody) -> SyntaxNode { + let header_start = header.range.start().raw() as usize; + let section_range = cursor.span_back_from_cursor(header_start); + + let mut section_children = Vec::new(); + section_children.push(SyntaxElement::Node(build_section_header_node(&header))); + section_children.extend(body.into_children()); + + SyntaxNode::new(SyntaxKind::NUMPY_SECTION, section_range, section_children) } // ============================================================================= @@ -977,56 +1195,33 @@ mod tests { } #[test] - fn test_try_parse_numpy_header() { + fn test_try_detect_header() { let c1 = LineCursor::new("Parameters\n----------"); - assert!(try_parse_numpy_header(&c1).is_some()); + assert!(try_detect_header(&c1).is_some()); assert_eq!( - try_parse_numpy_header(&c1).unwrap().kind, + try_detect_header(&c1).unwrap().kind, NumPySectionKind::Parameters ); - // No section let c2 = LineCursor::new("just text\nmore text"); - assert!(try_parse_numpy_header(&c2).is_none()); + assert!(try_detect_header(&c2).is_none()); - // Empty line before underline — not a header let c3 = LineCursor::new("\n----------"); - assert!(try_parse_numpy_header(&c3).is_none()); + assert!(try_detect_header(&c3).is_none()); - // Single line — no room for underline let c4 = LineCursor::new("Only one line"); - assert!(try_parse_numpy_header(&c4).is_none()); + assert!(try_detect_header(&c4).is_none()); - // Header at non-zero line let mut c5 = LineCursor::new("Parameters\n----------\nx : int\nReturns\n-------"); - assert!(try_parse_numpy_header(&c5).is_some()); + assert!(try_detect_header(&c5).is_some()); c5.line = 3; - assert!(try_parse_numpy_header(&c5).is_some()); + assert!(try_detect_header(&c5).is_some()); assert_eq!( - try_parse_numpy_header(&c5).unwrap().kind, + try_detect_header(&c5).unwrap().kind, NumPySectionKind::Returns ); } - // -- param header detection -- - - /// Check whether `trimmed` looks like a parameter header line. - /// A parameter header contains a colon (not inside brackets). - fn is_param_header(trimmed: &str) -> bool { - find_entry_colon(trimmed).is_some() - } - - #[test] - fn test_is_param_header() { - assert!(is_param_header("x : int")); - assert!(is_param_header("x: int")); - assert!(is_param_header("x:int")); - assert!(is_param_header("x:")); - assert!(!is_param_header("just a name")); - } - - // -- parse_name_and_type -- - #[test] fn test_parse_name_and_type_basic() { let src = "x : int"; @@ -1036,8 +1231,6 @@ mod tests { assert!(p.colon.is_some()); assert_eq!(p.param_type.unwrap().source_text(src), "int"); assert!(p.optional.is_none()); - assert!(p.default_keyword.is_none()); - assert!(p.default_value.is_none()); } #[test] @@ -1051,74 +1244,6 @@ mod tests { assert!(p.optional.is_some()); } - #[test] - fn test_parse_name_and_type_optional_no_space() { - let src = "x : int,optional"; - let cursor = LineCursor::new(src); - let p = parse_name_and_type(src, 0, 0, &cursor); - assert!(p.colon.is_some()); - assert_eq!(p.param_type.unwrap().source_text(src), "int"); - assert!(p.optional.is_some()); - } - - #[test] - fn test_parse_name_and_type_default_space() { - let src = "x : int, default True"; - let cursor = LineCursor::new(src); - let p = parse_name_and_type(src, 0, 0, &cursor); - assert!(p.colon.is_some()); - assert_eq!(p.param_type.unwrap().source_text(src), "int"); - assert_eq!( - p.default_keyword.as_ref().unwrap().source_text(src), - "default" - ); - assert!(p.default_separator.is_none()); // space-separated, no = or : - assert_eq!(p.default_value.unwrap().source_text(src), "True"); - } - - #[test] - fn test_parse_name_and_type_default_equals() { - let src = "x : int, default=True"; - let cursor = LineCursor::new(src); - let p = parse_name_and_type(src, 0, 0, &cursor); - assert_eq!(p.param_type.unwrap().source_text(src), "int"); - assert_eq!( - p.default_keyword.as_ref().unwrap().source_text(src), - "default" - ); - assert_eq!(p.default_separator.as_ref().unwrap().source_text(src), "="); - assert_eq!(p.default_value.unwrap().source_text(src), "True"); - } - - #[test] - fn test_parse_name_and_type_default_colon() { - let src = "x : int, default: True"; - let cursor = LineCursor::new(src); - let p = parse_name_and_type(src, 0, 0, &cursor); - assert_eq!(p.param_type.unwrap().source_text(src), "int"); - assert_eq!( - p.default_keyword.as_ref().unwrap().source_text(src), - "default" - ); - assert_eq!(p.default_separator.as_ref().unwrap().source_text(src), ":"); - assert_eq!(p.default_value.unwrap().source_text(src), "True"); - } - - #[test] - fn test_parse_name_and_type_default_bare() { - // "default" alone with no value - let src = "x : int, default"; - let cursor = LineCursor::new(src); - let p = parse_name_and_type(src, 0, 0, &cursor); - assert_eq!(p.param_type.unwrap().source_text(src), "int"); - assert_eq!( - p.default_keyword.as_ref().unwrap().source_text(src), - "default" - ); - assert!(p.default_separator.is_none()); - assert!(p.default_value.is_none()); - } - #[test] fn test_parse_name_and_type_complex() { let src = "x : Dict[str, int], optional"; @@ -1130,15 +1255,14 @@ mod tests { } #[test] - fn test_parse_name_and_type_no_colon() { - let src = "x"; - let cursor = LineCursor::new(src); - let p = parse_name_and_type(src, 0, 0, &cursor); - assert_eq!(p.names[0].source_text(src), "x"); - assert!(p.colon.is_none()); - assert!(p.param_type.is_none()); - assert!(p.optional.is_none()); - assert!(p.default_keyword.is_none()); - assert!(p.default_value.is_none()); + fn test_basic_parse() { + let input = "Summary.\n\nParameters\n----------\nx : int\n The value.\n"; + let parsed = parse_numpy(input); + let root = parsed.root(); + assert_eq!(root.kind(), SyntaxKind::NUMPY_DOCSTRING); + let summary = root.find_token(SyntaxKind::SUMMARY).unwrap(); + assert_eq!(summary.text(parsed.source()), "Summary."); + let sections: Vec<_> = root.nodes(SyntaxKind::NUMPY_SECTION).collect(); + assert_eq!(sections.len(), 1); } } diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 0000000..8def7fa --- /dev/null +++ b/src/syntax.rs @@ -0,0 +1,674 @@ +//! Unified syntax tree types. +//! +//! This module defines the core tree data structures shared by all docstring +//! styles. Every parsed docstring is represented as a tree of [`SyntaxNode`]s +//! (branches) and [`SyntaxToken`]s (leaves), each tagged with a [`SyntaxKind`]. +//! +//! The [`Parsed`] struct owns the source text and the root node, and provides +//! a convenience [`pretty_print`](Parsed::pretty_print) method for debugging. + +use core::fmt; +use core::fmt::Write; + +use crate::text::TextRange; + +// ============================================================================= +// SyntaxKind +// ============================================================================= + +/// Node and token kinds for all docstring styles. +/// +/// Google and NumPy variants coexist in a single enum, just as Biome puts +/// `JsIfStatement` and `TsInterface` in one `SyntaxKind`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[allow(non_camel_case_types)] +pub enum SyntaxKind { + // ── Common tokens ────────────────────────────────────────────────── + /// Section name, parameter name, exception type name, etc. + NAME, + /// Type annotation. + TYPE, + /// `:` separator. + COLON, + /// Description text. + DESCRIPTION, + /// Opening bracket: `(`, `[`, `{`, or `<`. + OPEN_BRACKET, + /// Closing bracket: `)`, `]`, `}`, or `>`. + CLOSE_BRACKET, + /// `optional` marker. + OPTIONAL, + /// Free-text section body. + BODY_TEXT, + /// Summary line. + SUMMARY, + /// Extended summary paragraph. + EXTENDED_SUMMARY, + /// Stray line between sections. + STRAY_LINE, + + // ── Google-specific tokens ───────────────────────────────────────── + /// Warning type (e.g. `UserWarning`). + WARNING_TYPE, + + // ── NumPy-specific tokens ────────────────────────────────────────── + /// Section header underline (`----------`). + UNDERLINE, + /// RST directive marker (`..`). + DIRECTIVE_MARKER, + /// Keyword such as `deprecated`. + KEYWORD, + /// RST double colon (`::`). + DOUBLE_COLON, + /// Deprecation version string. + VERSION, + /// Return type (NumPy-style). + RETURN_TYPE, + /// `default` keyword. + DEFAULT_KEYWORD, + /// Default value separator (`=` or `:`). + DEFAULT_SEPARATOR, + /// Default value text. + DEFAULT_VALUE, + /// Reference number. + NUMBER, + /// Reference content text. + CONTENT, + + // ── Google nodes ─────────────────────────────────────────────────── + /// Root node for a Google-style docstring. + GOOGLE_DOCSTRING, + /// A complete Google section (header + body items). + GOOGLE_SECTION, + /// Section header (`Args:`, `Returns:`, etc.). + GOOGLE_SECTION_HEADER, + /// A single argument entry. + GOOGLE_ARG, + /// A single return value entry. + GOOGLE_RETURNS, + /// A single exception entry. + GOOGLE_EXCEPTION, + /// A single warning entry. + GOOGLE_WARNING, + /// A single "See Also" item. + GOOGLE_SEE_ALSO_ITEM, + /// A single attribute entry. + GOOGLE_ATTRIBUTE, + /// A single method entry. + GOOGLE_METHOD, + + // ── NumPy nodes ──────────────────────────────────────────────────── + /// Root node for a NumPy-style docstring. + NUMPY_DOCSTRING, + /// A complete NumPy section (header + body items). + NUMPY_SECTION, + /// Section header (name + underline). + NUMPY_SECTION_HEADER, + /// Deprecation directive block. + NUMPY_DEPRECATION, + /// A single parameter entry. + NUMPY_PARAMETER, + /// A single return value entry. + NUMPY_RETURNS, + /// A single exception entry. + NUMPY_EXCEPTION, + /// A single warning entry. + NUMPY_WARNING, + /// A single "See Also" item. + NUMPY_SEE_ALSO_ITEM, + /// A single reference entry. + NUMPY_REFERENCE, + /// A single attribute entry. + NUMPY_ATTRIBUTE, + /// A single method entry. + NUMPY_METHOD, +} + +impl SyntaxKind { + /// Whether this kind represents a node (branch) rather than a token (leaf). + pub const fn is_node(self) -> bool { + matches!( + self, + Self::GOOGLE_DOCSTRING + | Self::GOOGLE_SECTION + | Self::GOOGLE_SECTION_HEADER + | Self::GOOGLE_ARG + | Self::GOOGLE_RETURNS + | Self::GOOGLE_EXCEPTION + | Self::GOOGLE_WARNING + | Self::GOOGLE_SEE_ALSO_ITEM + | Self::GOOGLE_ATTRIBUTE + | Self::GOOGLE_METHOD + | Self::NUMPY_DOCSTRING + | Self::NUMPY_SECTION + | Self::NUMPY_SECTION_HEADER + | Self::NUMPY_DEPRECATION + | Self::NUMPY_PARAMETER + | Self::NUMPY_RETURNS + | Self::NUMPY_EXCEPTION + | Self::NUMPY_WARNING + | Self::NUMPY_SEE_ALSO_ITEM + | Self::NUMPY_REFERENCE + | Self::NUMPY_ATTRIBUTE + | Self::NUMPY_METHOD + ) + } + + /// Whether this kind represents a token (leaf) rather than a node (branch). + pub const fn is_token(self) -> bool { + !self.is_node() + } + + /// Display name for pretty-printing (e.g. `"GOOGLE_ARG"`, `"NAME"`). + pub const fn name(self) -> &'static str { + match self { + // Common tokens + Self::NAME => "NAME", + Self::TYPE => "TYPE", + Self::COLON => "COLON", + Self::DESCRIPTION => "DESCRIPTION", + Self::OPEN_BRACKET => "OPEN_BRACKET", + Self::CLOSE_BRACKET => "CLOSE_BRACKET", + Self::OPTIONAL => "OPTIONAL", + Self::BODY_TEXT => "BODY_TEXT", + Self::SUMMARY => "SUMMARY", + Self::EXTENDED_SUMMARY => "EXTENDED_SUMMARY", + Self::STRAY_LINE => "STRAY_LINE", + // Google tokens + Self::WARNING_TYPE => "WARNING_TYPE", + // NumPy tokens + Self::UNDERLINE => "UNDERLINE", + Self::DIRECTIVE_MARKER => "DIRECTIVE_MARKER", + Self::KEYWORD => "KEYWORD", + Self::DOUBLE_COLON => "DOUBLE_COLON", + Self::VERSION => "VERSION", + Self::RETURN_TYPE => "RETURN_TYPE", + Self::DEFAULT_KEYWORD => "DEFAULT_KEYWORD", + Self::DEFAULT_SEPARATOR => "DEFAULT_SEPARATOR", + Self::DEFAULT_VALUE => "DEFAULT_VALUE", + Self::NUMBER => "NUMBER", + Self::CONTENT => "CONTENT", + // Google nodes + Self::GOOGLE_DOCSTRING => "GOOGLE_DOCSTRING", + Self::GOOGLE_SECTION => "GOOGLE_SECTION", + Self::GOOGLE_SECTION_HEADER => "GOOGLE_SECTION_HEADER", + Self::GOOGLE_ARG => "GOOGLE_ARG", + Self::GOOGLE_RETURNS => "GOOGLE_RETURNS", + Self::GOOGLE_EXCEPTION => "GOOGLE_EXCEPTION", + Self::GOOGLE_WARNING => "GOOGLE_WARNING", + Self::GOOGLE_SEE_ALSO_ITEM => "GOOGLE_SEE_ALSO_ITEM", + Self::GOOGLE_ATTRIBUTE => "GOOGLE_ATTRIBUTE", + Self::GOOGLE_METHOD => "GOOGLE_METHOD", + // NumPy nodes + Self::NUMPY_DOCSTRING => "NUMPY_DOCSTRING", + Self::NUMPY_SECTION => "NUMPY_SECTION", + Self::NUMPY_SECTION_HEADER => "NUMPY_SECTION_HEADER", + Self::NUMPY_DEPRECATION => "NUMPY_DEPRECATION", + Self::NUMPY_PARAMETER => "NUMPY_PARAMETER", + Self::NUMPY_RETURNS => "NUMPY_RETURNS", + Self::NUMPY_EXCEPTION => "NUMPY_EXCEPTION", + Self::NUMPY_WARNING => "NUMPY_WARNING", + Self::NUMPY_SEE_ALSO_ITEM => "NUMPY_SEE_ALSO_ITEM", + Self::NUMPY_REFERENCE => "NUMPY_REFERENCE", + Self::NUMPY_ATTRIBUTE => "NUMPY_ATTRIBUTE", + Self::NUMPY_METHOD => "NUMPY_METHOD", + } + } +} + +impl fmt::Display for SyntaxKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +// ============================================================================= +// SyntaxNode / SyntaxToken / SyntaxElement +// ============================================================================= + +/// A branch node in the syntax tree. +/// +/// Holds an ordered list of child [`SyntaxElement`]s (nodes or tokens). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxNode { + kind: SyntaxKind, + range: TextRange, + children: Vec, +} + +impl SyntaxNode { + /// Creates a new node with the given kind, range, and children. + pub fn new(kind: SyntaxKind, range: TextRange, children: Vec) -> Self { + Self { + kind, + range, + children, + } + } + + /// The kind of this node. + pub fn kind(&self) -> SyntaxKind { + self.kind + } + + /// The source range of this node. + pub fn range(&self) -> &TextRange { + &self.range + } + + /// The ordered child elements. + pub fn children(&self) -> &[SyntaxElement] { + &self.children + } + + /// Mutable access to the ordered child elements. + pub fn children_mut(&mut self) -> &mut [SyntaxElement] { + &mut self.children + } + + /// Append a child element. + pub fn push_child(&mut self, child: SyntaxElement) { + self.children.push(child); + } + + /// Extend this node's range end to `end`. + pub fn extend_range_to(&mut self, end: crate::text::TextSize) { + self.range = TextRange::new(self.range.start(), end); + } + + /// Find the first token child with the given kind. + pub fn find_token(&self, kind: SyntaxKind) -> Option<&SyntaxToken> { + self.children.iter().find_map(|c| match c { + SyntaxElement::Token(t) if t.kind() == kind => Some(t), + _ => None, + }) + } + + /// Return the first token child with the given kind. + /// + /// # Panics + /// + /// Panics if no such token exists. This should only be used for tokens + /// that the parser guarantees to be present. + pub fn required_token(&self, kind: SyntaxKind) -> &SyntaxToken { + self.find_token(kind) + .unwrap_or_else(|| panic!("required token {:?} not found in {:?}", kind, self.kind)) + } + + /// Iterate over all token children with the given kind. + pub fn tokens(&self, kind: SyntaxKind) -> impl Iterator { + self.children.iter().filter_map(move |c| match c { + SyntaxElement::Token(t) if t.kind() == kind => Some(t), + _ => None, + }) + } + + /// Find the first child node with the given kind. + pub fn find_node(&self, kind: SyntaxKind) -> Option<&SyntaxNode> { + self.children.iter().find_map(|c| match c { + SyntaxElement::Node(n) if n.kind() == kind => Some(n), + _ => None, + }) + } + + /// Iterate over all child nodes with the given kind. + pub fn nodes(&self, kind: SyntaxKind) -> impl Iterator { + self.children.iter().filter_map(move |c| match c { + SyntaxElement::Node(n) if n.kind() == kind => Some(n), + _ => None, + }) + } + + /// Write a Biome-style pretty-printed tree representation. + pub fn pretty_fmt(&self, src: &str, indent: usize, out: &mut String) { + pad(out, indent); + let _ = writeln!(out, "{}@{} {{", self.kind.name(), self.range); + for child in &self.children { + match child { + SyntaxElement::Node(n) => n.pretty_fmt(src, indent + 1, out), + SyntaxElement::Token(t) => t.pretty_fmt(src, indent + 1, out), + } + } + pad(out, indent); + out.push_str("}\n"); + } +} + +/// A leaf token in the syntax tree. +/// +/// Represents a contiguous span of source text with a known kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxToken { + kind: SyntaxKind, + range: TextRange, +} + +impl SyntaxToken { + /// Creates a new token with the given kind and range. + pub fn new(kind: SyntaxKind, range: TextRange) -> Self { + Self { kind, range } + } + + /// The kind of this token. + pub fn kind(&self) -> SyntaxKind { + self.kind + } + + /// The source range of this token. + pub fn range(&self) -> &TextRange { + &self.range + } + + /// Extract the corresponding text slice from source. + pub fn text<'a>(&self, source: &'a str) -> &'a str { + self.range.source_text(source) + } + + /// Extend this token's range to include `other`. + pub fn extend_range(&mut self, other: TextRange) { + self.range.extend(other); + } + + /// Write a Biome-style pretty-printed token line. + pub fn pretty_fmt(&self, src: &str, indent: usize, out: &mut String) { + pad(out, indent); + let _ = writeln!( + out, + "{}: {:?}@{}", + self.kind.name(), + self.text(src), + self.range + ); + } +} + +/// A child element of a [`SyntaxNode`] — either a node or a token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyntaxElement { + /// A branch node. + Node(SyntaxNode), + /// A leaf token. + Token(SyntaxToken), +} + +impl SyntaxElement { + /// The source range of this element. + pub fn range(&self) -> &TextRange { + match self { + Self::Node(n) => n.range(), + Self::Token(t) => t.range(), + } + } + + /// The kind of this element. + pub fn kind(&self) -> SyntaxKind { + match self { + Self::Node(n) => n.kind(), + Self::Token(t) => t.kind(), + } + } +} + +// ============================================================================= +// Parsed +// ============================================================================= + +/// The result of parsing a docstring. +/// +/// Owns the source text and the root [`SyntaxNode`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parsed { + source: String, + root: SyntaxNode, +} + +impl Parsed { + /// Creates a new `Parsed` from source text and root node. + pub fn new(source: String, root: SyntaxNode) -> Self { + Self { source, root } + } + + /// The full source text. + pub fn source(&self) -> &str { + &self.source + } + + /// The root node of the syntax tree. + pub fn root(&self) -> &SyntaxNode { + &self.root + } + + /// Produce a Biome-style pretty-printed representation of the tree. + pub fn pretty_print(&self) -> String { + let mut out = String::new(); + self.root.pretty_fmt(&self.source, 0, &mut out); + out + } +} + +// ============================================================================= +// Visitor +// ============================================================================= + +/// Trait for visiting syntax tree nodes and tokens. +/// +/// Implement this trait and pass it to [`walk`] for depth-first traversal. +pub trait Visitor { + /// Called when entering a node (before visiting its children). + fn enter(&mut self, _node: &SyntaxNode) {} + /// Called when leaving a node (after visiting its children). + fn leave(&mut self, _node: &SyntaxNode) {} + /// Called for each token leaf. + fn visit_token(&mut self, _token: &SyntaxToken) {} +} + +/// Walk the syntax tree depth-first, calling the visitor methods. +pub fn walk(node: &SyntaxNode, visitor: &mut dyn Visitor) { + visitor.enter(node); + for child in node.children() { + match child { + SyntaxElement::Node(n) => walk(n, visitor), + SyntaxElement::Token(t) => visitor.visit_token(t), + } + } + visitor.leave(node); +} + +// ============================================================================= +// Pretty-print helper +// ============================================================================= + +/// Write indentation (4 spaces per level). +fn pad(out: &mut String, indent: usize) { + for _ in 0..indent { + out.push_str(" "); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::text::{TextRange, TextSize}; + + #[test] + fn test_syntax_kind_name() { + assert_eq!(SyntaxKind::GOOGLE_ARG.name(), "GOOGLE_ARG"); + assert_eq!(SyntaxKind::NAME.name(), "NAME"); + assert_eq!(SyntaxKind::NUMPY_PARAMETER.name(), "NUMPY_PARAMETER"); + } + + #[test] + fn test_syntax_kind_is_node_is_token() { + assert!(SyntaxKind::GOOGLE_DOCSTRING.is_node()); + assert!(!SyntaxKind::GOOGLE_DOCSTRING.is_token()); + assert!(SyntaxKind::NAME.is_token()); + assert!(!SyntaxKind::NAME.is_node()); + } + + #[test] + fn test_syntax_token_text() { + let source = "hello world"; + let token = SyntaxToken::new( + SyntaxKind::NAME, + TextRange::new(TextSize::new(0), TextSize::new(5)), + ); + assert_eq!(token.text(source), "hello"); + } + + #[test] + fn test_syntax_node_find_token() { + let node = SyntaxNode::new( + SyntaxKind::GOOGLE_ARG, + TextRange::new(TextSize::new(0), TextSize::new(10)), + vec![ + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + TextRange::new(TextSize::new(0), TextSize::new(3)), + )), + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + TextRange::new(TextSize::new(3), TextSize::new(4)), + )), + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + TextRange::new(TextSize::new(5), TextSize::new(10)), + )), + ], + ); + + assert!(node.find_token(SyntaxKind::NAME).is_some()); + assert!(node.find_token(SyntaxKind::COLON).is_some()); + assert!(node.find_token(SyntaxKind::TYPE).is_none()); + assert_eq!(node.tokens(SyntaxKind::NAME).count(), 1); + } + + #[test] + fn test_syntax_node_find_node() { + let child = SyntaxNode::new( + SyntaxKind::GOOGLE_SECTION_HEADER, + TextRange::new(TextSize::new(0), TextSize::new(5)), + vec![SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + TextRange::new(TextSize::new(0), TextSize::new(4)), + ))], + ); + let parent = SyntaxNode::new( + SyntaxKind::GOOGLE_SECTION, + TextRange::new(TextSize::new(0), TextSize::new(20)), + vec![SyntaxElement::Node(child)], + ); + + assert!( + parent + .find_node(SyntaxKind::GOOGLE_SECTION_HEADER) + .is_some() + ); + assert!(parent.find_node(SyntaxKind::GOOGLE_ARG).is_none()); + assert_eq!(parent.nodes(SyntaxKind::GOOGLE_SECTION_HEADER).count(), 1); + } + + #[test] + fn test_pretty_print() { + let source = "Args:\n x: int"; + let root = SyntaxNode::new( + SyntaxKind::GOOGLE_DOCSTRING, + TextRange::new(TextSize::new(0), TextSize::new(source.len() as u32)), + vec![SyntaxElement::Node(SyntaxNode::new( + SyntaxKind::GOOGLE_SECTION, + TextRange::new(TextSize::new(0), TextSize::new(source.len() as u32)), + vec![ + SyntaxElement::Node(SyntaxNode::new( + SyntaxKind::GOOGLE_SECTION_HEADER, + TextRange::new(TextSize::new(0), TextSize::new(5)), + vec![ + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + TextRange::new(TextSize::new(0), TextSize::new(4)), + )), + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + TextRange::new(TextSize::new(4), TextSize::new(5)), + )), + ], + )), + SyntaxElement::Node(SyntaxNode::new( + SyntaxKind::GOOGLE_ARG, + TextRange::new(TextSize::new(10), TextSize::new(source.len() as u32)), + vec![ + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::NAME, + TextRange::new(TextSize::new(10), TextSize::new(11)), + )), + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::COLON, + TextRange::new(TextSize::new(11), TextSize::new(12)), + )), + SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::DESCRIPTION, + TextRange::new( + TextSize::new(13), + TextSize::new(source.len() as u32), + ), + )), + ], + )), + ], + ))], + ); + + let parsed = Parsed::new(source.to_string(), root); + let output = parsed.pretty_print(); + + // Verify structure is present + assert!(output.contains("GOOGLE_DOCSTRING@")); + assert!(output.contains("GOOGLE_SECTION@")); + assert!(output.contains("GOOGLE_SECTION_HEADER@")); + assert!(output.contains("GOOGLE_ARG@")); + assert!(output.contains("NAME: \"Args\"@")); + assert!(output.contains("COLON: \":\"@")); + assert!(output.contains("NAME: \"x\"@")); + assert!(output.contains("DESCRIPTION: \"int\"@")); + } + + #[test] + fn test_visitor_walk() { + let source = "hello"; + let root = SyntaxNode::new( + SyntaxKind::GOOGLE_DOCSTRING, + TextRange::new(TextSize::new(0), TextSize::new(5)), + vec![SyntaxElement::Token(SyntaxToken::new( + SyntaxKind::SUMMARY, + TextRange::new(TextSize::new(0), TextSize::new(5)), + ))], + ); + + struct Counter { + nodes: usize, + tokens: usize, + } + impl Visitor for Counter { + fn enter(&mut self, _node: &SyntaxNode) { + self.nodes += 1; + } + fn visit_token(&mut self, _token: &SyntaxToken) { + self.tokens += 1; + } + } + + let mut counter = Counter { + nodes: 0, + tokens: 0, + }; + walk(&root, &mut counter); + assert_eq!(counter.nodes, 1); + assert_eq!(counter.tokens, 1); + + // verify text extraction + let tok = root.required_token(SyntaxKind::SUMMARY); + assert_eq!(tok.text(source), "hello"); + } +} diff --git a/src/text.rs b/src/text.rs index 8d8eaea..3644b58 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,19 +1,19 @@ -//! Source location primitives. +//! Source location types (offset-only). //! -//! This module provides [`TextSize`] and [`TextRange`] for offset-based -//! source location tracking (inspired by ruff's `text-size` crate). +//! This module provides [`TextSize`] (a byte offset) and [`TextRange`] +//! (a half-open byte range) for tracking source positions. +//! Inspired by ruff / rust-analyzer's `text-size` crate. use core::fmt; use core::ops; // ============================================================================= -// Source location types (ruff-style, offset-only) +// TextSize // ============================================================================= /// A byte offset in the source text. /// /// Newtype over `u32` for type safety (prevents mixing with line numbers, etc.). -/// Inspired by ruff's `TextSize` (from the `text-size` crate). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub struct TextSize(u32); @@ -73,9 +73,13 @@ impl fmt::Display for TextSize { } } +// ============================================================================= +// TextRange +// ============================================================================= + /// A range in the source text `[start, end)`, represented as byte offsets. /// -/// Stores only offsets. Inspired by ruff's `TextRange`. +/// Stores only offsets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct TextRange { start: TextSize, @@ -154,3 +158,9 @@ impl TextRange { } } } + +impl fmt::Display for TextRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}..{}", self.start, self.end) + } +} diff --git a/tests/google/args.rs b/tests/google/args.rs index 7610b1e..c0416af 100644 --- a/tests/google/args.rs +++ b/tests/google/args.rs @@ -10,16 +10,10 @@ fn test_args_basic() { let result = parse_google(docstring); let a = args(&result); assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "int"); assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The value." ); } @@ -30,16 +24,10 @@ fn test_args_multiple() { let result = parse_google(docstring); let a = args(&result); assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!(a[1].name.source_text(&result.source), "y"); - assert_eq!( - a[1].r#type.as_ref().unwrap().source_text(&result.source), - "str" - ); + assert_eq!(a[0].name().text(result.source()), "x"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "int"); + assert_eq!(a[1].name().text(result.source()), "y"); + assert_eq!(a[1].r#type().unwrap().text(result.source()), "str"); } #[test] @@ -47,13 +35,10 @@ fn test_args_no_type() { let docstring = "Summary.\n\nArgs:\n x: The value."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert!(a[0].r#type.is_none()); + assert_eq!(a[0].name().text(result.source()), "x"); + assert!(a[0].r#type().is_none()); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The value." ); } @@ -64,12 +49,9 @@ fn test_args_no_space_after_colon() { let docstring = "Summary.\n\nArgs:\n x:The value."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The value." ); } @@ -80,12 +62,9 @@ fn test_args_extra_spaces_after_colon() { let docstring = "Summary.\n\nArgs:\n x: The value."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The value." ); } @@ -95,12 +74,9 @@ fn test_args_optional() { let docstring = "Summary.\n\nArgs:\n x (int, optional): The value."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert!(a[0].optional.is_some()); + assert_eq!(a[0].name().text(result.source()), "x"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "int"); + assert!(a[0].optional().is_some()); } #[test] @@ -108,11 +84,7 @@ fn test_args_complex_type() { let docstring = "Summary.\n\nArgs:\n data (Dict[str, List[int]]): The data."; let result = parse_google(docstring); assert_eq!( - args(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), + args(&result)[0].r#type().unwrap().text(result.source()), "Dict[str, List[int]]" ); } @@ -122,11 +94,7 @@ fn test_args_tuple_type() { let docstring = "Summary.\n\nArgs:\n pair (Tuple[int, str]): A pair of values."; let result = parse_google(docstring); assert_eq!( - args(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), + args(&result)[0].r#type().unwrap().text(result.source()), "Tuple[int, str]" ); } @@ -138,10 +106,9 @@ fn test_args_multiline_description() { let result = parse_google(docstring); assert_eq!( args(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "First line.\n Second line.\n Third line." ); } @@ -151,16 +118,10 @@ fn test_args_description_on_next_line() { let docstring = "Summary.\n\nArgs:\n x (int):\n The description."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); + assert_eq!(a[0].name().text(result.source()), "x"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "int"); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The description." ); } @@ -171,20 +132,14 @@ fn test_args_varargs() { let result = parse_google(docstring); let a = args(&result); assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "*args"); + assert_eq!(a[0].name().text(result.source()), "*args"); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "Positional args." ); - assert_eq!(a[1].name.source_text(&result.source), "**kwargs"); + assert_eq!(a[1].name().text(result.source()), "**kwargs"); assert_eq!( - a[1].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[1].description().unwrap().text(result.source()), "Keyword args." ); } @@ -194,11 +149,8 @@ fn test_args_kwargs_with_type() { let docstring = "Summary.\n\nArgs:\n **kwargs (dict): Keyword arguments."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "**kwargs"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "dict" - ); + assert_eq!(a[0].name().text(result.source()), "**kwargs"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "dict"); } // ============================================================================= @@ -211,7 +163,7 @@ fn test_arguments_alias() { let result = parse_google(docstring); let a = args(&result); assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); } #[test] @@ -219,15 +171,12 @@ fn test_parameters_alias() { let docstring = "Summary.\n\nParameters:\n x (int): The value."; let result = parse_google(docstring); assert_eq!(args(&result).len(), 1); - assert_eq!(args(&result)[0].name.source_text(&result.source), "x"); + assert_eq!(args(&result)[0].name().text(result.source()), "x"); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Parameters" ); } @@ -238,13 +187,10 @@ fn test_params_alias() { let result = parse_google(docstring); assert_eq!(args(&result).len(), 1); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Params" ); } @@ -258,10 +204,14 @@ fn test_args_name_span() { let docstring = "Summary.\n\nArgs:\n x (int): Value."; let result = parse_google(docstring); let arg = &args(&result)[0]; + let name = arg.name(); // "x" starts at byte offset 20 (line 3, col 4) - assert_eq!(arg.name.start(), TextSize::new(20)); - assert_eq!(arg.name.end(), TextSize::new(arg.name.start().raw() + 1)); - assert_eq!(arg.name.source_text(&result.source), "x"); + assert_eq!(name.range().start(), TextSize::new(20)); + assert_eq!( + name.range().end(), + TextSize::new(name.range().start().raw() + 1) + ); + assert_eq!(name.text(result.source()), "x"); } #[test] @@ -269,18 +219,18 @@ fn test_args_type_span() { let docstring = "Summary.\n\nArgs:\n x (int): Value."; let result = parse_google(docstring); let arg = &args(&result)[0]; - let type_span = arg.r#type.as_ref().unwrap(); + let type_token = arg.r#type().unwrap(); // "int" starts at byte offset 23 (line 3) - assert_eq!(type_span.start(), TextSize::new(23)); - assert_eq!(type_span.source_text(&result.source), "int"); + assert_eq!(type_token.range().start(), TextSize::new(23)); + assert_eq!(type_token.text(result.source()), "int"); } #[test] fn test_args_optional_span() { let docstring = "Summary.\n\nArgs:\n x (int, optional): Value."; let result = parse_google(docstring); - let opt_span = args(&result)[0].optional.as_ref().unwrap(); - assert_eq!(opt_span.source_text(&result.source), "optional"); + let opt_token = args(&result)[0].optional().unwrap(); + assert_eq!(opt_token.text(result.source()), "optional"); } // ============================================================================= @@ -292,26 +242,11 @@ fn test_args_square_bracket_type() { let docstring = "Summary.\n\nArgs:\n x [int]: The value."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "[" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "]" - ); - assert_eq!( - a.description.as_ref().unwrap().source_text(&result.source), - "The value." - ); + assert_eq!(a.name().text(result.source()), "x"); + assert_eq!(a.r#type().unwrap().text(result.source()), "int"); + assert_eq!(a.open_bracket().unwrap().text(result.source()), "["); + assert_eq!(a.close_bracket().unwrap().text(result.source()), "]"); + assert_eq!(a.description().unwrap().text(result.source()), "The value."); } #[test] @@ -319,26 +254,11 @@ fn test_args_curly_bracket_type() { let docstring = "Summary.\n\nArgs:\n x {int}: The value."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "{" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "}" - ); - assert_eq!( - a.description.as_ref().unwrap().source_text(&result.source), - "The value." - ); + assert_eq!(a.name().text(result.source()), "x"); + assert_eq!(a.r#type().unwrap().text(result.source()), "int"); + assert_eq!(a.open_bracket().unwrap().text(result.source()), "{"); + assert_eq!(a.close_bracket().unwrap().text(result.source()), "}"); + assert_eq!(a.description().unwrap().text(result.source()), "The value."); } #[test] @@ -346,28 +266,8 @@ fn test_args_paren_bracket_spans() { let docstring = "Summary.\n\nArgs:\n x (int): The value."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "(" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "(" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - ")" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - ")" - ); + assert_eq!(a.open_bracket().unwrap().text(result.source()), "("); + assert_eq!(a.close_bracket().unwrap().text(result.source()), ")"); } #[test] @@ -375,9 +275,9 @@ fn test_args_no_bracket_fields_when_no_type() { let docstring = "Summary.\n\nArgs:\n x: The value."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert!(a.open_bracket.is_none()); - assert!(a.close_bracket.is_none()); - assert!(a.r#type.is_none()); + assert!(a.open_bracket().is_none()); + assert!(a.close_bracket().is_none()); + assert!(a.r#type().is_none()); } #[test] @@ -385,23 +285,11 @@ fn test_args_square_bracket_optional() { let docstring = "Summary.\n\nArgs:\n x [int, optional]: The value."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "[" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "]" - ); - assert!(a.optional.is_some()); + assert_eq!(a.name().text(result.source()), "x"); + assert_eq!(a.r#type().unwrap().text(result.source()), "int"); + assert_eq!(a.open_bracket().unwrap().text(result.source()), "["); + assert_eq!(a.close_bracket().unwrap().text(result.source()), "]"); + assert!(a.optional().is_some()); } #[test] @@ -409,22 +297,10 @@ fn test_args_square_bracket_complex_type() { let docstring = "Summary.\n\nArgs:\n items [List[int]]: The items."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "items"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "List[int]" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "[" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - "]" - ); + assert_eq!(a.name().text(result.source()), "items"); + assert_eq!(a.r#type().unwrap().text(result.source()), "List[int]"); + assert_eq!(a.open_bracket().unwrap().text(result.source()), "["); + assert_eq!(a.close_bracket().unwrap().text(result.source()), "]"); } #[test] @@ -432,26 +308,11 @@ fn test_args_angle_bracket_type() { let docstring = "Summary.\n\nArgs:\n x : The value."; let result = parse_google(docstring); let a = &args(&result)[0]; - assert_eq!(a.name.source_text(&result.source), "x"); - assert_eq!( - a.r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - a.open_bracket.as_ref().unwrap().source_text(&result.source), - "<" - ); - assert_eq!( - a.close_bracket - .as_ref() - .unwrap() - .source_text(&result.source), - ">" - ); - assert_eq!( - a.description.as_ref().unwrap().source_text(&result.source), - "The value." - ); + assert_eq!(a.name().text(result.source()), "x"); + assert_eq!(a.r#type().unwrap().text(result.source()), "int"); + assert_eq!(a.open_bracket().unwrap().text(result.source()), "<"); + assert_eq!(a.close_bracket().unwrap().text(result.source()), ">"); + assert_eq!(a.description().unwrap().text(result.source()), "The value."); } // ============================================================================= @@ -463,9 +324,9 @@ fn test_optional_only_in_parens() { let docstring = "Summary.\n\nArgs:\n x (optional): Value."; let result = parse_google(docstring); let a = args(&result); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert!(a[0].r#type.is_none()); - assert!(a[0].optional.is_some()); + assert_eq!(a[0].name().text(result.source()), "x"); + assert!(a[0].r#type().is_none()); + assert!(a[0].optional().is_some()); } #[test] @@ -473,11 +334,8 @@ fn test_complex_optional_type() { let docstring = "Summary.\n\nArgs:\n x (List[int], optional): Values."; let result = parse_google(docstring); let a = args(&result); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "List[int]" - ); - assert!(a[0].optional.is_some()); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "List[int]"); + assert!(a[0].optional().is_some()); } // ============================================================================= @@ -490,12 +348,9 @@ fn test_keyword_args_basic() { let result = parse_google(docstring); let ka = keyword_args(&result); assert_eq!(ka.len(), 2); - assert_eq!(ka[0].name.source_text(&result.source), "timeout"); - assert_eq!( - ka[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!(ka[1].name.source_text(&result.source), "retries"); + assert_eq!(ka[0].name().text(result.source()), "timeout"); + assert_eq!(ka[0].r#type().unwrap().text(result.source()), "int"); + assert_eq!(ka[1].name().text(result.source()), "retries"); } #[test] @@ -504,13 +359,10 @@ fn test_keyword_arguments_alias() { let result = parse_google(docstring); assert_eq!(keyword_args(&result).len(), 1); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Keyword Arguments" ); } @@ -519,12 +371,12 @@ fn test_keyword_arguments_alias() { fn test_keyword_args_section_body_variant() { let docstring = "Summary.\n\nKeyword Args:\n k (str): Key."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::KeywordArgs(args) => { - assert_eq!(args.len(), 1); - } - _ => panic!("Expected KeywordArgs section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::KeywordArgs + ); + assert_eq!(sections[0].args().count(), 1); } // ============================================================================= @@ -537,21 +389,21 @@ fn test_other_parameters() { let result = parse_google(docstring); let op = other_parameters(&result); assert_eq!(op.len(), 2); - assert_eq!(op[0].name.source_text(&result.source), "debug"); - assert_eq!(op[1].name.source_text(&result.source), "verbose"); - assert!(op[1].optional.is_some()); + assert_eq!(op[0].name().text(result.source()), "debug"); + assert_eq!(op[1].name().text(result.source()), "verbose"); + assert!(op[1].optional().is_some()); } #[test] fn test_other_parameters_section_body_variant() { let docstring = "Summary.\n\nOther Parameters:\n x (int): Extra."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::OtherParameters(args) => { - assert_eq!(args.len(), 1); - } - _ => panic!("Expected OtherParameters section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::OtherParameters + ); + assert_eq!(sections[0].args().count(), 1); } // ============================================================================= @@ -564,11 +416,8 @@ fn test_receives() { let result = parse_google(docstring); let r = receives(&result); assert_eq!(r.len(), 1); - assert_eq!(r[0].name.source_text(&result.source), "data"); - assert_eq!( - r[0].r#type.as_ref().unwrap().source_text(&result.source), - "bytes" - ); + assert_eq!(r[0].name().text(result.source()), "data"); + assert_eq!(r[0].r#type().unwrap().text(result.source()), "bytes"); } #[test] @@ -577,13 +426,10 @@ fn test_receive_alias() { let result = parse_google(docstring); assert_eq!(receives(&result).len(), 1); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Receive" ); } @@ -598,14 +444,7 @@ fn test_docstring_like_parameters() { let result = parse_google(docstring); let params = args(&result); assert_eq!(params.len(), 2); - assert_eq!(params[0].name.source_text(&result.source), "x"); - assert_eq!( - params[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); - assert_eq!(params[1].name.source_text(&result.source), "y"); + assert_eq!(params[0].name().text(result.source()), "x"); + assert_eq!(params[0].r#type().unwrap().text(result.source()), "int"); + assert_eq!(params[1].name().text(result.source()), "y"); } diff --git a/tests/google/edge_cases.rs b/tests/google/edge_cases.rs index f2c1fe8..e7e2a50 100644 --- a/tests/google/edge_cases.rs +++ b/tests/google/edge_cases.rs @@ -9,28 +9,23 @@ fn test_indented_docstring() { let docstring = " Summary.\n\n Args:\n x (int): Value."; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary." ); let a = args(&result); assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); + assert_eq!(a[0].name().text(result.source()), "x"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "int"); } #[test] fn test_indented_summary_span() { let docstring = " Summary."; let result = parse_google(docstring); - assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(4)); - assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(12)); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Summary." - ); + let s = doc(&result).summary().unwrap(); + assert_eq!(s.range().start(), TextSize::new(4)); + assert_eq!(s.range().end(), TextSize::new(12)); + assert_eq!(s.text(result.source()), "Summary."); } // ============================================================================= @@ -42,30 +37,18 @@ fn test_indented_summary_span() { fn test_section_header_space_before_colon() { let input = "Summary.\n\nArgs :\n x (int): The value."; let result = parse_google(input); - let doc = &result; - let a = args(doc); + let a = args(&result); assert_eq!(a.len(), 1, "expected 1 arg from 'Args :'"); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); assert_eq!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Args" ); - assert!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .colon - .is_some() - ); + assert!(all_sections(&result)[0].header().colon().is_some()); } /// `Returns :` with space before colon. @@ -73,12 +56,8 @@ fn test_section_header_space_before_colon() { fn test_returns_space_before_colon() { let input = "Summary.\n\nReturns :\n int: The result."; let result = parse_google(input); - let doc = &result; - let r = returns(doc).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); + let r = returns(&result).unwrap(); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); } /// Colonless `Args` should be parsed as Args section. @@ -86,30 +65,18 @@ fn test_returns_space_before_colon() { fn test_section_header_no_colon() { let input = "Summary.\n\nArgs\n x (int): The value."; let result = parse_google(input); - let doc = &result; - let a = args(doc); + let a = args(&result); assert_eq!(a.len(), 1, "expected 1 arg from colonless 'Args'"); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); assert_eq!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Args" ); - assert!( - all_sections(doc) - .into_iter() - .next() - .unwrap() - .header - .colon - .is_none() - ); + assert!(all_sections(&result)[0].header().colon().is_none()); } /// Colonless `Returns` should be parsed as Returns section. @@ -117,12 +84,8 @@ fn test_section_header_no_colon() { fn test_returns_no_colon() { let input = "Summary.\n\nReturns\n int: The result."; let result = parse_google(input); - let doc = &result; - let r = returns(doc).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); + let r = returns(&result).unwrap(); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); } /// Colonless `Raises` should be parsed as Raises section. @@ -130,10 +93,9 @@ fn test_returns_no_colon() { fn test_raises_no_colon() { let input = "Summary.\n\nRaises\n ValueError: If invalid."; let result = parse_google(input); - let doc = &result; - let r = raises(doc); + let r = raises(&result); assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); } /// Unknown names without colon should NOT be treated as headers. @@ -141,9 +103,8 @@ fn test_raises_no_colon() { fn test_unknown_name_without_colon_not_header() { let input = "Summary.\n\nSomeWord\n x (int): value."; let result = parse_google(input); - let doc = &result; assert!( - all_sections(doc).is_empty(), + all_sections(&result).is_empty(), "unknown colonless name should not become a section" ); } @@ -153,10 +114,9 @@ fn test_unknown_name_without_colon_not_header() { fn test_mixed_colon_styles() { let input = "Summary.\n\nArgs:\n x: value.\n\nReturns\n int: result.\n\nRaises :\n ValueError: If bad."; let result = parse_google(input); - let doc = &result; - assert_eq!(args(doc).len(), 1); - assert!(returns(doc).is_some()); - assert_eq!(raises(doc).len(), 1); + assert_eq!(args(&result).len(), 1); + assert!(returns(&result).is_some()); + assert_eq!(raises(&result).len(), 1); } // ============================================================================= @@ -170,20 +130,14 @@ fn test_tab_indented_args() { let result = parse_google(input); let a = args(&result); assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The value." ); - assert_eq!(a[1].name.source_text(&result.source), "y"); + assert_eq!(a[1].name().text(result.source()), "y"); assert_eq!( - a[1].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[1].description().unwrap().text(result.source()), "Another value." ); } @@ -195,12 +149,8 @@ fn test_tab_args_with_continuation() { let result = parse_google(input); let a = args(&result); assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "x"); - let desc = a[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); + assert_eq!(a[0].name().text(result.source()), "x"); + let desc = a[0].description().unwrap().text(result.source()); assert!(desc.contains("First line."), "desc = {:?}", desc); assert!(desc.contains("Continuation."), "desc = {:?}", desc); } @@ -213,9 +163,9 @@ fn test_tab_indented_returns() { let r = returns(&result); assert!(r.is_some()); let r = r.unwrap(); - assert_eq!(r.return_type.unwrap().source_text(&result.source), "int"); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), + r.description().unwrap().text(result.source()), "The result." ); } @@ -227,8 +177,8 @@ fn test_tab_indented_raises() { let result = parse_google(input); let r = raises(&result); assert_eq!(r.len(), 2); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!(r[1].r#type.source_text(&result.source), "TypeError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); + assert_eq!(r[1].r#type().text(result.source()), "TypeError"); } /// Section header detection with tab indentation matches. @@ -238,5 +188,5 @@ fn test_tab_indented_section_header() { let result = parse_google(input); let a = args(&result); assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "x"); + assert_eq!(a[0].name().text(result.source()), "x"); } diff --git a/tests/google/freetext.rs b/tests/google/freetext.rs index 635c88b..491d7f7 100644 --- a/tests/google/freetext.rs +++ b/tests/google/freetext.rs @@ -9,7 +9,7 @@ fn test_note_section() { let docstring = "Summary.\n\nNote:\n This is a note."; let result = parse_google(docstring); assert_eq!( - notes(&result).unwrap().source_text(&result.source), + notes(&result).unwrap().text(result.source()), "This is a note." ); } @@ -19,7 +19,7 @@ fn test_notes_alias() { let docstring = "Summary.\n\nNotes:\n This is also a note."; let result = parse_google(docstring); assert_eq!( - notes(&result).unwrap().source_text(&result.source), + notes(&result).unwrap().text(result.source()), "This is also a note." ); } @@ -33,7 +33,7 @@ fn test_example_section() { let docstring = "Summary.\n\nExample:\n >>> func(1)\n 1"; let result = parse_google(docstring); assert_eq!( - examples(&result).unwrap().source_text(&result.source), + examples(&result).unwrap().text(result.source()), ">>> func(1)\n 1" ); } @@ -65,7 +65,7 @@ fn test_warnings_section() { let docstring = "Summary.\n\nWarnings:\n This function is deprecated."; let result = parse_google(docstring); assert_eq!( - warnings(&result).unwrap().source_text(&result.source), + warnings(&result).unwrap().text(result.source()), "This function is deprecated." ); } @@ -79,8 +79,8 @@ fn test_todo_freetext() { let docstring = "Summary.\n\nTodo:\n * Item one.\n * Item two."; let result = parse_google(docstring); let t = todo(&result).unwrap(); - assert!(t.source_text(&result.source).contains("Item one.")); - assert!(t.source_text(&result.source).contains("Item two.")); + assert!(t.text(result.source()).contains("Item one.")); + assert!(t.text(result.source()).contains("Item two.")); } #[test] @@ -88,11 +88,8 @@ fn test_todo_without_bullets() { let docstring = "Summary.\n\nTodo:\n Implement feature X.\n Fix bug Y."; let result = parse_google(docstring); let t = todo(&result).unwrap(); - assert!( - t.source_text(&result.source) - .contains("Implement feature X.") - ); - assert!(t.source_text(&result.source).contains("Fix bug Y.")); + assert!(t.text(result.source()).contains("Implement feature X.")); + assert!(t.text(result.source()).contains("Fix bug Y.")); } #[test] @@ -101,9 +98,9 @@ fn test_todo_multiline() { "Summary.\n\nTodo:\n * Item one that\n continues here.\n * Item two."; let result = parse_google(docstring); let t = todo(&result).unwrap(); - assert!(t.source_text(&result.source).contains("Item one that")); - assert!(t.source_text(&result.source).contains("continues here.")); - assert!(t.source_text(&result.source).contains("Item two.")); + assert!(t.text(result.source()).contains("Item one that")); + assert!(t.text(result.source()).contains("continues here.")); + assert!(t.text(result.source()).contains("Item two.")); } // ============================================================================= @@ -114,97 +111,103 @@ fn test_todo_multiline() { fn test_attention_section() { let docstring = "Summary.\n\nAttention:\n This requires careful handling."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Attention(text) => { - assert_eq!( - text.source_text(&result.source), - "This requires careful handling." - ); - } - _ => panic!("Expected Attention section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Attention + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "This requires careful handling." + ); } #[test] fn test_caution_section() { let docstring = "Summary.\n\nCaution:\n Use with care."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Caution(text) => { - assert_eq!(text.source_text(&result.source), "Use with care."); - } - _ => panic!("Expected Caution section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Caution + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "Use with care." + ); } #[test] fn test_danger_section() { let docstring = "Summary.\n\nDanger:\n May cause data loss."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Danger(text) => { - assert_eq!(text.source_text(&result.source), "May cause data loss."); - } - _ => panic!("Expected Danger section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Danger + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "May cause data loss." + ); } #[test] fn test_error_section() { let docstring = "Summary.\n\nError:\n Known issue with large inputs."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Error(text) => { - assert_eq!( - text.source_text(&result.source), - "Known issue with large inputs." - ); - } - _ => panic!("Expected Error section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Error + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "Known issue with large inputs." + ); } #[test] fn test_hint_section() { let docstring = "Summary.\n\nHint:\n Try using a smaller batch size."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Hint(text) => { - assert_eq!( - text.source_text(&result.source), - "Try using a smaller batch size." - ); - } - _ => panic!("Expected Hint section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Hint + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "Try using a smaller batch size." + ); } #[test] fn test_important_section() { let docstring = "Summary.\n\nImportant:\n Must be called before init()."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Important(text) => { - assert_eq!( - text.source_text(&result.source), - "Must be called before init()." - ); - } - _ => panic!("Expected Important section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Important + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "Must be called before init()." + ); } #[test] fn test_tip_section() { let docstring = "Summary.\n\nTip:\n Use vectorized operations for speed."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Tip(text) => { - assert_eq!( - text.source_text(&result.source), - "Use vectorized operations for speed." - ); - } - _ => panic!("Expected Tip section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Tip + ); + assert_eq!( + sections[0].body_text().unwrap().text(result.source()), + "Use vectorized operations for speed." + ); } diff --git a/tests/google/main.rs b/tests/google/main.rs index 9356ecd..ca7dfc4 100644 --- a/tests/google/main.rs +++ b/tests/google/main.rs @@ -1,12 +1,12 @@ //! Integration tests for Google-style docstring parser. -pub use pydocstring::GoogleSectionBody; -pub use pydocstring::TextSize; -pub use pydocstring::google::parse_google; +pub use pydocstring::Parsed; pub use pydocstring::google::{ - GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, - GoogleMethod, GoogleReturns, GoogleSection, GoogleSeeAlsoItem, GoogleWarning, + GoogleArg, GoogleAttribute, GoogleDocstring, GoogleException, GoogleMethod, GoogleReturns, + GoogleSection, GoogleSectionKind, GoogleSeeAlsoItem, GoogleWarning, parse_google, }; +pub use pydocstring::syntax::SyntaxToken; +pub use pydocstring::text::TextSize; mod args; mod edge_cases; @@ -21,223 +21,152 @@ mod summary; // Shared helpers // ============================================================================= -pub fn all_sections(doc: &GoogleDocstring) -> Vec<&GoogleSection> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .collect() +/// Get the typed GoogleDocstring wrapper from a Parsed result. +pub fn doc(result: &Parsed) -> GoogleDocstring<'_> { + GoogleDocstring::cast(result.root()).unwrap() } -pub fn args(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Args(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() +pub fn all_sections<'a>(result: &'a Parsed) -> Vec> { + doc(result).sections().collect() +} + +pub fn args<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Args)) + .flat_map(|s| s.args().collect::>()) .collect() } -pub fn returns(doc: &GoogleDocstring) -> Option<&GoogleReturns> { - doc.items.iter().find_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Returns(r) => Some(r), - _ => None, - }, - _ => None, - }) -} - -pub fn yields(doc: &GoogleDocstring) -> Option<&GoogleReturns> { - doc.items.iter().find_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Yields(r) => Some(r), - _ => None, - }, - _ => None, - }) -} - -pub fn raises(doc: &GoogleDocstring) -> Vec<&GoogleException> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Raises(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() +pub fn returns<'a>(result: &'a Parsed) -> Option> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Returns)) + .find_map(|s| s.returns()) +} + +pub fn yields<'a>(result: &'a Parsed) -> Option> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Yields)) + .find_map(|s| s.returns()) +} + +pub fn raises<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Raises)) + .flat_map(|s| s.exceptions().collect::>()) .collect() } -pub fn attributes(doc: &GoogleDocstring) -> Vec<&GoogleAttribute> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Attributes(v) => Some(v.iter()), - _ => None, - }, - _ => None, +pub fn attributes<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + GoogleSectionKind::Attributes + ) }) - .flatten() + .flat_map(|s| s.attributes().collect::>()) .collect() } -pub fn keyword_args(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::KeywordArgs(v) => Some(v.iter()), - _ => None, - }, - _ => None, +pub fn keyword_args<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + GoogleSectionKind::KeywordArgs + ) }) - .flatten() + .flat_map(|s| s.args().collect::>()) .collect() } -pub fn other_parameters(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::OtherParameters(v) => Some(v.iter()), - _ => None, - }, - _ => None, +pub fn other_parameters<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + GoogleSectionKind::OtherParameters + ) }) - .flatten() + .flat_map(|s| s.args().collect::>()) .collect() } -pub fn receives(doc: &GoogleDocstring) -> Vec<&GoogleArg> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Receives(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() +pub fn receives<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Receives)) + .flat_map(|s| s.args().collect::>()) .collect() } -pub fn warns(doc: &GoogleDocstring) -> Vec<&GoogleWarning> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Warns(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() +pub fn warns<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Warns)) + .flat_map(|s| s.warnings().collect::>()) .collect() } -pub fn see_also(doc: &GoogleDocstring) -> Vec<&GoogleSeeAlsoItem> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::SeeAlso(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() +pub fn see_also<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::SeeAlso)) + .flat_map(|s| s.see_also_items().collect::>()) .collect() } -pub fn methods(doc: &GoogleDocstring) -> Vec<&GoogleMethod> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => match &s.body { - GoogleSectionBody::Methods(v) => Some(v.iter()), - _ => None, - }, - _ => None, - }) - .flatten() +pub fn methods<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Methods)) + .flat_map(|s| s.methods().collect::>()) .collect() } -pub fn notes(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Notes(v) => Some(v), - _ => None, - }) +pub fn notes(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Notes)) + .and_then(|s| s.body_text()) } -pub fn examples(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Examples(v) => Some(v), - _ => None, - }) +pub fn examples(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Examples)) + .and_then(|s| s.body_text()) } -pub fn todo(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Todo(v) => Some(v), - _ => None, - }) +pub fn todo(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Todo)) + .and_then(|s| s.body_text()) } -pub fn references(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::References(v) => Some(v), - _ => None, +pub fn references(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| { + matches!( + s.section_kind(result.source()), + GoogleSectionKind::References + ) }) + .and_then(|s| s.body_text()) } -pub fn warnings(doc: &GoogleDocstring) -> Option<&pydocstring::TextRange> { - doc.items - .iter() - .filter_map(|item| match item { - GoogleDocstringItem::Section(s) => Some(s), - _ => None, - }) - .find_map(|s| match &s.body { - GoogleSectionBody::Warnings(v) => Some(v), - _ => None, - }) +pub fn warnings(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), GoogleSectionKind::Warnings)) + .and_then(|s| s.body_text()) } diff --git a/tests/google/raises.rs b/tests/google/raises.rs index ca972b7..b5b156d 100644 --- a/tests/google/raises.rs +++ b/tests/google/raises.rs @@ -10,12 +10,9 @@ fn test_raises_single() { let result = parse_google(docstring); let r = raises(&result); assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + r[0].description().unwrap().text(result.source()), "If the input is invalid." ); } @@ -27,8 +24,8 @@ fn test_raises_multiple() { let result = parse_google(docstring); let r = raises(&result); assert_eq!(r.len(), 2); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); - assert_eq!(r[1].r#type.source_text(&result.source), "TypeError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); + assert_eq!(r[1].r#type().text(result.source()), "TypeError"); } #[test] @@ -37,10 +34,9 @@ fn test_raises_multiline_description() { let result = parse_google(docstring); assert_eq!( raises(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "If the\n input is invalid." ); } @@ -50,7 +46,7 @@ fn test_raises_exception_type_span() { let docstring = "Summary.\n\nRaises:\n ValueError: If bad."; let result = parse_google(docstring); assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), + raises(&result)[0].r#type().text(result.source()), "ValueError" ); } @@ -61,12 +57,9 @@ fn test_raises_no_space_after_colon() { let docstring = "Summary.\n\nRaises:\n ValueError:If invalid."; let result = parse_google(docstring); let r = raises(&result); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + r[0].description().unwrap().text(result.source()), "If invalid." ); } @@ -77,12 +70,9 @@ fn test_raises_extra_spaces_after_colon() { let docstring = "Summary.\n\nRaises:\n ValueError: If invalid."; let result = parse_google(docstring); let r = raises(&result); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + r[0].description().unwrap().text(result.source()), "If invalid." ); } @@ -93,15 +83,12 @@ fn test_raise_alias() { let result = parse_google(docstring); let r = raises(&result); assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Raise" ); } @@ -112,7 +99,7 @@ fn test_docstring_like_raises() { let result = parse_google(docstring); let r = raises(&result); assert_eq!(r.len(), 1); - assert_eq!(r[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(r[0].r#type().text(result.source()), "ValueError"); } // ============================================================================= @@ -126,14 +113,11 @@ fn test_warns_basic() { let w = warns(&result); assert_eq!(w.len(), 1); assert_eq!( - w[0].warning_type.source_text(&result.source), + w[0].warning_type().text(result.source()), "DeprecationWarning" ); assert_eq!( - w[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + w[0].description().unwrap().text(result.source()), "If using old API." ); } @@ -146,10 +130,10 @@ fn test_warns_multiple() { let w = warns(&result); assert_eq!(w.len(), 2); assert_eq!( - w[0].warning_type.source_text(&result.source), + w[0].warning_type().text(result.source()), "DeprecationWarning" ); - assert_eq!(w[1].warning_type.source_text(&result.source), "UserWarning"); + assert_eq!(w[1].warning_type().text(result.source()), "UserWarning"); } #[test] @@ -158,13 +142,10 @@ fn test_warn_alias() { let result = parse_google(docstring); assert_eq!(warns(&result).len(), 1); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Warn" ); } @@ -175,10 +156,9 @@ fn test_warns_multiline_description() { let result = parse_google(docstring); assert_eq!( warns(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "First line.\n Second line." ); } @@ -187,12 +167,12 @@ fn test_warns_multiline_description() { fn test_warns_section_body_variant() { let docstring = "Summary.\n\nWarns:\n UserWarning: Desc."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Warns(warns) => { - assert_eq!(warns.len(), 1); - } - _ => panic!("Expected Warns section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Warns + ); + assert_eq!(sections[0].warnings().count(), 1); } // ============================================================================= @@ -204,17 +184,14 @@ fn test_warning_singular_alias() { let docstring = "Summary.\n\nWarning:\n This is deprecated."; let result = parse_google(docstring); assert_eq!( - warnings(&result).unwrap().source_text(&result.source), + warnings(&result).unwrap().text(result.source()), "This is deprecated." ); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Warning" ); } diff --git a/tests/google/returns.rs b/tests/google/returns.rs index 0793a0b..ba9a8f1 100644 --- a/tests/google/returns.rs +++ b/tests/google/returns.rs @@ -9,12 +9,9 @@ fn test_returns_with_type() { let docstring = "Summary.\n\nReturns:\n int: The result."; let result = parse_google(docstring); let r = returns(&result).unwrap(); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), + r.description().unwrap().text(result.source()), "The result." ); } @@ -24,12 +21,9 @@ fn test_returns_multiple_lines() { let docstring = "Summary.\n\nReturns:\n int: The count.\n str: The message."; let result = parse_google(docstring); let r = returns(&result).unwrap(); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), + r.description().unwrap().text(result.source()), "The count.\n str: The message." ); } @@ -39,9 +33,9 @@ fn test_returns_without_type() { let docstring = "Summary.\n\nReturns:\n The computed result."; let result = parse_google(docstring); let r = returns(&result).unwrap(); - assert!(r.return_type.is_none()); + assert!(r.return_type().is_none()); assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), + r.description().unwrap().text(result.source()), "The computed result." ); } @@ -53,10 +47,9 @@ fn test_returns_multiline_description() { assert_eq!( returns(&result) .unwrap() - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "The result\n of the computation." ); } @@ -74,12 +67,9 @@ fn test_returns_no_space_after_colon() { let docstring = "Summary.\n\nReturns:\n int:The result."; let result = parse_google(docstring); let r = returns(&result).unwrap(); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), + r.description().unwrap().text(result.source()), "The result." ); } @@ -90,12 +80,9 @@ fn test_returns_extra_spaces_after_colon() { let docstring = "Summary.\n\nReturns:\n int: The result."; let result = parse_google(docstring); let r = returns(&result).unwrap(); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - r.description.as_ref().unwrap().source_text(&result.source), + r.description().unwrap().text(result.source()), "The result." ); } @@ -105,10 +92,7 @@ fn test_docstring_like_returns() { let docstring = "Summary.\n\nReturns:\n int: The result."; let result = parse_google(docstring); let r = returns(&result).unwrap(); - assert_eq!( - r.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); + assert_eq!(r.return_type().unwrap().text(result.source()), "int"); } // ============================================================================= @@ -120,12 +104,9 @@ fn test_yields() { let docstring = "Summary.\n\nYields:\n int: The next value."; let result = parse_google(docstring); let y = yields(&result).unwrap(); + assert_eq!(y.return_type().unwrap().text(result.source()), "int"); assert_eq!( - y.return_type.as_ref().unwrap().source_text(&result.source), - "int" - ); - assert_eq!( - y.description.as_ref().unwrap().source_text(&result.source), + y.description().unwrap().text(result.source()), "The next value." ); } diff --git a/tests/google/sections.rs b/tests/google/sections.rs index 83ad7d6..929b051 100644 --- a/tests/google/sections.rs +++ b/tests/google/sections.rs @@ -29,10 +29,10 @@ Note: let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Calculate the sum." ); - assert!(result.extended_summary.is_some()); + assert!(doc(&result).extended_summary().is_some()); assert_eq!(args(&result).len(), 2); assert!(returns(&result).is_some()); assert_eq!(raises(&result).len(), 1); @@ -56,13 +56,10 @@ fn test_sections_with_blank_lines() { fn test_section_order() { let docstring = "Summary.\n\nReturns:\n int: Value.\n\nArgs:\n x: Input."; let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); + let sections = all_sections(&result); assert_eq!(sections.len(), 2); - assert_eq!( - sections[0].header.name.source_text(&result.source), - "Returns" - ); - assert_eq!(sections[1].header.name.source_text(&result.source), "Args"); + assert_eq!(sections[0].header().name().text(result.source()), "Returns"); + assert_eq!(sections[1].header().name().text(result.source()), "Args"); } // ============================================================================= @@ -73,19 +70,21 @@ fn test_section_order() { fn test_section_header_span() { let docstring = "Summary.\n\nArgs:\n x: Value."; let result = parse_google(docstring); - let header = &all_sections(&result).into_iter().next().unwrap().header; - assert_eq!(header.name.source_text(&result.source), "Args"); - assert_eq!(header.name.source_text(&result.source), "Args"); - assert_eq!(header.range.source_text(&result.source), "Args:"); + let header = all_sections(&result)[0].header(); + assert_eq!(header.name().text(result.source()), "Args"); + assert_eq!( + header.syntax().range().source_text(result.source()), + "Args:" + ); } #[test] fn test_section_span() { let docstring = "Summary.\n\nArgs:\n x: Value."; let result = parse_google(docstring); - let section = all_sections(&result).into_iter().next().unwrap(); + let section = &all_sections(&result)[0]; assert_eq!( - section.range.source_text(&result.source), + section.syntax().range().source_text(result.source()), "Args:\n x: Value." ); } @@ -98,18 +97,17 @@ fn test_section_span() { fn test_unknown_section_preserved() { let docstring = "Summary.\n\nCustom:\n Some custom content."; let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); + let sections = all_sections(&result); assert_eq!(sections.len(), 1); + assert_eq!(sections[0].header().name().text(result.source()), "Custom"); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Unknown + ); assert_eq!( - sections[0].header.name.source_text(&result.source), - "Custom" + sections[0].body_text().unwrap().text(result.source()), + "Some custom content." ); - match §ions[0].body { - GoogleSectionBody::Unknown(text) => { - assert_eq!(text.source_text(&result.source), "Some custom content."); - } - _ => panic!("Expected Unknown section body"), - } } #[test] @@ -117,17 +115,11 @@ fn test_unknown_section_with_known() { let docstring = "Summary.\n\nArgs:\n x: Value.\n\nCustom:\n Content.\n\nReturns:\n int: Result."; let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); + let sections = all_sections(&result); assert_eq!(sections.len(), 3); - assert_eq!(sections[0].header.name.source_text(&result.source), "Args"); - assert_eq!( - sections[1].header.name.source_text(&result.source), - "Custom" - ); - assert_eq!( - sections[2].header.name.source_text(&result.source), - "Returns" - ); + assert_eq!(sections[0].header().name().text(result.source()), "Args"); + assert_eq!(sections[1].header().name().text(result.source()), "Custom"); + assert_eq!(sections[2].header().name().text(result.source()), "Returns"); assert_eq!(args(&result).len(), 1); assert!(returns(&result).is_some()); } @@ -136,14 +128,14 @@ fn test_unknown_section_with_known() { fn test_multiple_unknown_sections() { let docstring = "Summary.\n\nCustom One:\n First.\n\nCustom Two:\n Second."; let result = parse_google(docstring); - let sections: Vec<_> = all_sections(&result); + let sections = all_sections(&result); assert_eq!(sections.len(), 2); assert_eq!( - sections[0].header.name.source_text(&result.source), + sections[0].header().name().text(result.source()), "Custom One" ); assert_eq!( - sections[1].header.name.source_text(&result.source), + sections[1].header().name().text(result.source()), "Custom Two" ); } @@ -203,10 +195,10 @@ Example: let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Calculate something." ); - assert!(result.extended_summary.is_some()); + assert!(doc(&result).extended_summary().is_some()); assert_eq!(args(&result).len(), 1); assert_eq!(keyword_args(&result).len(), 1); assert!(returns(&result).is_some()); @@ -227,25 +219,20 @@ fn test_span_source_text_round_trip() { let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary." ); - assert_eq!(args(&result)[0].name.source_text(&result.source), "x"); + assert_eq!(args(&result)[0].name().text(result.source()), "x"); assert_eq!( - args(&result)[0] - .r#type - .as_ref() - .unwrap() - .source_text(&result.source), + args(&result)[0].r#type().unwrap().text(result.source()), "int" ); assert_eq!( returns(&result) .unwrap() - .return_type - .as_ref() + .return_type() .unwrap() - .source_text(&result.source), + .text(result.source()), "bool" ); } diff --git a/tests/google/structured.rs b/tests/google/structured.rs index 4d49161..7d492dc 100644 --- a/tests/google/structured.rs +++ b/tests/google/structured.rs @@ -10,12 +10,9 @@ fn test_attributes() { let result = parse_google(docstring); let a = attributes(&result); assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "name"); - assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "str" - ); - assert_eq!(a[1].name.source_text(&result.source), "age"); + assert_eq!(a[0].name().text(result.source()), "name"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "str"); + assert_eq!(a[1].name().text(result.source()), "age"); } #[test] @@ -23,8 +20,8 @@ fn test_attributes_no_type() { let docstring = "Summary.\n\nAttributes:\n name: The name."; let result = parse_google(docstring); let a = attributes(&result); - assert_eq!(a[0].name.source_text(&result.source), "name"); - assert!(a[0].r#type.is_none()); + assert_eq!(a[0].name().text(result.source()), "name"); + assert!(a[0].r#type().is_none()); } #[test] @@ -33,13 +30,10 @@ fn test_attribute_singular_alias() { let result = parse_google(docstring); assert_eq!(attributes(&result).len(), 1); assert_eq!( - all_sections(&result) - .into_iter() - .next() - .unwrap() - .header - .name - .source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Attribute" ); } @@ -54,15 +48,12 @@ fn test_methods_basic() { let result = parse_google(docstring); let m = methods(&result); assert_eq!(m.len(), 2); - assert_eq!(m[0].name.source_text(&result.source), "reset()"); + assert_eq!(m[0].name().text(result.source()), "reset()"); assert_eq!( - m[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + m[0].description().unwrap().text(result.source()), "Reset the state." ); - assert_eq!(m[1].name.source_text(&result.source), "update(data)"); + assert_eq!(m[1].name().text(result.source()), "update(data)"); } #[test] @@ -71,12 +62,9 @@ fn test_methods_without_parens() { let result = parse_google(docstring); let m = methods(&result); assert_eq!(m.len(), 1); - assert_eq!(m[0].name.source_text(&result.source), "do_stuff"); + assert_eq!(m[0].name().text(result.source()), "do_stuff"); assert_eq!( - m[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + m[0].description().unwrap().text(result.source()), "Performs the operation." ); } @@ -85,12 +73,12 @@ fn test_methods_without_parens() { fn test_methods_section_body_variant() { let docstring = "Summary.\n\nMethods:\n foo(): Does bar."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::Methods(methods) => { - assert_eq!(methods.len(), 1); - } - _ => panic!("Expected Methods section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::Methods + ); + assert_eq!(sections[0].methods().count(), 1); } // ============================================================================= @@ -103,14 +91,11 @@ fn test_see_also_basic() { let result = parse_google(docstring); let sa = see_also(&result); assert_eq!(sa.len(), 1); - assert_eq!(sa[0].names.len(), 1); - assert_eq!(sa[0].names[0].source_text(&result.source), "other_func"); + let names: Vec<_> = sa[0].names().collect(); + assert_eq!(names.len(), 1); + assert_eq!(names[0].text(result.source()), "other_func"); assert_eq!( - sa[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + sa[0].description().unwrap().text(result.source()), "Does something else." ); } @@ -121,11 +106,12 @@ fn test_see_also_multiple_names() { let result = parse_google(docstring); let sa = see_also(&result); assert_eq!(sa.len(), 1); - assert_eq!(sa[0].names.len(), 3); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); - assert_eq!(sa[0].names[1].source_text(&result.source), "func_b"); - assert_eq!(sa[0].names[2].source_text(&result.source), "func_c"); - assert!(sa[0].description.is_none()); + let names: Vec<_> = sa[0].names().collect(); + assert_eq!(names.len(), 3); + assert_eq!(names[0].text(result.source()), "func_a"); + assert_eq!(names[1].text(result.source()), "func_b"); + assert_eq!(names[2].text(result.source()), "func_c"); + assert!(sa[0].description().is_none()); } #[test] @@ -134,20 +120,22 @@ fn test_see_also_mixed() { let result = parse_google(docstring); let sa = see_also(&result); assert_eq!(sa.len(), 2); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); - assert!(sa[0].description.is_some()); - assert_eq!(sa[1].names.len(), 2); - assert!(sa[1].description.is_none()); + let names0: Vec<_> = sa[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "func_a"); + assert!(sa[0].description().is_some()); + let names1: Vec<_> = sa[1].names().collect(); + assert_eq!(names1.len(), 2); + assert!(sa[1].description().is_none()); } #[test] fn test_see_also_section_body_variant() { let docstring = "Summary.\n\nSee Also:\n func_a: Desc."; let result = parse_google(docstring); - match &all_sections(&result).into_iter().next().unwrap().body { - GoogleSectionBody::SeeAlso(items) => { - assert_eq!(items.len(), 1); - } - _ => panic!("Expected SeeAlso section body"), - } + let sections = all_sections(&result); + assert_eq!( + sections[0].section_kind(result.source()), + GoogleSectionKind::SeeAlso + ); + assert_eq!(sections[0].see_also_items().count(), 1); } diff --git a/tests/google/summary.rs b/tests/google/summary.rs index a0320f1..eeddbc3 100644 --- a/tests/google/summary.rs +++ b/tests/google/summary.rs @@ -9,7 +9,7 @@ fn test_simple_summary() { let docstring = "This is a brief summary."; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "This is a brief summary." ); } @@ -18,24 +18,22 @@ fn test_simple_summary() { fn test_summary_span() { let docstring = "Brief description."; let result = parse_google(docstring); - assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(0)); - assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(18)); - assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), - "Brief description." - ); + let s = doc(&result).summary().unwrap(); + assert_eq!(s.range().start(), TextSize::new(0)); + assert_eq!(s.range().end(), TextSize::new(18)); + assert_eq!(s.text(result.source()), "Brief description."); } #[test] fn test_empty_docstring() { let result = parse_google(""); - assert!(result.summary.is_none()); + assert!(doc(&result).summary().is_none()); } #[test] fn test_whitespace_only_docstring() { let result = parse_google(" \n \n"); - assert!(result.summary.is_none()); + assert!(doc(&result).summary().is_none()); } #[test] @@ -45,12 +43,12 @@ fn test_summary_with_description() { let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Brief summary." ); - let desc = result.extended_summary.as_ref().unwrap(); + let desc = doc(&result).extended_summary().unwrap(); assert_eq!( - desc.source_text(&result.source), + desc.text(result.source()), "Extended description that provides\nmore details about the function." ); } @@ -64,15 +62,12 @@ First paragraph of description. Second paragraph of description."#; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Brief summary." ); - let desc = result.extended_summary.as_ref().unwrap(); - assert!(desc.source_text(&result.source).contains("First paragraph")); - assert!( - desc.source_text(&result.source) - .contains("Second paragraph") - ); + let desc = doc(&result).extended_summary().unwrap(); + assert!(desc.text(result.source()).contains("First paragraph")); + assert!(desc.text(result.source()).contains("Second paragraph")); } #[test] @@ -80,11 +75,11 @@ fn test_multiline_summary() { let docstring = "This is a long summary\nthat spans two lines.\n\nExtended description."; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "This is a long summary\nthat spans two lines." ); - let desc = result.extended_summary.as_ref().unwrap(); - assert_eq!(desc.source_text(&result.source), "Extended description."); + let desc = doc(&result).extended_summary().unwrap(); + assert_eq!(desc.text(result.source()), "Extended description."); } #[test] @@ -92,10 +87,10 @@ fn test_multiline_summary_no_extended() { let docstring = "Summary line one\ncontinues here."; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary line one\ncontinues here." ); - assert!(result.extended_summary.is_none()); + assert!(doc(&result).extended_summary().is_none()); } #[test] @@ -103,11 +98,11 @@ fn test_multiline_summary_then_section() { let docstring = "Summary line one\ncontinues here.\nArgs:\n x (int): val"; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary line one\ncontinues here." ); - assert!(result.extended_summary.is_none()); - assert_eq!(result.items.len(), 1); + assert!(doc(&result).extended_summary().is_none()); + assert_eq!(doc(&result).sections().count(), 1); } #[test] @@ -122,7 +117,7 @@ fn test_leading_blank_lines() { let docstring = "\n\n\nSummary.\n\nArgs:\n x: Value."; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary." ); assert_eq!(args(&result).len(), 1); @@ -133,7 +128,7 @@ fn test_docstring_like_summary() { let docstring = "Summary."; let result = parse_google(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary." ); } diff --git a/tests/numpy/edge_cases.rs b/tests/numpy/edge_cases.rs index e2018b3..8828e79 100644 --- a/tests/numpy/edge_cases.rs +++ b/tests/numpy/edge_cases.rs @@ -10,49 +10,40 @@ fn test_indented_docstring() { let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary line." ); assert_eq!(parameters(&result).len(), 2); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names0: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "x"); assert_eq!( parameters(&result)[0] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), + .r#type() + .map(|t| t.text(result.source())), Some("int") ); - assert_eq!( - parameters(&result)[1].names[0].source_text(&result.source), - "y" - ); - assert!(parameters(&result)[1].optional.is_some()); + let names1: Vec<_> = parameters(&result)[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "y"); + assert!(parameters(&result)[1].optional().is_some()); assert_eq!(returns(&result).len(), 1); assert_eq!( returns(&result)[0] - .return_type - .as_ref() - .map(|t| t.source_text(&result.source)), + .return_type() + .map(|t| t.text(result.source())), Some("bool") ); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary line." ); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names0b: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names0b[0].text(result.source()), "x"); assert_eq!( parameters(&result)[0] - .r#type - .as_ref() + .r#type() .unwrap() - .source_text(&result.source), + .text(result.source()), "int" ); } @@ -63,21 +54,19 @@ fn test_deeply_indented_docstring() { let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Brief." ); assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "a" - ); + let names: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names[0].text(result.source()), "a"); assert_eq!(raises(&result).len(), 1); assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), + raises(&result)[0].r#type().text(result.source()), "ValueError" ); assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), + raises(&result)[0].r#type().text(result.source()), "ValueError" ); } @@ -88,24 +77,20 @@ fn test_indented_with_deprecation() { let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary." ); - let dep = result - .deprecation - .as_ref() - .expect("should have deprecation"); - assert_eq!(dep.version.source_text(&result.source), "2.0.0"); + let dep = doc(&result).deprecation().expect("should have deprecation"); + assert_eq!(dep.version().text(result.source()), "2.0.0"); assert!( - dep.description - .source_text(&result.source) + dep.description() + .unwrap() + .text(result.source()) .contains("new_func") ); assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); } #[test] @@ -115,20 +100,17 @@ fn test_mixed_indent_first_line() { let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary." ); assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); assert_eq!( parameters(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "Description." ); } @@ -144,22 +126,16 @@ fn test_tab_indented_parameters() { let result = parse_numpy(docstring); let params = parameters(&result); assert_eq!(params.len(), 2); - assert_eq!(params[0].names[0].source_text(&result.source), "x"); + let names0: Vec<_> = params[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "x"); assert_eq!( - params[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + params[0].description().unwrap().text(result.source()), "Description of x." ); - assert_eq!(params[1].names[0].source_text(&result.source), "y"); + let names1: Vec<_> = params[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "y"); assert_eq!( - params[1] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + params[1].description().unwrap().text(result.source()), "Description of y." ); } @@ -171,12 +147,9 @@ fn test_mixed_tab_space_parameters() { let result = parse_numpy(docstring); let params = parameters(&result); assert_eq!(params.len(), 1); - assert_eq!(params[0].names[0].source_text(&result.source), "x"); - let desc = params[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); + let names: Vec<_> = params[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); + let desc = params[0].description().unwrap().text(result.source()); assert!(desc.contains("The value."), "desc = {:?}", desc); } @@ -188,11 +161,7 @@ fn test_tab_indented_returns() { let rets = returns(&result); assert_eq!(rets.len(), 1); assert_eq!( - rets[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + rets[0].description().unwrap().text(result.source()), "The result value." ); } @@ -204,13 +173,9 @@ fn test_tab_indented_raises() { let result = parse_numpy(docstring); let exc = raises(&result); assert_eq!(exc.len(), 1); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); + assert_eq!(exc[0].r#type().text(result.source()), "ValueError"); assert_eq!( - exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + exc[0].description().unwrap().text(result.source()), "If the input is invalid." ); } diff --git a/tests/numpy/freetext.rs b/tests/numpy/freetext.rs index 7517b57..119bdc4 100644 --- a/tests/numpy/freetext.rs +++ b/tests/numpy/freetext.rs @@ -18,7 +18,7 @@ This is an important note about the function. assert!( notes(&result) .unwrap() - .source_text(&result.source) + .text(result.source()) .contains("important note") ); } @@ -30,10 +30,16 @@ fn test_note_alias() { let result = parse_numpy(docstring); assert!(notes(&result).is_some()); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Note" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Notes); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Notes + ); } /// Notes with multi-paragraph content. @@ -41,7 +47,7 @@ fn test_note_alias() { fn test_notes_multi_paragraph() { let docstring = "Summary.\n\nNotes\n-----\nFirst paragraph.\n\nSecond paragraph.\n"; let result = parse_numpy(docstring); - let n = notes(&result).unwrap().source_text(&result.source); + let n = notes(&result).unwrap().text(result.source()); assert!(n.contains("First paragraph.")); assert!(n.contains("Second paragraph.")); } @@ -55,7 +61,7 @@ fn test_warnings_section() { let docstring = "Summary.\n\nWarnings\n--------\nThis function is deprecated.\n"; let result = parse_numpy(docstring); assert_eq!( - warnings_text(&result).unwrap().source_text(&result.source), + warnings_text(&result).unwrap().text(result.source()), "This function is deprecated." ); } @@ -67,10 +73,16 @@ fn test_warning_alias() { let result = parse_numpy(docstring); assert!(warnings_text(&result).is_some()); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Warning" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Warnings); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Warnings + ); } /// Warnings section body variant check. @@ -78,12 +90,9 @@ fn test_warning_alias() { fn test_warnings_section_body_variant() { let docstring = "Summary.\n\nWarnings\n--------\nDo not use.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Warnings(text) => { - assert!(text.is_some()); - } - other => panic!("Expected Warnings section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Warnings); + assert!(s.body_text().is_some()); } // ============================================================================= @@ -94,7 +103,7 @@ fn test_warnings_section_body_variant() { fn test_examples_basic() { let docstring = "Summary.\n\nExamples\n--------\n>>> func(1)\n1\n"; let result = parse_numpy(docstring); - let ex = examples(&result).unwrap().source_text(&result.source); + let ex = examples(&result).unwrap().text(result.source()); assert!(ex.contains(">>> func(1)")); assert!(ex.contains("1")); } @@ -106,10 +115,16 @@ fn test_example_alias() { let result = parse_numpy(docstring); assert!(examples(&result).is_some()); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Example" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Examples); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Examples + ); } /// Examples with narrative text and doctest. @@ -117,7 +132,7 @@ fn test_example_alias() { fn test_examples_with_narrative() { let docstring = "Summary.\n\nExamples\n--------\nHere is an example:\n\n>>> func(2)\n4\n"; let result = parse_numpy(docstring); - let ex = examples(&result).unwrap().source_text(&result.source); + let ex = examples(&result).unwrap().text(result.source()); assert!(ex.contains("Here is an example:")); assert!(ex.contains(">>> func(2)")); } @@ -127,12 +142,9 @@ fn test_examples_with_narrative() { fn test_examples_section_body_variant() { let docstring = "Summary.\n\nExamples\n--------\n>>> pass\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Examples(text) => { - assert!(text.is_some()); - } - other => panic!("Expected Examples section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Examples); + assert!(s.body_text().is_some()); } // ============================================================================= @@ -151,17 +163,16 @@ func_b, func_c let result = parse_numpy(docstring); let items = see_also(&result); assert_eq!(items.len(), 2); - assert_eq!(items[0].names[0].source_text(&result.source), "func_a"); + let names0: Vec<_> = items[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "func_a"); assert_eq!( - items[0] - .description - .as_ref() - .map(|d| d.source_text(&result.source)), + items[0].description().map(|d| d.text(result.source())), Some("Does something.") ); - assert_eq!(items[1].names.len(), 2); - assert_eq!(items[1].names[0].source_text(&result.source), "func_b"); - assert_eq!(items[1].names[1].source_text(&result.source), "func_c"); + assert_eq!(items[1].names().count(), 2); + let names1: Vec<_> = items[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "func_b"); + assert_eq!(names1[1].text(result.source()), "func_c"); } /// See Also with no space before colon. @@ -171,13 +182,13 @@ fn test_see_also_no_space_before_colon() { let result = parse_numpy(docstring); let sa = see_also(&result); assert_eq!(sa.len(), 1); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); + let names: Vec<_> = sa[0].names().collect(); + assert_eq!(names[0].text(result.source()), "func_a"); assert!( sa[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source) + .text(result.source()) .contains("Description") ); } @@ -190,16 +201,14 @@ fn test_see_also_multiple_with_descriptions() { let result = parse_numpy(docstring); let sa = see_also(&result); assert_eq!(sa.len(), 2); - assert_eq!(sa[0].names[0].source_text(&result.source), "func_a"); + let names0: Vec<_> = sa[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "func_a"); assert_eq!( - sa[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + sa[0].description().unwrap().text(result.source()), "First function." ); - assert_eq!(sa[1].names[0].source_text(&result.source), "func_b"); + let names1: Vec<_> = sa[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "func_b"); } /// See Also section body variant check. @@ -207,12 +216,10 @@ fn test_see_also_multiple_with_descriptions() { fn test_see_also_section_body_variant() { let docstring = "Summary.\n\nSee Also\n--------\nfunc_a : Desc.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::SeeAlso(items) => { - assert_eq!(items.len(), 1); - } - other => panic!("Expected SeeAlso section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::SeeAlso); + let items: Vec<_> = s.see_also_items().collect(); + assert_eq!(items.len(), 1); } // ============================================================================= @@ -231,28 +238,20 @@ References let result = parse_numpy(docstring); let refs = references(&result); assert_eq!(refs.len(), 2); - assert_eq!( - refs[0].number.as_ref().unwrap().source_text(&result.source), - "1" - ); + assert_eq!(refs[0].number().unwrap().text(result.source()), "1"); assert!( refs[0] - .content - .as_ref() + .content() .unwrap() - .source_text(&result.source) + .text(result.source()) .contains("Author A") ); - assert_eq!( - refs[1].number.as_ref().unwrap().source_text(&result.source), - "2" - ); + assert_eq!(refs[1].number().unwrap().text(result.source()), "2"); assert!( refs[1] - .content - .as_ref() + .content() .unwrap() - .source_text(&result.source) + .text(result.source()) .contains("Author B") ); } @@ -264,15 +263,11 @@ fn test_references_directive_markers() { let result = parse_numpy(docstring); let refs = references(&result); assert_eq!(refs.len(), 1); - assert!(refs[0].directive_marker.is_some()); + assert!(refs[0].directive_marker().is_some()); assert_eq!( - refs[0] - .directive_marker - .as_ref() - .unwrap() - .source_text(&result.source), + refs[0].directive_marker().unwrap().text(result.source()), ".." ); - assert!(refs[0].open_bracket.is_some()); - assert!(refs[0].close_bracket.is_some()); + assert!(refs[0].open_bracket().is_some()); + assert!(refs[0].close_bracket().is_some()); } diff --git a/tests/numpy/main.rs b/tests/numpy/main.rs index 2af0e7a..fc2d510 100644 --- a/tests/numpy/main.rs +++ b/tests/numpy/main.rs @@ -1,13 +1,16 @@ //! Integration tests for NumPy-style docstring parser. -pub use pydocstring::NumPySectionBody; -pub use pydocstring::NumPySectionKind; -pub use pydocstring::TextSize; -pub use pydocstring::numpy::parse_numpy; +pub use pydocstring::Parsed; pub use pydocstring::numpy::{ - NumPyAttribute, NumPyDocstring, NumPyDocstringItem, NumPyException, NumPyMethod, - NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPyWarning, SeeAlsoItem, + kind::NumPySectionKind, + nodes::{ + NumPyAttribute, NumPyDeprecation, NumPyDocstring, NumPyException, NumPyMethod, + NumPyParameter, NumPyReference, NumPyReturns, NumPySection, NumPySeeAlsoItem, NumPyWarning, + }, + parse_numpy, }; +pub use pydocstring::syntax::SyntaxToken; +pub use pydocstring::text::TextSize; mod edge_cases; mod freetext; @@ -22,162 +25,148 @@ mod summary; // Shared helpers // ============================================================================= -/// Extract all sections from a docstring, ignoring stray lines. -pub fn sections(doc: &NumPyDocstring) -> Vec<&NumPySection> { - doc.items - .iter() - .filter_map(|item| match item { - NumPyDocstringItem::Section(s) => Some(s), - _ => None, - }) - .collect() +/// Get the typed NumPyDocstring wrapper from a Parsed result. +pub fn doc(result: &Parsed) -> NumPyDocstring<'_> { + NumPyDocstring::cast(result.root()).unwrap() +} + +/// Extract all sections from a docstring. +pub fn all_sections<'a>(result: &'a Parsed) -> Vec> { + doc(result).sections().collect() } -pub fn parameters(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Parameters(v) => Some(v.iter()), - _ => None, +pub fn parameters<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + NumPySectionKind::Parameters + ) }) - .flatten() + .flat_map(|s| s.parameters().collect::>()) .collect() } -pub fn returns(doc: &NumPyDocstring) -> Vec<&NumPyReturns> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Returns(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn returns<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Returns)) + .flat_map(|s| s.returns().collect::>()) .collect() } -pub fn raises(doc: &NumPyDocstring) -> Vec<&NumPyException> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Raises(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn yields<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Yields)) + .flat_map(|s| s.returns().collect::>()) .collect() } -pub fn warns(doc: &NumPyDocstring) -> Vec<&NumPyWarning> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Warns(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn raises<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Raises)) + .flat_map(|s| s.exceptions().collect::>()) .collect() } -pub fn see_also(doc: &NumPyDocstring) -> Vec<&SeeAlsoItem> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::SeeAlso(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn warns<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Warns)) + .flat_map(|s| s.warnings().collect::>()) .collect() } -pub fn references(doc: &NumPyDocstring) -> Vec<&NumPyReference> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::References(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn see_also<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::SeeAlso)) + .flat_map(|s| s.see_also_items().collect::>()) .collect() } -pub fn notes(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { - sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Notes(v) => v.as_ref(), - _ => None, - }) +pub fn references<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + NumPySectionKind::References + ) + }) + .flat_map(|s| s.references().collect::>()) + .collect() } -pub fn examples(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { - sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Examples(v) => v.as_ref(), - _ => None, - }) +pub fn notes(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Notes)) + .and_then(|s| s.body_text()) } -pub fn yields(doc: &NumPyDocstring) -> Vec<&NumPyReturns> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Yields(v) => Some(v.iter()), - _ => None, - }) - .flatten() - .collect() +pub fn examples(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Examples)) + .and_then(|s| s.body_text()) } -pub fn receives(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Receives(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn receives<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Receives)) + .flat_map(|s| s.parameters().collect::>()) .collect() } -pub fn other_parameters(doc: &NumPyDocstring) -> Vec<&NumPyParameter> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::OtherParameters(v) => Some(v.iter()), - _ => None, +pub fn other_parameters<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + NumPySectionKind::OtherParameters + ) }) - .flatten() + .flat_map(|s| s.parameters().collect::>()) .collect() } -pub fn attributes(doc: &NumPyDocstring) -> Vec<&NumPyAttribute> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Attributes(v) => Some(v.iter()), - _ => None, +pub fn attributes<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| { + matches!( + s.section_kind(result.source()), + NumPySectionKind::Attributes + ) }) - .flatten() + .flat_map(|s| s.attributes().collect::>()) .collect() } -pub fn methods(doc: &NumPyDocstring) -> Vec<&NumPyMethod> { - sections(doc) - .iter() - .filter_map(|s| match &s.body { - NumPySectionBody::Methods(v) => Some(v.iter()), - _ => None, - }) - .flatten() +pub fn methods<'a>(result: &'a Parsed) -> Vec> { + doc(result) + .sections() + .filter(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Methods)) + .flat_map(|s| s.methods().collect::>()) .collect() } -pub fn warnings_text(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { - sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Warnings(v) => v.as_ref(), - _ => None, - }) +pub fn warnings_text(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Warnings)) + .and_then(|s| s.body_text()) } -pub fn unknown_text(doc: &NumPyDocstring) -> Option<&pydocstring::TextRange> { - sections(doc).iter().find_map(|s| match &s.body { - NumPySectionBody::Unknown(v) => v.as_ref(), - _ => None, - }) +pub fn unknown_text(result: &Parsed) -> Option<&SyntaxToken> { + doc(result) + .sections() + .find(|s| matches!(s.section_kind(result.source()), NumPySectionKind::Unknown)) + .and_then(|s| s.body_text()) } diff --git a/tests/numpy/parameters.rs b/tests/numpy/parameters.rs index 34f4838..e43e127 100644 --- a/tests/numpy/parameters.rs +++ b/tests/numpy/parameters.rs @@ -23,49 +23,41 @@ int let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Calculate the sum of two numbers." ); assert_eq!(parameters(&result).len(), 2); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names0: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "x"); assert_eq!( parameters(&result)[0] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), + .r#type() + .map(|t| t.text(result.source())), Some("int") ); assert_eq!( parameters(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "The first number." ); - assert_eq!( - parameters(&result)[1].names[0].source_text(&result.source), - "y" - ); + let names1: Vec<_> = parameters(&result)[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "y"); assert_eq!( parameters(&result)[1] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), + .r#type() + .map(|t| t.text(result.source())), Some("int") ); assert!(!returns(&result).is_empty()); assert_eq!( returns(&result)[0] - .return_type - .as_ref() - .map(|t| t.source_text(&result.source)), + .return_type() + .map(|t| t.text(result.source())), Some("int") ); } @@ -84,13 +76,12 @@ optional : int, optional let result = parse_numpy(docstring); assert_eq!(parameters(&result).len(), 2); - assert!(parameters(&result)[0].optional.is_none()); - assert!(parameters(&result)[1].optional.is_some()); + assert!(parameters(&result)[0].optional().is_none()); + assert!(parameters(&result)[1].optional().is_some()); assert_eq!( parameters(&result)[1] - .r#type - .as_ref() - .map(|t| t.source_text(&result.source)), + .r#type() + .map(|t| t.text(result.source())), Some("int") ); } @@ -109,20 +100,15 @@ y : str, optional let result = parse_numpy(docstring); assert_eq!(parameters(&result).len(), 2); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); - assert_eq!( - parameters(&result)[1].names[0].source_text(&result.source), - "y" - ); + let names0: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "x"); + let names1: Vec<_> = parameters(&result)[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "y"); assert_eq!( parameters(&result)[0] - .r#type - .as_ref() + .r#type() .unwrap() - .source_text(&result.source), + .text(result.source()), "int" ); } @@ -134,16 +120,11 @@ fn test_parameters_no_space_before_colon() { let result = parse_numpy(docstring); let p = parameters(&result); assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); - assert_eq!( - p[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); + let names: Vec<_> = p[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); + assert_eq!(p[0].r#type().unwrap().text(result.source()), "int"); assert_eq!( - p[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + p[0].description().unwrap().text(result.source()), "The value." ); } @@ -155,11 +136,9 @@ fn test_parameters_no_space_after_colon() { let result = parse_numpy(docstring); let p = parameters(&result); assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); - assert_eq!( - p[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); + let names: Vec<_> = p[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); + assert_eq!(p[0].r#type().unwrap().text(result.source()), "int"); } /// Parameters with no spaces around colon: `x:int` @@ -169,11 +148,9 @@ fn test_parameters_no_spaces_around_colon() { let result = parse_numpy(docstring); let p = parameters(&result); assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); - assert_eq!( - p[0].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); + let names: Vec<_> = p[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); + assert_eq!(p[0].r#type().unwrap().text(result.source()), "int"); } #[test] @@ -187,11 +164,10 @@ x1, x2 : array_like "#; let result = parse_numpy(docstring); let p = ¶meters(&result)[0]; - assert_eq!(p.names.len(), 2); - assert_eq!(p.names[0].source_text(&result.source), "x1"); - assert_eq!(p.names[1].source_text(&result.source), "x2"); - assert_eq!(p.names[0].source_text(&result.source), "x1"); - assert_eq!(p.names[1].source_text(&result.source), "x2"); + let names: Vec<_> = p.names().collect(); + assert_eq!(names.len(), 2); + assert_eq!(names[0].text(result.source()), "x1"); + assert_eq!(names[1].text(result.source()), "x2"); } #[test] @@ -205,16 +181,13 @@ x : int "#; let result = parse_numpy(docstring); assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); assert!( parameters(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source) + .text(result.source()) .contains("key: value") ); } @@ -231,11 +204,10 @@ x : int Second paragraph of x. "#; let result = parse_numpy(docstring); - let desc = ¶meters(&result)[0] - .description - .as_ref() + let desc = parameters(&result)[0] + .description() .unwrap() - .source_text(&result.source); + .text(result.source()); assert!(desc.contains("First paragraph of x.")); assert!(desc.contains("Second paragraph of x.")); assert!(desc.contains('\n')); @@ -254,13 +226,11 @@ fn test_enum_type_as_string() { assert_eq!(params.len(), 1); let p = ¶ms[0]; - assert_eq!(p.names[0].source_text(&result.source), "order"); - assert_eq!( - p.r#type.as_ref().unwrap().source_text(&result.source), - "{'C', 'F', 'A'}" - ); + let names: Vec<_> = p.names().collect(); + assert_eq!(names[0].text(result.source()), "order"); + assert_eq!(p.r#type().unwrap().text(result.source()), "{'C', 'F', 'A'}"); assert_eq!( - p.description.as_ref().unwrap().source_text(&result.source), + p.description().unwrap().text(result.source()), "Memory layout." ); } @@ -273,11 +243,8 @@ fn test_enum_type_with_optional() { let params = parameters(&result); let p = ¶ms[0]; - assert!(p.optional.is_some()); - assert_eq!( - p.r#type.as_ref().unwrap().source_text(&result.source), - "{'C', 'F'}" - ); + assert!(p.optional().is_some()); + assert_eq!(p.r#type().unwrap().text(result.source()), "{'C', 'F'}"); } #[test] @@ -287,25 +254,13 @@ fn test_enum_type_with_default() { let params = parameters(&result); let p = ¶ms[0]; + assert_eq!(p.r#type().unwrap().text(result.source()), "{'C', 'F', 'A'}"); assert_eq!( - p.r#type.as_ref().unwrap().source_text(&result.source), - "{'C', 'F', 'A'}" - ); - assert_eq!( - p.default_keyword - .as_ref() - .unwrap() - .source_text(&result.source), + p.default_keyword().unwrap().text(result.source()), "default" ); - assert!(p.default_separator.is_none()); - assert_eq!( - p.default_value - .as_ref() - .unwrap() - .source_text(&result.source), - "'C'" - ); + assert!(p.default_separator().is_none()); + assert_eq!(p.default_value().unwrap().text(result.source()), "'C'"); } // ============================================================================= @@ -319,13 +274,17 @@ fn test_params_alias() { let result = parse_numpy(docstring); let p = parameters(&result); assert_eq!(p.len(), 1); - assert_eq!(p[0].names[0].source_text(&result.source), "x"); + let names: Vec<_> = p[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Params" ); assert_eq!( - sections(&result)[0].header.kind, + all_sections(&result)[0].section_kind(result.source()), NumPySectionKind::Parameters ); } @@ -337,7 +296,10 @@ fn test_param_alias() { let result = parse_numpy(docstring); assert_eq!(parameters(&result).len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Param" ); } @@ -349,7 +311,10 @@ fn test_parameter_alias() { let result = parse_numpy(docstring); assert_eq!(parameters(&result).len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Parameter" ); } @@ -364,13 +329,12 @@ fn test_other_parameters_basic() { let result = parse_numpy(docstring); let op = other_parameters(&result); assert_eq!(op.len(), 2); - assert_eq!(op[0].names[0].source_text(&result.source), "debug"); - assert_eq!( - op[0].r#type.as_ref().unwrap().source_text(&result.source), - "bool" - ); - assert_eq!(op[1].names[0].source_text(&result.source), "verbose"); - assert!(op[1].optional.is_some()); + let names0: Vec<_> = op[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "debug"); + assert_eq!(op[0].r#type().unwrap().text(result.source()), "bool"); + let names1: Vec<_> = op[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "verbose"); + assert!(op[1].optional().is_some()); } /// `Other Params` alias. @@ -380,11 +344,14 @@ fn test_other_params_alias() { let result = parse_numpy(docstring); assert_eq!(other_parameters(&result).len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Other Params" ); assert_eq!( - sections(&result)[0].header.kind, + all_sections(&result)[0].section_kind(result.source()), NumPySectionKind::OtherParameters ); } @@ -394,12 +361,13 @@ fn test_other_params_alias() { fn test_other_parameters_section_body_variant() { let docstring = "Summary.\n\nOther Parameters\n----------------\nx : int\n Extra.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::OtherParameters(params) => { - assert_eq!(params.len(), 1); - } - other => panic!("Expected OtherParameters section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!( + s.section_kind(result.source()), + NumPySectionKind::OtherParameters + ); + let params: Vec<_> = s.parameters().collect(); + assert_eq!(params.len(), 1); } // ============================================================================= @@ -412,16 +380,11 @@ fn test_receives_basic() { let result = parse_numpy(docstring); let r = receives(&result); assert_eq!(r.len(), 1); - assert_eq!(r[0].names[0].source_text(&result.source), "data"); - assert_eq!( - r[0].r#type.as_ref().unwrap().source_text(&result.source), - "bytes" - ); + let names: Vec<_> = r[0].names().collect(); + assert_eq!(names[0].text(result.source()), "data"); + assert_eq!(r[0].r#type().unwrap().text(result.source()), "bytes"); assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + r[0].description().unwrap().text(result.source()), "The received data." ); } @@ -432,8 +395,10 @@ fn test_receives_multiple() { let result = parse_numpy(docstring); let r = receives(&result); assert_eq!(r.len(), 2); - assert_eq!(r[0].names[0].source_text(&result.source), "msg"); - assert_eq!(r[1].names[0].source_text(&result.source), "data"); + let names0: Vec<_> = r[0].names().collect(); + assert_eq!(names0[0].text(result.source()), "msg"); + let names1: Vec<_> = r[1].names().collect(); + assert_eq!(names1[0].text(result.source()), "data"); } /// `Receive` alias. @@ -443,10 +408,16 @@ fn test_receive_alias() { let result = parse_numpy(docstring); assert_eq!(receives(&result).len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Receive" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Receives); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Receives + ); } /// Receives section body variant check. @@ -454,10 +425,8 @@ fn test_receive_alias() { fn test_receives_section_body_variant() { let docstring = "Summary.\n\nReceives\n--------\ndata : bytes\n Payload.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Receives(params) => { - assert_eq!(params.len(), 1); - } - other => panic!("Expected Receives section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Receives); + let params: Vec<_> = s.parameters().collect(); + assert_eq!(params.len(), 1); } diff --git a/tests/numpy/raises.rs b/tests/numpy/raises.rs index 750ad65..42d40d1 100644 --- a/tests/numpy/raises.rs +++ b/tests/numpy/raises.rs @@ -19,11 +19,11 @@ TypeError assert_eq!(raises(&result).len(), 2); assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), + raises(&result)[0].r#type().text(result.source()), "ValueError" ); assert_eq!( - raises(&result)[1].r#type.source_text(&result.source), + raises(&result)[1].r#type().text(result.source()), "TypeError" ); } @@ -42,11 +42,11 @@ TypeError let result = parse_numpy(docstring); assert_eq!(raises(&result).len(), 2); assert_eq!( - raises(&result)[0].r#type.source_text(&result.source), + raises(&result)[0].r#type().text(result.source()), "ValueError" ); assert_eq!( - raises(&result)[1].r#type.source_text(&result.source), + raises(&result)[1].r#type().text(result.source()), "TypeError" ); } @@ -62,24 +62,16 @@ fn test_raises_colon_split() { let result = parse_numpy(docstring); let exc = raises(&result); assert_eq!(exc.len(), 2); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert!(exc[0].colon.is_some()); + assert_eq!(exc[0].r#type().text(result.source()), "ValueError"); + assert!(exc[0].colon().is_some()); assert_eq!( - exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + exc[0].description().unwrap().text(result.source()), "If the input is invalid." ); - assert_eq!(exc[1].r#type.source_text(&result.source), "TypeError"); - assert!(exc[1].colon.is_some()); + assert_eq!(exc[1].r#type().text(result.source()), "TypeError"); + assert!(exc[1].colon().is_some()); assert_eq!( - exc[1] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + exc[1].description().unwrap().text(result.source()), "If the type is wrong." ); } @@ -91,14 +83,10 @@ fn test_raises_no_colon() { let result = parse_numpy(docstring); let exc = raises(&result); assert_eq!(exc.len(), 1); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert!(exc[0].colon.is_none()); + assert_eq!(exc[0].r#type().text(result.source()), "ValueError"); + assert!(exc[0].colon().is_none()); assert_eq!( - exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source), + exc[0].description().unwrap().text(result.source()), "If the input is invalid." ); } @@ -110,13 +98,9 @@ fn test_raises_colon_with_continuation() { let result = parse_numpy(docstring); let exc = raises(&result); assert_eq!(exc.len(), 1); - assert_eq!(exc[0].r#type.source_text(&result.source), "ValueError"); - assert!(exc[0].colon.is_some()); - let desc = exc[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); + assert_eq!(exc[0].r#type().text(result.source()), "ValueError"); + assert!(exc[0].colon().is_some()); + let desc = exc[0].description().unwrap().text(result.source()); assert!(desc.contains("If bad."), "desc = {:?}", desc); assert!(desc.contains("More detail here."), "desc = {:?}", desc); } @@ -129,10 +113,16 @@ fn test_raise_alias() { let exc = raises(&result); assert_eq!(exc.len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Raise" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Raises); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Raises + ); } // ============================================================================= @@ -145,15 +135,9 @@ fn test_warns_basic() { let result = parse_numpy(docstring); let w = warns(&result); assert_eq!(w.len(), 1); + assert_eq!(w[0].r#type().text(result.source()), "DeprecationWarning"); assert_eq!( - w[0].r#type.source_text(&result.source), - "DeprecationWarning" - ); - assert_eq!( - w[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + w[0].description().unwrap().text(result.source()), "If the old API is used." ); } @@ -165,11 +149,8 @@ fn test_warns_multiple() { let result = parse_numpy(docstring); let w = warns(&result); assert_eq!(w.len(), 2); - assert_eq!( - w[0].r#type.source_text(&result.source), - "DeprecationWarning" - ); - assert_eq!(w[1].r#type.source_text(&result.source), "UserWarning"); + assert_eq!(w[0].r#type().text(result.source()), "DeprecationWarning"); + assert_eq!(w[1].r#type().text(result.source()), "UserWarning"); } /// Warns with colon separating type and description on the same line. @@ -179,13 +160,10 @@ fn test_warns_colon_split() { let result = parse_numpy(docstring); let w = warns(&result); assert_eq!(w.len(), 1); - assert_eq!(w[0].r#type.source_text(&result.source), "UserWarning"); - assert!(w[0].colon.is_some()); + assert_eq!(w[0].r#type().text(result.source()), "UserWarning"); + assert!(w[0].colon().is_some()); assert_eq!( - w[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + w[0].description().unwrap().text(result.source()), "If input is unusual." ); } @@ -198,10 +176,16 @@ fn test_warn_alias() { let w = warns(&result); assert_eq!(w.len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Warn" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Warns); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Warns + ); } /// Warns section body variant check. @@ -209,10 +193,8 @@ fn test_warn_alias() { fn test_warns_section_body_variant() { let docstring = "Summary.\n\nWarns\n-----\nUserWarning\n Bad.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Warns(items) => { - assert_eq!(items.len(), 1); - } - other => panic!("Expected Warns section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Warns); + let items: Vec<_> = s.warnings().collect(); + assert_eq!(items.len(), 1); } diff --git a/tests/numpy/returns.rs b/tests/numpy/returns.rs index 567cc7b..fd4b9ff 100644 --- a/tests/numpy/returns.rs +++ b/tests/numpy/returns.rs @@ -18,32 +18,24 @@ y : float let result = parse_numpy(docstring); assert_eq!(returns(&result).len(), 2); assert_eq!( - returns(&result)[0] - .name - .as_ref() - .map(|n| n.source_text(&result.source)), + returns(&result)[0].name().map(|n| n.text(result.source())), Some("x") ); assert_eq!( returns(&result)[0] - .return_type - .as_ref() - .map(|t| t.source_text(&result.source)), + .return_type() + .map(|t| t.text(result.source())), Some("int") ); assert_eq!( returns(&result)[0] - .description - .as_ref() + .description() .unwrap() - .source_text(&result.source), + .text(result.source()), "The first value." ); assert_eq!( - returns(&result)[1] - .name - .as_ref() - .map(|n| n.source_text(&result.source)), + returns(&result)[1].name().map(|n| n.text(result.source())), Some("y") ); } @@ -55,17 +47,8 @@ fn test_returns_no_spaces_around_colon() { let result = parse_numpy(docstring); let r = returns(&result); assert_eq!(r.len(), 1); - assert_eq!( - r[0].name.as_ref().unwrap().source_text(&result.source), - "result" - ); - assert_eq!( - r[0].return_type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); + assert_eq!(r[0].name().unwrap().text(result.source()), "result"); + assert_eq!(r[0].return_type().unwrap().text(result.source()), "int"); } /// Returns with type only (no name). @@ -75,18 +58,9 @@ fn test_returns_type_only() { let result = parse_numpy(docstring); let r = returns(&result); assert_eq!(r.len(), 1); + assert_eq!(r[0].return_type().unwrap().text(result.source()), "int"); assert_eq!( - r[0].return_type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); - assert_eq!( - r[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + r[0].description().unwrap().text(result.source()), "The result." ); } @@ -99,10 +73,16 @@ fn test_return_alias() { let r = returns(&result); assert_eq!(r.len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Return" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Returns); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Returns + ); } /// Returns with multiline description. @@ -113,11 +93,7 @@ fn test_returns_multiline_description() { let result = parse_numpy(docstring); let r = returns(&result); assert_eq!(r.len(), 1); - let desc = r[0] - .description - .as_ref() - .unwrap() - .source_text(&result.source); + let desc = r[0].description().unwrap().text(result.source()); assert!(desc.contains("First line.")); assert!(desc.contains("Second paragraph.")); } @@ -132,18 +108,9 @@ fn test_yields_basic() { let result = parse_numpy(docstring); let y = yields(&result); assert_eq!(y.len(), 1); + assert_eq!(y[0].return_type().unwrap().text(result.source()), "int"); assert_eq!( - y[0].return_type - .as_ref() - .unwrap() - .source_text(&result.source), - "int" - ); - assert_eq!( - y[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + y[0].description().unwrap().text(result.source()), "The next value." ); } @@ -154,17 +121,8 @@ fn test_yields_named() { let result = parse_numpy(docstring); let y = yields(&result); assert_eq!(y.len(), 1); - assert_eq!( - y[0].name.as_ref().unwrap().source_text(&result.source), - "value" - ); - assert_eq!( - y[0].return_type - .as_ref() - .unwrap() - .source_text(&result.source), - "str" - ); + assert_eq!(y[0].name().unwrap().text(result.source()), "value"); + assert_eq!(y[0].return_type().unwrap().text(result.source()), "str"); } #[test] @@ -174,14 +132,8 @@ fn test_yields_multiple() { let result = parse_numpy(docstring); let y = yields(&result); assert_eq!(y.len(), 2); - assert_eq!( - y[0].name.as_ref().unwrap().source_text(&result.source), - "index" - ); - assert_eq!( - y[1].name.as_ref().unwrap().source_text(&result.source), - "value" - ); + assert_eq!(y[0].name().unwrap().text(result.source()), "index"); + assert_eq!(y[1].name().unwrap().text(result.source()), "value"); } /// Yields — `Yield` alias. @@ -192,10 +144,16 @@ fn test_yield_alias() { let y = yields(&result); assert_eq!(y.len(), 1); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "Yield" ); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Yields); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Yields + ); } /// Yields section body variant check. @@ -203,10 +161,8 @@ fn test_yield_alias() { fn test_yields_section_body_variant() { let docstring = "Summary.\n\nYields\n------\nint\n Value.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Yields(items) => { - assert_eq!(items.len(), 1); - } - other => panic!("Expected Yields section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Yields); + let items: Vec<_> = s.returns().collect(); + assert_eq!(items.len(), 1); } diff --git a/tests/numpy/sections.rs b/tests/numpy/sections.rs index 8c8d958..896b8a5 100644 --- a/tests/numpy/sections.rs +++ b/tests/numpy/sections.rs @@ -24,18 +24,22 @@ Some notes here. "#; let result = parse_numpy(docstring); assert_eq!(parameters(&result).len(), 1); - assert_eq!( - parameters(&result)[0].names[0].source_text(&result.source), - "x" - ); + let names: Vec<_> = parameters(&result)[0].names().collect(); + assert_eq!(names[0].text(result.source()), "x"); assert_eq!(returns(&result).len(), 1); assert!(notes(&result).is_some()); assert_eq!( - sections(&result)[0].header.name.source_text(&result.source), + all_sections(&result)[0] + .header() + .name() + .text(result.source()), "parameters" ); assert_eq!( - sections(&result)[2].header.name.source_text(&result.source), + all_sections(&result)[2] + .header() + .name() + .text(result.source()), "NOTES" ); } @@ -54,9 +58,9 @@ x : int Desc. "#; let result = parse_numpy(docstring); - let hdr = §ions(&result)[0].header; - assert_eq!(hdr.name.source_text(&result.source), "Parameters"); - assert_eq!(hdr.underline.source_text(&result.source), "----------"); + let hdr = all_sections(&result)[0].header(); + assert_eq!(hdr.name().text(result.source()), "Parameters"); + assert_eq!(hdr.underline().text(result.source()), "----------"); } // ============================================================================= @@ -73,29 +77,24 @@ x : int Description of x. "#; let result = parse_numpy(docstring); - let src = &result.source; + let src = result.source(); + assert_eq!(doc(&result).summary().unwrap().text(src), "Summary line."); assert_eq!( - result.summary.as_ref().unwrap().source_text(src), - "Summary line." - ); - assert_eq!( - sections(&result)[0].header.name.source_text(src), + all_sections(&result)[0].header().name().text(src), "Parameters" ); - let underline = §ions(&result)[0] - .header - .underline - .source_text(&result.source); + let underline = all_sections(&result)[0] + .header() + .underline() + .text(result.source()); assert!(underline.chars().all(|c| c == '-')); let p = ¶meters(&result)[0]; - assert_eq!(p.names[0].source_text(src), "x"); - assert_eq!(p.r#type.as_ref().unwrap().source_text(src), "int"); - assert_eq!( - p.description.as_ref().unwrap().source_text(src), - "Description of x." - ); + let names: Vec<_> = p.names().collect(); + assert_eq!(names[0].text(src), "x"); + assert_eq!(p.r#type().unwrap().text(src), "int"); + assert_eq!(p.description().unwrap().text(src), "Description of x."); } // ============================================================================= @@ -115,16 +114,15 @@ x : int Desc. "#; let result = parse_numpy(docstring); - let dep = result - .deprecation - .as_ref() + let dep = doc(&result) + .deprecation() .expect("deprecation should be parsed"); - assert_eq!(dep.version.source_text(&result.source), "1.6.0"); + assert_eq!(dep.version().text(result.source()), "1.6.0"); assert_eq!( - dep.description.source_text(&result.source), + dep.description().unwrap().text(result.source()), "Use `new_func` instead." ); - assert_eq!(dep.version.source_text(&result.source), "1.6.0"); + assert_eq!(dep.version().text(result.source()), "1.6.0"); } // ============================================================================= @@ -155,12 +153,18 @@ Notes Some notes. "#; let result = parse_numpy(docstring); - let s = sections(&result); + let s = all_sections(&result); assert_eq!(s.len(), 4); - assert_eq!(s[0].header.kind, NumPySectionKind::Parameters); - assert_eq!(s[1].header.kind, NumPySectionKind::Returns); - assert_eq!(s[2].header.kind, NumPySectionKind::Raises); - assert_eq!(s[3].header.kind, NumPySectionKind::Notes); + assert_eq!( + s[0].section_kind(result.source()), + NumPySectionKind::Parameters + ); + assert_eq!( + s[1].section_kind(result.source()), + NumPySectionKind::Returns + ); + assert_eq!(s[2].section_kind(result.source()), NumPySectionKind::Raises); + assert_eq!(s[3].section_kind(result.source()), NumPySectionKind::Notes); } #[test] @@ -222,8 +226,9 @@ fn test_section_kind_display() { fn test_docstring_display() { let docstring = "My summary."; let result = parse_numpy(docstring); + // The root node covers the full source text assert_eq!( - format!("{}", result), - "NumPyDocstring(summary: My summary.)" + doc(&result).syntax().range().source_text(result.source()), + "My summary." ); } diff --git a/tests/numpy/structured.rs b/tests/numpy/structured.rs index e4736f1..00af32a 100644 --- a/tests/numpy/structured.rs +++ b/tests/numpy/structured.rs @@ -11,23 +11,14 @@ fn test_attributes_basic() { let result = parse_numpy(docstring); let a = attributes(&result); assert_eq!(a.len(), 2); - assert_eq!(a[0].name.source_text(&result.source), "name"); + assert_eq!(a[0].name().text(result.source()), "name"); + assert_eq!(a[0].r#type().unwrap().text(result.source()), "str"); assert_eq!( - a[0].r#type.as_ref().unwrap().source_text(&result.source), - "str" - ); - assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The name." ); - assert_eq!(a[1].name.source_text(&result.source), "age"); - assert_eq!( - a[1].r#type.as_ref().unwrap().source_text(&result.source), - "int" - ); + assert_eq!(a[1].name().text(result.source()), "age"); + assert_eq!(a[1].r#type().unwrap().text(result.source()), "int"); } #[test] @@ -36,13 +27,10 @@ fn test_attributes_no_type() { let result = parse_numpy(docstring); let a = attributes(&result); assert_eq!(a.len(), 1); - assert_eq!(a[0].name.source_text(&result.source), "name"); - assert!(a[0].r#type.is_none()); + assert_eq!(a[0].name().text(result.source()), "name"); + assert!(a[0].r#type().is_none()); assert_eq!( - a[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + a[0].description().unwrap().text(result.source()), "The name." ); } @@ -53,11 +41,8 @@ fn test_attributes_with_colon() { let result = parse_numpy(docstring); let a = attributes(&result); assert_eq!(a.len(), 1); - assert!(a[0].colon.is_some()); - assert_eq!( - a[0].colon.as_ref().unwrap().source_text(&result.source), - ":" - ); + assert!(a[0].colon().is_some()); + assert_eq!(a[0].colon().unwrap().text(result.source()), ":"); } /// Attributes section body variant check. @@ -65,12 +50,13 @@ fn test_attributes_with_colon() { fn test_attributes_section_body_variant() { let docstring = "Summary.\n\nAttributes\n----------\nx : int\n Value.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Attributes(attrs) => { - assert_eq!(attrs.len(), 1); - } - other => panic!("Expected Attributes section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!( + s.section_kind(result.source()), + NumPySectionKind::Attributes + ); + let attrs: Vec<_> = s.attributes().collect(); + assert_eq!(attrs.len(), 1); } /// Attributes section kind check. @@ -79,7 +65,7 @@ fn test_attributes_section_kind() { let docstring = "Summary.\n\nAttributes\n----------\nx : int\n Value.\n"; let result = parse_numpy(docstring); assert_eq!( - sections(&result)[0].header.kind, + all_sections(&result)[0].section_kind(result.source()), NumPySectionKind::Attributes ); } @@ -94,20 +80,14 @@ fn test_methods_basic() { let result = parse_numpy(docstring); let m = methods(&result); assert_eq!(m.len(), 2); - assert_eq!(m[0].name.source_text(&result.source), "reset()"); + assert_eq!(m[0].name().text(result.source()), "reset()"); assert_eq!( - m[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + m[0].description().unwrap().text(result.source()), "Reset the state." ); - assert_eq!(m[1].name.source_text(&result.source), "update(data)"); + assert_eq!(m[1].name().text(result.source()), "update(data)"); assert_eq!( - m[1].description - .as_ref() - .unwrap() - .source_text(&result.source), + m[1].description().unwrap().text(result.source()), "Update with new data." ); } @@ -118,11 +98,11 @@ fn test_methods_with_colon() { let result = parse_numpy(docstring); let m = methods(&result); assert_eq!(m.len(), 1); - assert_eq!(m[0].name.source_text(&result.source), "reset()"); - assert!(m[0].colon.is_some()); + assert_eq!(m[0].name().text(result.source()), "reset()"); + assert!(m[0].colon().is_some()); // Description may be inline or on next line depending on parser - if let Some(desc) = &m[0].description { - assert!(desc.source_text(&result.source).contains("Reset")); + if let Some(desc) = m[0].description() { + assert!(desc.text(result.source()).contains("Reset")); } } @@ -132,12 +112,9 @@ fn test_methods_without_parens() { let result = parse_numpy(docstring); let m = methods(&result); assert_eq!(m.len(), 1); - assert_eq!(m[0].name.source_text(&result.source), "do_stuff"); + assert_eq!(m[0].name().text(result.source()), "do_stuff"); assert_eq!( - m[0].description - .as_ref() - .unwrap() - .source_text(&result.source), + m[0].description().unwrap().text(result.source()), "Performs the operation." ); } @@ -147,12 +124,10 @@ fn test_methods_without_parens() { fn test_methods_section_body_variant() { let docstring = "Summary.\n\nMethods\n-------\nfoo()\n Does bar.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Methods(methods) => { - assert_eq!(methods.len(), 1); - } - other => panic!("Expected Methods section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Methods); + let m: Vec<_> = s.methods().collect(); + assert_eq!(m.len(), 1); } /// Methods section kind check. @@ -160,7 +135,10 @@ fn test_methods_section_body_variant() { fn test_methods_section_kind() { let docstring = "Summary.\n\nMethods\n-------\nfoo()\n Does bar.\n"; let result = parse_numpy(docstring); - assert_eq!(sections(&result)[0].header.kind, NumPySectionKind::Methods); + assert_eq!( + all_sections(&result)[0].section_kind(result.source()), + NumPySectionKind::Methods + ); } // ============================================================================= @@ -171,31 +149,28 @@ fn test_methods_section_kind() { fn test_unknown_section() { let docstring = "Summary.\n\nCustomSection\n-------------\nSome custom content.\n"; let result = parse_numpy(docstring); - let s = sections(&result); + let s = all_sections(&result); assert_eq!(s.len(), 1); - assert_eq!(s[0].header.kind, NumPySectionKind::Unknown); assert_eq!( - s[0].header.name.source_text(&result.source), - "CustomSection" + s[0].section_kind(result.source()), + NumPySectionKind::Unknown ); + assert_eq!(s[0].header().name().text(result.source()), "CustomSection"); } #[test] fn test_unknown_section_body_variant() { let docstring = "Summary.\n\nCustomSection\n-------------\nSome content.\n"; let result = parse_numpy(docstring); - match §ions(&result)[0].body { - NumPySectionBody::Unknown(text) => { - assert!(text.is_some()); - assert!( - text.as_ref() - .unwrap() - .source_text(&result.source) - .contains("Some content.") - ); - } - other => panic!("Expected Unknown section body, got {:?}", other), - } + let s = &all_sections(&result)[0]; + assert_eq!(s.section_kind(result.source()), NumPySectionKind::Unknown); + let text = s.body_text(); + assert!(text.is_some()); + assert!( + text.unwrap() + .text(result.source()) + .contains("Some content.") + ); } #[test] @@ -203,8 +178,14 @@ fn test_unknown_section_with_known_sections() { let docstring = "Summary.\n\nParameters\n----------\nx : int\n Value.\n\nCustom\n------\nExtra info.\n"; let result = parse_numpy(docstring); - let s = sections(&result); + let s = all_sections(&result); assert_eq!(s.len(), 2); - assert_eq!(s[0].header.kind, NumPySectionKind::Parameters); - assert_eq!(s[1].header.kind, NumPySectionKind::Unknown); + assert_eq!( + s[0].section_kind(result.source()), + NumPySectionKind::Parameters + ); + assert_eq!( + s[1].section_kind(result.source()), + NumPySectionKind::Unknown + ); } diff --git a/tests/numpy/summary.rs b/tests/numpy/summary.rs index 6b0fdee..98d5f97 100644 --- a/tests/numpy/summary.rs +++ b/tests/numpy/summary.rs @@ -10,10 +10,10 @@ fn test_simple_summary() { let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "This is a brief summary." ); - assert!(result.extended_summary.is_none()); + assert!(doc(&result).extended_summary().is_none()); assert!(parameters(&result).is_empty()); } @@ -22,13 +22,19 @@ fn test_parse_simple_span() { let docstring = "Brief description."; let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Brief description." ); - assert_eq!(result.summary.as_ref().unwrap().start(), TextSize::new(0)); - assert_eq!(result.summary.as_ref().unwrap().end(), TextSize::new(18)); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().range().start(), + TextSize::new(0) + ); + assert_eq!( + doc(&result).summary().unwrap().range().end(), + TextSize::new(18) + ); + assert_eq!( + doc(&result).summary().unwrap().text(result.source()), "Brief description." ); } @@ -43,10 +49,10 @@ more details about the function. let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Brief summary." ); - assert!(result.extended_summary.is_some()); + assert!(doc(&result).extended_summary().is_some()); } #[test] @@ -54,11 +60,11 @@ fn test_multiline_summary() { let docstring = "This is a long summary\nthat spans two lines.\n\nExtended description."; let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "This is a long summary\nthat spans two lines." ); - let desc = result.extended_summary.as_ref().unwrap(); - assert_eq!(desc.source_text(&result.source), "Extended description."); + let desc = doc(&result).extended_summary().unwrap(); + assert_eq!(desc.text(result.source()), "Extended description."); } #[test] @@ -66,30 +72,33 @@ fn test_multiline_summary_no_extended() { let docstring = "Summary line one\ncontinues here."; let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "Summary line one\ncontinues here." ); - assert!(result.extended_summary.is_none()); + assert!(doc(&result).extended_summary().is_none()); } #[test] fn test_empty_docstring() { let result = parse_numpy(""); - assert!(result.summary.is_none()); + assert!(doc(&result).summary().is_none()); } #[test] fn test_whitespace_only_docstring() { let result = parse_numpy(" \n\n "); - assert!(result.summary.is_none()); + assert!(doc(&result).summary().is_none()); } #[test] fn test_docstring_span_covers_entire_input() { let docstring = "First line.\n\nSecond line."; let result = parse_numpy(docstring); - assert_eq!(result.range.start(), TextSize::new(0)); - assert_eq!(result.range.end().raw() as usize, docstring.len()); + assert_eq!(doc(&result).syntax().range().start(), TextSize::new(0)); + assert_eq!( + doc(&result).syntax().range().end().raw() as usize, + docstring.len() + ); } // ============================================================================= @@ -111,7 +120,7 @@ b : int "#; let result = parse_numpy(docstring); assert_eq!( - result.summary.as_ref().unwrap().source_text(&result.source), + doc(&result).summary().unwrap().text(result.source()), "add(a, b)" ); assert_eq!(parameters(&result).len(), 2); @@ -135,8 +144,8 @@ x : int Desc. "#; let result = parse_numpy(docstring); - let ext = result.extended_summary.as_ref().unwrap(); - assert!(ext.source_text(&result.source).contains("First paragraph")); - assert!(ext.source_text(&result.source).contains("Second paragraph")); - assert!(ext.source_text(&result.source).contains('\n')); + let ext = doc(&result).extended_summary().unwrap(); + assert!(ext.text(result.source()).contains("First paragraph")); + assert!(ext.text(result.source()).contains("Second paragraph")); + assert!(ext.text(result.source()).contains('\n')); } From 58163486fdb06aa5b5eb5aabae1596853b021862 Mon Sep 17 00:00:00 2001 From: qraqras Date: Fri, 6 Mar 2026 05:35:10 +0000 Subject: [PATCH 7/8] chore: v0.0.2 --- .github/copilot-instructions.md | 22 +-- Cargo.lock | 2 +- Cargo.toml | 6 +- README.md | 245 ++++++++++---------------------- examples/parse_google.rs | 10 +- examples/parse_numpy.rs | 10 +- examples/test_ret.rs | 37 ----- src/syntax.rs | 27 ++-- 8 files changed, 107 insertions(+), 252 deletions(-) delete mode 100644 examples/test_ret.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index abfced8..f6b6138 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,16 +1,6 @@ -# pydocstringについて -- pydocstringはPythonのdocstringをパースするライブラリです -- pydocstringはNumPy/Googleスタイルをサポートします -- 将来的にリンタやフォーマッタで使用されることを想定しています -- Rustで実装します -- Rustの外部クレートは使用しません - -# 実装方針 -- NumPy->Googleの順で実装します -- リンタ向けにパース結果には位置情報を含めます -- 設計方針はBiomeを参考にします -- 内部実装はスタイルごとに具体的な構造体を定義します -- 公開APIは抽象化された構造体を返すようにします - -# docstringのスタイルガイド -- NumPy: https://numpydoc.readthedocs.io/en/latest/format.html +# About pydocstring +- Parses Python docstrings +- Supports NumPy and Google style docstrings +- Designed to be used by linters and formatters +- Implemented in Rust +- No external crate dependencies diff --git a/Cargo.lock b/Cargo.lock index 37fc483..ca2d913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "pydocstring" -version = "0.0.1" +version = "0.0.2" diff --git a/Cargo.toml b/Cargo.toml index 4af6a7a..fd66f16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] name = "pydocstring" -version = "0.0.1" +version = "0.0.2" edition = "2024" authors = ["Ryuma Asai"] -description = "A fast, zero-dependency Rust parser for Python docstrings (NumPy and Google styles) with a unified syntax tree and byte-precise source locations" +description = "A zero-dependency Rust parser for Python docstrings (Google and NumPy styles) with a unified syntax tree and byte-precise source locations" license = "MIT" repository = "https://github.com/qraqras/pydocstring" homepage = "https://github.com/qraqras/pydocstring" documentation = "https://docs.rs/pydocstring" readme = "README.md" -keywords = ["python", "docstring", "parser", "numpy", "google"] +keywords = ["python", "docstring", "parser", "google", "numpy"] categories = ["parser-implementations", "development-tools"] rust-version = "1.85" exclude = ["target/", "tests/", ".github/", ".devcontainer/"] diff --git a/README.md b/README.md index af0a8b0..d54a9d4 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,88 @@ # pydocstring -A fast, zero-dependency Rust parser for Python docstrings. -Parses Google and NumPy style docstrings into a **unified syntax tree** with **byte-precise source locations** on every token. +![Crates.io Version](https://img.shields.io/crates/v/pydocstring?color=CC6688) +![Crates.io MSRV](https://img.shields.io/crates/msrv/pydocstring?color=CC6688) +![Crates.io License](https://img.shields.io/crates/l/pydocstring?color=CC6688) -## Why pydocstring? +A zero-dependency Rust parser for Python docstrings (Google / NumPy style). -Existing Python docstring parsers (docstring_parser, griffe, etc.) return flat lists of extracted values with no positional information. pydocstring is designed as **infrastructure for linters and formatters**: +Produces a **unified syntax tree** with **byte-precise source locations** on every token — designed as infrastructure for linters and formatters. -- **Byte-precise source locations** — every `SyntaxToken` carries a `TextRange` (byte offset pair), so tools can emit diagnostics pointing to exact positions in the original text -- **Uniform syntax tree** — Google and NumPy styles produce the same `SyntaxNode` / `SyntaxToken` tree structure (inspired by [Biome](https://biomejs.dev/)), enabling style-agnostic tree traversal via `Visitor` + `walk` -- **Zero dependencies, never panics** — pure Rust with no external crates; always returns a best-effort tree for any input -- **Native performance** — suitable for embedding in Rust-based toolchains like Ruff -## Installation +## Features + +- **Byte-precise source locations** — every token carries a `TextRange` (byte offset pair) for exact diagnostic positions +- **Unified syntax tree** — both styles produce the same `SyntaxNode` / `SyntaxToken` tree, enabling style-agnostic traversal via `Visitor` + `walk` +- **Zero dependencies** — pure Rust, no external crates +- **Never panics** — always returns a best-effort tree for any input +- **Style auto-detection** — `detect_style()` identifies the docstring convention automatically -Add to your `Cargo.toml`: +## Installation ```toml [dependencies] -pydocstring = "0.0.1" +pydocstring = "0.0.2" ``` -## Quick Start - -### NumPy Style - -```rust -use pydocstring::numpy::{parse_numpy, nodes::NumPyDocstring}; -use pydocstring::NumPySectionKind; - -let docstring = "\ -Calculate the area of a rectangle. - -Parameters ----------- -width : float - The width of the rectangle. -height : float - The height of the rectangle. - -Returns -------- -float - The area of the rectangle. -"; - -let result = parse_numpy(docstring); -let doc = NumPyDocstring::cast(result.root()).unwrap(); +## Usage -// Summary -println!("{}", doc.summary().unwrap().text(result.source())); - -// Iterate sections -for section in doc.sections() { - match section.section_kind(result.source()) { - NumPySectionKind::Parameters => { - for param in section.parameters() { - let names: Vec<&str> = param.names() - .map(|n| n.text(result.source())) - .collect(); - let ty = param.r#type().map(|t| t.text(result.source())); - println!(" {:?}: {:?}", names, ty); - } - } - NumPySectionKind::Returns => { - for ret in section.returns() { - let ty = ret.return_type().map(|t| t.text(result.source())); - println!(" -> {:?}", ty); - } - } - _ => {} - } -} -``` - -### Google Style +### Parsing ```rust use pydocstring::google::{parse_google, nodes::GoogleDocstring}; use pydocstring::GoogleSectionKind; -let docstring = "\ -Calculate the area of a rectangle. - -Args: - width (float): The width of the rectangle. - height (float): The height of the rectangle. - -Returns: - float: The area of the rectangle. - -Raises: - ValueError: If width or height is negative. -"; - -let result = parse_google(docstring); +let input = "Summary.\n\nArgs:\n x (int): The value.\n y (int): Another value."; +let result = parse_google(input); let doc = GoogleDocstring::cast(result.root()).unwrap(); -// Summary println!("{}", doc.summary().unwrap().text(result.source())); -// Iterate sections for section in doc.sections() { - match section.section_kind(result.source()) { - GoogleSectionKind::Args => { - for arg in section.args() { - println!(" {} ({:?}): {:?}", - arg.name().text(result.source()), - arg.r#type().map(|t| t.text(result.source())), - arg.description().map(|d| d.text(result.source()))); - } + if section.section_kind(result.source()) == GoogleSectionKind::Args { + for arg in section.args() { + println!("{}: {}", + arg.name().text(result.source()), + arg.r#type().map(|t| t.text(result.source())).unwrap_or("")); } - GoogleSectionKind::Raises => { - for exc in section.exceptions() { - println!(" {}: {:?}", - exc.r#type().text(result.source()), - exc.description().map(|d| d.text(result.source()))); - } - } - _ => {} } } ``` +NumPy style works the same way — use `parse_numpy` / `NumPyDocstring` instead. + ### Style Auto-Detection ```rust use pydocstring::{detect_style, Style}; -let numpy_doc = "Summary.\n\nParameters\n----------\nx : int\n Desc."; -assert_eq!(detect_style(numpy_doc), Style::NumPy); - -let google_doc = "Summary.\n\nArgs:\n x: Desc."; -assert_eq!(detect_style(google_doc), Style::Google); +assert_eq!(detect_style("Summary.\n\nArgs:\n x: Desc."), Style::Google); +assert_eq!(detect_style("Summary.\n\nParameters\n----------\nx : int"), Style::NumPy); ``` -## Source Locations +### Source Locations -Every token carries a `TextRange` (byte offsets), so linters can report precise positions: +Every token carries byte offsets for precise diagnostics: ```rust -use pydocstring::numpy::{parse_numpy, nodes::NumPyDocstring}; -use pydocstring::NumPySectionKind; +use pydocstring::google::{parse_google, nodes::GoogleDocstring}; +use pydocstring::GoogleSectionKind; -let docstring = "Summary.\n\nParameters\n----------\nx : int\n The value."; -let result = parse_numpy(docstring); -let doc = NumPyDocstring::cast(result.root()).unwrap(); +let result = parse_google("Summary.\n\nArgs:\n x (int): The value."); +let doc = GoogleDocstring::cast(result.root()).unwrap(); for section in doc.sections() { - if section.section_kind(result.source()) == NumPySectionKind::Parameters { - for param in section.parameters() { - let name = param.names().next().unwrap(); - println!("Parameter '{}' at byte {}..{}", - name.text(result.source()), - name.range().start(), - name.range().end()); - // => Parameter 'x' at byte 31..32 + if section.section_kind(result.source()) == GoogleSectionKind::Args { + for arg in section.args() { + let name = arg.name(); + println!("'{}' at byte {}..{}", + name.text(result.source()), name.range().start(), name.range().end()); } } } ``` -## Syntax Tree +### Syntax Tree The parse result is a tree of `SyntaxNode` (branches) and `SyntaxToken` (leaves), each tagged with a `SyntaxKind`. Use `pretty_print()` to visualize: @@ -172,24 +93,23 @@ let result = parse_google("Summary.\n\nArgs:\n x (int): The value."); println!("{}", result.pretty_print()); ``` -Output: -``` +```text GOOGLE_DOCSTRING@0..42 { - SUMMARY: "Summary."@0..8 - GOOGLE_SECTION@10..42 { - GOOGLE_SECTION_HEADER@10..15 { - NAME: "Args"@10..14 - COLON: ":"@14..15 - } - GOOGLE_ARG@20..42 { - NAME: "x"@20..21 - OPEN_BRACKET: "("@22..23 - TYPE: "int"@23..26 - CLOSE_BRACKET: ")"@26..27 - COLON: ":"@27..28 - DESCRIPTION: "The value."@29..39 - } + SUMMARY: "Summary."@0..8 + GOOGLE_SECTION@10..42 { + GOOGLE_SECTION_HEADER@10..15 { + NAME: "Args"@10..14 + COLON: ":"@14..15 } + GOOGLE_ARG@20..42 { + NAME: "x"@20..21 + OPEN_BRACKET: "("@22..23 + TYPE: "int"@23..26 + CLOSE_BRACKET: ")"@26..27 + COLON: ":"@27..28 + DESCRIPTION: "The value."@29..39 + } + } } ``` @@ -198,7 +118,7 @@ GOOGLE_DOCSTRING@0..42 { Walk the tree with the `Visitor` trait for style-agnostic analysis: ```rust -use pydocstring::{Visitor, walk, SyntaxNode, SyntaxToken, SyntaxKind}; +use pydocstring::{Visitor, walk, SyntaxToken, SyntaxKind}; use pydocstring::google::parse_google; struct NameCollector<'a> { @@ -222,47 +142,26 @@ assert_eq!(collector.names, vec!["Args", "x", "y"]); ## Supported Sections -### NumPy Style - -| Section | Typed accessor | Entry type | -|---------|---------------|------------| -| Parameters / Other Parameters / Receives | `parameters()` | `NumPyParameter` | -| Returns / Yields | `returns()` | `NumPyReturns` | -| Raises | `exceptions()` | `NumPyException` | -| Warns | `warnings()` | `NumPyWarning` | -| See Also | `see_also_items()` | `NumPySeeAlsoItem` | -| References | `references()` | `NumPyReference` | -| Attributes | `attributes()` | `NumPyAttribute` | -| Methods | `methods()` | `NumPyMethod` | -| Notes / Examples / Warnings | `body_text()` | Free text | - -Additional root-level elements: `summary()`, `extended_summary()`, `deprecation()`. - -### Google Style - -| Section | Typed accessor | Entry type | -|---------|---------------|------------| -| Args / Keyword Args / Other Parameters / Receives | `args()` | `GoogleArg` | -| Returns / Yields | `returns()` | `GoogleReturns` | -| Raises | `exceptions()` | `GoogleException` | -| Warns | `warnings()` | `GoogleWarning` | -| See Also | `see_also_items()` | `GoogleSeeAlsoItem` | -| Attributes | `attributes()` | `GoogleAttribute` | -| Methods | `methods()` | `GoogleMethod` | -| Notes / Examples / Todo / References / Warnings | `body_text()` | Free text | -| Admonitions (Attention, Caution, Danger, ...) | `body_text()` | Free text | - -Additional root-level elements: `summary()`, `extended_summary()`. +Both styles support the following section categories. Typed accessor methods are available on each style's section node. + +| Category | Google | NumPy | +|-----------------------------------|------------------------------------------|-----------------------------------------| +| Parameters | `args()` → `GoogleArg` | `parameters()` → `NumPyParameter` | +| Returns / Yields | `returns()` → `GoogleReturns` | `returns()` → `NumPyReturns` | +| Raises | `exceptions()` → `GoogleException` | `exceptions()` → `NumPyException` | +| Warns | `warnings()` → `GoogleWarning` | `warnings()` → `NumPyWarning` | +| See Also | `see_also_items()` → `GoogleSeeAlsoItem` | `see_also_items()` → `NumPySeeAlsoItem` | +| Attributes | `attributes()` → `GoogleAttribute` | `attributes()` → `NumPyAttribute` | +| Methods | `methods()` → `GoogleMethod` | `methods()` → `NumPyMethod` | +| Free text (Notes, Examples, etc.) | `body_text()` | `body_text()` | + +Root-level accessors: `summary()`, `extended_summary()` (NumPy also has `deprecation()`). ## Development ```bash -cargo build # Build -cargo test # Run all 270+ tests -cargo run --example parse_numpy +cargo build +cargo test cargo run --example parse_google +cargo run --example parse_numpy ``` - -## License - -MIT diff --git a/examples/parse_google.rs b/examples/parse_google.rs index 3ad53c0..9889963 100644 --- a/examples/parse_google.rs +++ b/examples/parse_google.rs @@ -24,15 +24,19 @@ Raises: let parsed = parse_google(docstring); - // Display: raw source text println!("╔══════════════════════════════════════════════════╗"); println!("║ Google-style Docstring Example ║"); println!("╚══════════════════════════════════════════════════╝"); + println!(); - println!("── Display (raw text) ─────────────────────────────"); + + // Display: raw source text + println!("── raw text ────────────────────────────────────────"); println!("{}", parsed.source()); + println!(); + // pretty_print: structured AST - println!("── pretty_print (parsed AST) ──────────────────────"); + println!("── parsed AST ──────────────────────────────────────"); print!("{}", parsed.pretty_print()); } diff --git a/examples/parse_numpy.rs b/examples/parse_numpy.rs index 0bf8eb3..7c6d4b8 100644 --- a/examples/parse_numpy.rs +++ b/examples/parse_numpy.rs @@ -37,15 +37,19 @@ Examples let parsed = parse_numpy(docstring); let doc = NumPyDocstring::cast(parsed.root()).unwrap(); - // Display: raw source text println!("╔══════════════════════════════════════════════════╗"); println!("║ NumPy-style Docstring Example ║"); println!("╚══════════════════════════════════════════════════╝"); + println!(); - println!("── Display (raw text) ─────────────────────────────"); + + // Display: raw source text + println!("── raw text ────────────────────────────────────────"); println!("{}", doc.syntax().range().source_text(parsed.source())); + println!(); + // pretty_print: structured AST - println!("── pretty_print (parsed AST) ──────────────────────"); + println!("── parsed AST ──────────────────────────────────────"); print!("{}", parsed.pretty_print()); } diff --git a/examples/test_ret.rs b/examples/test_ret.rs deleted file mode 100644 index 205e3f7..0000000 --- a/examples/test_ret.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Example: Parsing a Returns-only Google-style docstring -//! -//! Demonstrates how pydocstring handles a docstring that starts -//! directly with a section header (no summary line). - -use pydocstring::google::parse_google; - -fn main() { - let docstring = "\ -Returns: - A dict mapping keys to the corresponding table row data - fetched. Each row is represented as a tuple of strings. For - example: - - {b'Serak': ('Rigel VII', 'Preparer'), - b'Zim': ('Irk', 'Invader'), - b'Lrrr': ('Omicron Persei 8', 'Emperor')} - - Returned keys are always bytes. If a key from the keys argument is - missing from the dictionary, then that row was not found in the - table (and require_all_keys must have been False). -"; - - let parsed = parse_google(docstring); - - // Display: raw source text - println!("╔══════════════════════════════════════════════════╗"); - println!("║ Returns-only Google Docstring Example ║"); - println!("╚══════════════════════════════════════════════════╝"); - println!(); - println!("── Display (raw text) ─────────────────────────────"); - println!("{}", parsed.source()); - - // pretty_print: structured AST - println!("── pretty_print (parsed AST) ──────────────────────"); - print!("{}", parsed.pretty_print()); -} diff --git a/src/syntax.rs b/src/syntax.rs index 8def7fa..c43d67c 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -319,9 +319,11 @@ impl SyntaxNode { }) } - /// Write a Biome-style pretty-printed tree representation. + /// Write a pretty-printed tree representation. pub fn pretty_fmt(&self, src: &str, indent: usize, out: &mut String) { - pad(out, indent); + for _ in 0..indent { + out.push_str(" "); + } let _ = writeln!(out, "{}@{} {{", self.kind.name(), self.range); for child in &self.children { match child { @@ -329,7 +331,9 @@ impl SyntaxNode { SyntaxElement::Token(t) => t.pretty_fmt(src, indent + 1, out), } } - pad(out, indent); + for _ in 0..indent { + out.push_str(" "); + } out.push_str("}\n"); } } @@ -369,9 +373,11 @@ impl SyntaxToken { self.range.extend(other); } - /// Write a Biome-style pretty-printed token line. + /// Write a pretty-printed token line. pub fn pretty_fmt(&self, src: &str, indent: usize, out: &mut String) { - pad(out, indent); + for _ in 0..indent { + out.push_str(" "); + } let _ = writeln!( out, "{}: {:?}@{}", @@ -474,17 +480,6 @@ pub fn walk(node: &SyntaxNode, visitor: &mut dyn Visitor) { visitor.leave(node); } -// ============================================================================= -// Pretty-print helper -// ============================================================================= - -/// Write indentation (4 spaces per level). -fn pad(out: &mut String, indent: usize) { - for _ in 0..indent { - out.push_str(" "); - } -} - // ============================================================================= // Tests // ============================================================================= From daa52c15f682cb243b6d9f957a861bd245d5e7a9 Mon Sep 17 00:00:00 2001 From: qraqras Date: Fri, 6 Mar 2026 05:45:12 +0000 Subject: [PATCH 8/8] fix: clippy --- .githooks/pre-commit | 4 ++-- src/styles/google/parser.rs | 2 +- src/styles/numpy/parser.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a5735b3..df7a58a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,8 +7,8 @@ echo "--- Format ---" cargo fmt --all echo "OK" -echo "--- Clippy fix ---" -cargo clippy --all-targets --fix --allow-dirty --allow-staged -- -D warnings +echo "--- Clippy check ---" +cargo clippy --all-targets -- -D warnings echo "OK" echo "--- Stage fixed files ---" diff --git a/src/styles/google/parser.rs b/src/styles/google/parser.rs index a8342c4..368524c 100644 --- a/src/styles/google/parser.rs +++ b/src/styles/google/parser.rs @@ -439,7 +439,7 @@ fn build_content_range(cursor: &LineCursor, first: Option, last: usize) - // ============================================================================= /// Extend the DESCRIPTION token of the last child node, or add one. -fn extend_last_node_description(nodes: &mut Vec, cont: TextRange) { +fn extend_last_node_description(nodes: &mut [SyntaxElement], cont: TextRange) { if let Some(SyntaxElement::Node(node)) = nodes.last_mut() { // Find or add description token, extend range let mut found_desc = false; diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index 7421b01..2dc6c02 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -474,7 +474,7 @@ fn build_reference_node_plain(content: TextRange, range: TextRange) -> SyntaxNod // Per-line section body processors // ============================================================================= -fn extend_last_node_description(nodes: &mut Vec, cont: TextRange) { +fn extend_last_node_description(nodes: &mut [SyntaxElement], cont: TextRange) { if let Some(SyntaxElement::Node(node)) = nodes.last_mut() { let mut found_desc = false; for child in node.children_mut() { @@ -497,7 +497,7 @@ fn extend_last_node_description(nodes: &mut Vec, cont: TextRange) } /// Extend `content` field on a reference node. -fn extend_last_ref_content(nodes: &mut Vec, cont: TextRange) { +fn extend_last_ref_content(nodes: &mut [SyntaxElement], cont: TextRange) { if let Some(SyntaxElement::Node(node)) = nodes.last_mut() { let mut found_content = false; for child in node.children_mut() {