From 6834f046c569e9efa3d075e26c3d58e3c09dcf08 Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 27 Oct 2026 21:15:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(lang):=20hint=20dict=20k?= =?UTF-8?q?ey=20access=20for=20selectors=20written=20after=20an=20identifi?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #2591 --- crates/mq-lang/src/error.rs | 50 +++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/mq-lang/src/error.rs b/crates/mq-lang/src/error.rs index 31042f9f4..dc64a8b53 100644 --- a/crates/mq-lang/src/error.rs +++ b/crates/mq-lang/src/error.rs @@ -237,6 +237,33 @@ pub(crate) fn selector_help(sel: &selector::UnknownSelector) -> Cow<'static, str } } +impl Error { + // Like `selector_help`, but a selector written directly after an identifier (`d.a`) is + // read as an attempted dict key access. + #[cold] + fn selector_help_in_source(&self, sel: &selector::UnknownSelector) -> Cow<'static, str> { + if let TokenKind::Selector(s) = &sel.0.kind + && let Some(key) = s.strip_prefix('.') + && !key.is_empty() + && key.chars().all(|c| c.is_alphanumeric() || c == '_') + && let Some(receiver) = preceding_identifier(self.source_code.inner(), self.location.offset()) + { + return Cow::Owned(format!( + "`.{key}` selects Markdown nodes, not dict keys. To read a dict key, use `{receiver}[\"{key}\"]` or `get({receiver}, \"{key}\")`." + )); + } + selector_help(sel) + } +} + +// Identifier ending exactly at byte `offset`, with no gap. +fn preceding_identifier(source: &str, offset: usize) -> Option<&str> { + let head = source.get(..offset)?; + let ident = head.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_'); + let name = &head[ident.len()..]; + (!name.is_empty() && !name.starts_with(|c: char| c.is_ascii_digit())).then_some(name) +} + impl Diagnostic for Error { #[cold] fn code<'a>(&'a self) -> Option> { @@ -283,7 +310,7 @@ impl Diagnostic for Error { InnerError::Syntax(SyntaxError::InsufficientTokens(_)) => Some(Cow::Borrowed( "Parsing could not continue here. Check for missing arguments, operators, or mismatched delimiters.", )), - InnerError::Syntax(SyntaxError::UnknownSelector(sel)) => Some(selector_help(sel)), + InnerError::Syntax(SyntaxError::UnknownSelector(sel)) => Some(self.selector_help_in_source(sel)), InnerError::Syntax(SyntaxError::ExpectedClosingParen(_, _)) => Some(Cow::Borrowed( "Expected a closing parenthesis ')'. Check your parentheses for balance.", )), @@ -422,7 +449,9 @@ impl Diagnostic for Error { InnerError::Module(ModuleError::SyntaxError(SyntaxError::InvalidAssignmentTarget(_))) => Some( Cow::Borrowed("Invalid assignment target. Ensure you're assigning to a valid variable or property."), ), - InnerError::Module(ModuleError::SyntaxError(SyntaxError::UnknownSelector(sel))) => Some(selector_help(sel)), + InnerError::Module(ModuleError::SyntaxError(SyntaxError::UnknownSelector(sel))) => { + Some(self.selector_help_in_source(sel)) + } InnerError::Module(ModuleError::InvalidModule) => Some(Cow::Borrowed("Invalid module format or content.")), InnerError::Module(ModuleError::SyntaxError(SyntaxError::ParameterWithoutDefaultAfterDefault(_))) => { Some(Cow::Borrowed( @@ -1210,4 +1239,21 @@ mod test { ) ); } + + #[rstest] + #[case::attached("let d = {\"a\":1} | d.a", 19, true)] + #[case::separated("let d = {\"a\":1} | d .a", 20, false)] + fn test_unknown_selector_help_dict_access(#[case] source: &str, #[case] offset: usize, #[case] dict_hint: bool) { + let token = Token { + range: Range::default(), + kind: TokenKind::Selector(smol_str::SmolStr::new(".a")), + module_id: ArenaId::new(0), + }; + let cause = InnerError::Syntax(SyntaxError::UnknownSelector(selector::UnknownSelector::new(token))); + let module_loader: ModuleLoader = ModuleLoader::default(); + let mut error = Error::from_error(source, cause, module_loader); + error.location = SourceSpan::new(offset.into(), 2); + let help = error.help().map(|h| h.to_string()).unwrap(); + assert_eq!(help.contains("d[\"a\"]") && help.contains("get(d, \"a\")"), dict_hint); + } } From 3740adcfafe6152aca91dbb72fb89afb42314b50 Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 27 Oct 2026 22:09:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix(lang):=20keep=20similar-?= =?UTF-8?q?selector=20hint=20and=20skip=20selector=20tails=20in=20dict=20k?= =?UTF-8?q?ey=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/mq-lang/src/error.rs | 42 ++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/mq-lang/src/error.rs b/crates/mq-lang/src/error.rs index dc64a8b53..3be07da3e 100644 --- a/crates/mq-lang/src/error.rs +++ b/crates/mq-lang/src/error.rs @@ -238,30 +238,35 @@ pub(crate) fn selector_help(sel: &selector::UnknownSelector) -> Cow<'static, str } impl Error { - // Like `selector_help`, but a selector written directly after an identifier (`d.a`) is - // read as an attempted dict key access. + // Like `selector_help`, but a selector written directly after an identifier (`d.a`) may be + // an attempted dict key access, so the key access hint is added to any similar-selector hint. #[cold] fn selector_help_in_source(&self, sel: &selector::UnknownSelector) -> Cow<'static, str> { + let help = selector_help(sel); if let TokenKind::Selector(s) = &sel.0.kind && let Some(key) = s.strip_prefix('.') && !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') && let Some(receiver) = preceding_identifier(self.source_code.inner(), self.location.offset()) { - return Cow::Owned(format!( - "`.{key}` selects Markdown nodes, not dict keys. To read a dict key, use `{receiver}[\"{key}\"]` or `get({receiver}, \"{key}\")`." - )); + let access = format!("To read a dict key, use `{receiver}[\"{key}\"]` or `get({receiver}, \"{key}\")`."); + return if crate::suggest::suggest_selector(s).is_some() { + Cow::Owned(format!("{help} {access}")) + } else { + Cow::Owned(format!("`.{key}` selects Markdown nodes, not dict keys. {access}")) + }; } - selector_help(sel) + help } } -// Identifier ending exactly at byte `offset`, with no gap. +// Identifier ending exactly at byte `offset`, with no gap. A word preceded by `.` is the tail of +// a selector (`.h` in `.h.bad`), not an identifier. fn preceding_identifier(source: &str, offset: usize) -> Option<&str> { let head = source.get(..offset)?; let ident = head.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_'); let name = &head[ident.len()..]; - (!name.is_empty() && !name.starts_with(|c: char| c.is_ascii_digit())).then_some(name) + (!name.is_empty() && !name.starts_with(|c: char| c.is_ascii_digit()) && !ident.ends_with('.')).then_some(name) } impl Diagnostic for Error { @@ -1241,19 +1246,28 @@ mod test { } #[rstest] - #[case::attached("let d = {\"a\":1} | d.a", 19, true)] - #[case::separated("let d = {\"a\":1} | d .a", 20, false)] - fn test_unknown_selector_help_dict_access(#[case] source: &str, #[case] offset: usize, #[case] dict_hint: bool) { + #[case::attached("let d = {\"a\":1} | d.zzzzzz", 19, ".zzzzzz", true, false)] + #[case::separated("let d = {\"a\":1} | d .zzzzzz", 20, ".zzzzzz", false, false)] + #[case::selector_chain(".h.zzzzzz", 2, ".zzzzzz", false, false)] + #[case::attribute_typo("node.vlaue", 4, ".vlaue", true, true)] + fn test_unknown_selector_help_dict_access( + #[case] source: &str, + #[case] offset: usize, + #[case] selector: &str, + #[case] dict_hint: bool, + #[case] similar_hint: bool, + ) { let token = Token { range: Range::default(), - kind: TokenKind::Selector(smol_str::SmolStr::new(".a")), + kind: TokenKind::Selector(smol_str::SmolStr::new(selector)), module_id: ArenaId::new(0), }; let cause = InnerError::Syntax(SyntaxError::UnknownSelector(selector::UnknownSelector::new(token))); let module_loader: ModuleLoader = ModuleLoader::default(); let mut error = Error::from_error(source, cause, module_loader); - error.location = SourceSpan::new(offset.into(), 2); + error.location = SourceSpan::new(offset.into(), selector.len()); let help = error.help().map(|h| h.to_string()).unwrap(); - assert_eq!(help.contains("d[\"a\"]") && help.contains("get(d, \"a\")"), dict_hint); + assert_eq!(help.contains("get("), dict_hint); + assert_eq!(help.contains("similar name exists"), similar_hint); } } From 7edcceb9b5d443ae23f5af330e27ab3a8237a6f9 Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 27 Oct 2026 22:13:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A7=20chore(typos):=20allow=20inte?= =?UTF-8?q?ntional=20misspelling=20in=20selector=20hint=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.typos.toml b/.typos.toml index 71cf2657a..a8ce8fbaa 100644 --- a/.typos.toml +++ b/.typos.toml @@ -14,6 +14,8 @@ juxt = "juxt" mport = "mport" reakpoint = "reakpoint" sction = "sction" +# Intentional misspelling of `.value` in the unknown-selector hint test +vlaue = "vlaue" [default.extend-identifiers] # bimap is a crate name (bijective map)