Repository: yshavit/mdq
Pull request #285 (2025-03-30 07:57:03)

use `PUSH_LITERAL` to simplify grammar
Use pest's new (as of 2.8.0) `PUSH_LITERAL`. This reduces the number of rules we need in the grammar.

One complication is that the `string` rule now needs to end with `DROP`. So we could either keep the `string` as only non-empty and then add the `DROP` after each `string?`, or we could have it include empty. The latter is simpler, but means we need to then add a check for explicit `*` in the table matcher.

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The changes update dependency versions in the Cargo configuration from version 2.7 to 2.8 for both pest and pest_derive. Modifications in the matcher module adjust how the `Matcher::Any` variant is handled, now accepting an associated value, and alter related test expectations. The grammar file has been refined by replacing indirect string handling with explicit PUSH_LITERAL operations and by removing several old rules, while adding new helper rules for unit testing. In the query module, string parsing rules have been streamlined and the StringVariant enum has been updated to remove some variants and include a new AngleBracket variant. The selectors module introduces a new AnyVariant enum to differentiate between implicit and explicit matches. The strings module now tracks explicit wildcard usage, and error reporting in the API shows improved string conversion details. Additionally, test cases have been updated with revised error messages and position indications.

</details>

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c4ae8a4a6912fd4fc971c34f449d75f24a253ba2 and 68fb32c82d5ee76579388cfe292808c2059838f1.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `Cargo.lock` is excluded by `!**/*.lock`

</details>

<details>
<summary>📒 Files selected for processing (9)</summary>

* `Cargo.toml` (1 hunks)
* `src/matcher.rs` (10 hunks)
* `src/query/grammar.pest` (2 hunks)
* `src/query/query.rs` (3 hunks)
* `src/query/selectors.rs` (31 hunks)
* `src/query/strings.rs` (11 hunks)
* `src/select/api.rs` (2 hunks)
* `tests/md_cases/bad_queries.toml` (2 hunks)
* `tests/md_cases/select_html.toml` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (1)</summary>

<details>
<summary>src/query/selectors.rs (5)</summary><blockquote>

<details>
<summary>src/matcher.rs (2)</summary>

* `from` (70-87)
* `from` (364-368)

</details>
<details>
<summary>src/select/sel_code_block.rs (1)</summary>

* `from` (13-18)

</details>
<details>
<summary>src/select/sel_link_like.rs (3)</summary>

* `from` (12-14)
* `from` (30-32)
* `from` (48-53)

</details>
<details>
<summary>src/select/sel_section.rs (1)</summary>

* `from` (12-14)

</details>
<details>
<summary>src/select/sel_table.rs (1)</summary>

* `from` (31-36)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (78)</summary><blockquote>

<details>
<summary>Cargo.toml (1)</summary>

`16-17`: **Dependency version update for pest and pest_derive looks appropriate.**

The update to pest 2.8 aligns with your PR objectives to use the new `PUSH_LITERAL` feature. This is a targeted change that should help simplify your grammar as planned.

</details>
<details>
<summary>tests/md_cases/select_html.toml (1)</summary>

`62-62`: **Error message improvement provides better guidance to users.**

The updated error message now provides more specific options for what's expected: end of input, "*", unquoted string, regex, quoted string, or "^". This makes it clearer what valid inputs are possible at this location.

</details>
<details>
<summary>src/select/api.rs (2)</summary>

`15-16`: **Good addition of necessary imports for error handling.**

These imports from the pest crate support the new error formatting functionality.

---

`29-47`: **Well-implemented error formatting method.**

The new `to_string` method provides improved error reporting by converting both Pest and Other error variants to readable string representations. For the Other variant, it nicely leverages the pest error formatting capabilities when possible.

</details>
<details>
<summary>tests/md_cases/bad_queries.toml (2)</summary>

`171-176`: **Error message and position have been updated appropriately.**

The error position indicator has been adjusted from position 4 to 3, and the message now provides more specific guidance about expected inputs.

---

`226-228`: **More specific error guidance for table column matcher.**

The error message now explicitly states that table column matchers cannot be empty and instructs users to use an explicit "*". This is much clearer than the previous message.

</details>
<details>
<summary>src/matcher.rs (15)</summary>

`85-85`: **Properly handling the updated Matcher::Any variant with parameter**

The change accommodates the new `AnyVariant` parameter in `Matcher::Any`, properly ignoring it with the underscore pattern while still calling the same implementation. This ensures backward compatibility with existing code while supporting the new wildcard distinction.

---

`130-135`: **Good adaptation of test case for StringVariant::AngleBracket**

Test has been updated to use the new `StringVariant::AngleBracket` enum variant, maintaining test coverage for the new string handling mechanism.

---

`148-148`: **Fixed whitespace handling in bareword_anchor_end test**

The updated test correctly reflects the improved whitespace handling in the parser, removing unnecessary trailing space.

---

`156-157`: **Properly aligned expectations for starting anchor test**

The test expectations have been correctly adjusted to accommodate the updated grammar handling of anchors.

---

`162-163`: **Properly aligned expectations for ending anchor test**

The test expectations have been correctly adjusted to accommodate the updated grammar handling of anchors.

---

`168-168`: **Updated test for both anchors to reflect new parsing behavior**

The test has been properly updated to match the new grammar's handling of combined anchors.

---

`173-173`: **Correctly handling whitespace between anchors**

The updated test properly reflects the new grammar's improved handling of whitespace between anchors.

---

`216-216`: **Properly handling multiple whitespace in anchored strings**

The test update correctly reflects the improved whitespace handling in the updated grammar.

---

`228-233`: **Updated test for StringVariant::AngleBracket in bareword_end_delimiters**

The test has been properly updated to use the new `AngleBracket` variant, ensuring correct testing of the delimiter handling.

---

`237-238`: **Properly updated AngleBracket handling in dollar sign test**

The test correctly uses the new `AngleBracket` enum variant while maintaining the same test logic.

---

`274-282`: **Replaced error expectation with empty expectation**

Good switch from expecting errors to expecting empty results for invalid quoted strings, which aligns with the new grammar behavior.

---

`300-300`: **Updated error handling for unclosed regex**

The test now correctly uses `expect_empty` instead of expecting an error, reflecting the new grammar's behavior.

---

`312-313`: **Added tests for the wildcard with space and AngleBracket variant**

Good addition of test cases to cover the new wildcard behavior with spaces and with the new `AngleBracket` variant.

---

`322-328`: **Improved error handling in parse_and_check_with**

Good improvement in error reporting when parsing fails, now printing the error message before panicking.

---

`339-341`: **Added helpful expect_empty utility function**

Good addition of a utility function to check for empty parse results, which simplifies test code and improves readability.

</details>
<details>
<summary>src/query/selectors.rs (33)</summary>

`20-27`: **Good enhancement of Matcher with AnyVariant enum**

The enhancement to `Matcher::Any` with the `AnyVariant` enum nicely distinguishes between implicit and explicit wildcard matches. This is an elegant solution to the problem described in the PR, allowing different handling for explicit `*` versus implied empty matchers.

---

`47-47`: **Updated PartialEq implementation for Matcher::Any**

The PartialEq implementation has been properly updated to handle the new AnyVariant parameter in Matcher::Any, correctly ignoring the specific variant when comparing equality.

---

`57-69`: **Well-implemented logic for handling explicit vs implicit wildcards**

The implementation nicely differentiates between explicit and implicit wildcards based on the new `explicit_wildcard` field, maintaining backward compatibility while adding the new functionality.

