Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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 ==="
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion examples/parse_google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
7 changes: 6 additions & 1 deletion examples/parse_numpy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion examples/test_ret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 32 additions & 17 deletions src/cursor.rs
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
//! 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<usize>,
total: usize,
/// Current line index (0-based).
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);
Expand All @@ -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.
Expand All @@ -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;
}
Expand Down Expand Up @@ -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)`.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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("<int>");
let c = LineCursor::new("<int>");
assert_eq!(c.find_matching_close(0), Some(4));
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions src/styles/google/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextRange>,
/// Extended summary (multiple paragraphs before any section header).
pub extended_summary: Option<TextRange>,
/// All sections and stray lines in document order.
Expand Down Expand Up @@ -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(),
}
Expand All @@ -452,7 +452,7 @@ impl GoogleDocstring {

impl Default for GoogleDocstring {
fn default() -> Self {
Self::new()
Self::new("")
}
}

Expand All @@ -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))
)
}
}
Loading