Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 62 additions & 2 deletions crates/mq-lang/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,38 @@ 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`) 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())
{
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}"))
};
}
help
}
}

// 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()) && !ident.ends_with('.')).then_some(name)
}

impl Diagnostic for Error {
#[cold]
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
Expand Down Expand Up @@ -283,7 +315,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.",
)),
Expand Down Expand Up @@ -422,7 +454,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(
Expand Down Expand Up @@ -1210,4 +1244,30 @@ mod test {
)
);
}

#[rstest]
#[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(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(), selector.len());
let help = error.help().map(|h| h.to_string()).unwrap();
assert_eq!(help.contains("get("), dict_hint);
assert_eq!(help.contains("similar name exists"), similar_hint);
}
}
Loading