---

`228-239`: **Good enhancement for table column matcher validation**

This change improves usability by requiring an explicit wildcard for table column matchers rather than accepting an implicit empty matcher. The error message clearly explains what the user needs to do, enhancing the user experience.

---

`331-332`: **Updated test expectation for wildcard handling**

The test has been properly updated to use `AnyVariant::Implicit` in the section prefix_chaining test.

---

`336-337`: **Updated test expectation for suffix_chaining**

The test has been properly updated to use `AnyVariant::Implicit` in the section suffix_chaining test.

---

`344-348`: **Updated chaining test expectations**

The tests for chaining have been properly updated to use `AnyVariant::Implicit` in both the section and link matchers.

---

`358-362`: **Updated empty_intermediate_chains test**

The test has been properly updated to use `AnyVariant::Implicit` in both the section and link matchers.

---

`373-373`: **Updated section_no_matcher test**

The test has been properly updated to use `AnyVariant::Implicit` in the section matcher.

---

`393-393`: **Updated unordered_list_item_no_matcher test**

The test has been properly updated to use `AnyVariant::Implicit` in the matcher.

---

`431-431`: **Updated ordered_list_item_no_matcher test**

The test has been properly updated to use `AnyVariant::Implicit` in the matcher.

---

`467-467`: **Updated unordered_unchecked_task test**

The test has been properly updated to use `AnyVariant::Implicit` in the matcher.

---

`479-479`: **Updated unordered_checked_task test**

The test has been properly updated to use `AnyVariant::Implicit` in the matcher.

---

`491-491`: **Updated unordered_either_task test**

The test has been properly updated to use `AnyVariant::Implicit` in the matcher.

---

`543-544`: **Updated link_no_matchers test**

The test has been properly updated to use `AnyVariant::Implicit` in both the display and URL matchers.

---

`555-555`: **Updated link_with_display test**

The test has been properly updated to use `AnyVariant::Implicit` in the URL matcher.

---

`565-565`: **Updated link_with_url test**

The test has been properly updated to use `AnyVariant::Implicit` in the display matcher.

---

`587-588`: **Updated image_no_matchers test**

The test has been properly updated to use `AnyVariant::Implicit` in both the display and URL matchers.

---

`599-599`: **Updated image_with_alt test**

The test has been properly updated to use `AnyVariant::Implicit` in the URL matcher.

---

`610-610`: **Updated block_quote_no_matcher test**

The test has been properly updated to use `AnyVariant::Implicit` in the block quote matcher.

---

`638-639`: **Updated code_block_no_matchers test**

The test has been properly updated to use `AnyVariant::Implicit` in both the language and contents matchers.

---

`650-650`: **Updated code_block_with_only_language test**

The test has been properly updated to use `AnyVariant::Implicit` in the contents matcher.

---

`661-661`: **Updated code_block_with_only_language_and_trailing_space test**

The test has been properly updated to use `AnyVariant::Implicit` in the contents matcher.

---

`671-671`: **Updated code_block_with_only_content test**

The test has been properly updated to use `AnyVariant::Implicit` in the language matcher.

---

`695-695`: **Updated html_no_matcher test**

The test has been properly updated to use `AnyVariant::Implicit` in the HTML matcher.

---

`719-719`: **Updated expected error message in html_with_unquoted_text test**

The error message expectation has been updated to include the new `*` pattern option in the list of expected tokens.

---

`737-737`: **Updated paragraph_no_matcher test**

The test has been properly updated to use `AnyVariant::Implicit` in the paragraph matcher.

---

`766-766`: **Improved error message position indicator**

The error message position indicator (caret) is now correctly positioned to clearly identify the error location.

---

`768-768`: **Improved table column error message**

The error message has been improved to clearly explain that an explicit wildcard (`*`) is required for table column matchers.

---

`791-792`: **Updated table_asterisk_column test**

The test has been properly updated to use `AnyVariant::Explicit` for the column matcher and `AnyVariant::Implicit` for the row matcher.

---

`803-803`: **Updated table_with_column test**

The test has been properly updated to use `AnyVariant::Implicit` in the row matcher.

---

`813-813`: **Updated table_with_row test**

The test has been properly updated to use `AnyVariant::Explicit` in the column matcher.

---

`873-887`: **Enhanced error message handling in expect_parse_error**

The error handling code has been enhanced to properly format error messages from both pest errors and custom errors, improving test diagnostics.

</details>
<details>
<summary>src/query/strings.rs (12)</summary>

`12-12`: **Good addition of explicit_wildcard flag to ParsedString**

This addition enables tracking whether a wildcard was explicitly specified with `*` or implicitly through an empty string, which is key to the enhanced wildcard handling.

---

`41-41`: **Enhanced Debug implementation for ParsedString**

The Debug implementation now clearly distinguishes between explicit and implicit wildcards, improving debugging and error messages.

---

`81-81`: **Default explicit_wildcard to false when initializing ParsedString**

Properly initializes the new field to false by default, maintaining backward compatibility.

---

`92-94`: **Good handling of asterisk rule**

The code properly sets the explicit_wildcard flag when an asterisk is encountered during parsing, enabling the distinction between explicit and implicit wildcards.

---

`205-211`: **Updated unquoted_string_to_angle_bracket test**

The test has been renamed and updated to use StringVariant::AngleBracket, with appropriate adjustments to the input and expected output.

---

`236-238`: **Added asterisk test**

Good addition of a test for the asterisk wildcard, verifying both parsing and the is_equivalent_to_asterisk function.

---

`240-248`: **Improved empty string test**

The test for empty strings has been enhanced to also verify the is_equivalent_to_asterisk function returns true for empty strings.

---

`277-277`: **Fixed expected output in anchors_unquoted_to_pipe_with_space test**

The test now correctly expects the remainder without leading space, reflecting the improved handling of whitespace in the updated grammar.

---

`287-287`: **Fixed expected output in anchors_unquoted_to_pipe_no_space test**

The test now correctly expects the remainder without leading space, reflecting the improved handling of whitespace in the updated grammar.

---

`331-332`: **Added explicit_wildcard to parsed_text helper function**

The helper function has been updated to include the new explicit_wildcard field, initialized to false for backward compatibility.

---

`341-342`: **Added explicit_wildcard to parsed_regex helper function**

The helper function has been updated to include the new explicit_wildcard field, initialized to false for backward compatibility.

---

`345-352`: **Added parsed_wildcard helper function**

Good addition of a helper function to create a ParsedString with explicit_wildcard set to true, simplifying test code.

</details>
<details>
<summary>src/query/query.rs (4)</summary>

`49-54`: **Good consolidation of string rules**

The string rules have been nicely consolidated, simplifying the grammar by using the new helper rules and properly handling regex.

---

`59-59`: **Added asterisk rule renaming**

Good addition of the asterisk rule renaming to provide clearer error messages.

---

`80-81`: **Simplified StringVariant enum**

The StringVariant enum has been simplified by removing unused variants and adding the new AngleBracket variant, which aligns with the grammar simplification goal.

---

`98-99`: **Updated as_rule method for StringVariant**

The as_rule method has been properly updated to map the new AngleBracket variant to the appropriate test helper rule.

</details>
<details>
<summary>src/query/grammar.pest (8)</summary>

`21-21`: **Good use of PUSH_LITERAL for delimiters**

