Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added Selection::add_selection and other add methods #31

Merged
merged 3 commits into from
Nov 8, 2024
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
10 changes: 5 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ All notable changes to the `dom_query` crate will be documented in this file.
- `NodeRef::children_it` now require `rev` argument. Set `true` to iterate children in reverse order.

### Added
- Added `NodeRef::prepend_child` method, that inserts a child at the beginning of node content.
- Added `NodeRef::prepend_children` method, that inserts a child and its siblings at the beginning of the node content.
- Added `NodeRef::prepend_html` method, that parses html string and inserts its parsed nodes at the beginning of the node content.
- Added `Selection::prepend_html` method, which parses an HTML string and inserts its parsed nodes at the beginning of the content of all matched nodes.

- Introduced new `NodeRef::prepend_child` method, that inserts a child at the beginning of node content.
- Introduced new `NodeRef::prepend_children` method, that inserts a child and its siblings at the beginning of the node content.
- Introduced new `NodeRef::prepend_html` method, that parses html string and inserts its parsed nodes at the beginning of the node content.
- Introduced new `Selection::prepend_html` method, which parses an HTML string and inserts its parsed nodes at the beginning of the content of all matched nodes.
- Introduced new selection methods: `Selection::add_selection`, `Selection:add_matcher`, `Selection::add` and `Selection::try_add` to extend current selection with other selections.
### Fixed
- Fixed `Selection::append_selection` to work with selections with multiple nodes and selections from another tree.
- Fixed `Selection::replace_with_selection` to work with selections with multiple nodes and selections from another tree.
Expand Down
2 changes: 1 addition & 1 deletion src/dom_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::node::{Element, NodeData, NodeId, NodeRef, TreeNode};

/// fixes node ids
fn fix_node(n: &mut TreeNode, offset: usize) {
n.id = NodeId::new(n.id.value + offset);
n.id = NodeId::new(n.id.value + offset);
n.parent = n.parent.map(|id| NodeId::new(id.value + offset));
n.prev_sibling = n.prev_sibling.map(|id| NodeId::new(id.value + offset));
n.next_sibling = n.next_sibling.map(|id| NodeId::new(id.value + offset));
Expand Down
116 changes: 116 additions & 0 deletions src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,107 @@
.collect();
Selection { nodes }
}

/// Adds nodes that match the given CSS selector to the current selection.
///
/// # Panics
///
/// If matcher contains invalid CSS selector it panics.
///
/// # Arguments
///
/// * `sel` - The CSS selector to match against.
///
/// # Returns
///
/// The new `Selection` containing the original nodes and the new nodes.
pub fn add(&self, sel: &str) -> Selection<'a> {
if self.is_empty() {
return self.clone();

Check warning on line 305 in src/selection.rs

View check run for this annotation

Codecov / codecov/patch

src/selection.rs#L305

Added line #L305 was not covered by tests
}
let matcher = Matcher::new(sel).expect("Invalid CSS selector");
self.add_matcher(&matcher)
}

/// Adds nodes that match the given CSS selector to the current selection.
///
/// If matcher contains invalid CSS selector it returns `None`.
///
/// # Arguments
///
/// * `sel` - The CSS selector to match against.
///
/// # Returns
///
/// The new `Selection` containing the original nodes and the new nodes.
pub fn try_add(&self, sel: &str) -> Option<Selection> {
if self.is_empty() {
return Some(self.clone());

Check warning on line 324 in src/selection.rs

View check run for this annotation

Codecov / codecov/patch

src/selection.rs#L324

Added line #L324 was not covered by tests
}
Matcher::new(sel).ok().map(|m| self.add_matcher(&m))
}

/// Adds nodes that match the given matcher to the current selection.
///
/// # Arguments
///
/// * `matcher` - The matcher to match against.
///
/// # Returns
///
/// The new `Selection` containing the original nodes and the new nodes.
pub fn add_matcher(&self, matcher: &Matcher) -> Selection<'a> {
if self.is_empty() {
return self.clone();

Check warning on line 340 in src/selection.rs

View check run for this annotation

Codecov / codecov/patch

src/selection.rs#L340

Added line #L340 was not covered by tests
}
let root = self.nodes().first().unwrap().tree.root();
let other_nodes: Vec<NodeRef> =
Matches::from_one(root, matcher, MatchScope::IncludeNode).collect();
let new_nodes = self.merge_nodes(other_nodes);
Selection { nodes: new_nodes }
}
Comment on lines +338 to +347
Copy link

@coderabbitai coderabbitai bot Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle empty self in add_matcher to include matches from the entire document

