From 26e3e167715d51ede2eada187b6d5c9791dcaee2 Mon Sep 17 00:00:00 2001 From: harehare Date: Thu, 23 Apr 2026 21:44:21 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(lang):=20rewrite=20standalone?= =?UTF-8?q?=20.attr=20to=20self.attr=20at=20AST=20stage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone attribute selectors like `.lang` or `.depth` previously returned NONE because `eval_selector` treats `Selector::Attr(_)` as non-matching. They now rewrite to `attr(self, "attrName")` at parse time, enabling both read and |= assignment forms. --- crates/mq-formatter/src/formatter.rs | 8 ++- crates/mq-hir/src/hir.rs | 5 ++ crates/mq-hir/src/hir/lower.rs | 4 +- crates/mq-lang/src/ast/parser.rs | 68 +++++++++++++++++++++++ crates/mq-lang/src/cst/node.rs | 1 + crates/mq-lang/src/cst/parser.rs | 24 +++++++- crates/mq-lang/tests/integration_tests.rs | 16 ++++++ 7 files changed, 121 insertions(+), 5 deletions(-) diff --git a/crates/mq-formatter/src/formatter.rs b/crates/mq-formatter/src/formatter.rs index c6bfe17d4..4be0456ea 100644 --- a/crates/mq-formatter/src/formatter.rs +++ b/crates/mq-formatter/src/formatter.rs @@ -288,7 +288,9 @@ impl Formatter { | mq_lang::CstNodeKind::Do | mq_lang::CstNodeKind::Continue => self.format_keyword(&node, indent_level_consider_new_line), mq_lang::CstNodeKind::Break => self.format_break(&node, indent_level_consider_new_line), - mq_lang::CstNodeKind::Selector => self.format_selector(&node, indent_level_consider_new_line), + mq_lang::CstNodeKind::Selector | mq_lang::CstNodeKind::SelfAttr => { + self.format_selector(&node, indent_level_consider_new_line) + } mq_lang::CstNodeKind::Try => self.format_try(&node, indent_level_consider_new_line), mq_lang::CstNodeKind::Catch => self.format_catch(&node, indent_level_consider_new_line), mq_lang::CstNodeKind::Match => self.format_match(&node, indent_level_consider_new_line, indent_level), @@ -1391,7 +1393,7 @@ impl Formatter { fn format_selector(&mut self, node: &mq_lang::Shared, indent_level: usize) { if let mq_lang::CstNode { - kind: mq_lang::CstNodeKind::Selector, + kind: mq_lang::CstNodeKind::Selector | mq_lang::CstNodeKind::SelfAttr, token: Some(token), children, .. @@ -2021,6 +2023,8 @@ process();"#, #[case::range_operator_with_variables("x..y", "x..y")] #[case::range_operator_with_string(r#""1" .. "2""#, r#""1".."2""#)] #[case::selector_attr(".code.lang", ".code.lang")] + #[case::standalone_attr_selector(".lang", ".lang")] + #[case::standalone_attr_selector_value(".value", ".value")] #[case::env("let ENV = $env", "let ENV = $env")] #[case::mul("1 * 1", "1 * 1")] #[case::mul("1 / 1", "1 / 1")] diff --git a/crates/mq-hir/src/hir.rs b/crates/mq-hir/src/hir.rs index 5da57d811..2b8502d5a 100644 --- a/crates/mq-hir/src/hir.rs +++ b/crates/mq-hir/src/hir.rs @@ -354,6 +354,11 @@ def foo(): 1", vec![" test".to_owned(), " test".to_owned(), "".to_owned()], vec! #[case::literal("42", "42", SymbolKind::Number)] #[case::selector(".h", ".h", SymbolKind::Selector(mq_lang::Selector::Heading(None)))] #[case::selector(".code.lang", ".code", SymbolKind::Selector(mq_lang::Selector::Code))] + #[case::standalone_attr_selector( + ".lang", + ".lang", + SymbolKind::Selector(mq_lang::Selector::Attr(mq_lang::AttrKind::Lang)) + )] // Bracket selectors: .[n] → List, .[n][m] → Table #[case::selector_list_any(".[]", ".", SymbolKind::Selector(mq_lang::Selector::List(None, None)))] #[case::selector_list_index(".[1]", ".", SymbolKind::Selector(mq_lang::Selector::List(Some(1), None)))] diff --git a/crates/mq-hir/src/hir/lower.rs b/crates/mq-hir/src/hir/lower.rs index c6776de46..7392cdaf2 100644 --- a/crates/mq-hir/src/hir/lower.rs +++ b/crates/mq-hir/src/hir/lower.rs @@ -169,7 +169,7 @@ impl Hir { mq_lang::CstNodeKind::Literal => { self.add_literal_expr(node, source_id, scope_id, parent); } - mq_lang::CstNodeKind::Selector => { + mq_lang::CstNodeKind::Selector | mq_lang::CstNodeKind::SelfAttr => { self.add_selector_expr(node, source_id, scope_id, parent); } mq_lang::CstNodeKind::While => { @@ -651,7 +651,7 @@ impl Hir { parent: Option, ) { if let mq_lang::CstNode { - kind: mq_lang::CstNodeKind::Selector, + kind: mq_lang::CstNodeKind::Selector | mq_lang::CstNodeKind::SelfAttr, .. } = &**node && let Some(selector) = selector_from_cst_node(node) diff --git a/crates/mq-lang/src/ast/parser.rs b/crates/mq-lang/src/ast/parser.rs index 81573e442..faea7317c 100644 --- a/crates/mq-lang/src/ast/parser.rs +++ b/crates/mq-lang/src/ast/parser.rs @@ -2414,6 +2414,33 @@ impl<'a, 'alloc> Parser<'a, 'alloc> { } else { let selector = Selector::try_from(&**token).map_err(SyntaxError::UnknownSelector)?; + if selector.is_attribute_selector() { + let token_id = self.token_arena.alloc(Shared::clone(token)); + let self_node = Shared::new(Node { + token_id, + expr: Shared::new(Expr::Self_), + }); + let TokenKind::Selector(selector_str) = &token.kind else { + unreachable!() + }; + let attribute_name = selector_str[1..].to_string(); + let attr_literal = Shared::new(Node { + token_id, + expr: Shared::new(Expr::Literal(Literal::String(attribute_name))), + }); + if self.is_next_token(|kind| matches!(kind, TokenKind::PipeEqual)) { + self.tokens.next(); + return self.parse_set_attr_call_with_selector(self_node, attr_literal); + } + return Ok(Shared::new(Node { + token_id, + expr: Shared::new(Expr::Call( + IdentWithToken::new_with_token(constants::builtins::ATTR, Some(Shared::clone(token))), + smallvec![self_node, attr_literal], + )), + })); + } + Ok(Shared::new(Node { token_id: self.token_arena.alloc(Shared::clone(token)), expr: Shared::new(Expr::Selector(selector)), @@ -7906,6 +7933,47 @@ mod tests { } } + #[rstest] + #[case::lang(".lang", "lang")] + #[case::value(".value", "value")] + #[case::depth(".depth", "depth")] + fn test_parse_standalone_attr_selector(#[case] selector: &str, #[case] attribute: &str) { + let mut arena = Arena::new(10); + let tokens = [ + Shared::new(Token { + range: Range::default(), + kind: TokenKind::Selector(SmolStr::new(selector)), + module_id: 1.into(), + }), + 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::Self_)); + if let Expr::Literal(Literal::String(attr_str)) = &*args[1].expr { + assert_eq!(attr_str, attribute); + } else { + panic!("Expected String literal in second argument, got {:?}", args[1].expr); + } + } else { + panic!("Expected Call expression, got {:?}", program[0].expr); + } + } + Err(err) => panic!("Parse error: {:?}", err), + } + } + #[rstest] #[case::h_value(vec![".h", ".value"], "h", "value")] #[case::h1_value(vec![".h1", ".value"], "h1", "value")] diff --git a/crates/mq-lang/src/cst/node.rs b/crates/mq-lang/src/cst/node.rs index 1d1a557cd..9cd4c0ab2 100644 --- a/crates/mq-lang/src/cst/node.rs +++ b/crates/mq-lang/src/cst/node.rs @@ -116,6 +116,7 @@ pub enum NodeKind { Quote, Selector, Self_, + SelfAttr, Token, Try, Catch, diff --git a/crates/mq-lang/src/cst/parser.rs b/crates/mq-lang/src/cst/parser.rs index 04ec9f93e..529e941c6 100644 --- a/crates/mq-lang/src/cst/parser.rs +++ b/crates/mq-lang/src/cst/parser.rs @@ -951,7 +951,12 @@ impl<'a> Parser<'a> { } TokenKind::DoubleDot => Ok(Shared::new(node)), _ => { - Selector::try_from(&**token).map_err(ParseError::UnknownSelector)?; + let selector = Selector::try_from(&**token).map_err(ParseError::UnknownSelector)?; + + if selector.is_attribute_selector() { + node.kind = NodeKind::SelfAttr; + return Ok(Shared::new(node)); + } if let Some(attr_token) = self.peek() && attr_token.is_selector() @@ -3825,6 +3830,23 @@ mod tests { ErrorReporter::default() ) )] + #[case::standalone_attr_selector( + vec![ + Shared::new(token(TokenKind::Selector(".lang".into()))), + ], + ( + vec![ + Shared::new(Node { + kind: NodeKind::SelfAttr, + token: Some(Shared::new(token(TokenKind::Selector(".lang".into())))), + leading_trivia: Vec::new(), + trailing_trivia: Vec::new(), + children: Vec::new(), + }), + ], + ErrorReporter::default() + ) + )] #[case::include( vec![ Shared::new(token(TokenKind::Include)), diff --git a/crates/mq-lang/tests/integration_tests.rs b/crates/mq-lang/tests/integration_tests.rs index 99792f52f..6e6af0a5e 100644 --- a/crates/mq-lang/tests/integration_tests.rs +++ b/crates/mq-lang/tests/integration_tests.rs @@ -1461,6 +1461,22 @@ fn engine() -> DefaultEngine { value: "rust".to_string(), position: None, }), None)].into()))] +#[case::standalone_attr_selector_lang(".lang", + vec![ + RuntimeValue::Markdown(mq_markdown::Node::Code(mq_markdown::Code{ lang: Some("rust".to_string()), meta: None, fence: true, value: "value".to_string(), position: None }), None), + ], + Ok(vec![RuntimeValue::Markdown(mq_markdown::Node::Text(mq_markdown::Text { + value: "rust".to_string(), + position: None, + }), None)].into()))] +#[case::standalone_attr_selector_set(".lang |= \"python\" | .lang", + vec![ + RuntimeValue::Markdown(mq_markdown::Node::Code(mq_markdown::Code{ lang: Some("rust".to_string()), meta: None, fence: true, value: "".to_string(), position: None }), None), + ], + Ok(vec![RuntimeValue::Markdown(mq_markdown::Node::Text(mq_markdown::Text { + value: "python".to_string(), + position: None, + }), None)].into()))] #[case::recursive_selector_with_children("nodes | ..", vec![ RuntimeValue::Markdown(mq_markdown::Node::Heading(mq_markdown::Heading{