The change from previous string handling rules to using PUSH_LITERAL directly simplifies the grammar and follows the PR objectives to leverage this new Pest 2.8.0 feature.

---

`24-24`: **Consistent application of PUSH_LITERAL pattern across selectors**

The consistent approach of using PUSH_LITERAL to push delimiters onto the stack across all selector types shows good attention to detail and maintains a uniform pattern throughout the grammar.



Also applies to: 33-33, 37-37, 40-40, 43-43, 46-46

---

`41-41`: **Explicit space handling in code blocks**

Explicitly pushing a space character before the language reference ensures proper parsing of code block language specifications.

---

`49-49`: **Table-specific delimiter handling**

The table selector now correctly uses PUSH_LITERAL for both colon and pipe delimiters, maintaining consistency with other selectors while handling the unique structure of table matching.

---

`52-54`: **Helper rules for unit testing**

These new helper rules appear to be specifically designed for unit testing purposes. The naming convention clearly indicates they're not intended for general use within the grammar.


Would the unit tests that use these rules benefit from additional documentation or comments to explain their purpose?

---

`55-64`: **Streamlined string rule with DROP operation**

The consolidation of string parsing with the DROP operation aligns with the PR objectives. This implementation allows for empty strings, which was mentioned as one approach in the PR description.

As noted in the PR objectives, allowing empty strings in the string rule may require an additional check for explicit `*` in the table matcher. Has this check been implemented in the corresponding code that processes these parse results?

---

`65-65`: **Explicit asterisk handling**

Adding a dedicated rule for asterisk matching supports the special handling needed for wildcard operations, which is important for the checks mentioned in the PR objectives.

---

`66-66`: **Improved unquoted string definition**

The unquoted string rule now properly checks for both the pushed delimiter (via PEEK) and the end anchor, ensuring correct parsing boundaries.

</details>