Currently, add_matcher returns an empty selection when self is empty, which might not be the expected behavior. When the current selection is empty, it may be more intuitive to perform the addition starting from the document root. Consider modifying add_matcher to handle the empty self case by initiating the search from the document root.

Proposed change:

pub fn add_matcher(&self, matcher: &Matcher) -> Selection<'a> {
-    if self.is_empty() {
-        return self.clone();
-    }
-    let root = self.nodes().first().unwrap().tree.root();
+    let root = if self.is_empty() {
+        // If self is empty, start from the root of the tree
+        self.nodes().first().map_or_else(
+            || matcher.document_root(),
+            |node| node.tree.root()
+        )
+    } else {
+        self.nodes().first().unwrap().tree.root()
+    };
    let other_nodes: Vec<NodeRef> =
        Matches::from_one(root, matcher, MatchScope::IncludeNode).collect();
    let new_nodes = self.merge_nodes(other_nodes);
    Selection { nodes: new_nodes }
}

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 340-340: src/selection.rs#L340
Added line #L340 was not covered by tests

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@niklak, understood.


✏️ Learnings added
Learnt from: niklak
PR: niklak/dom_query#31
File: src/selection.rs:338-347
Timestamp: 2024-11-08T15:39:07.660Z
Learning: In `Selection::add_matcher` within `src/selection.rs`, when `self` is empty, it should return an empty selection instead of starting from the document root.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


/// Adds a selection to the current selection.
///
/// Behaves like `Union` for sets.
///
/// # Arguments
///
/// * `other` - The selection to add to the current selection.
///
/// # Returns
///
/// A new `Selection` object containing the combined elements.
pub fn add_selection(&self, other: &'a Selection) -> Selection<'a> {

if self.is_empty() {
return other.clone();

Check warning on line 363 in src/selection.rs

View check run for this annotation

Codecov / codecov/patch

src/selection.rs#L363

Added line #L363 was not covered by tests
}

if other.is_empty() {
return self.clone();

Check warning on line 367 in src/selection.rs

View check run for this annotation

Codecov / codecov/patch

src/selection.rs#L367

Added line #L367 was not covered by tests
}

self.ensure_same_tree(other);

let other_nodes = other.nodes();
let new_nodes = self.merge_nodes(other_nodes.to_vec());

Selection { nodes: new_nodes }
}

fn merge_nodes(&self, other_nodes: Vec<NodeRef<'a>>) -> Vec<NodeRef<'a>> {
let m: Vec<usize> = self.nodes().iter().map(|node| node.id.value).collect();
let add_nodes: Vec<NodeRef> = other_nodes
.iter()
.filter(|&node| !m.contains(&node.id.value))
.cloned()
.collect();

let mut new_nodes = self.nodes().to_vec();
new_nodes.extend(add_nodes);
new_nodes
}
}

//manipulating methods
Expand Down Expand Up @@ -624,6 +725,21 @@
}
}

