From e6b03dcbaa5af172704f43297dc352785f46f5a1 Mon Sep 17 00:00:00 2001 From: harehare Date: Fri, 14 Aug 2026 21:34:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(mq-lang,mq-run):=20add=20d?= =?UTF-8?q?escendant=20selectors,=20link/image=20URL=20filtering,=20and=20?= =?UTF-8?q?selector=20help=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `.a .b` selector chains now match `.b` nodes nested anywhere under `.a` (sugar for `.a | .. | .b`), chainable to any depth and combinable with selector-call filters and trailing attribute access. - `.link("url")` / `.image("url")` selector calls filter by URL, matching the existing `.code("lang")` pattern. - `mq help` now documents call-arg params for every filterable selector, and adds entries for `.callout`/`.wikilink`/`.embed`/`..`, which had none. - Fix `.list`/`.[]` help text to match actual behavior (index-only filtering; `checked` was never implemented, use `.task`/`.todo`/`.done` instead). - Fix `mq help ` permanently hiding a same-named selector behind a same-named module (`.table`/`table`, `.toml`/`toml`, `.yaml`/`yaml`); both are now shown for human/markdown output, JSON stays module-only. --- crates/mq-lang/src/ast/parser.rs | 177 ++++++++++++++++++++-- crates/mq-lang/src/eval/builtin.rs | 165 +++++++++++++++----- crates/mq-lang/tests/integration_tests.rs | 26 ++++ crates/mq-run/src/cli.rs | 28 +++- crates/mq-run/tests/integration_tests.rs | 32 ++++ docs/books/src/reference/selectors.md | 59 ++++++-- 6 files changed, 419 insertions(+), 68 deletions(-) diff --git a/crates/mq-lang/src/ast/parser.rs b/crates/mq-lang/src/ast/parser.rs index 46a06ae27..227356bb1 100644 --- a/crates/mq-lang/src/ast/parser.rs +++ b/crates/mq-lang/src/ast/parser.rs @@ -2553,14 +2553,65 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { } } - /// Parse a selector with an attribute suffix and convert it to an attr() function call - fn parse_selector_with_attribute( + /// Consumes any selector token(s) following an already-parsed `base_node`. + fn parse_selector_tail( &mut self, token: &Shared, - attr_token: Shared, + base_node: Shared, ) -> Result, SyntaxError> { - let base_node = self.parse_selector_direct(token)?; - self.build_attr_call_for_node(base_node, attr_token, token) + if !self.is_next_token(|kind| matches!(kind, TokenKind::Selector(_))) { + return Ok(base_node); + } + let next_token = Shared::clone(self.tokens.next().unwrap()); + let selector = Selector::try_from(&*next_token).map_err(SyntaxError::UnknownSelector)?; + + if selector.is_attribute_selector() { + return self.build_attr_call_for_node(base_node, next_token, token); + } + + // Descendant chain: `.blockquote .code` → Block([base, .., .code, ...]) + let mut nodes: Program = vec![base_node]; + let mut step_token = next_token; + let mut step_selector = selector; + + loop { + nodes.push(Shared::new(Node { + token_id: self.token_arena.alloc(Shared::clone(token)), + expr: Shared::new(Expr::Selector(Selector::Recursive)), + })); + + let step_expr = if self.is_next_token(|kind| matches!(kind, TokenKind::LParen)) { + Expr::SelectorCall(step_selector, self.parse_args()?) + } else { + Expr::Selector(step_selector) + }; + nodes.push(Shared::new(Node { + token_id: self.token_arena.alloc(Shared::clone(&step_token)), + expr: Shared::new(step_expr), + })); + + if !self.is_next_token(|kind| matches!(kind, TokenKind::Selector(_))) { + break; + } + let peeked_token = Shared::clone(self.tokens.next().unwrap()); + let peeked_selector = Selector::try_from(&*peeked_token).map_err(SyntaxError::UnknownSelector)?; + + if peeked_selector.is_attribute_selector() { + let chained = Shared::new(Node { + token_id: self.token_arena.alloc(Shared::clone(token)), + expr: Shared::new(Expr::Block(nodes)), + }); + return self.build_attr_call_for_node(chained, peeked_token, token); + } + + step_token = peeked_token; + step_selector = peeked_selector; + } + + Ok(Shared::new(Node { + token_id: self.token_arena.alloc(Shared::clone(token)), + expr: Shared::new(Expr::Block(nodes)), + })) } /// Parse a selector without checking for attributes (to avoid infinite recursion) @@ -2682,10 +2733,9 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { } } - if self.is_next_token(|kind| matches!(kind, TokenKind::Selector(_))) - && let Some(attr_token) = self.tokens.next() - { - return self.parse_selector_with_attribute(token, Shared::clone(attr_token)); + if self.is_next_token(|kind| matches!(kind, TokenKind::Selector(_))) { + let base_node = self.parse_selector_direct(token)?; + return self.parse_selector_tail(token, base_node); } // Check for selector call: `.h(...)`, `.code(...)` @@ -2700,13 +2750,8 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { token_id: self.token_arena.alloc(Shared::clone(token)), expr: Shared::new(Expr::SelectorCall(selector, args)), }); - // Check for attribute access on SelectorCall: `.h(1).level` - if self.is_next_token(|kind| matches!(kind, TokenKind::Selector(_))) - && let Some(attr_token) = self.tokens.next() - { - return self.build_attr_call_for_node(base_node, Shared::clone(attr_token), token); - } - return Ok(base_node); + // Check for attribute access or a descendant chain continuation: `.h(1).level`, `.h(1) .code` + return self.parse_selector_tail(token, base_node); } } @@ -9076,6 +9121,106 @@ mod tests { } } + #[rstest] + // Two selectors: base, then a single descendant hop. + #[case::two_levels(vec![".blockquote", ".code"], vec![Selector::Blockquote, Selector::Code])] + // Three selectors: two descendant hops. + #[case::three_levels( + vec![".blockquote", ".list", ".code"], + vec![Selector::Blockquote, Selector::List(None, None), Selector::Code] + )] + fn test_parse_selector_descendant_chain(#[case] selectors: Vec<&str>, #[case] expected_selectors: Vec) { + let mut arena = Arena::new(10); + let mut tokens = selectors + .iter() + .map(|selector| { + Shared::new(Token { + range: Range::default(), + kind: TokenKind::Selector(SmolStr::new(selector)), + module_id: 1.into(), + }) + }) + .collect::>(); + tokens.push(Shared::new(Token { + range: Range::default(), + kind: TokenKind::Eof, + module_id: 1.into(), + })); + + let result = Parser::new(tokens.iter(), &mut arena, Module::TOP_LEVEL_MODULE_ID).parse(); + + match result { + Ok(program) => { + assert_eq!(program.len(), 1); + if let Expr::Block(nodes) = &*program[0].expr { + // Desugars to `base | .. | step1 | .. | step2 | ...`, i.e. a plain + // selector interleaved with a `Recursive` selector between each hop. + let expected: Vec = expected_selectors + .into_iter() + .enumerate() + .flat_map(|(i, sel)| { + if i == 0 { + vec![sel] + } else { + vec![Selector::Recursive, sel] + } + }) + .collect(); + assert_eq!(nodes.len(), expected.len()); + for (node, expected_sel) in nodes.iter().zip(expected.iter()) { + if let Expr::Selector(sel) = &*node.expr { + assert_eq!(sel, expected_sel); + } else { + panic!("Expected Selector expression, got {:?}", node.expr); + } + } + } else { + panic!("Expected Block expression, got {:?}", program[0].expr); + } + } + Err(err) => panic!("Parse error: {:?}", err), + } + } + + #[test] + fn test_parse_selector_descendant_chain_trailing_attribute() { + // `.blockquote .code.lang` → attr(Block([.blockquote, .., .code]), "lang") + let mut arena = Arena::new(10); + let selectors = [".blockquote", ".code", ".lang"]; + let mut tokens = selectors + .iter() + .map(|selector| { + Shared::new(Token { + range: Range::default(), + kind: TokenKind::Selector(SmolStr::new(*selector)), + module_id: 1.into(), + }) + }) + .collect::>(); + tokens.push(Shared::new(Token { + range: Range::default(), + kind: TokenKind::Eof, + module_id: 1.into(), + })); + + let result = Parser::new(tokens.iter(), &mut arena, Module::TOP_LEVEL_MODULE_ID).parse(); + + match result { + Ok(program) => { + assert_eq!(program.len(), 1); + if let Expr::Call(ident, args) = &*program[0].expr { + assert_eq!(ident.name, "attr".into()); + assert_eq!(args.len(), 2); + assert!(matches!(&*args[0].expr, Expr::Block(nodes) if nodes.len() == 3)); + assert!(matches!(&*args[1].expr, Expr::Literal(Literal::String(s)) if s == "lang")); + } else { + panic!("Expected Call expression, got {:?}", program[0].expr); + } + } + Err(err) => panic!("Parse error: {:?}", err), + } + } + #[rstest] #[case::match_simple_literal( vec![ diff --git a/crates/mq-lang/src/eval/builtin.rs b/crates/mq-lang/src/eval/builtin.rs index 9eec28704..c3afc5ed2 100644 --- a/crates/mq-lang/src/eval/builtin.rs +++ b/crates/mq-lang/src/eval/builtin.rs @@ -5103,8 +5103,8 @@ pub static BUILTIN_SELECTOR_DOC: LazyLock SmolStr::new(".h"), BuiltinSelectorDoc { description: "Selects a heading node with the specified depth.", - params: &[], - param_types: &[], + params: &["depth", "..."], + param_types: &["number"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_h("Title", 3) | .h"#, @@ -5129,6 +5129,21 @@ pub static BUILTIN_SELECTOR_DOC: LazyLock }, ); + map.insert( + SmolStr::new(".."), + BuiltinSelectorDoc { + description: "Recursively selects every descendant node (depth-first), not the node itself. Combine with a following selector for a descendant chain, e.g. `.blockquote .code` (sugar for `.blockquote | .. | .code`).", + params: &[], + param_types: &[], + returns: "array", + examples: &[BuiltinExample { + code: r#"to_markdown("> ## Nested")[0] | .."#, + expected: "[Nested, ## Nested]", + }], + capability: None, + }, + ); + map.insert( SmolStr::new(".h1"), BuiltinSelectorDoc { @@ -5223,8 +5238,8 @@ pub static BUILTIN_SELECTOR_DOC: LazyLock SmolStr::new(".code"), BuiltinSelectorDoc { description: "Selects a code block node with the specified language.", - params: &[], - param_types: &[], + params: &["lang", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_code("x = 1", "python") | .code"#, @@ -5314,9 +5329,9 @@ x = 1 map.insert( SmolStr::new(".link"), BuiltinSelectorDoc { - description: "Selects a link node.", - params: &[], - param_types: &[], + description: "Selects a link node, optionally filtered by URL (e.g. `.link(\"https://example.com\")`).", + params: &["url", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_link("https://example.com", "Example", "") | .link"#, @@ -5329,9 +5344,9 @@ x = 1 map.insert( SmolStr::new(".link_ref"), BuiltinSelectorDoc { - description: "Selects a link reference node.", - params: &[], - param_types: &[], + description: "Selects a link reference node, optionally filtered by identifier.", + params: &["ident", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_markdown("[text][ref]\n\n[ref]: https://example.com")[0] | .link_ref"#, @@ -5344,9 +5359,9 @@ x = 1 map.insert( SmolStr::new(".image"), BuiltinSelectorDoc { - description: "Selects an image node.", - params: &[], - param_types: &[], + description: "Selects an image node, optionally filtered by URL (e.g. `.image(\"a.png\")`).", + params: &["url", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_image("https://example.com/a.png", "Alt", "") | .image"#, @@ -5360,8 +5375,8 @@ x = 1 SmolStr::new(".heading"), BuiltinSelectorDoc { description: "Selects a heading node with the specified depth.", - params: &[], - param_types: &[], + params: &["depth", "..."], + param_types: &["number"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_h("Title", 2) | .heading"#, @@ -5476,9 +5491,9 @@ x = 1 map.insert( SmolStr::new(".footnote"), BuiltinSelectorDoc { - description: "Selects a footnote node.", - params: &[], - param_types: &[], + description: "Selects a footnote node, optionally filtered by identifier.", + params: &["ident", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_markdown("Text[^1]\n\n[^1]: Note")[2] | .footnote"#, @@ -5491,9 +5506,9 @@ x = 1 map.insert( SmolStr::new(".mdx_jsx_flow_element"), BuiltinSelectorDoc { - description: "Selects an MDX JSX flow element node.", - params: &[], - param_types: &[], + description: "Selects an MDX JSX flow element node, optionally filtered by tag name.", + params: &["name", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_mdx("")[0] | .mdx_jsx_flow_element"#, @@ -5506,9 +5521,9 @@ x = 1 map.insert( SmolStr::new(".list"), BuiltinSelectorDoc { - description: "Selects a list node with the specified index and checked state.", - params: &["indent", "checked"], - param_types: &["number", "bool"], + description: "Selects a list item node, optionally filtered by item index (e.g. `.list(0)`). To filter by checked state, use `.task`/`.todo`/`.done` instead.", + params: &["index", "..."], + param_types: &["number"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_md_list("Item", 0) | .list"#, @@ -5521,9 +5536,9 @@ x = 1 map.insert( SmolStr::new(".[]"), BuiltinSelectorDoc { - description: "Selects a list node with the specified index and checked state.", - params: &["indent", "checked"], - param_types: &["number", "bool"], + description: "Selects a list item node, optionally filtered by item index (e.g. `.[](0)`). To filter by checked state, use `.task`/`.todo`/`.done` instead.", + params: &["index", "..."], + param_types: &["number"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_md_list("Item", 0) | .[]"#, @@ -5612,9 +5627,9 @@ key: 1 map.insert( SmolStr::new(".footnote_ref"), BuiltinSelectorDoc { - description: "Selects a footnote reference node.", - params: &[], - param_types: &[], + description: "Selects a footnote reference node, optionally filtered by identifier.", + params: &["ident", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_markdown("Text[^1]\n\n[^1]: Note")[1] | .footnote_ref"#, @@ -5627,9 +5642,9 @@ key: 1 map.insert( SmolStr::new(".image_ref"), BuiltinSelectorDoc { - description: "Selects an image reference node.", - params: &[], - param_types: &[], + description: "Selects an image reference node, optionally filtered by identifier.", + params: &["ident", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_markdown("![alt][ref]\n\n[ref]: https://example.com/a.png")[0] | .image_ref"#, @@ -5642,9 +5657,9 @@ key: 1 map.insert( SmolStr::new(".mdx_jsx_text_element"), BuiltinSelectorDoc { - description: "Selects an MDX JSX text element node.", - params: &[], - param_types: &[], + description: "Selects an MDX JSX text element node, optionally filtered by tag name.", + params: &["name", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_mdx("Hello world.")[1] | .mdx_jsx_text_element"#, @@ -5704,9 +5719,9 @@ $$"#, map.insert( SmolStr::new(".definition"), BuiltinSelectorDoc { - description: "Selects a definition node.", - params: &[], - param_types: &[], + description: "Selects a definition node, optionally filtered by identifier.", + params: &["ident", "..."], + param_types: &["string"], returns: "markdown", examples: &[BuiltinExample { code: r#"to_markdown("[ref]: https://example.com")[0] | .definition"#, @@ -5716,6 +5731,52 @@ $$"#, }, ); + map.insert( + SmolStr::new(".callout"), + BuiltinSelectorDoc { + description: "Selects an Obsidian-style callout node, optionally filtered by kind (e.g. `.callout(\"note\")`).", + params: &["kind", "..."], + param_types: &["string"], + returns: "markdown", + examples: &[BuiltinExample { + code: r#"to_markdown("> [!NOTE]\n> body")[0] | .callout"#, + expected: r#"> [!NOTE] +> body"#, + }], + capability: None, + }, + ); + + map.insert( + SmolStr::new(".wikilink"), + BuiltinSelectorDoc { + description: "Selects an Obsidian-style wikilink node, optionally filtered by target.", + params: &["target", "..."], + param_types: &["string"], + returns: "markdown", + examples: &[BuiltinExample { + code: r#"to_markdown("[[target]]")[0] | .wikilink"#, + expected: r#"[[target]]"#, + }], + capability: None, + }, + ); + + map.insert( + SmolStr::new(".embed"), + BuiltinSelectorDoc { + description: "Selects an Obsidian-style embed node, optionally filtered by target.", + params: &["target", "..."], + param_types: &["string"], + returns: "markdown", + examples: &[BuiltinExample { + code: r#"to_markdown("![[image.png]]")[0] | .embed"#, + expected: r#"![[image.png]]"#, + }], + capability: None, + }, + ); + map.insert( SmolStr::new(".task"), BuiltinSelectorDoc { @@ -8687,6 +8748,32 @@ pub fn eval_selector_with_args(node: &mq_markdown::Node, selector: &Selector, ar false } } + Selector::Link => { + let urls = collect_string_values(args); + + if urls.is_empty() { + return eval_selector(node, selector); + } + + if let mq_markdown::Node::Link(mq_markdown::Link { url, .. }) = node { + urls.iter().any(|u| u == url.as_str()) + } else { + false + } + } + Selector::Image => { + let urls = collect_string_values(args); + + if urls.is_empty() { + return eval_selector(node, selector); + } + + if let mq_markdown::Node::Image(mq_markdown::Image { url, .. }) = node { + urls.iter().any(|u| u == url) + } else { + false + } + } Selector::Callout => { let kinds = collect_string_values(args); diff --git a/crates/mq-lang/tests/integration_tests.rs b/crates/mq-lang/tests/integration_tests.rs index ebfdf260e..819402aca 100644 --- a/crates/mq-lang/tests/integration_tests.rs +++ b/crates/mq-lang/tests/integration_tests.rs @@ -2829,6 +2829,11 @@ fn engine() -> DefaultEngine { #[case::selector_array_of_dicts_preserves_dict(r##"array({"docs": to_markdown("# Title")}) | .h | first() | is_dict()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::TRUE].into()))] #[case::selector_call_h(r##"to_markdown("# h1\n\n## h2\n\ntest") | .h(2).depth | compact() | first()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(2.into())].into()))] #[case::selector_call_code_lang(r##"to_markdown("```rust\ncode\n```") | .code("rust").lang | compact() | first()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("rust".to_string())].into()))] +#[case::selector_call_link_url_match(r##"to_markdown("[a](https://a.com) [b](https://b.com)") | .link("https://a.com").value | compact() | first()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("a".to_string())].into()))] +#[case::selector_call_link_url_no_match(r##"to_markdown("[a](https://a.com)") | .link("https://nope.com") | compact() | len()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(0.into())].into()))] +#[case::selector_call_link_url_multi(r##"to_markdown("[a](https://a.com) [b](https://b.com) [c](https://c.com)") | .link("https://a.com", "https://c.com") | compact() | len()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(2.into())].into()))] +#[case::selector_call_image_url_match(r##"to_markdown("![alt1](a.png) ![alt2](b.png)") | .image("a.png").alt | compact() | first()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("alt1".to_string())].into()))] +#[case::selector_call_image_url_no_match(r##"to_markdown("![alt](a.png)") | .image("nope.png") | compact() | len()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(0.into())].into()))] #[case::selector_call_link_ref_match(r##"to_markdown("[link][id]\n\n[id]: url") | .link_ref("id") | compact() | len()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(1.into())].into()))] #[case::selector_call_link_ref_no_match(r##"to_markdown("[link][id]\n\n[id]: url") | .link_ref("other") | compact() | len()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(0.into())].into()))] #[case::selector_call_image_ref_match(r##"to_markdown("![alt][id]\n\n[id]: url") | .image_ref("id") | compact() | len()"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(1.into())].into()))] @@ -3629,6 +3634,27 @@ fn engine() -> DefaultEngine { r#"to_markdown("[[target]]") | first() | .embed"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::NONE].into()))] +// descendant selector chain: `.a .b` matches `.b` nodes nested anywhere under `.a` +#[case::descendant_chain_matches_nested_only( + r#"to_markdown("> ```rust\n> fn a() {}\n> ```\n\n```python\nprint(1)\n```") | .blockquote .code | .lang | compact | first()"#, + vec![RuntimeValue::None], + Ok(vec![RuntimeValue::String("rust".to_string())].into()))] +#[case::descendant_chain_no_match_is_empty( + r#"to_markdown("> plain quote") | .blockquote .code | compact | first()"#, + vec![RuntimeValue::None], + Ok(vec![RuntimeValue::NONE].into()))] +#[case::descendant_chain_three_levels( + r#"to_markdown("> - item\n>\n> ```rust\n> fn a() {}\n> ```\n\n- top\n\n ```rust\n fn b() {}\n ```") | .blockquote .list .code | .value | compact | first()"#, + vec![RuntimeValue::None], + Ok(vec![RuntimeValue::String("fn a() {}".to_string())].into()))] +#[case::descendant_chain_step_with_selector_call( + r#"to_markdown("> ```rust\n> fn a() {}\n> ```") | .blockquote .code("python") | compact | first()"#, + vec![RuntimeValue::None], + Ok(vec![RuntimeValue::NONE].into()))] +#[case::descendant_chain_trailing_attribute( + r#"to_markdown("> ```rust\n> fn a() {}\n> ```") | .blockquote .code.lang | compact | first()"#, + vec![RuntimeValue::None], + Ok(vec![RuntimeValue::String("rust".to_string())].into()))] fn test_eval(mut engine: Engine, #[case] program: &str, #[case] input: Vec, #[case] expected: MqResult) { assert_eq!(engine.eval(program, input.into_iter()), expected); } diff --git a/crates/mq-run/src/cli.rs b/crates/mq-run/src/cli.rs index e88ef34b1..0fec01f83 100644 --- a/crates/mq-run/src/cli.rs +++ b/crates/mq-run/src/cli.rs @@ -850,10 +850,34 @@ impl Cli { if !name.contains("::") && let Some(module) = help::lookup_module(name) { + // A selector can also share a module's bare name (e.g. `.table`/`table`, + // `.toml`/`toml`, `.yaml`/`yaml`); append it after the module overview instead + // of leaving it unreachable without the leading dot. JSON keeps the module + // alone for output stability — pass the leading dot (`.table`) for the + // selector's own JSON. + let selector_entries: Vec<_> = help::lookup(name) + .into_iter() + .filter(|e| e.kind == "selector") + .collect(); + let out = match format { HelpFormat::Json => serde_json::to_string_pretty(&module).into_diagnostic()?, - HelpFormat::Markdown => help::render_module_markdown(&module), - HelpFormat::Human => help::render_module_human(&module), + HelpFormat::Markdown => { + let mut s = help::render_module_markdown(&module); + for entry in &selector_entries { + s.push('\n'); + s.push_str(&help::render_markdown(entry)); + } + s + } + HelpFormat::Human => { + let mut s = help::render_module_human(&module); + for entry in &selector_entries { + s.push('\n'); + s.push_str(&help::render_human(entry)); + } + s + } }; Self::write_ignore_pipe(&mut handle, out.as_bytes())?; Self::write_ignore_pipe(&mut handle, b"\n")?; diff --git a/crates/mq-run/tests/integration_tests.rs b/crates/mq-run/tests/integration_tests.rs index d2933696f..49ff5447d 100644 --- a/crates/mq-run/tests/integration_tests.rs +++ b/crates/mq-run/tests/integration_tests.rs @@ -41,6 +41,38 @@ fn test_help_markdown_renders_fenced_mq_example() -> Result<(), Box Result<(), Box> { + // `.table` (selector) and `table` (module) share a bare name; the module must not + // shadow the selector entirely. + let mut cmd = cargo::cargo_bin_cmd!("mq"); + + let assert = cmd.arg("help").arg("table").assert(); + let output = String::from_utf8(assert.get_output().stdout.clone())?; + + assert!(output.starts_with("table (module)")); + assert!(output.contains(".table (selector)")); + + Ok(()) +} + +#[test] +fn test_help_bare_name_json_stays_module_only() -> Result<(), Box> { + let mut cmd = cargo::cargo_bin_cmd!("mq"); + + let assert = cmd.arg("help").arg("table").arg("--json").assert(); + let output = String::from_utf8(assert.get_output().stdout.clone())?; + let json: serde_json::Value = serde_json::from_str(&output)?; + + assert_eq!(json["name"], "table"); + assert!( + json.get("kind").is_none(), + "module JSON should not gain a selector's `kind` field" + ); + + Ok(()) +} + #[test] fn test_help_json_and_markdown_conflict() { let mut cmd = cargo::cargo_bin_cmd!("mq"); diff --git a/docs/books/src/reference/selectors.md b/docs/books/src/reference/selectors.md index 371177c91..95ac976ac 100644 --- a/docs/books/src/reference/selectors.md +++ b/docs/books/src/reference/selectors.md @@ -16,15 +16,15 @@ Selectors use the `.` prefix to select markdown nodes. For example: Many selectors have shorter or alternative names that you can use interchangeably: -| Canonical Selector | Aliases | Description | -| ------------------ | ---------------------------- | ------------------------ | -| `.text` | `.p`, `.paragraph` | Paragraph / text nodes | -| `.list` | `.li` | List items | -| `.code` | `.code_block` | Fenced code blocks | -| `.code_inline` | `.inline_code` | Inline code spans | -| `.math_inline` | `.inline_math` | Inline math spans | -| `.horizontal_rule` | `.hr`, `.---`, `.***`, `.___` | Horizontal rules | -| `.break` | `.br` | Line breaks | +| Canonical Selector | Aliases | Description | +| ------------------ | ----------------------------- | ---------------------- | +| `.text` | `.p`, `.paragraph` | Paragraph / text nodes | +| `.list` | `.li` | List items | +| `.code` | `.code_block` | Fenced code blocks | +| `.code_inline` | `.inline_code` | Inline code spans | +| `.math_inline` | `.inline_math` | Inline math spans | +| `.horizontal_rule` | `.hr`, `.---`, `.***`, `.___` | Horizontal rules | +| `.break` | `.br` | Line breaks | Example: @@ -73,6 +73,21 @@ Pass a string argument to match code blocks with a specific language: .code("python") | to_array | concat(.code("javascript")) ``` +### Link and Image URL Filtering + +Pass one or more string arguments to match links or images by their exact URL: + +```mq +# Select only links pointing to a specific URL +.link("https://example.com") + +# Select links pointing to either of two URLs +.link("https://a.com", "https://b.com") + +# Select only images with a specific URL +.image("photo.png") +``` + ### Reference Identifier Filtering Pass one or more string arguments to match reference-style nodes by their identifier: @@ -109,6 +124,28 @@ Selector calls can be combined with pipes and functions just like plain selector .code("typescript").lang |= "ts" ``` +## Descendant Selectors + +Chaining two or more selectors with a space selects nodes of the second type that are nested anywhere underneath a node of the first type — similar to a descendant combinator in CSS: + +```mq +# Code blocks nested inside a blockquote (not top-level code blocks) +.blockquote .code + +# Chains can go arbitrarily deep +.blockquote .list .code + +# Each step can use a selector call to filter further +.blockquote .code("rust") + +# A trailing attribute access still works after the chain +.blockquote .code.lang +``` + +This is sugar for recursing into each match and then filtering by the next selector, i.e. `.blockquote .code` is equivalent to `.blockquote | .. | .code`. + +Note this only matches _descendants_ (nodes nested at any depth), not direct children specifically. To get only the immediate children of a match, use the `.children` attribute: `.blockquote.children | .code`. + ## Attribute Access Once you've selected a node, you can access its attributes using dot notation. The available attributes depend on the node type. @@ -154,14 +191,14 @@ Code block nodes support the following attributes: Example: -```mq +````mq # Input: ```rust # fn main() {} # ``` .code.lang # Returns: "rust" .code.value # Returns: "fn main() {}" -``` +```` ### Link Attributes From caa498dca852771bc72d12e9b8312bf6e2954f29 Mon Sep 17 00:00:00 2001 From: harehare Date: Fri, 14 Aug 2026 22:02:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix(mq-lang,mq-formatter):?= =?UTF-8?q?=20support=20descendant=20selector=20chains=20in=20the=20CST=20?= =?UTF-8?q?parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CST parser (used by the formatter and LSP) still rejected `.a .b` descendant chains with UnexpectedToken after the AST parser learned to accept them, so `mq fmt`/editor tooling would flag valid queries as syntax errors. Mirror the AST parser's chain-building in the CST parser, and render the synthetic `..` bridge as a plain space so formatted output still matches what was typed (`.a.b` and `.a .b` both normalize to `.a .b`). --- crates/mq-formatter/src/formatter.rs | 17 +++++++- crates/mq-lang/src/cst/parser.rs | 63 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/mq-formatter/src/formatter.rs b/crates/mq-formatter/src/formatter.rs index cb9223686..dfef64e9d 100644 --- a/crates/mq-formatter/src/formatter.rs +++ b/crates/mq-formatter/src/formatter.rs @@ -699,7 +699,16 @@ impl Formatter { child.token.as_ref().map(|t| &t.kind), Some(mq_lang::TokenKind::Selector(s)) if s == "." ); - if is_list_iterator { + + let is_descendant_bridge = matches!( + child.token.as_ref().map(|t| &t.kind), + Some(mq_lang::TokenKind::DoubleDot) + ); + if is_descendant_bridge { + if !self.output.ends_with(' ') { + self.append_space(); + } + } else if is_list_iterator { child.children.iter().for_each(|bracket_child| { self.format_node(mq_lang::Shared::clone(bracket_child), 0); }); @@ -2935,6 +2944,12 @@ end #[case::selector_call_heading_multi_arg(".h(1,2)", ".h(1, 2)")] #[case::selector_call_code_lang(".code(\"rust\")", ".code(\"rust\")")] #[case::selector_call_pipe(".h(1)|.text()", ".h(1) | .text()")] + #[case::descendant_chain(".blockquote .code", ".blockquote .code")] + #[case::descendant_chain_no_space(".blockquote.code", ".blockquote .code")] + #[case::descendant_chain_extra_space(".blockquote .code", ".blockquote .code")] + #[case::descendant_chain_three_levels(".blockquote .list .code", ".blockquote .list .code")] + #[case::descendant_chain_with_selector_call(".blockquote .code(\"rust\")", ".blockquote .code(\"rust\")")] + #[case::descendant_chain_trailing_attr(".blockquote .code.lang", ".blockquote .code.lang")] #[case::arrow_simple("->(): program;", "->(): program;")] #[case::arrow_multiline( "->(arg1,arg2): diff --git a/crates/mq-lang/src/cst/parser.rs b/crates/mq-lang/src/cst/parser.rs index b128ae987..a261f4d3f 100644 --- a/crates/mq-lang/src/cst/parser.rs +++ b/crates/mq-lang/src/cst/parser.rs @@ -1135,6 +1135,69 @@ impl<'a> Parser<'a> { { node.children = vec![self.next_node(|kind| matches!(kind, TokenKind::Selector(_)), NodeKind::Selector)?]; + return Ok(Shared::new(node)); + } + + if let Some(next_token) = self.peek() + && next_token.is_selector() + && matches!(Selector::try_from(&**next_token), Ok(sel) if !sel.is_attribute_selector()) + { + let first_token = Shared::clone(node.token.as_ref().unwrap()); + let block_leading = node.leading_trivia.clone(); + let mut nodes = vec![Shared::new(node)]; + + while let Some(step_token) = self.peek() { + if !step_token.is_selector() { + break; + } + + match Selector::try_from(&**step_token) { + Ok(sel) if sel.is_attribute_selector() => { + nodes.push( + self.next_node(|kind| matches!(kind, TokenKind::Selector(_)), NodeKind::Selector)?, + ); + break; + } + Ok(_) => { + nodes.push(Shared::new(Node { + kind: NodeKind::Selector, + token: Some(Shared::new(Token { + kind: TokenKind::DoubleDot, + range: step_token.range, + module_id: step_token.module_id, + })), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + })); + + let leading_trivia = self.parse_leading_trivia(); + let step_src_token = self.next_token(|kind| matches!(kind, TokenKind::Selector(_)))?; + let trailing_trivia = self.parse_trailing_trivia(); + let mut step_node = Node { + kind: NodeKind::Selector, + token: Some(Shared::clone(&step_src_token)), + leading_trivia, + trailing_trivia, + children: Vec::new(), + }; + if self.try_next_token(|kind| matches!(kind, TokenKind::LParen)) { + step_node.kind = NodeKind::SelectorCall; + step_node.children = self.parse_args()?; + } + nodes.push(Shared::new(step_node)); + } + Err(_) => break, + } + } + + return Ok(Shared::new(Node { + kind: NodeKind::Block, + token: Some(first_token), + leading_trivia: block_leading, + trailing_trivia: Vec::new(), + children: nodes, + })); } Ok(Shared::new(node)) From 0a6204f03fa2e93bf5c11936d83784f5fa1d17ba Mon Sep 17 00:00:00 2001 From: harehare Date: Sat, 15 Aug 2026 10:06:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix(mq-lang,mq-formatter,mq-?= =?UTF-8?q?hir):=20stop=20synthesizing=20a=20DoubleDot=20bridge=20node=20f?= =?UTF-8?q?or=20descendant=20selector=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CST parser represented a descendant chain's `.a .b` bridge as a synthetic `DoubleDot` child node reusing the following selector's source range, which shadowed that selector in range-based lookups (e.g. HIR/LSP hover on `.code` in `.blockquote .code` resolved to a phantom `..` symbol instead). Drop the synthetic node; the formatter now decides spacing by comparing the actual `Selector` kind of adjacent chain children instead of checking for the bridge token. --- crates/mq-formatter/src/formatter.rs | 31 +++++++++++++++------- crates/mq-hir/src/find.rs | 13 ++++++++++ crates/mq-lang/src/cst/parser.rs | 39 +++++++++++++++++++--------- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/crates/mq-formatter/src/formatter.rs b/crates/mq-formatter/src/formatter.rs index dfef64e9d..25a445fdb 100644 --- a/crates/mq-formatter/src/formatter.rs +++ b/crates/mq-formatter/src/formatter.rs @@ -683,6 +683,21 @@ impl Formatter { }); } + fn needs_descendant_space( + prev: &mq_lang::Shared, + current: &mq_lang::Shared, + ) -> bool { + let selector_of = |n: &mq_lang::Shared| { + n.token.as_ref().and_then(|t| mq_lang::Selector::try_from(&**t).ok()) + }; + + match selector_of(current) { + Some(selector) if selector.is_attribute_selector() => false, + Some(mq_lang::Selector::Property(_)) => !matches!(selector_of(prev), Some(mq_lang::Selector::Property(_))), + _ => true, + } + } + fn format_block( &mut self, node: &mq_lang::Shared, @@ -700,19 +715,17 @@ impl Formatter { Some(mq_lang::TokenKind::Selector(s)) if s == "." ); - let is_descendant_bridge = matches!( - child.token.as_ref().map(|t| &t.kind), - Some(mq_lang::TokenKind::DoubleDot) - ); - if is_descendant_bridge { - if !self.output.ends_with(' ') { - self.append_space(); - } - } else if is_list_iterator { + if is_list_iterator { child.children.iter().for_each(|bracket_child| { self.format_node(mq_lang::Shared::clone(bracket_child), 0); }); } else { + if i > 0 + && Self::needs_descendant_space(&node.children[i - 1], child) + && !self.output.ends_with(' ') + { + self.append_space(); + } self.format_node(mq_lang::Shared::clone(child), if i == 0 { indent_level } else { 0 }); } }); diff --git a/crates/mq-hir/src/find.rs b/crates/mq-hir/src/find.rs index 4972767c1..64b64b4c0 100644 --- a/crates/mq-hir/src/find.rs +++ b/crates/mq-hir/src/find.rs @@ -158,6 +158,19 @@ mod tests { assert!(hir.find_symbol_in_position(source_id, pos).is_some()); } + #[test] + fn test_find_symbol_in_position_resolves_descendant_chain_step() { + // Regression: a synthetic `..` bridge node used to shadow `.code`'s range. + let mut hir = Hir::default(); + let (source_id, _) = hir.add_code(None, ".blockquote .code"); + let pos = mq_lang::Position::new(1, 14); + + let (_, symbol) = hir + .find_symbol_in_position(source_id, pos) + .expect("symbol at .code position"); + assert_eq!(symbol.value.as_deref(), Some(".code")); + } + #[test] fn test_find_scope_in_position() { let mut hir = Hir::default(); diff --git a/crates/mq-lang/src/cst/parser.rs b/crates/mq-lang/src/cst/parser.rs index a261f4d3f..f1c6e0f6c 100644 --- a/crates/mq-lang/src/cst/parser.rs +++ b/crates/mq-lang/src/cst/parser.rs @@ -1159,18 +1159,6 @@ impl<'a> Parser<'a> { break; } Ok(_) => { - nodes.push(Shared::new(Node { - kind: NodeKind::Selector, - token: Some(Shared::new(Token { - kind: TokenKind::DoubleDot, - range: step_token.range, - module_id: step_token.module_id, - })), - leading_trivia: Vec::new(), - trailing_trivia: Vec::new(), - children: Vec::new(), - })); - let leading_trivia = self.parse_leading_trivia(); let step_src_token = self.next_token(|kind| matches!(kind, TokenKind::Selector(_)))?; let trailing_trivia = self.parse_trailing_trivia(); @@ -11425,6 +11413,33 @@ mod tests { assert_eq!(nodes, expected.0); } + #[test] + fn test_descendant_chain_has_no_synthetic_bridge_node() { + // No synthetic DoubleDot node: it would duplicate the next selector's source range. + let (nodes, errors) = crate::parse_recovery(".blockquote .code"); + assert!(!errors.has_errors()); + + let block = nodes + .iter() + .find(|node| node.kind == NodeKind::Block) + .expect("descendant chain should parse as a Block"); + + assert_eq!( + block.children.len(), + 2, + "expected exactly the two real selectors, got {:#?}", + block.children + ); + assert!( + block + .children + .iter() + .all(|child| !matches!(child.token.as_ref().map(|t| &t.kind), Some(TokenKind::DoubleDot))), + "no child should carry a synthetic DoubleDot token: {:#?}", + block.children + ); + } + #[test] fn test_unmatched_end_error_message() { // Verify the error message text for `UnmatchedEnd`.