</blockquote></details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yshavit/mdq&utm_content=285):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 85f2ff0..a2c1c13 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -78,9 +78,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "clap"
-version = "4.5.26"
+version = "4.5.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783"
+checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -88,9 +88,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.26"
+version = "4.5.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121"
+checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8"
 dependencies = [
  "anstream",
  "anstyle",
@@ -100,9 +100,9 @@ dependencies = [
 
 [[package]]
 name = "clap_derive"
-version = "4.5.24"
+version = "4.5.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c"
+checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
 dependencies = [
  "heck",
  "proc-macro2",
@@ -159,9 +159,9 @@ dependencies = [
 
 [[package]]
 name = "equivalent"
-version = "1.0.1"
+version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
 [[package]]
 name = "generic-array"
@@ -187,9 +187,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
 [[package]]
 name = "indexmap"
-version = "2.7.0"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f"
+checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058"
 dependencies = [
  "equivalent",
  "hashbrown",
@@ -197,9 +197,9 @@ dependencies = [
 
 [[package]]
 name = "indoc"
-version = "2.0.5"
+version = "2.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
+checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
 
 [[package]]
 name = "is_terminal_polyfill"
@@ -209,9 +209,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
 [[package]]
 name = "itoa"
-version = "1.0.14"
+version = "1.0.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
 
 [[package]]
 name = "lazy_static"
@@ -221,15 +221,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
 
 [[package]]
 name = "libc"
-version = "0.2.169"
+version = "0.2.171"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
+checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
 
 [[package]]
 name = "markdown"
-version = "1.0.0-alpha.21"
+version = "1.0.0-alpha.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6491e6c702bf7e3b24e769d800746d5f2c06a6c6a2db7992612e0f429029e81"
+checksum = "9047e0a37a596d4e15411a1ffbdabe71c328908cb90a721cb9bf8dcf3434e6d2"
 dependencies = [
  "unicode-id",
 ]
@@ -261,9 +261,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
 [[package]]
 name = "once_cell"
-version = "1.20.2"
+version = "1.21.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
+checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
 
 [[package]]
 name = "paste"
@@ -273,9 +273,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
 
 [[package]]
 name = "pest"
-version = "2.7.15"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
+checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
 dependencies = [
  "memchr",
  "thiserror",
@@ -284,9 +284,9 @@ dependencies = [
 
 [[package]]
 name = "pest_derive"
-version = "2.7.15"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e"
+checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5"
 dependencies = [
  "pest",
  "pest_generator",
@@ -294,9 +294,9 @@ dependencies = [
 
 [[package]]
 name = "pest_generator"
-version = "2.7.15"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b"
+checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841"
 dependencies = [
  "pest",
  "pest_meta",
@@ -307,9 +307,9 @@ dependencies = [
 
 [[package]]
 name = "pest_meta"
-version = "2.7.15"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea"
+checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
 dependencies = [
  "once_cell",
  "pest",
@@ -328,18 +328,18 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.93"
+version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99"
+checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
 dependencies = [
  "unicode-ident",
 ]
 
 [[package]]
 name = "quote"
-version = "1.0.38"
+version = "1.0.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
 dependencies = [
  "proc-macro2",
 ]
@@ -375,24 +375,24 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 
 [[package]]
 name = "ryu"
-version = "1.0.18"
+version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
 
 [[package]]
 name = "serde"
-version = "1.0.217"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.217"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -401,9 +401,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.136"
+version = "1.0.140"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "336a0c23cf42a38d9eaa7cd22c7040d04e1228a19a933890805ffd00a16437d2"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
 dependencies = [
  "itoa",
  "memchr",
@@ -439,9 +439,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
 [[package]]
 name = "syn"
-version = "2.0.96"
+version = "2.0.100"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80"
+checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -470,9 +470,9 @@ dependencies = [
 
 [[package]]
 name = "toml"
-version = "0.8.19"
+version = "0.8.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e"
+checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
 dependencies = [
  "serde",
  "serde_spanned",
@@ -491,9 +491,9 @@ dependencies = [
 
 [[package]]
 name = "toml_edit"
-version = "0.22.22"
+version = "0.22.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5"
+checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
 dependencies = [
  "indexmap",
  "serde",
@@ -522,9 +522,9 @@ checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561"
 
 [[package]]
 name = "unicode-ident"
-version = "1.0.14"
+version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
 
 [[package]]
 name = "utf8parse"
@@ -613,9 +613,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
 [[package]]
 name = "winnow"
-version = "0.6.24"
+version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
+checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36"
 dependencies = [
  "memchr",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 8f8f42a..bfd8d24 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,8 +13,8 @@ clap = { version = "4.5.7", features = ["derive"] }
 markdown = "1.0.0-alpha.20"
 memchr = "2.7.4"
 paste = "1.0"
-pest = "2.7"
-pest_derive = { version = "2.7", features = ["grammar-extras"] }
+pest = "2.8"
+pest_derive = { version = "2.8", features = ["grammar-extras"] }
 regex = "1.10.4"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1.0"
diff --git a/src/matcher.rs b/src/matcher.rs
index e9e2f9b..087ead6 100644
--- a/src/matcher.rs
+++ b/src/matcher.rs
@@ -82,7 +82,7 @@ impl From<Matcher> for StringMatcher {
             }
             .to_string_matcher(),
             Matcher::Regex(re) => Self::regex(re),
-            Matcher::Any => Self::any(),
+            Matcher::Any(_) => Self::any(),
         }
     }
 }
@@ -127,7 +127,12 @@ mod test {
         parse_and_check("hello / goodbye", re_insensitive("hello / goodbye"), "");
         parse_and_check("hello| goodbye", re_insensitive("hello"), "| goodbye");
         parse_and_check("hello | goodbye", re_insensitive("hello"), "| goodbye");
-        parse_and_check_with(StringVariant::Bracket, "foo] rest", re_insensitive("foo"), "] rest");
+        parse_and_check_with(
+            StringVariant::AngleBracket,
+            "foo> rest",
+            re_insensitive("foo"),
+            "> rest",
+        );
     }
 
     #[test]
@@ -140,7 +145,7 @@ mod test {
 
     #[test]
     fn bareword_anchor_end() {
-        let m = parse_and_check(" hello $ |after", re_insensitive("hello$"), " |after");
+        let m = parse_and_check(" hello $ |after", re_insensitive("hello$"), "|after");
         assert_eq!(true, m.matches("pre hello"));
         assert_eq!(true, m.matches("hello"));
         assert_eq!(false, m.matches("hello post"));
@@ -148,24 +153,24 @@ mod test {
 
     #[test]
     fn only_starting_anchor() {
-        parse_and_check("^ |", StringMatcher::any(), "|");
-        parse_and_check("^", StringMatcher::any(), "");
+        parse_and_check("^ |", StringMatcher::any(), "^ |");
+        parse_and_check("^", StringMatcher::any(), "^");
     }
 
     #[test]
     fn only_ending_anchor() {
-        parse_and_check("$ |", StringMatcher::any(), " |");
-        parse_and_check("$", StringMatcher::any(), "");
+        parse_and_check("$ |", StringMatcher::any(), "$ |");
+        parse_and_check("$", StringMatcher::any(), "$");
     }
 
     #[test]
     fn only_both_anchors() {
-        let matcher = parse_and_check("^$ |after", re("^$"), " |after");
+        let matcher = parse_and_check("^$ |after", re("^$"), "|after");
         assert_eq!(matcher.matches(""), true);
         assert_eq!(matcher.matches("x"), false);
         assert_eq!(matcher.matches("\n"), false);
 
-        parse_and_check("^  $ |after", re("^$"), " |after");
+        parse_and_check("^  $ |after", re("^$"), "|after");
     }
 
     #[test]
@@ -208,7 +213,7 @@ mod test {
         parse_and_check("bar     $", re("(?i)bar$"), "");
         parse_and_check("'bar'   $", re("bar$"), "");
 
-        parse_and_check("^  foobar  $  ", re("(?i)^foobar$"), "  ");
+        parse_and_check("^  foobar  $  ", re("(?i)^foobar$"), "");
     }
 
     #[test]
@@ -220,11 +225,16 @@ mod test {
 
     #[test]
     fn bareword_end_delimiters() {
-        parse_and_check_with(StringVariant::Colon, "hello:world", re_insensitive("hello"), ":world");
+        parse_and_check_with(
+            StringVariant::AngleBracket,
+            "hello>world",
+            re_insensitive("hello"),
+            ">world",
+        );
 
         // "$" is always an end delimiter
         parse_and_check_with(
-            StringVariant::Colon,
+            StringVariant::AngleBracket,
             "hello$world",
             re_insensitive("hello$"),
             "world", // note: the dollar sign got consumed, since it's part of the string
@@ -261,15 +271,15 @@ mod test {
 
     #[test]
     fn quote_errs() {
-        expect_err(r#"" "#);
-        expect_err(r#"' "#);
-        expect_err(r#"'\"#);
-        expect_err(r#""\x" "#);
-        expect_err(r#""\u2603" "#);
-        expect_err(r#""\u{}" "#);
-        expect_err(r#""\u{12345678}" "#); // out of range
-        expect_err(r#""\u{snowman}" "#);
-        expect_err(r#""\u{2603"#);
+        expect_empty(r#"" "#);
+        expect_empty(r#"' "#);
+        expect_empty(r#"'\"#);
+        expect_empty(r#""\x" "#);
+        expect_empty(r#""\u2603" "#);
+        expect_empty(r#""\u{}" "#);
+        expect_empty(r#""\u{12345678}" "#); // out of range
+        expect_empty(r#""\u{snowman}" "#);
+        expect_empty(r#""\u{2603"#);
     }
 
     //noinspection RegExpSingleCharAlternation (for the "(a|b)" case)
@@ -287,7 +297,7 @@ mod test {
             "",
         );
 
-        expect_err(r#"/unclosed"#);
+        expect_empty(r#"/unclosed"#);
 
         expect_err(r#"/(unclosed paren/"#);
     }
@@ -299,8 +309,8 @@ mod test {
 
         parse_and_check("| rest", StringMatcher::any(), "| rest");
         parse_and_check("*| rest", StringMatcher::any(), "| rest");
-        parse_and_check("* | rest", StringMatcher::any(), " | rest");
-        parse_and_check_with(StringVariant::Bracket, "] rest", StringMatcher::any(), "] rest");
+        parse_and_check("* | rest", StringMatcher::any(), "| rest");
+        parse_and_check_with(StringVariant::AngleBracket, "> rest", StringMatcher::any(), "> rest");
     }
 
     fn parse_and_check_with(
@@ -309,7 +319,13 @@ mod test {
         expect: StringMatcher,
         expect_remaining: &str,
     ) -> StringMatcher {
-        let (actual_matcher, actual_remaining) = Matcher::parse(string_variant, text).unwrap();
+        let (actual_matcher, actual_remaining) = match Matcher::parse(string_variant, text) {
+            Ok(parsed) => parsed,
+            Err(err) => {
+                eprintln!("{}", err.to_string(text));
+                panic!("{err:?}")
+            }
+        };
         let actual_string_matcher: StringMatcher = actual_matcher.into();
         assert_eq!(actual_string_matcher, expect);
         assert_eq!(actual_remaining, expect_remaining);
@@ -320,6 +336,10 @@ mod test {
         parse_and_check_with(StringVariant::Pipe, text, expect, expect_remaining)
     }
 
+    fn expect_empty(text: &str) {
+        parse_and_check(text, StringMatcher::any(), text);
+    }
+
     fn expect_err(text: &str) {
         match Matcher::parse(StringVariant::Pipe, text) {
             Ok(unexpected) => panic!("unexpected success: {unexpected:?}"),
diff --git a/src/query/grammar.pest b/src/query/grammar.pest
index 60cdc4e..941042d 100644
--- a/src/query/grammar.pest
+++ b/src/query/grammar.pest
@@ -18,10 +18,10 @@ selector = {
 selector_delim = _{ explicit_space | EOI }
 explicit_space = !{ " " } // making this a rule lets us have nicer error messages if the user doesn't include it
 
-select_section =  { section_start ~ #title = string_to_pipe? }
+select_section =  { section_start ~ PUSH_LITERAL("|") ~ #title = string }
 section_start  = @{ "#" ~ selector_delim }
 
-select_list_item  =  { list_start ~ list_task_options? ~ #contents = string_to_pipe? }
+select_list_item  =  { list_start ~ list_task_options? ~ PUSH_LITERAL("|") ~ #contents = string }
 list_start        = ${ (list_ordered | "-") ~ selector_delim }
 list_ordered      = ${ "1." }
 list_task_options = ${ "[" ~ (task_unchecked | task_checked | task_either) ~ task_end }
@@ -30,42 +30,40 @@ task_unchecked    = ${ " " }
 task_either       = ${ "?" }
 task_end          = ${ "]" }
 
-select_link =  { link_start ~ #display_text = string_to_bracket? ~ "](" ~ #url_text = string_to_paren? ~ ")" }
+select_link =  { link_start ~ PUSH_LITERAL("]") ~ #display_text = string ~ "](" ~ PUSH_LITERAL(")")~ #url_text = string ~ ")" }
 link_start  = ${ image_start? ~ "[" }
 image_start = @{ "!" }
 
-select_block_quote       =  { select_block_quote_start ~ #text = string_to_pipe? }
+select_block_quote       =  { select_block_quote_start ~ PUSH_LITERAL("|") ~ #text = string }
 select_block_quote_start = @{ ">" ~ selector_delim }
 
-select_code_block =  { code_block_start ~ #text = string_to_pipe? }
-code_block_start  = ${ "```" ~ #language = string_to_space? ~ selector_delim }
+select_code_block =  { code_block_start ~ PUSH_LITERAL("|") ~ #text = string }
+code_block_start  = ${ "```" ~ PUSH_LITERAL(" ") ~ #language = string ~ selector_delim }
 
-select_html =  { html_start ~ #text = string_to_pipe? }
+select_html =  { html_start ~ PUSH_LITERAL("|") ~ #text = string }
 html_start  = @{ "</>" ~ selector_delim }
 
-select_paragraph       =  { select_paragraph_start ~ #text = string_to_pipe? }
+select_paragraph       =  { select_paragraph_start ~ PUSH_LITERAL("|") ~ #text = string }
 select_paragraph_start = @{ "P:" ~ selector_delim }
 
-select_table = { table_start ~ (explicit_asterisk | #column = string_to_colon) ~ ":-:" ~ #row = string_to_pipe? }
+select_table = { table_start ~ PUSH_LITERAL(":") ~ #column = string ~ ":-:" ~ PUSH_LITERAL("|") ~ #row = string }
 table_start = ${":-:" ~ explicit_space }
-explicit_asterisk = ${ "*" }
 
-// What I'd really want is to push a literal for the closing delimiter, and then do (!("|" | PEEK) in unquoted_string.
-// But that's not possible (see GH pest-parser/pest#880), so this is the next best.
-
-// NOTE: If you add a variant here, make sure to add it to the big matcher in strings.rs's "fn build_string":
-
-string_to_pipe    = { "*" | regex | (anchor_start? ~ (quoted_string | unquoted_string_to_pipe) ~ anchor_end?) | only_anchors }
-string_to_paren   = { "*" | regex | (anchor_start? ~ (quoted_string | unquoted_string_to_paren) ~ anchor_end? | only_anchors) }
-string_to_bracket = { "*" | regex | (anchor_start? ~ (quoted_string | unquoted_string_to_bracket) ~ anchor_end? | only_anchors) }
-string_to_space   = { "*" | regex | (anchor_start? ~ (quoted_string | unquoted_string_to_space) ~ anchor_end? | only_anchors) }
-string_to_colon   = { "*" | regex | (anchor_start? ~ (quoted_string | unquoted_string_to_colon) ~ anchor_end? | only_anchors) }
-
-unquoted_string_to_pipe    = @{ LETTER ~ (!("|" | "$") ~ ANY)* }
-unquoted_string_to_paren   = @{ LETTER ~ (!(")" | "$") ~ ANY)* }
-unquoted_string_to_bracket = @{ LETTER ~ (!("]" | "$") ~ ANY)* }
-unquoted_string_to_space   = @{ LETTER ~ (!(" " | "$") ~ ANY)* }
-unquoted_string_to_colon   = @{ LETTER ~ (!(":" | "$") ~ ANY)* }
+// helper rule, just for unit tests
+string_for_unit_tests__do_not_use_pipe = { PUSH_LITERAL("|") ~ string }
+string_for_unit_tests__do_not_use_angle = { PUSH_LITERAL(">") ~ string }
+string = {
+  // end delimiter for unquoted string will have been PUSH_LITERAL'd by here
+  (
+        asterisk
+      | regex
+      | ( anchor_start? ~ ( quoted_string | unquoted_string ) ~ anchor_end? )
+      | ( anchor_start ~ anchor_end )
+  )?
+  ~ DROP
+}
+asterisk = @{ "*" }
+unquoted_string = @{ LETTER ~ (!(PEEK | "$") ~ ANY)* }
 
 regex               = ${ "/" ~ regex_char* ~ "/" }
 regex_char          = ${
@@ -85,11 +83,6 @@ anchor_start = @{ "^" }
 
 anchor_end = @{ "$" }
 
-only_anchors = {
-  (anchor_start ~ anchor_end?)
-  | anchor_end
-}
-
 quoted_plain_chars = @{ (!(PEEK | "\\") ~ ANY)+ }
 
 escaped_char = @{ ("\"" | "'" | "`" | "\\" | "n" | "r" | "t") }
diff --git a/src/query/query.rs b/src/query/query.rs
index bfbde5a..6401df4 100644
--- a/src/query/query.rs
+++ b/src/query/query.rs
@@ -46,26 +46,19 @@ impl Query {
                 Rule::select_html | Rule::html_start => "_</>_",
                 Rule::select_paragraph | Rule::select_paragraph_start => "_P:_",
                 Rule::select_table | Rule::table_start => "_:-:_",
-                Rule::explicit_asterisk => "explicit _*_",
-                Rule::string_to_pipe
-                | Rule::string_to_paren
-                | Rule::string_to_bracket
-                | Rule::string_to_space
-                | Rule::string_to_colon => "string",
-                Rule::unquoted_string_to_pipe
-                | Rule::unquoted_string_to_paren
-                | Rule::unquoted_string_to_bracket
-                | Rule::unquoted_string_to_space
-                | Rule::unquoted_string_to_colon => "unquoted string",
-                Rule::regex => "_/_",
+                Rule::string
+                | Rule::string_for_unit_tests__do_not_use_angle
+                | Rule::string_for_unit_tests__do_not_use_pipe => "string",
+                Rule::unquoted_string => "unquoted string",
+                Rule::regex => "regex",
                 Rule::regex_char => "regex character",
                 Rule::regex_escaped_slash => "_/_",
                 Rule::regex_normal_char => "regex character",
                 Rule::quoted_string => "quoted string",
                 Rule::quoted_char => "character in quoted string",
+                Rule::asterisk => "_*_",
                 Rule::anchor_start => "_^_",
                 Rule::anchor_end => "_$_",
-                Rule::only_anchors => "_^_ _$_",
                 Rule::quoted_plain_chars => "character in quoted string",
                 Rule::escaped_char => "\", ', `, \\, n, r, or t",
                 Rule::unicode_seq => "1 - 6 hex characters",
@@ -85,10 +78,7 @@ mod test_helpers {
     #[derive(Clone, Copy, PartialEq, Eq)]
     pub enum StringVariant {
         Pipe,
-        Paren,
-        Bracket,
-        Space,
-        Colon,
+        AngleBracket,
     }
 
     impl StringVariant {
@@ -105,11 +95,8 @@ mod test_helpers {
 
         pub fn as_rule(self) -> Rule {
             let rule = match self {
-                StringVariant::Pipe => Rule::string_to_pipe,
-                StringVariant::Paren => Rule::string_to_paren,
-                StringVariant::Bracket => Rule::string_to_bracket,
-                StringVariant::Space => Rule::string_to_space,
-                StringVariant::Colon => Rule::string_to_colon,
+                StringVariant::AngleBracket => Rule::string_for_unit_tests__do_not_use_angle,
+                StringVariant::Pipe => Rule::string_for_unit_tests__do_not_use_pipe,
             };
             rule
         }
diff --git a/src/query/selectors.rs b/src/query/selectors.rs
index 0edcb53..6869897 100644
--- a/src/query/selectors.rs
+++ b/src/query/selectors.rs
@@ -17,7 +17,13 @@ pub enum Matcher {
         anchor_end: bool,
     },
     Regex(Regex),
-    Any,
+    Any(AnyVariant),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AnyVariant {
+    Implicit,
+    Explicit,
 }
 
 impl PartialEq for Matcher {
@@ -38,7 +44,7 @@ impl PartialEq for Matcher {
                 },
             ) => s1 == s2 && a1 == a2 && e1 == e2 && t1 == t2,
             (Self::Regex(r1), Self::Regex(r2)) => r1.as_str() == r2.as_str(),
-            (Self::Any, Self::Any) => true,
+            (Self::Any(_), Self::Any(_)) => true,
             _ => false,
         }
     }
@@ -48,11 +54,18 @@ impl Eq for Matcher {}
 
 impl Matcher {
     fn take_from_option(pair: Option<Pair>) -> Result<Matcher, ParseError> {
-        let Some(pair) = pair else { return Ok(Matcher::Any) };
+        let Some(pair) = pair else {
+            return Ok(Matcher::Any(AnyVariant::Implicit));
+        };
         let span = DetachedSpan::from(&pair);
         let parsed_string: ParsedString = pair.into_inner().try_into()?;
         if parsed_string.is_equivalent_to_asterisk() {
-            return Ok(Matcher::Any);
+            let any_variant = if parsed_string.explicit_wildcard {
+                AnyVariant::Explicit
+            } else {
+                AnyVariant::Implicit
+            };
+            return Ok(Matcher::Any(any_variant));
         }
         let matcher = match parsed_string.mode {
             ParsedStringMode::CaseSensitive => Matcher::Text {
@@ -212,7 +225,18 @@ impl Selector {
             }
             Rule::select_table => {
                 let res = TableTraverser::traverse(children);
-                let column_matcher = Matcher::take_from_option(res.column.take().map_err(to_parse_error)?)?;
+                let column_matcher_span = res.column.take().map_err(to_parse_error)?;
+                let detached_span = match &column_matcher_span {
+                    None => DetachedSpan::from(span),
+                    Some(column_matcher_span) => DetachedSpan::from(column_matcher_span),
+                };
+                let column_matcher = Matcher::take_from_option(column_matcher_span)?;
+                if matches!(column_matcher, Matcher::Any(AnyVariant::Implicit)) {
+                    return Err(ParseError::Other(
+                        detached_span,
+                        "table column matcher cannot empty; use an explicit \"*\"".to_string(),
+                    ));
+                }
                 let row_matcher = Matcher::take_from_option(res.row.take().map_err(to_parse_error)?)?;
                 Ok(Self::TableSlice(TableSliceMatcher {
                     column: column_matcher,
@@ -239,7 +263,9 @@ mod tests {
     use super::*;
     use crate::query::query::{Query, StringVariant};
     use crate::query::traversal::OneOf;
+    use crate::query::Error;
     use pest::error::ErrorVariant;
+    use pest::Span;
     use std::cmp::{max, min};
 
     impl Matcher {
@@ -302,12 +328,12 @@ mod tests {
 
         #[test]
         fn prefix_chaining() {
-            find_selector("| #", Selector::Section(Matcher::Any))
+            find_selector("| #", Selector::Section(Matcher::Any(AnyVariant::Implicit)))
         }
 
         #[test]
         fn suffix_chaining() {
-            find_selector("# |", Selector::Section(Matcher::Any))
+            find_selector("# |", Selector::Section(Matcher::Any(AnyVariant::Implicit)))
         }
 
         #[test]
@@ -315,10 +341,10 @@ mod tests {
             find_selectors(
                 "# | []()",
                 vec![
-                    Selector::Section(Matcher::Any),
+                    Selector::Section(Matcher::Any(AnyVariant::Implicit)),
                     Selector::Link(LinklikeMatcher {
-                        display_matcher: Matcher::Any,
-                        url_matcher: Matcher::Any,
+                        display_matcher: Matcher::Any(AnyVariant::Implicit),
+                        url_matcher: Matcher::Any(AnyVariant::Implicit),
                     }),
                 ],
             );
@@ -329,10 +355,10 @@ mod tests {
             find_selectors(
                 "# || | []()",
                 vec![
-                    Selector::Section(Matcher::Any),
+                    Selector::Section(Matcher::Any(AnyVariant::Implicit)),
                     Selector::Link(LinklikeMatcher {
-                        display_matcher: Matcher::Any,
-                        url_matcher: Matcher::Any,
+                        display_matcher: Matcher::Any(AnyVariant::Implicit),
+                        url_matcher: Matcher::Any(AnyVariant::Implicit),
                     }),
                 ],
             );
@@ -344,7 +370,7 @@ mod tests {
 
         #[test]
         fn section_no_matcher() {
-            find_selector("#", Selector::Section(Matcher::Any));
+            find_selector("#", Selector::Section(Matcher::Any(AnyVariant::Implicit)));
         }
 
         #[test]
@@ -364,7 +390,7 @@ mod tests {
                 Selector::ListItem(ListItemMatcher {
                     ordered: false,
                     task: ListItemTask::None,
-                    matcher: Matcher::Any,
+                    matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -402,7 +428,7 @@ mod tests {
                 Selector::ListItem(ListItemMatcher {
                     ordered: true,
                     task: ListItemTask::None,
-                    matcher: Matcher::Any,
+                    matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -438,7 +464,7 @@ mod tests {
                 Selector::ListItem(ListItemMatcher {
                     ordered: false,
                     task: ListItemTask::Unselected,
-                    matcher: Matcher::Any,
+                    matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -450,7 +476,7 @@ mod tests {
                 Selector::ListItem(ListItemMatcher {
                     ordered: false,
                     task: ListItemTask::Selected,
-                    matcher: Matcher::Any,
+                    matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -462,7 +488,7 @@ mod tests {
                 Selector::ListItem(ListItemMatcher {
                     ordered: false,
                     task: ListItemTask::Either,
-                    matcher: Matcher::Any,
+                    matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -514,8 +540,8 @@ mod tests {
             find_selector(
                 "[]()",
                 Selector::Link(LinklikeMatcher {
-                    display_matcher: Matcher::Any,
-                    url_matcher: Matcher::Any,
+                    display_matcher: Matcher::Any(AnyVariant::Implicit),
+                    url_matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -526,7 +552,7 @@ mod tests {
                 "[hello]()",
                 Selector::Link(LinklikeMatcher {
                     display_matcher: matcher_text(false, "hello", false),
-                    url_matcher: Matcher::Any,
+                    url_matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -536,7 +562,7 @@ mod tests {
             find_selector(
                 "[](example.com)",
                 Selector::Link(LinklikeMatcher {
-                    display_matcher: Matcher::Any,
+                    display_matcher: Matcher::Any(AnyVariant::Implicit),
                     url_matcher: matcher_text(false, "example.com", false),
                 }),
             )
@@ -558,8 +584,8 @@ mod tests {
             find_selector(
                 "![]()",
                 Selector::Image(LinklikeMatcher {
-                    display_matcher: Matcher::Any,
-                    url_matcher: Matcher::Any,
+                    display_matcher: Matcher::Any(AnyVariant::Implicit),
+                    url_matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -570,7 +596,7 @@ mod tests {
                 "![alt text]()",
                 Selector::Image(LinklikeMatcher {
                     display_matcher: matcher_text(false, "alt text", false),
-                    url_matcher: Matcher::Any,
+                    url_matcher: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -581,7 +607,7 @@ mod tests {
 
         #[test]
         fn block_quote_no_matcher() {
-            find_selector(">", Selector::BlockQuote(Matcher::Any))
+            find_selector(">", Selector::BlockQuote(Matcher::Any(AnyVariant::Implicit)))
         }
 
         #[test]
@@ -609,8 +635,8 @@ mod tests {
             find_selector(
                 "```",
                 Selector::CodeBlock(CodeBlockMatcher {
-                    language: Matcher::Any,
-                    contents: Matcher::Any,
+                    language: Matcher::Any(AnyVariant::Implicit),
+                    contents: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -621,7 +647,7 @@ mod tests {
                 "```rust",
                 Selector::CodeBlock(CodeBlockMatcher {
                     language: matcher_text(false, "rust", false),
-                    contents: Matcher::Any,
+                    contents: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -632,7 +658,7 @@ mod tests {
                 "```rust",
                 Selector::CodeBlock(CodeBlockMatcher {
                     language: matcher_text(false, "rust", false),
-                    contents: Matcher::Any,
+                    contents: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -642,7 +668,7 @@ mod tests {
             find_selector(
                 "``` fn main()",
                 Selector::CodeBlock(CodeBlockMatcher {
-                    language: Matcher::Any,
+                    language: Matcher::Any(AnyVariant::Implicit),
                     contents: matcher_text(false, "fn main()", false),
                 }),
             )
@@ -666,7 +692,7 @@ mod tests {
 
         #[test]
         fn html_no_matcher() {
-            find_selector("</>", Selector::Html(Matcher::Any))
+            find_selector("</>", Selector::Html(Matcher::Any(AnyVariant::Implicit)))
         }
 
         #[test]
@@ -690,7 +716,7 @@ mod tests {
                     1 | </> <div>
                       |     ^---
                       |
-                      = expected end of input or string"#},
+                      = expected end of input, "*", unquoted string, regex, quoted string, or "^""#},
             );
         }
 
@@ -708,7 +734,7 @@ mod tests {
 
         #[test]
         fn paragraph_no_matcher() {
-            find_selector("P:", Selector::Paragraph(Matcher::Any))
+            find_selector("P:", Selector::Paragraph(Matcher::Any(AnyVariant::Implicit)))
         }
 
         #[test]
@@ -737,9 +763,9 @@ mod tests {
                      --> 1:5
                       |
                     1 | :-: :-:
-                      |     ^---
+                      |     ^
                       |
-                      = expected explicit "*" or string"#},
+                      = table column matcher cannot empty; use an explicit "*""#},
             );
         }
 
@@ -762,8 +788,8 @@ mod tests {
             find_selector(
                 ":-: * :-:",
                 Selector::TableSlice(TableSliceMatcher {
-                    column: Matcher::Any,
-                    row: Matcher::Any,
+                    column: Matcher::Any(AnyVariant::Explicit),
+                    row: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -774,7 +800,7 @@ mod tests {
                 ":-: Header :-:",
                 Selector::TableSlice(TableSliceMatcher {
                     column: matcher_text(false, "Header", false),
-                    row: Matcher::Any,
+                    row: Matcher::Any(AnyVariant::Implicit),
                 }),
             )
         }
@@ -784,7 +810,7 @@ mod tests {
             find_selector(
                 ":-: * :-: Data",
                 Selector::TableSlice(TableSliceMatcher {
-                    column: Matcher::Any,
+                    column: Matcher::Any(AnyVariant::Explicit),
                     row: matcher_text(false, "Data", false),
                 }),
             )
@@ -844,13 +870,21 @@ mod tests {
 
     fn expect_parse_error(query_text: &str, expect: &str) {
         let pairs = Query::parse(query_text);
-        match pairs {
-            Ok(pairs) => panic! {"{pairs}"},
-            Err(err) => {
-                let err_msg = format!("{err}");
-                assert_eq!(err_msg, expect);
-            }
-        }
+        let err_msg = match pairs {
+            Ok(pairs) => match Selector::from_top_pairs(pairs) {
+                Ok(selectors) => panic!("{selectors:?}"),
+                Err(ParseError::Pest(err)) => format!("{err}"),
+                Err(ParseError::Other(span, message)) => {
+                    let error = Error::new_from_span(
+                        ErrorVariant::CustomError { message },
+                        Span::new(query_text, span.start, span.end).unwrap(),
+                    );
+                    format! {"{error}"}
+                }
+            },
+            Err(err) => format!("{err}"),
+        };
+        assert_eq!(err_msg, expect);
     }
 
     fn matcher_text(anchor_start: bool, text: &str, anchor_end: bool) -> Matcher {
diff --git a/src/query/strings.rs b/src/query/strings.rs
index 57cdb4c..8a69a56 100644
--- a/src/query/strings.rs
+++ b/src/query/strings.rs
@@ -9,6 +9,7 @@ pub struct ParsedString {
     pub anchor_start: bool,
     pub anchor_end: bool,
     pub mode: ParsedStringMode,
+    pub explicit_wildcard: bool,
 }
 
 #[derive(Eq, PartialEq, Debug)]
@@ -37,7 +38,7 @@ impl ParsedString {
 impl Debug for ParsedString {
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         if self.is_equivalent_to_asterisk() {
-            return f.write_char('*');
+            return write!(f, "{} *", if self.explicit_wildcard { "explicit" } else { "implicit" });
         }
 
         match self.mode {
@@ -77,6 +78,7 @@ impl TryFrom<Pairs<'_>> for ParsedString {
             anchor_start: false,
             anchor_end: false,
             mode: ParsedStringMode::CaseSensitive,
+            explicit_wildcard: false,
         };
 
         fn build_string(me: &mut ParsedString, pairs: Pairs) -> Result<(), ParseError> {
@@ -87,6 +89,9 @@ impl TryFrom<Pairs<'_>> for ParsedString {
                     Rule::quoted_plain_chars => {
                         me.text.push_str(pair.as_str());
                     }
+                    Rule::asterisk => {
+                        me.explicit_wildcard = true;
+                    }
                     Rule::escaped_char => {
                         // we'll iterate, but we should really only have one
                         for input_ch in pair.as_str().chars() {
@@ -119,11 +124,7 @@ impl TryFrom<Pairs<'_>> for ParsedString {
                     Rule::anchor_end => {
                         me.anchor_end = true;
                     }
-                    Rule::unquoted_string_to_pipe
-                    | Rule::unquoted_string_to_paren
-                    | Rule::unquoted_string_to_bracket
-                    | Rule::unquoted_string_to_space
-                    | Rule::unquoted_string_to_colon => {
+                    Rule::unquoted_string => {
                         me.mode = ParsedStringMode::CaseInsensitive;
                         me.text.push_str(pair.as_str().trim_end());
                     }
@@ -201,12 +202,12 @@ mod tests {
         }
 
         #[test]
-        fn unquoted_string_to_pipe() {
+        fn unquoted_string_to_angle_bracket() {
             check_parse(
-                StringVariant::Pipe,
-                r"hello'\n | world | multiple | pipes",
+                StringVariant::AngleBracket,
+                r"hello'\n > world > multiple > brackets",
                 parsed_text(CaseInsensitive, false, r"hello'\n", false),
-                "| world | multiple | pipes",
+                "> world > multiple > brackets",
             );
         }
 
@@ -220,86 +221,6 @@ mod tests {
             );
         }
 
-        #[test]
-        fn unquoted_string_to_paren() {
-            check_parse(
-                StringVariant::Paren,
-                r"hello'\n ) world ) multiple ) parens",
-                parsed_text(CaseInsensitive, false, r"hello'\n", false),
-                ") world ) multiple ) parens",
-            );
-        }
-
-        #[test]
-        fn unquoted_no_end_paren() {
-            check_parse(
-                StringVariant::Paren,
-                r"hello world   ",
-                parsed_text(CaseInsensitive, false, r"hello world", false),
-                "",
-            );
-        }
-
-        #[test]
-        fn unquoted_string_to_bracket() {
-            check_parse(
-                StringVariant::Bracket,
-                r"hello'\n ] world ] multiple ] brackets",
-                parsed_text(CaseInsensitive, false, r"hello'\n", false),
-                "] world ] multiple ] brackets",
-            );
-        }
-
-        #[test]
-        fn unquoted_no_end_bracket() {
-            check_parse(
-                StringVariant::Bracket,
-                r"hello world   ",
-                parsed_text(CaseInsensitive, false, r"hello world", false),
-                "",
-            );
-        }
-
-        #[test]
-        fn unquoted_string_to_colon() {
-            check_parse(
-                StringVariant::Colon,
-                r"hello :-: there",
-                parsed_text(CaseInsensitive, false, r"hello", false),
-                ":-: there",
-            );
-        }
-
-        #[test]
-        fn unquoted_no_end_colon() {
-            check_parse(
-                StringVariant::Colon,
-                r"hello",
-                parsed_text(CaseInsensitive, false, r"hello", false),
-                "",
-            );
-        }
-
-        #[test]
-        fn unquoted_string_to_space() {
-            check_parse(
-                StringVariant::Space,
-                r"hello there world",
-                parsed_text(CaseInsensitive, false, r"hello", false),
-                "there world", // note: the trailing space gets trimmed, but that's fine
-            );
-        }
-
-        #[test]
-        fn unquoted_no_end_space() {
-            check_parse(
-                StringVariant::Space,
-                r"hello",
-                parsed_text(CaseInsensitive, false, r"hello", false),
-                "",
-            );
-        }
-
         #[test]
         fn unquoted_string_to_pipe_unicode() {
             check_parse(
@@ -312,13 +233,19 @@ mod tests {
 
         #[test]
         fn asterisk() {
+            check_parse(StringVariant::Pipe, r"*", parsed_wildcard(), "");
+            assert!(parsed_wildcard().is_equivalent_to_asterisk());
+        }
+
+        #[test]
+        fn empty() {
             check_parse(
                 StringVariant::Pipe,
-                r"*",
-                parsed_text(CaseSensitive, false, r"", false),
+                r"",
+                parsed_text(CaseSensitive, false, "", false),
                 "",
             );
-            assert!(parsed_text(CaseSensitive, false, r"", false).is_equivalent_to_asterisk());
+            assert!(parsed_text(CaseSensitive, false, "", false).is_equivalent_to_asterisk());
         }
 
         #[test]
@@ -347,7 +274,7 @@ mod tests {
                 StringVariant::Pipe,
                 "^ hello $ there",
                 parsed_text(CaseInsensitive, true, "hello", true),
-                " there",
+                "there",
             );
         }
 
@@ -357,7 +284,7 @@ mod tests {
                 StringVariant::Pipe,
                 "^hello$ there",
                 parsed_text(CaseInsensitive, true, "hello", true),
-                " there",
+                "there",
             );
         }
     }
@@ -401,6 +328,7 @@ mod tests {
                 CaseMode::CaseSensitive => ParsedStringMode::CaseSensitive,
                 CaseMode::CaseInsensitive => ParsedStringMode::CaseInsensitive,
             },
+            explicit_wildcard: false,
         }
     }
 
@@ -410,6 +338,17 @@ mod tests {
             text: text.to_string(),
             anchor_end: false,
             mode: ParsedStringMode::Regex,
+            explicit_wildcard: false,
+        }
+    }
+
+    fn parsed_wildcard() -> ParsedString {
+        ParsedString {
+            anchor_start: false,
+            text: String::new(),
+            anchor_end: false,
+            mode: ParsedStringMode::CaseSensitive,
+            explicit_wildcard: true,
         }
     }
 }
diff --git a/src/select/api.rs b/src/select/api.rs
index 358e364..cd6765e 100644
--- a/src/select/api.rs
+++ b/src/select/api.rs
@@ -12,6 +12,8 @@ use crate::select::sel_table::TableSliceSelector;
 use crate::tree::{FootnoteId, Formatting, Inline, Link, MdContext, Text, TextVariant};
 use crate::tree_ref::{HtmlRef, ListItemRef, MdElemRef};
 use paste::paste;
+use pest::error::ErrorVariant;
+use pest::Span;
 use std::collections::HashSet;
 
 pub trait Selector<'md, I: Into<MdElemRef<'md>>> {
@@ -24,6 +26,26 @@ pub enum ParseError {
     Other(DetachedSpan, String),
 }
 
+impl ParseError {
+    pub fn to_string(&self, query_text: &str) -> String {
+        match self {
+            ParseError::Pest(e) => format!("{e}"),
+            ParseError::Other(span, message) => match Span::new(query_text, span.start, span.end) {
+                None => format!("{message}"),
+                Some(span) => {
+                    let pest_err = query::Error::new_from_span(
+                        ErrorVariant::CustomError {
+                            message: message.to_string(),
+                        },
+                        span,
+                    );
+                    pest_err.to_string()
+                }
+            },
+        }
+    }
+}
+
 impl From<query::Error> for ParseError {
     fn from(err: query::Error) -> Self {
         Self::Pest(err)


Test patch:
diff --git a/tests/md_cases/bad_queries.toml b/tests/md_cases/bad_queries.toml
index 20755b4..2fb9792 100644
--- a/tests/md_cases/bad_queries.toml
+++ b/tests/md_cases/bad_queries.toml
@@ -168,12 +168,12 @@ cli_args = ['# $hello^']
 expect_success = false
 output = ''
 output_err = '''Syntax error in select specifier:
- --> 1:4
+ --> 1:3
   |
 1 | # $hello^
-  |    ^---
+  |   ^---
   |
-  = expected end of input
+  = expected end of input, "*", unquoted string, regex, quoted string, or "^"
 '''
 
 [expect."invalid selector"]
@@ -223,9 +223,9 @@ output_err = '''Syntax error in select specifier:
  --> 1:5
   |
 1 | :-: :-: row
-  |     ^---
+  |     ^
   |
-  = expected explicit "*" or string
+  = table column matcher cannot empty; use an explicit "*"
 '''
 
 [expect."table missing second delimiter"]
diff --git a/tests/md_cases/select_html.toml b/tests/md_cases/select_html.toml
index d570e65..1d249e3 100644
--- a/tests/md_cases/select_html.toml
+++ b/tests/md_cases/select_html.toml
@@ -59,7 +59,7 @@ output_err = '''Syntax error in select specifier:
 1 | </> <span>
   |     ^---
   |
-  = expected end of input or string
+  = expected end of input, "*", unquoted string, regex, quoted string, or "^"
 '''