impl<'a> Selection<'a> {
/// Ensures that the two selections are from the same tree.
///
/// # Panics
///
/// Panics if the selections are from different trees or if they are empty.
fn ensure_same_tree(&self, other: &Selection) {
let tree = self.nodes().first().unwrap().tree;
let other_tree = other.nodes().first().unwrap().tree;
if !std::ptr::eq(tree, other_tree) {
panic!("Selections must be from the same tree");

Check warning on line 738 in src/selection.rs

View check run for this annotation

Codecov / codecov/patch

src/selection.rs#L738

Added line #L738 was not covered by tests
}
}
Comment on lines +734 to +740
Copy link

@coderabbitai coderabbitai bot Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential panic when selections are empty in ensure_same_tree

The method ensure_same_tree may panic when either self or other is empty because it calls unwrap() on first() without checking if there are any nodes. This can lead to a runtime panic. Consider adding checks for empty selections before accessing self.nodes().first().unwrap() and handle the empty case appropriately.

Apply this diff to handle empty selections:

fn ensure_same_tree(&self, other: &Selection) {
+    if self.is_empty() || other.is_empty() {
+        panic!("Selections must not be empty");
+    }
    let tree = self.nodes().first().unwrap().tree;
    let other_tree = other.nodes().first().unwrap().tree;
    if !std::ptr::eq(tree, other_tree) {
        panic!("Selections must be from the same tree");
    }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn ensure_same_tree(&self, other: &Selection) {
let tree = self.nodes().first().unwrap().tree;
let other_tree = other.nodes().first().unwrap().tree;
if !std::ptr::eq(tree, other_tree) {
panic!("Selections must be from the same tree");
}
}
fn ensure_same_tree(&self, other: &Selection) {
if self.is_empty() || other.is_empty() {
panic!("Selections must not be empty");
}
let tree = self.nodes().first().unwrap().tree;
let other_tree = other.nodes().first().unwrap().tree;
if !std::ptr::eq(tree, other_tree) {
panic!("Selections must be from the same tree");
}
}
🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 733-733: src/selection.rs#L733
Added line #L733 was not covered by tests

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a fix in commit 88dfbd7, please review it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@niklak, thank you for fixing the issue. I have reviewed the changes in commit 88dfbd7, and the added checks in ensure_same_tree now properly handle empty selections, preventing potential panics. Great job!


If you found this review helpful, would you consider giving us a shout-out on X?
Thank you for using CodeRabbit!

}

/// Iterator over a collection of matched elements.
pub struct Selections<I> {
iter: IntoIter<I>,
Expand Down
19 changes: 10 additions & 9 deletions tests/node-traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use wasm_bindgen_test::*;

mod alloc;


#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
fn test_first_element_child_edge_cases() {
Expand All @@ -24,31 +23,33 @@ fn test_first_element_child_edge_cases() {
</div>
</div>
"#;

let doc: Document = html.into();

// Test empty parent
let empty_sel = doc.select("#empty");
let empty = empty_sel.nodes().first().unwrap();
assert!(empty.first_element_child().is_none());

// Test text-only parent
let text_only_sel = doc.select("#text-only");
let text_only = text_only_sel.nodes().first().unwrap();
assert!(text_only.first_element_child().is_none());



// Test multiple children
let multiple_sel = doc.select("#multiple");
let multiple = multiple_sel.nodes().first().unwrap();
let first = multiple.first_element_child().unwrap();
assert_eq!(first.text(), "First".into());
assert!(first.is_element());

// Test nested elements
let nested_sel = doc.select("#nested");
let nested = nested_sel.nodes().first().unwrap();
let first_nested = nested.first_element_child().unwrap();
assert!(first_nested.is_element());
assert_eq!(first_nested.first_element_child().unwrap().text(), "Nested".into());
}
assert_eq!(
first_nested.first_element_child().unwrap().text(),
"Nested".into()
);
}
3 changes: 0 additions & 3 deletions tests/pseudo-classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ fn pseudo_class_only_text() {
assert_eq!(sel.inner_html(), "Only text".into());
}


#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
fn pseudo_class_not() {
Expand All @@ -191,8 +190,6 @@ fn pseudo_class_not() {
assert_eq!(text, "Three");
}



#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
fn test_is() {
Expand Down
46 changes: 46 additions & 0 deletions tests/selection-traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,49 @@ fn test_ancestors_selection_with_limit() {
assert!(ancestor_sel.is("#parent"));
assert!(!ancestor_sel.is("#great-ancestor"));
}

#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
fn test_selection_add_selection() {
let doc: Document = ANCESTORS_CONTENTS.into();

let first_sel = doc.select("#first-child");
assert_eq!(first_sel.length(), 1);
let second_sel = doc.select("#second-child");
assert_eq!(second_sel.length(), 1);
let children_sel = first_sel.add_selection(&second_sel);
assert_eq!(children_sel.length(), 2);
}

#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
fn test_selection_add() {
let doc: Document = ANCESTORS_CONTENTS.into();

let children_sel = doc.select("#first-child").add("#second-child");
assert_eq!(children_sel.length(), 2);
}

#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
#[should_panic]
fn test_selection_add_panic() {
let doc: Document = ANCESTORS_CONTENTS.into();

doc.select("#first-child").add(":;'");
}
Comment on lines +412 to +419
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve panic testing specificity.

Instead of using a broad #[should_panic], consider using #[should_panic(expected = "...")] to verify the specific error message. This ensures that the test fails for the expected reason rather than any panic.

-#[should_panic]
+#[should_panic(expected = "failed to parse selector")]
 fn test_selection_add_panic() {

Committable suggestion skipped: line range outside the PR's diff.


#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
fn test_selection_try_add() {
let doc: Document = ANCESTORS_CONTENTS.into();

let first_child_sel = doc.select("#first-child");
assert_eq!(first_child_sel.length(), 1);

let children_sel = first_child_sel.try_add(":;'");
assert!(children_sel.is_none());

let children_sel = first_child_sel.try_add("#second-child");
assert_eq!(children_sel.unwrap().length(), 2);
}
Loading