diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a5735b3 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Pre-commit: Auto-fix fmt & clippy ===" + +echo "--- Format ---" +cargo fmt --all +echo "OK" + +echo "--- Clippy fix ---" +cargo clippy --all-targets --fix --allow-dirty --allow-staged -- -D warnings +echo "OK" + +echo "--- Stage fixed files ---" +git add -u +echo "OK" + +echo "=== All checks passed ===" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e58af3d..7f8afd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,9 @@ jobs: fi - name: Publish to crates.io - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/README.md b/README.md index b5f6f7d..a22e594 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ ValueError let result = parse_numpy(docstring); -println!("Summary: {}", result.summary.source_text(&result.source)); +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 { @@ -94,7 +94,7 @@ Raises: let result = parse_google(docstring); -println!("Summary: {}", result.summary.source_text(&result.source)); +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 { @@ -136,7 +136,7 @@ if result.has_errors() { } // The AST is still available -println!("Summary: {}", result.summary.source_text(&result.source)); +println!("Summary: {}", result.summary.as_ref().map_or("", |s| s.source_text(&result.source))); ``` ## Source Locations diff --git a/examples/parse_google.rs b/examples/parse_google.rs index a6a63be..45fbef2 100644 --- a/examples/parse_google.rs +++ b/examples/parse_google.rs @@ -24,7 +24,12 @@ Raises: let result = parse_google(docstring); let doc = &result; - println!("Summary: {}", doc.summary.source_text(&doc.source)); + 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)); } diff --git a/examples/parse_numpy.rs b/examples/parse_numpy.rs index 9d71a45..2760a3b 100644 --- a/examples/parse_numpy.rs +++ b/examples/parse_numpy.rs @@ -36,7 +36,12 @@ Examples let result = parse_numpy(docstring); let doc = &result; - println!("Summary: {}", doc.summary.source_text(&doc.source)); + println!( + "Summary: {}", + doc.summary + .as_ref() + .map_or("", |s| s.source_text(&doc.source)) + ); println!( "\nExtended Summary: {}", doc.extended_summary diff --git a/examples/test_ret.rs b/examples/test_ret.rs index a2f9259..802436f 100644 --- a/examples/test_ret.rs +++ b/examples/test_ret.rs @@ -17,7 +17,12 @@ Returns: table (and require_all_keys must have been False). "; let doc = parse_google(input); - println!("Summary: {:?}", doc.summary.source_text(&doc.source)); + 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 { diff --git a/src/cursor.rs b/src/cursor.rs index 364718d..b7a5630 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -1,24 +1,24 @@ //! Source cursor for line-oriented docstring parsing. //! -//! [`Cursor`] bundles the source text, line-offset table, and current +//! [`LineCursor`] bundles the source text, line-offset table, and current //! line position into a single struct, eliminating the need to thread //! `(source, &offsets, total_lines)` through every helper function. use crate::ast::{TextRange, TextSize}; // ============================================================================= -// Cursor +// LineCursor // ============================================================================= /// A read/write cursor over a source string, providing line-oriented /// navigation and span construction helpers. /// -/// Callers advance the cursor by mutating [`Cursor::line`] directly -/// (or via convenience methods like [`advance`](Cursor::advance) and -/// [`skip_blank_lines`](Cursor::skip_blank_lines)). Sub-parsers -/// receive `&mut Cursor` and leave it positioned after the last +/// Callers advance the cursor by mutating [`LineCursor::line`] directly +/// (or via convenience methods like [`advance`](LineCursor::advance) and +/// [`skip_blank_lines`](LineCursor::skip_blank_lines)). Sub-parsers +/// receive `&mut LineCursor` and leave it positioned after the last /// consumed line. -pub(crate) struct Cursor<'a> { +pub(crate) struct LineCursor<'a> { source: &'a str, offsets: Vec, total: usize, @@ -26,7 +26,7 @@ pub(crate) struct Cursor<'a> { pub line: usize, } -impl<'a> Cursor<'a> { +impl<'a> LineCursor<'a> { /// Create a new cursor over `source`, starting at line 0. pub fn new(source: &'a str) -> Self { let offsets = build_line_offsets(source); @@ -46,6 +46,13 @@ impl<'a> Cursor<'a> { self.source } + /// A `TextRange` spanning the entire source text. + pub fn full_range(&self) -> TextRange { + let last_line = self.total.saturating_sub(1); + let last_col = self.line_text(last_line).len(); + self.make_range(0, 0, last_line, last_col) + } + // ── Position ──────────────────────────────────────────────────── /// Whether the cursor has reached or passed the end of the source. @@ -64,7 +71,7 @@ impl<'a> Cursor<'a> { } /// Skip blank (whitespace-only) lines starting at the current position. - pub fn skip_blank_lines(&mut self) { + pub fn skip_blanks(&mut self) { while !self.is_eof() && self.current_line_text().trim().is_empty() { self.line += 1; } @@ -144,6 +151,14 @@ impl<'a> Cursor<'a> { ) } + /// Build a [`TextRange`] spanning `len` bytes on a single line. + /// + /// Equivalent to `make_range(line, col, line, col + len)`. + pub fn make_line_range(&self, line: usize, col: usize, len: usize) -> TextRange { + let start = self.offsets[line] + col; + TextRange::from_offset_len(start, len) + } + // ── Offset utilities ─────────────────────────────────────────── /// Convert a byte offset to `(line, col)`. @@ -196,13 +211,13 @@ impl<'a> Cursor<'a> { } // ============================================================================= -// Standalone helpers (still useful outside Cursor) +// Standalone helpers (still useful outside LineCursor) // ============================================================================= /// Number of leading whitespace bytes in `line`. /// /// Use this for **byte-offset** calculations (e.g. column parameters to -/// [`Cursor::make_range`]). For indentation-level *comparison*, prefer +/// [`LineCursor::make_range`]). For indentation-level *comparison*, prefer /// [`indent_columns`] which handles tab characters correctly. pub(crate) fn indent_len(line: &str) -> usize { line.len() - line.trim_start().len() @@ -293,39 +308,39 @@ mod tests { #[test] fn test_find_matching_close_basic() { - let c = Cursor::new("(abc)"); + let c = LineCursor::new("(abc)"); assert_eq!(c.find_matching_close(0), Some(4)); } #[test] fn test_find_matching_close_nested_same() { - let c = Cursor::new("(a(b)c)"); + 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 = Cursor::new("(a[b]c)"); + 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 = Cursor::new("(a]b)"); + 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 = Cursor::new("(a]b}c"); + 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 = Cursor::new(""); + let c = LineCursor::new(""); assert_eq!(c.find_matching_close(0), Some(4)); } } diff --git a/src/lib.rs b/src/lib.rs index 775ff02..fedac3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,7 @@ //! "#; //! //! let result = parse_numpy(docstring); -//! assert_eq!(result.summary.source_text(&result.source), "Brief description."); +//! assert_eq!(result.summary.as_ref().unwrap().source_text(&result.source), "Brief description."); //! ``` //! //! ## Style Auto-Detection diff --git a/src/styles/google/ast.rs b/src/styles/google/ast.rs index 21d4c16..394b99e 100644 --- a/src/styles/google/ast.rs +++ b/src/styles/google/ast.rs @@ -303,8 +303,8 @@ pub struct GoogleDocstring { pub source: String, /// Source range of the entire docstring. pub range: TextRange, - /// Brief summary (first line). - pub summary: 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. @@ -438,12 +438,12 @@ pub struct GoogleMethod { } impl GoogleDocstring { - /// Creates a new empty Google-style docstring. - pub fn new() -> Self { + /// Creates a new empty Google-style docstring with the given source. + pub fn new(input: &str) -> Self { Self { - source: String::new(), + source: input.to_string(), range: TextRange::empty(), - summary: TextRange::empty(), + summary: None, extended_summary: None, items: Vec::new(), } @@ -452,7 +452,7 @@ impl GoogleDocstring { impl Default for GoogleDocstring { fn default() -> Self { - Self::new() + Self::new("") } } @@ -461,7 +461,9 @@ impl fmt::Display for GoogleDocstring { write!( f, "GoogleDocstring(summary: {})", - self.summary.source_text(&self.source) + self.summary + .as_ref() + .map_or("", |s| s.source_text(&self.source)) ) } } diff --git a/src/styles/google/parser.rs b/src/styles/google/parser.rs index 8e5fa67..ddbd376 100644 --- a/src/styles/google/parser.rs +++ b/src/styles/google/parser.rs @@ -18,7 +18,7 @@ //! ``` use crate::ast::TextRange; -use crate::cursor::{Cursor, indent_columns, indent_len}; +use crate::cursor::{LineCursor, indent_columns, indent_len}; use crate::styles::google::ast::{ GoogleArg, GoogleAttribute, GoogleDocstring, GoogleDocstringItem, GoogleException, GoogleMethod, GoogleReturns, GoogleSection, GoogleSectionBody, GoogleSectionHeader, @@ -42,9 +42,9 @@ fn extract_section_name(trimmed: &str) -> (&str, bool) { } } -/// Check if a line is a Google-style section header at the given base indentation. +/// Check if a line is a Google-style section header. /// -/// A section header is a line at `base_indent` that matches one of: +/// 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 @@ -53,12 +53,13 @@ fn extract_section_name(trimmed: &str) -> (&str, bool) { /// embedded colons) is accepted (dispatched as Unknown if unrecognised). /// For the colonless form, only names in [`KNOWN_SECTIONS`] are accepted /// to avoid treating ordinary text lines as headers. -fn is_section_header(line: &str, base_indent: usize) -> bool { - let indent = indent_columns(line); - if indent != base_indent { - return false; - } - let trimmed = line.trim(); +/// +/// The caller must pass a line with leading / trailing whitespace +/// already stripped. 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. +fn is_section_header(trimmed: &str) -> bool { let (name, has_colon) = extract_section_name(trimmed); if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic()) { @@ -67,8 +68,10 @@ fn is_section_header(line: &str, base_indent: usize) -> bool { if has_colon { // Standard / space-before-colon form: accept any short name without - // embedded colons. - name.len() <= 40 && !name.contains(':') + // embedded colons or entry-like characters (brackets, asterisks). + name.len() <= 40 + && !name.contains(':') + && name.chars().all(|c| c.is_alphanumeric() || c == ' ') } else { // Colonless form: only known names. GoogleSectionKind::is_known(&name.to_ascii_lowercase()) @@ -111,6 +114,47 @@ fn strip_optional(type_content: &str) -> (&str, Option) { } } +// ============================================================================= +// Text range collector +// ============================================================================= + +/// Collect consecutive lines into a [`TextRange`], stopping at section headers +/// and EOF. +/// +/// When `stop_at_blank` is `true`, also stops at blank lines (used for +/// single-paragraph collection such as Summary). Leading and trailing +/// blank lines within the collected range are excluded from the span. +/// +/// On return, `cursor.line` points to the first unconsumed line. +fn collect_text_range(cursor: &mut LineCursor, stop_at_blank: bool) -> TextRange { + let mut first_content: Option = None; + let mut last_content = cursor.line; + + while !cursor.is_eof() { + let trimmed = cursor.current_trimmed(); + if is_section_header(trimmed) || (stop_at_blank && trimmed.is_empty()) { + break; + } + if !trimmed.is_empty() { + if first_content.is_none() { + first_content = Some(cursor.line); + } + last_content = cursor.line; + } + cursor.advance(); + } + + if let Some(first) = first_content { + let first_line = cursor.line_text(first); + let first_col = indent_len(first_line); + let last_line = cursor.line_text(last_content); + let last_col = indent_len(last_line) + last_line.trim().len(); + cursor.make_range(first, first_col, last_content, last_col) + } else { + TextRange::empty() + } +} + // ============================================================================= // Description collector // ============================================================================= @@ -118,24 +162,24 @@ fn strip_optional(type_content: &str) -> (&str, Option) { /// Collect indented description continuation lines starting at `cursor.line`. /// /// Stops at: -/// - Section headers at `base_indent` +/// - Section headers (detected by pattern) /// - Non-empty lines at or below `entry_indent` (i.e. a new entry) /// - End of input /// /// On return, `cursor.line` points to the first unconsumed line. -fn collect_description(cursor: &mut Cursor, entry_indent: usize, base_indent: usize) -> TextRange { +fn collect_description(cursor: &mut LineCursor, entry_indent: usize) -> TextRange { let mut desc_parts: Vec<&str> = Vec::new(); 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 is_section_header(line, base_indent) { + let trimmed = line.trim(); + + if is_section_header(trimmed) { break; } - let trimmed = line.trim(); - // Non-empty line at or below entry indent ⇒ new entry if !trimmed.is_empty() && indent_columns(line) <= entry_indent { break; @@ -234,7 +278,7 @@ struct EntryHeader { /// /// Does **not** advance the cursor — the caller must derive the end line /// from `header.range` and advance past it. -fn parse_entry_header(cursor: &Cursor) -> EntryHeader { +fn parse_entry_header(cursor: &LineCursor) -> EntryHeader { let line = cursor.current_line_text(); let trimmed = line.trim(); let entry_start = cursor.substr_offset(trimmed); @@ -399,7 +443,7 @@ fn extract_desc_after_colon( /// 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.source_text(&doc.source), "Summary."); +/// assert_eq!(doc.summary.as_ref().unwrap().source_text(&doc.source), "Summary."); /// /// let args: Vec<_> = doc.items.iter().filter_map(|item| match item { /// pydocstring::GoogleDocstringItem::Section(s) => match &s.body { @@ -421,79 +465,51 @@ fn extract_desc_after_colon( /// assert_eq!(ret.return_type.as_ref().unwrap().source_text(&doc.source), "int"); /// ``` pub fn parse_google(input: &str) -> GoogleDocstring { - let mut cursor = Cursor::new(input); - let mut docstring = GoogleDocstring::new(); - docstring.source = input.to_string(); - - if cursor.total_lines() == 0 { - return docstring; - } + let mut line_cursor = LineCursor::new(input); + let mut docstring = GoogleDocstring::new(input); - // --- Skip leading blank lines --- - cursor.skip_blank_lines(); - if cursor.is_eof() { + line_cursor.skip_blanks(); + if line_cursor.is_eof() { return docstring; } - // Detect base indentation from the first non-empty line - let base_indent = cursor.current_indent_columns(); - // --- Summary --- - if !is_section_header(cursor.current_line_text(), base_indent) { - let trimmed = cursor.current_trimmed(); - if !trimmed.is_empty() { - let col = cursor.current_indent(); - docstring.summary = - cursor.make_range(cursor.line, col, cursor.line, col + trimmed.len()); - cursor.advance(); + if !is_section_header(line_cursor.current_trimmed()) { + let range = collect_text_range(&mut line_cursor, true); + if !range.is_empty() { + docstring.summary = Some(range); + } + line_cursor.skip_blanks(); + if line_cursor.is_eof() { + docstring.range = line_cursor.full_range(); + return docstring; } } - // skip blanks - cursor.skip_blank_lines(); - - // --- Extended description --- - if !cursor.is_eof() && !is_section_header(cursor.current_line_text(), base_indent) { - let start_line = cursor.line; - let mut desc_lines: Vec<&str> = Vec::new(); - let mut last_non_empty = cursor.line; - - while !cursor.is_eof() && !is_section_header(cursor.current_line_text(), base_indent) { - let trimmed = cursor.current_trimmed(); - desc_lines.push(trimmed); - if !trimmed.is_empty() { - last_non_empty = cursor.line; - } - cursor.advance(); + // --- Extended Summary --- + if !is_section_header(line_cursor.current_trimmed()) { + let range = collect_text_range(&mut line_cursor, false); + if !range.is_empty() { + docstring.extended_summary = Some(range); } - - let keep = last_non_empty - 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); - 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, last_col)); + line_cursor.skip_blanks(); + if line_cursor.is_eof() { + docstring.range = line_cursor.full_range(); + return docstring; } } // --- Sections --- - while !cursor.is_eof() { - if cursor.current_is_blank() { - cursor.advance(); + while !line_cursor.is_eof() { + if line_cursor.current_is_blank() { + line_cursor.advance(); continue; } - if is_section_header(cursor.current_line_text(), base_indent) { - let section_start = cursor.line; - let header_line = cursor.current_line_text(); - let header_trimmed = header_line.trim(); - let header_col = cursor.current_indent(); + let header_trimmed = line_cursor.current_trimmed(); + if is_section_header(header_trimmed) { + let section_start = line_cursor.line; + let header_col = line_cursor.current_indent(); // Extract the section name and whether a colon is present. // Handles "Args:", "Args :", and colonless "Args". @@ -503,7 +519,7 @@ pub fn parse_google(input: &str) -> GoogleDocstring { let colon = if has_colon { // Colon is always the last character of the trimmed line let colon_col = header_col + header_trimmed.len() - 1; - Some(cursor.make_range(cursor.line, colon_col, cursor.line, colon_col + 1)) + Some(line_cursor.make_line_range(line_cursor.line, colon_col, 1)) } else { None }; @@ -512,50 +528,42 @@ pub fn parse_google(input: &str) -> GoogleDocstring { let section_kind = GoogleSectionKind::from_name(&normalized); let header = GoogleSectionHeader { - range: cursor.make_range( - cursor.line, + range: line_cursor.make_line_range( + line_cursor.line, header_col, - cursor.line, - header_col + header_trimmed.len(), + header_trimmed.len(), ), kind: section_kind, - name: cursor.make_range( - cursor.line, - header_col, - cursor.line, - header_col + header_name.len(), - ), + name: line_cursor.make_line_range(line_cursor.line, header_col, header_name.len()), colon, }; - cursor.advance(); // skip header line + line_cursor.advance(); // skip header line let body = match section_kind { // ----- Parameter-like sections ----- - GoogleSectionKind::Args => { - GoogleSectionBody::Args(parse_args(&mut cursor, base_indent)) - } + GoogleSectionKind::Args => GoogleSectionBody::Args(parse_args(&mut line_cursor)), GoogleSectionKind::KeywordArgs => { - GoogleSectionBody::KeywordArgs(parse_args(&mut cursor, base_indent)) + GoogleSectionBody::KeywordArgs(parse_args(&mut line_cursor)) } GoogleSectionKind::OtherParameters => { - GoogleSectionBody::OtherParameters(parse_args(&mut cursor, base_indent)) + GoogleSectionBody::OtherParameters(parse_args(&mut line_cursor)) } GoogleSectionKind::Receives => { - GoogleSectionBody::Receives(parse_args(&mut cursor, base_indent)) + GoogleSectionBody::Receives(parse_args(&mut line_cursor)) } // ----- Return/yield sections ----- GoogleSectionKind::Returns => { - GoogleSectionBody::Returns(parse_returns_section(&mut cursor, base_indent)) + GoogleSectionBody::Returns(parse_returns_section(&mut line_cursor)) } GoogleSectionKind::Yields => { - GoogleSectionBody::Yields(parse_returns_section(&mut cursor, base_indent)) + GoogleSectionBody::Yields(parse_returns_section(&mut line_cursor)) } // ----- Exception/warning sections ----- GoogleSectionKind::Raises => { - GoogleSectionBody::Raises(parse_raises_section(&mut cursor, base_indent)) + GoogleSectionBody::Raises(parse_raises_section(&mut line_cursor)) } GoogleSectionKind::Warns => { - let raises = parse_raises_section(&mut cursor, base_indent); + let raises = parse_raises_section(&mut line_cursor); let warns = raises .into_iter() .map(|e| GoogleWarning { @@ -569,7 +577,7 @@ pub fn parse_google(input: &str) -> GoogleDocstring { } // ----- Structured sections ----- GoogleSectionKind::Attributes => { - let args = parse_args(&mut cursor, base_indent); + let args = parse_args(&mut line_cursor); let attrs = args .into_iter() .map(|a| GoogleAttribute { @@ -585,7 +593,7 @@ pub fn parse_google(input: &str) -> GoogleDocstring { GoogleSectionBody::Attributes(attrs) } GoogleSectionKind::Methods => { - let args = parse_args(&mut cursor, base_indent); + let args = parse_args(&mut line_cursor); let methods = args .into_iter() .map(|a| GoogleMethod { @@ -601,55 +609,55 @@ pub fn parse_google(input: &str) -> GoogleDocstring { GoogleSectionBody::Methods(methods) } GoogleSectionKind::SeeAlso => { - GoogleSectionBody::SeeAlso(parse_see_also_section(&mut cursor, base_indent)) + GoogleSectionBody::SeeAlso(parse_see_also_section(&mut line_cursor)) } // ----- Free-text / admonition sections ----- GoogleSectionKind::Notes => { - GoogleSectionBody::Notes(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Notes(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Examples => { - GoogleSectionBody::Examples(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Examples(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Todo => { - GoogleSectionBody::Todo(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Todo(parse_section_content(&mut line_cursor)) } GoogleSectionKind::References => { - GoogleSectionBody::References(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::References(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Warnings => { - GoogleSectionBody::Warnings(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Warnings(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Attention => { - GoogleSectionBody::Attention(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Attention(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Caution => { - GoogleSectionBody::Caution(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Caution(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Danger => { - GoogleSectionBody::Danger(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Danger(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Error => { - GoogleSectionBody::Error(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Error(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Hint => { - GoogleSectionBody::Hint(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Hint(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Important => { - GoogleSectionBody::Important(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Important(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Tip => { - GoogleSectionBody::Tip(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Tip(parse_section_content(&mut line_cursor)) } GoogleSectionKind::Unknown => { - GoogleSectionBody::Unknown(parse_section_content(&mut cursor, base_indent)) + GoogleSectionBody::Unknown(parse_section_content(&mut line_cursor)) } }; // Compute section span let section_end_line = { - let mut end = cursor.line.saturating_sub(1); + let mut end = line_cursor.line.saturating_sub(1); while end > section_start { - if !cursor.line_text(end).trim().is_empty() { + if !line_cursor.line_text(end).trim().is_empty() { break; } end -= 1; @@ -657,14 +665,14 @@ pub fn parse_google(input: &str) -> GoogleDocstring { end }; let section_end_col = { - let end_line = cursor.line_text(section_end_line); + let end_line = line_cursor.line_text(section_end_line); indent_len(end_line) + end_line.trim().len() }; docstring .items .push(GoogleDocstringItem::Section(GoogleSection { - range: cursor.make_range( + range: line_cursor.make_range( section_start, header_col, section_end_line, @@ -676,23 +684,20 @@ pub fn parse_google(input: &str) -> GoogleDocstring { } else { // Not a section header and not blank: record as a stray line // so that a linter layer can inspect it later. - let line = cursor.current_line_text(); - let trimmed = line.trim(); + let trimmed = line_cursor.current_trimmed(); if !trimmed.is_empty() { - let col = cursor.current_indent(); - let spanned = cursor.make_range(cursor.line, col, cursor.line, col + trimmed.len()); + let col = line_cursor.current_indent(); + let spanned = line_cursor.make_line_range(line_cursor.line, col, trimmed.len()); docstring .items .push(GoogleDocstringItem::StrayLine(spanned)); } - cursor.advance(); + line_cursor.advance(); } } // --- Docstring span --- - let last_line_idx = cursor.total_lines().saturating_sub(1); - let last_col = cursor.line_text(last_line_idx).len(); - docstring.range = cursor.make_range(0, 0, last_line_idx, last_col); + docstring.range = line_cursor.full_range(); docstring } @@ -704,18 +709,16 @@ pub fn parse_google(input: &str) -> GoogleDocstring { /// Parse the Args / Arguments section body. /// /// On return, `cursor.line` points to the first line after the section. -fn parse_args(cursor: &mut Cursor, base_indent: usize) -> Vec { +fn parse_args(cursor: &mut LineCursor) -> Vec { let mut args = Vec::new(); let mut entry_indent: Option = None; while !cursor.is_eof() { - let line = cursor.current_line_text(); - if is_section_header(line, base_indent) { + let trimmed = cursor.current_trimmed(); + if is_section_header(trimmed) { break; } - let trimmed = line.trim(); - if trimmed.is_empty() { cursor.advance(); continue; @@ -723,9 +726,6 @@ fn parse_args(cursor: &mut Cursor, base_indent: usize) -> Vec { let indent = cursor.current_indent(); let indent_cols = cursor.current_indent_columns(); - if indent_cols <= base_indent { - break; - } let ei = *entry_indent.get_or_insert(indent_cols); @@ -752,7 +752,7 @@ fn parse_args(cursor: &mut Cursor, base_indent: usize) -> Vec { .offset_to_line_col(header.range.end().raw() as usize) .0; cursor.line = header_end_line + 1; - let cont_desc = collect_description(cursor, ei, base_indent); + let cont_desc = collect_description(cursor, ei); let full_desc = merge_descriptions(header.first_description, cont_desc); let range_end = if full_desc.is_empty() { @@ -798,11 +798,11 @@ fn parse_args(cursor: &mut Cursor, base_indent: usize) -> Vec { /// ``` /// /// On return, `cursor.line` points to the first line after the section. -fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleReturns { +fn parse_returns_section(cursor: &mut LineCursor) -> GoogleReturns { // Skip leading blank lines within the section while !cursor.is_eof() { - let line = cursor.current_line_text(); - if is_section_header(line, base_indent) { + let trimmed = cursor.current_trimmed(); + if is_section_header(trimmed) { return GoogleReturns { range: TextRange::empty(), return_type: None, @@ -810,17 +810,7 @@ fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleRetur description: TextRange::empty(), }; } - let trimmed = line.trim(); if !trimmed.is_empty() { - let indent = cursor.current_indent_columns(); - if indent <= base_indent { - return GoogleReturns { - range: TextRange::empty(), - return_type: None, - colon: None, - description: TextRange::empty(), - }; - } break; } cursor.advance(); @@ -835,8 +825,7 @@ fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleRetur }; } - let line = cursor.current_line_text(); - let trimmed = line.trim(); + let trimmed = cursor.current_trimmed(); let col = cursor.current_indent(); let entry_start = cursor.line; @@ -849,28 +838,13 @@ fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleRetur let desc_str = after_colon.trim_start(); let ws_after = after_colon.len() - desc_str.len(); let type_col = col; - let rt = Some(cursor.make_range( - cursor.line, - type_col, - cursor.line, - type_col + type_str.len(), - )); - let colon_spanned = Some(cursor.make_range( - cursor.line, - col + colon_pos, - cursor.line, - col + colon_pos + 1, - )); + let rt = Some(cursor.make_line_range(cursor.line, type_col, type_str.len())); + let colon_spanned = Some(cursor.make_line_range(cursor.line, col + colon_pos, 1)); let desc_start = col + colon_pos + 1 + ws_after; let desc_range = if desc_str.is_empty() { TextRange::empty() } else { - cursor.make_range( - cursor.line, - desc_start, - cursor.line, - desc_start + desc_str.len(), - ) + cursor.make_line_range(cursor.line, desc_start, desc_str.len()) }; (rt, colon_spanned, desc_range) } else { @@ -878,7 +852,7 @@ fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleRetur let desc_range = if trimmed.is_empty() { TextRange::empty() } else { - cursor.make_range(cursor.line, col, cursor.line, col + trimmed.len()) + cursor.make_line_range(cursor.line, col, trimmed.len()) }; (None, None, desc_range) }; @@ -886,7 +860,7 @@ fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleRetur cursor.advance(); // Collect all remaining indented lines as continuation description. - let cont_desc = parse_section_content(cursor, base_indent); + let cont_desc = parse_section_content(cursor); let full_desc = merge_descriptions(first_desc_range, cont_desc); let (end_line, end_col) = if full_desc.is_empty() { @@ -912,18 +886,16 @@ fn parse_returns_section(cursor: &mut Cursor, base_indent: usize) -> GoogleRetur /// Format: `ExceptionType: description` /// /// On return, `cursor.line` points to the first line after the section. -fn parse_raises_section(cursor: &mut Cursor, base_indent: usize) -> Vec { +fn parse_raises_section(cursor: &mut LineCursor) -> Vec { let mut raises = Vec::new(); let mut entry_indent: Option = None; while !cursor.is_eof() { - let line = cursor.current_line_text(); - if is_section_header(line, base_indent) { + let trimmed = cursor.current_trimmed(); + if is_section_header(trimmed) { break; } - let trimmed = line.trim(); - if trimmed.is_empty() { cursor.advance(); continue; @@ -931,9 +903,6 @@ fn parse_raises_section(cursor: &mut Cursor, base_indent: usize) -> Vec Vec Vec Vec TextRange { - let mut content_lines: Vec<&str> = Vec::new(); - 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 is_section_header(line, base_indent) { - break; - } - - let trimmed = line.trim(); - // Non-empty line at or below base indent ⇒ outside the section - if !trimmed.is_empty() && indent_columns(line) <= base_indent { - break; - } - - content_lines.push(trimmed); - if !trimmed.is_empty() { - if first_content_line.is_none() { - first_content_line = Some(cursor.line); - } - last_content_line = cursor.line; - } - cursor.advance(); - } - - // Trim leading / 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 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) - } else { - TextRange::empty() - } +fn parse_section_content(cursor: &mut LineCursor) -> TextRange { + collect_text_range(cursor, false) } // ============================================================================= @@ -1065,18 +984,16 @@ fn parse_section_content(cursor: &mut Cursor, base_indent: usize) -> TextRange { /// ``` /// /// On return, `cursor.line` points to the first line after the section. -fn parse_see_also_section(cursor: &mut Cursor, base_indent: usize) -> Vec { +fn parse_see_also_section(cursor: &mut LineCursor) -> Vec { let mut items = Vec::new(); let mut entry_indent: Option = None; while !cursor.is_eof() { - let line = cursor.current_line_text(); - if is_section_header(line, base_indent) { + let trimmed = cursor.current_trimmed(); + if is_section_header(trimmed) { break; } - let trimmed = line.trim(); - if trimmed.is_empty() { cursor.advance(); continue; @@ -1084,9 +1001,6 @@ fn parse_see_also_section(cursor: &mut Cursor, base_indent: usize) -> Vec Vec Vec Vec EntryHeader { - let cursor = Cursor::new(text); + let cursor = LineCursor::new(text); parse_entry_header(&cursor) } @@ -1296,7 +1193,7 @@ mod tests { #[test] fn test_parse_entry_header_multiline_type() { let input = "x (Dict[str,\n int]): The value."; - let cursor = Cursor::new(input); + let cursor = LineCursor::new(input); let header = parse_entry_header(&cursor); assert_eq!(header.name.source_text(input), "x"); let ti = header.type_info.unwrap(); diff --git a/src/styles/numpy/ast.rs b/src/styles/numpy/ast.rs index 7203075..d071af1 100644 --- a/src/styles/numpy/ast.rs +++ b/src/styles/numpy/ast.rs @@ -230,8 +230,8 @@ pub struct NumPyDocstring { pub source: String, /// Source span of the entire docstring. pub range: TextRange, - /// Brief summary (first line). - pub summary: 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). @@ -399,12 +399,12 @@ pub struct NumPyMethod { } impl NumPyDocstring { - /// Creates a new empty NumPy-style docstring. - pub fn new() -> Self { + /// Creates a new empty NumPy-style docstring with the given source. + pub fn new(input: &str) -> Self { Self { - source: String::new(), + source: input.to_string(), range: TextRange::empty(), - summary: TextRange::empty(), + summary: None, deprecation: None, extended_summary: None, items: Vec::new(), @@ -414,7 +414,7 @@ impl NumPyDocstring { impl Default for NumPyDocstring { fn default() -> Self { - Self::new() + Self::new("") } } @@ -423,7 +423,9 @@ impl fmt::Display for NumPyDocstring { write!( f, "NumPyDocstring(summary: {})", - self.summary.source_text(&self.source) + self.summary + .as_ref() + .map_or("", |s| s.source_text(&self.source)) ) } } diff --git a/src/styles/numpy/parser.rs b/src/styles/numpy/parser.rs index c76c5b0..8278fbe 100644 --- a/src/styles/numpy/parser.rs +++ b/src/styles/numpy/parser.rs @@ -20,7 +20,7 @@ //! ``` use crate::ast::TextRange; -use crate::cursor::{Cursor, indent_columns, indent_len}; +use crate::cursor::{LineCursor, indent_columns, indent_len}; use crate::styles::numpy::ast::{ NumPyDeprecation, NumPyDocstring, NumPyDocstringItem, NumPyException, NumPyParameter, NumPyReturns, NumPySection, NumPySectionBody, NumPySectionHeader, NumPySectionKind, @@ -41,7 +41,7 @@ fn is_underline(trimmed: &str) -> bool { /// /// Uses the "pending line" pattern: each line is read once, and when a dash /// line is encountered the previous non-empty line is identified as the header. -fn find_next_section_start(cursor: &Cursor, start: usize) -> usize { +fn find_next_section_start(cursor: &LineCursor, start: usize) -> usize { let mut prev_non_empty = false; let mut prev_idx = start; for i in start..cursor.total_lines() { @@ -67,7 +67,7 @@ fn find_next_section_start(cursor: &Cursor, start: usize) -> usize { /// below `entry_indent`. /// /// On return, `cursor.line` points to the first unconsumed line. -fn collect_description(cursor: &mut Cursor, end: usize, entry_indent: usize) -> TextRange { +fn collect_description(cursor: &mut LineCursor, end: usize, entry_indent: usize) -> TextRange { let mut desc_parts: Vec<&str> = Vec::new(); let mut first_content_line: Option = None; let mut last_content_line = cursor.line; @@ -115,34 +115,48 @@ fn collect_description(cursor: &mut Cursor, end: usize, entry_indent: usize) -> /// Parse a NumPy-style docstring. pub fn parse_numpy(input: &str) -> NumPyDocstring { - let mut cursor = Cursor::new(input); + let mut cursor = LineCursor::new(input); let first_section = find_next_section_start(&cursor, 0); - let mut docstring = NumPyDocstring::new(); - docstring.source = input.to_string(); + let mut docstring = NumPyDocstring::new(input); if cursor.total_lines() == 0 { return docstring; } // --- Skip leading blank lines --- - cursor.skip_blank_lines(); + cursor.skip_blanks(); if cursor.is_eof() { return docstring; } - // --- Summary --- + // --- Summary (all lines until blank line or first section) --- if cursor.line < first_section { let trimmed = cursor.current_trimmed(); if !trimmed.is_empty() { - let col = cursor.current_indent(); - docstring.summary = - cursor.make_range(cursor.line, col, cursor.line, col + trimmed.len()); - cursor.advance(); + let start_line = cursor.line; + let start_col = cursor.current_indent(); + let mut last_line = start_line; + + while cursor.line < first_section && !cursor.is_eof() { + 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_blank_lines(); + cursor.skip_blanks(); // --- Deprecation directive --- if !cursor.is_eof() { @@ -157,21 +171,16 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { let version_col = col + prefix.len() + ws_len; // `..` at col..col+2 - let directive_marker = Some(cursor.make_range(cursor.line, col, cursor.line, 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_range(cursor.line, kw_col, cursor.line, kw_col + 10)); + 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_range(cursor.line, dc_col, cursor.line, dc_col + 2)); + let double_colon = Some(cursor.make_line_range(cursor.line, dc_col, 2)); - let version_spanned = cursor.make_range( - cursor.line, - version_col, - cursor.line, - version_col + version_str.len(), - ); + let version_spanned = + cursor.make_line_range(cursor.line, version_col, version_str.len()); let dep_start_line = cursor.line; cursor.advance(); @@ -196,7 +205,7 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { }); // skip blanks - cursor.skip_blank_lines(); + cursor.skip_blanks(); } } @@ -243,8 +252,7 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { // Non-blank lines that are not section headers are stray lines. if !header_trimmed.is_empty() { let col = cursor.current_indent(); - let spanned = - cursor.make_range(cursor.line, col, cursor.line, col + header_trimmed.len()); + let spanned = cursor.make_line_range(cursor.line, col, header_trimmed.len()); docstring.items.push(NumPyDocstringItem::StrayLine(spanned)); } cursor.advance(); @@ -270,17 +278,11 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { underline_col + underline_trimmed.len(), ), kind: section_kind, - name: cursor.make_range( - cursor.line, - header_col, - cursor.line, - header_col + header_trimmed.len(), - ), - underline: cursor.make_range( + name: cursor.make_line_range(cursor.line, header_col, header_trimmed.len()), + underline: cursor.make_line_range( cursor.line + 1, underline_col, - cursor.line + 1, - underline_col + underline_trimmed.len(), + underline_trimmed.len(), ), }; @@ -409,9 +411,7 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { } // Docstring span - let last_line_idx = cursor.total_lines().saturating_sub(1); - let last_col = cursor.line_text(last_line_idx).len(); - docstring.range = cursor.make_range(0, 0, last_line_idx, last_col); + docstring.range = cursor.full_range(); docstring } @@ -423,7 +423,11 @@ pub fn parse_numpy(input: &str) -> NumPyDocstring { /// Parse the Parameters section body. /// /// On return, `cursor.line` points to the first line after the section. -fn parse_parameters(cursor: &mut Cursor, end: usize, entry_indent: usize) -> Vec { +fn parse_parameters( + cursor: &mut LineCursor, + end: usize, + entry_indent: usize, +) -> Vec { let mut parameters = Vec::new(); while cursor.line < end { @@ -503,13 +507,13 @@ fn parse_name_and_type( text: &str, line_idx: usize, col_base: usize, - cursor: &Cursor, + 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_range(line_idx, colon_col, line_idx, colon_col + 1)); + let colon = Some(cursor.make_line_range(line_idx, colon_col, 1)); (before, colon, Some(colon_pos)) } else { // No separator — whole text is the name @@ -661,7 +665,7 @@ fn parse_name_list( text: &str, line_idx: usize, col_base: usize, - cursor: &Cursor, + cursor: &LineCursor, ) -> Vec { let mut names = Vec::new(); let mut byte_pos = 0usize; @@ -671,7 +675,7 @@ fn parse_name_list( let trimmed = part.trim(); if !trimmed.is_empty() { let name_col = col_base + byte_pos + leading; - names.push(cursor.make_range(line_idx, name_col, line_idx, name_col + trimmed.len())); + names.push(cursor.make_line_range(line_idx, name_col, trimmed.len())); } byte_pos += part.len() + 1; // +1 for the comma } @@ -695,7 +699,7 @@ fn parse_name_list( /// ``` /// /// On return, `cursor.line` points to the first line after the section. -fn parse_returns(cursor: &mut Cursor, end: usize, entry_indent: usize) -> Vec { +fn parse_returns(cursor: &mut LineCursor, end: usize, entry_indent: usize) -> Vec { let mut returns = Vec::new(); while cursor.line < end { @@ -716,16 +720,16 @@ fn parse_returns(cursor: &mut Cursor, end: usize, entry_indent: usize) -> Vec Vec Vec { +fn parse_raises(cursor: &mut LineCursor, end: usize, entry_indent: usize) -> Vec { let mut raises = Vec::new(); while cursor.line < end { @@ -782,22 +786,17 @@ fn parse_raises(cursor: &mut Cursor, end: usize, entry_indent: usize) -> Vec Vec TextRange { +fn parse_section_content(cursor: &mut LineCursor, end: usize) -> TextRange { let mut content_lines: Vec<&str> = Vec::new(); let mut first_content_line: Option = None; let mut last_content_line = cursor.line; @@ -893,7 +892,10 @@ fn parse_section_content(cursor: &mut Cursor, end: usize) -> TextRange { /// ``` /// /// On return, `cursor.line` points to the first line after the section. -fn parse_see_also(cursor: &mut Cursor, end: usize) -> Vec { +fn parse_see_also( + cursor: &mut LineCursor, + end: usize, +) -> Vec { let mut items = Vec::new(); while cursor.line < end { @@ -917,23 +919,17 @@ fn parse_see_also(cursor: &mut Cursor, end: usize) -> Vec Vec Vec { let mut refs = Vec::new(); @@ -1108,23 +1104,23 @@ mod tests { #[test] fn test_find_next_section_start() { - let c1 = Cursor::new("Parameters\n----------"); + let c1 = LineCursor::new("Parameters\n----------"); assert_eq!(find_next_section_start(&c1, 0), 0); // No section - let c2 = Cursor::new("just text\nmore text"); + let c2 = LineCursor::new("just text\nmore text"); assert_eq!(find_next_section_start(&c2, 0), c2.total_lines()); // Empty line before underline — not a header - let c3 = Cursor::new("\n----------"); + let c3 = LineCursor::new("\n----------"); assert_eq!(find_next_section_start(&c3, 0), c3.total_lines()); // Single line — no room for underline - let c4 = Cursor::new("Only one line"); + let c4 = LineCursor::new("Only one line"); assert_eq!(find_next_section_start(&c4, 0), c4.total_lines()); // Start after first section finds second - let c5 = Cursor::new("Parameters\n----------\nx : int\nReturns\n-------"); + let c5 = LineCursor::new("Parameters\n----------\nx : int\nReturns\n-------"); assert_eq!(find_next_section_start(&c5, 0), 0); assert_eq!(find_next_section_start(&c5, 2), 3); } @@ -1150,7 +1146,7 @@ mod tests { #[test] fn test_parse_name_and_type_basic() { let src = "x : int"; - let cursor = Cursor::new(src); + 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_some()); @@ -1163,7 +1159,7 @@ mod tests { #[test] fn test_parse_name_and_type_optional() { let src = "x : int, optional"; - let cursor = Cursor::new(src); + 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_some()); @@ -1174,7 +1170,7 @@ mod tests { #[test] fn test_parse_name_and_type_optional_no_space() { let src = "x : int,optional"; - let cursor = Cursor::new(src); + 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"); @@ -1184,7 +1180,7 @@ mod tests { #[test] fn test_parse_name_and_type_default_space() { let src = "x : int, default True"; - let cursor = Cursor::new(src); + 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"); @@ -1199,7 +1195,7 @@ mod tests { #[test] fn test_parse_name_and_type_default_equals() { let src = "x : int, default=True"; - let cursor = Cursor::new(src); + 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!( @@ -1213,7 +1209,7 @@ mod tests { #[test] fn test_parse_name_and_type_default_colon() { let src = "x : int, default: True"; - let cursor = Cursor::new(src); + 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!( @@ -1228,7 +1224,7 @@ mod tests { fn test_parse_name_and_type_default_bare() { // "default" alone with no value let src = "x : int, default"; - let cursor = Cursor::new(src); + 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!( @@ -1242,7 +1238,7 @@ mod tests { #[test] fn test_parse_name_and_type_complex() { let src = "x : Dict[str, int], optional"; - let cursor = Cursor::new(src); + 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), "Dict[str, int]"); @@ -1252,7 +1248,7 @@ mod tests { #[test] fn test_parse_name_and_type_no_colon() { let src = "x"; - let cursor = Cursor::new(src); + 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()); diff --git a/tests/google_tests.rs b/tests/google_tests.rs index 7a717ca..f98132d 100644 --- a/tests/google_tests.rs +++ b/tests/google_tests.rs @@ -242,7 +242,7 @@ fn test_simple_summary() { let docstring = "This is a brief summary."; let result = parse_google(docstring); assert_eq!( - result.summary.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "This is a brief summary." ); } @@ -251,10 +251,10 @@ fn test_simple_summary() { fn test_summary_span() { let docstring = "Brief description."; let result = parse_google(docstring); - assert_eq!(result.summary.start(), TextSize::new(0)); - assert_eq!(result.summary.end(), TextSize::new(18)); + 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.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "Brief description." ); } @@ -262,13 +262,13 @@ fn test_summary_span() { #[test] fn test_empty_docstring() { let result = parse_google(""); - assert_eq!(result.summary.source_text(&result.source), ""); + assert!(result.summary.is_none()); } #[test] fn test_whitespace_only_docstring() { let result = parse_google(" \n \n"); - assert_eq!(result.summary.source_text(&result.source), ""); + assert!(result.summary.is_none()); } #[test] @@ -277,7 +277,10 @@ fn test_summary_with_description() { "Brief summary.\n\nExtended description that provides\nmore details about the function."; let result = parse_google(docstring); - assert_eq!(result.summary.source_text(&result.source), "Brief summary."); + 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), @@ -293,7 +296,10 @@ First paragraph of description. Second paragraph of description."#; let result = parse_google(docstring); - assert_eq!(result.summary.source_text(&result.source), "Brief summary."); + 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!( @@ -302,6 +308,41 @@ Second paragraph of description."#; ); } +#[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 // ============================================================================= @@ -1002,7 +1043,7 @@ Note: let result = parse_google(docstring); assert_eq!( - result.summary.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "Calculate the sum." ); assert!(result.extended_summary.is_some()); @@ -1126,7 +1167,10 @@ fn test_multiple_unknown_sections() { fn test_indented_docstring() { let docstring = " Summary.\n\n Args:\n x (int): Value."; let result = parse_google(docstring); - assert_eq!(result.summary.source_text(&result.source), "Summary."); + 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"); @@ -1140,9 +1184,12 @@ fn test_indented_docstring() { fn test_indented_summary_span() { let docstring = " Summary."; let result = parse_google(docstring); - assert_eq!(result.summary.start(), TextSize::new(4)); - assert_eq!(result.summary.end(), TextSize::new(12)); - assert_eq!(result.summary.source_text(&result.source), "Summary."); + 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." + ); } // ============================================================================= @@ -1153,7 +1200,10 @@ fn test_indented_summary_span() { fn test_docstring_like_summary() { let docstring = "Summary."; let result = parse_google(docstring); - assert_eq!(result.summary.source_text(&result.source), "Summary."); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); } #[test] @@ -1204,7 +1254,10 @@ fn test_span_source_text_round_trip() { let result = parse_google(docstring); // Summary - assert_eq!(result.summary.source_text(&result.source), "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"); @@ -1247,7 +1300,10 @@ fn test_section_only_no_summary() { fn test_leading_blank_lines() { let docstring = "\n\n\nSummary.\n\nArgs:\n x: Value."; let result = parse_google(docstring); - assert_eq!(result.summary.source_text(&result.source), "Summary."); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); assert_eq!(args(&result).len(), 1); } @@ -1821,7 +1877,7 @@ Example: let result = parse_google(docstring); assert_eq!( - result.summary.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "Calculate something." ); assert!(result.extended_summary.is_some()); diff --git a/tests/numpy_tests.rs b/tests/numpy_tests.rs index 4717097..0c0f7e1 100644 --- a/tests/numpy_tests.rs +++ b/tests/numpy_tests.rs @@ -115,7 +115,7 @@ fn test_simple_summary() { let result = parse_numpy(docstring); assert_eq!( - result.summary.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "This is a brief summary." ); assert!(result.extended_summary.is_none()); @@ -127,13 +127,13 @@ fn test_parse_simple_span() { let docstring = "Brief description."; let result = parse_numpy(docstring); assert_eq!( - result.summary.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "Brief description." ); - assert_eq!(result.summary.start(), TextSize::new(0)); - assert_eq!(result.summary.end(), TextSize::new(18)); + 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.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "Brief description." ); } @@ -147,20 +147,46 @@ more details about the function. "#; let result = parse_numpy(docstring); - assert_eq!(result.summary.source_text(&result.source), "Brief summary."); + 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_eq!(result.summary.source_text(&result.source), ""); + assert!(result.summary.is_none()); } #[test] fn test_whitespace_only_docstring() { let result = parse_numpy(" \n\n "); - assert_eq!(result.summary.source_text(&result.source), ""); + assert!(result.summary.is_none()); } #[test] @@ -190,7 +216,10 @@ b : int "#; let result = parse_numpy(docstring); // The signature-like line is now parsed as the summary - assert_eq!(result.summary.source_text(&result.source), "add(a, b)"); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "add(a, b)" + ); assert_eq!(parameters(&result).len(), 2); } @@ -241,7 +270,7 @@ int let result = parse_numpy(docstring); assert_eq!( - result.summary.source_text(&result.source), + result.summary.as_ref().unwrap().source_text(&result.source), "Calculate the sum of two numbers." ); assert_eq!(parameters(&result).len(), 2); @@ -734,7 +763,10 @@ x : int let result = parse_numpy(docstring); let src = &result.source; - assert_eq!(result.summary.source_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), "Parameters" @@ -789,7 +821,10 @@ 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.source_text(&result.source), "Summary line."); + 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), @@ -817,7 +852,10 @@ fn test_indented_docstring() { ); // Spans point to correct positions in indented source - assert_eq!(result.summary.source_text(&result.source), "Summary line."); + 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" @@ -837,7 +875,10 @@ 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.source_text(&result.source), "Brief."); + 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), @@ -859,7 +900,10 @@ 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.source_text(&result.source), "Summary."); + assert_eq!( + result.summary.as_ref().unwrap().source_text(&result.source), + "Summary." + ); let dep = result .deprecation .as_ref() @@ -883,7 +927,10 @@ fn test_mixed_indent_first_line() { "Summary.\n\n Parameters\n ----------\n x : int\n Description.\n"; let result = parse_numpy(docstring); - assert_eq!(result.summary.source_text(&result.source), "Summary."); + 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),