From 38f36f7016d158fbb099941bc4c4d946bd1ccf39 Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Mon, 22 Jun 2026 15:06:08 +0200 Subject: [PATCH 1/3] fix: rewrite directive scanner + handle JSX quotes --- .../lingui_macro/src/comment_directive/mod.rs | 485 +++++++----------- .../src/comment_directive/source_scanner.rs | 320 ------------ .../src/comment_directive/tests.rs | 405 +++++++++++++++ crates/lingui_macro/tests/lingui_directive.rs | 71 +++ ...ective__jsdoc_block_comment_directive.snap | 15 + ..._comment_directive_with_trailing_code.snap | 16 + ...comment_reset_in_expression_container.snap | 28 + ...k_comment_set_in_expression_container.snap | 22 + ...ctive__jsx_trans_with_unclosed_quotes.snap | 36 ++ 9 files changed, 777 insertions(+), 621 deletions(-) delete mode 100644 crates/lingui_macro/src/comment_directive/source_scanner.rs create mode 100644 crates/lingui_macro/src/comment_directive/tests.rs create mode 100644 crates/lingui_macro/tests/snapshots/lingui_directive__jsdoc_block_comment_directive.snap create mode 100644 crates/lingui_macro/tests/snapshots/lingui_directive__jsx_block_comment_directive_with_trailing_code.snap create mode 100644 crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_reset_in_expression_container.snap create mode 100644 crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_set_in_expression_container.snap create mode 100644 crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_unclosed_quotes.snap diff --git a/crates/lingui_macro/src/comment_directive/mod.rs b/crates/lingui_macro/src/comment_directive/mod.rs index 68f579c..1a8267f 100644 --- a/crates/lingui_macro/src/comment_directive/mod.rs +++ b/crates/lingui_macro/src/comment_directive/mod.rs @@ -1,13 +1,6 @@ -mod source_scanner; - -use source_scanner::{scan_source_comments, CommentKind}; use swc_core::common::{BytePos, Span}; use swc_core::plugin::errors::HANDLER; -fn is_lingui_directive_prefix(comment: &str) -> bool { - comment.starts_with("lingui-set") || comment.starts_with("lingui-reset") -} - #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct DirectiveValues { pub context: Option, @@ -32,6 +25,15 @@ enum DirectiveValueUpdate { Unset, } +impl From for Option { + fn from(update: DirectiveValueUpdate) -> Self { + match update { + DirectiveValueUpdate::Set(value) => Some(value), + DirectiveValueUpdate::Unset => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] struct DirectiveUpdate { context: Option, @@ -64,24 +66,13 @@ impl LinguiCommentDirectives { impl DirectiveValues { fn apply_update(&mut self, update: DirectiveUpdate) { if let Some(value) = update.context { - self.context = match value { - DirectiveValueUpdate::Set(value) => Some(value), - DirectiveValueUpdate::Unset => None, - }; + self.context = value.into(); } - if let Some(value) = update.comment { - self.comment = match value { - DirectiveValueUpdate::Set(value) => Some(value), - DirectiveValueUpdate::Unset => None, - }; + self.comment = value.into(); } - if let Some(value) = update.id_prefix { - self.id_prefix = match value { - DirectiveValueUpdate::Set(value) => Some(value), - DirectiveValueUpdate::Unset => None, - }; + self.id_prefix = value.into(); } } } @@ -94,92 +85,187 @@ fn parse_value_update(value: &str) -> DirectiveValueUpdate { } } -fn parse_lingui_directive(comment_value: &str) -> Result, String> { - let trimmed = comment_value.trim(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommentKind { + Line, + Block, +} - let (directive_name, rest) = if let Some(rest) = trimmed.strip_prefix("lingui-set") { - ("lingui-set", rest) - } else if let Some(rest) = trimmed.strip_prefix("lingui-reset") { - ("lingui-reset", rest) - } else { - return Ok(None); - }; +#[derive(Debug, PartialEq, Eq)] +struct LocatedDirective<'a> { + /// Byte offset of the comment opener (`//`, `/*` or `/**`) introducing it. + opener_start: usize, + /// Byte offset just past the comment (after `*/`, the newline, or EOF). + comment_end: usize, + reset: bool, + /// Raw parameter text between the directive name and the comment end. + params: &'a str, +} + +/// Common prefix of both directives (`lingui-set` / `lingui-reset`); used as +/// the substring anchor for the scan and the cheap "any directives?" check. +const LINGUI_PREFIX: &str = "lingui-"; + +fn locate_directives(source: &str) -> Vec> { + let bytes = source.as_bytes(); + let mut out = Vec::new(); + let mut from = 0usize; + + while let Some(rel) = source[from..].find(LINGUI_PREFIX) { + let keyword = from + rel; + let after = keyword + LINGUI_PREFIX.len(); + // Always advance past this occurrence so the loop terminates regardless + // of whether it turns out to be a real directive. + from = after; + + let (reset, name_end) = if source[after..].starts_with("reset") { + (true, after + "reset".len()) + } else if source[after..].starts_with("set") { + (false, after + "set".len()) + } else { + continue; + }; + + // Word boundary: `lingui-settings` is not a `lingui-set` directive. + if let Some(&b) = bytes.get(name_end) { + if b.is_ascii_alphanumeric() || b == b'_' { + continue; + } + } + + let Some((opener_start, kind)) = find_comment_opener(source, keyword) else { + continue; + }; - if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { - return Ok(None); + let (params, comment_end) = match kind { + CommentKind::Line => { + let end = line_end(bytes, name_end); + (&source[name_end..end], end) + } + CommentKind::Block => match block_comment_close(bytes, name_end) { + Some(star) => (&source[name_end..star], star + 2), + None => (&source[name_end..], bytes.len()), + }, + }; + + out.push(LocatedDirective { + opener_start, + comment_end, + reset, + params, + }); + } + + out +} + +/// Walk backwards from a `lingui-…` keyword over the horizontal whitespace +/// separating it from its comment opener. Returns the opener's start offset and +/// kind, or `None` if the keyword is not at the start of a `//`, `/*` or `/**` +/// comment on the same line. +fn find_comment_opener(source: &str, keyword: usize) -> Option<(usize, CommentKind)> { + let bytes = source.as_bytes(); + + // The directive keyword must sit on the opener's line, separated from it by + // spaces/tabs only — no intervening newline. + let mut j = keyword; + while j > 0 && matches!(bytes[j - 1], b' ' | b'\t') { + j -= 1; + } + if source[..j].ends_with("//") { + return Some((j - 2, CommentKind::Line)); + } + if source[..j].ends_with("/**") { + return Some((j - 3, CommentKind::Block)); + } + if source[..j].ends_with("/*") { + return Some((j - 2, CommentKind::Block)); } - let reset = directive_name == "lingui-reset"; - let rest = rest.trim(); + None +} + +fn line_end(bytes: &[u8], from: usize) -> usize { + bytes[from..] + .iter() + .position(|&b| b == b'\n') + .map_or(bytes.len(), |offset| from + offset) +} + +/// Find the closing `*/` of a block comment. Returns the offset of the `*`, or +/// `None` for an unterminated block comment (parameters then run to EOF). +fn block_comment_close(bytes: &[u8], from: usize) -> Option { + bytes[from..] + .windows(2) + .position(|pair| pair == b"*/") + .map(|offset| from + offset) +} + +fn parse_lingui_directive(reset: bool, params: &str) -> Result { + let directive_name = if reset { "lingui-reset" } else { "lingui-set" }; + let rest = params.trim(); + let bytes = rest.as_bytes(); + + let invalid_syntax = + || format!("`{directive_name}` directive has invalid syntax: {directive_name} {rest}"); let mut values = DirectiveUpdate::default(); let mut has_params = false; let mut pos = 0; - let rest_bytes = rest.as_bytes(); - while pos < rest_bytes.len() { - // skip whitespace - while pos < rest_bytes.len() && rest_bytes[pos].is_ascii_whitespace() { + while pos < bytes.len() { + // Skip whitespace between params + if bytes[pos].is_ascii_whitespace() { pos += 1; - } - if pos >= rest_bytes.len() { - break; + continue; } - // parse key (word chars) + // Parse the key (word chars) let key_start = pos; - while pos < rest_bytes.len() - && (rest_bytes[pos].is_ascii_alphanumeric() || rest_bytes[pos] == b'_') - { + while pos < bytes.len() && (bytes[pos].is_ascii_alphanumeric() || bytes[pos] == b'_') { pos += 1; } if pos == key_start { - return Err(format!( - "`{directive_name}` directive has invalid syntax: {trimmed}" - )); + return Err(invalid_syntax()); } let key = &rest[key_start..pos]; - // expect ="..." - if pos >= rest_bytes.len() || rest_bytes[pos] != b'=' { - return Err(format!( - "`{directive_name}` directive: \"{key}\" requires a value, e.g. {key}=\"...\"" - )); + // Expect `="` + let requires_value = || { + format!("`{directive_name}` directive: \"{key}\" requires a value, e.g. {key}=\"...\"") + }; + if bytes.get(pos) != Some(&b'=') { + return Err(requires_value()); } - pos += 1; // skip '=' - - if pos >= rest_bytes.len() || rest_bytes[pos] != b'"' { - return Err(format!( - "`{directive_name}` directive: \"{key}\" requires a value, e.g. {key}=\"...\"" - )); + pos += 1; + if bytes.get(pos) != Some(&b'"') { + return Err(requires_value()); } - pos += 1; // skip opening '"' + pos += 1; + // Read the value up to the closing `"` let value_start = pos; - while pos < rest_bytes.len() && rest_bytes[pos] != b'"' { + while pos < bytes.len() && bytes[pos] != b'"' { pos += 1; } - if pos >= rest_bytes.len() { - return Err(format!( - "`{directive_name}` directive has invalid syntax: {trimmed}" - )); + if pos >= bytes.len() { + return Err(invalid_syntax()); } let value = &rest[value_start..pos]; - pos += 1; // skip closing '"' - - has_params = true; - let update = parse_value_update(value); + pos += 1; // closing quote - match key { - "context" => values.context = Some(update), - "comment" => values.comment = Some(update), - "idPrefix" => values.id_prefix = Some(update), + let field = match key { + "context" => &mut values.context, + "comment" => &mut values.comment, + "idPrefix" => &mut values.id_prefix, _ => { return Err(format!( "`{directive_name}` directive has unknown param \"{key}\". Valid params: context, comment, idPrefix" )); } - } + }; + *field = Some(parse_value_update(value)); + has_params = true; } if !has_params && !reset { @@ -188,73 +274,46 @@ fn parse_lingui_directive(comment_value: &str) -> Result )); } - Ok(Some(ParsedDirective { reset, values })) + Ok(ParsedDirective { reset, values }) } fn find_directive_for_pos(directives: &[DirectiveEntry], pos: BytePos) -> Option<&DirectiveValues> { - if directives.is_empty() { - return None; - } - - let mut lo = 0usize; - let mut hi = directives.len(); - - while lo < hi { - let mid = (lo + hi) / 2; - if directives[mid].pos <= pos { - lo = mid + 1; - } else { - hi = mid; - } - } - - if lo == 0 { - None - } else { - Some(&directives[lo - 1].values) - } + // `directives` is sorted by `pos` ascending, so the closest directive at or + // before `pos` is the one just before the first entry that starts after it. + let after = directives.partition_point(|entry| entry.pos <= pos); + after.checked_sub(1).map(|i| &directives[i].values) } fn collect_lingui_directives_from_source(source: &str, start_pos: BytePos) -> Vec { - if !source.contains("lingui-set") && !source.contains("lingui-reset") { - return vec![]; + if !source.contains(LINGUI_PREFIX) { + return Vec::new(); } let mut directives = Vec::new(); let mut accumulated = DirectiveValues::default(); - for comment in scan_source_comments(source) { - let comment_start = BytePos(start_pos.0 + comment.byte_offset as u32); - let trimmed = comment.content.trim(); - - if !is_lingui_directive_prefix(trimmed) { - continue; - } - - let content_end = match comment.kind { - CommentKind::Line => BytePos(comment_start.0 + 2 + comment.content.len() as u32), - CommentKind::Block => BytePos(comment_start.0 + 2 + comment.content.len() as u32 + 2), - }; - let span = Span::new(comment_start, content_end); + for located in locate_directives(source) { + let comment_start = BytePos(start_pos.0 + located.opener_start as u32); - match parse_lingui_directive(trimmed) { - Ok(Some(parsed)) => { - let mut values = if parsed.reset { - DirectiveValues::default() - } else { - accumulated.clone() - }; - - values.apply_update(parsed.values); - accumulated = values.clone(); + match parse_lingui_directive(located.reset, located.params) { + Ok(parsed) => { + // A reset starts from a clean slate; otherwise updates layer on + // top of the values accumulated from preceding directives. + if parsed.reset { + accumulated = DirectiveValues::default(); + } + accumulated.apply_update(parsed.values); directives.push(DirectiveEntry { pos: comment_start, - values, - }) + values: accumulated.clone(), + }); } - Ok(None) => {} Err(message) => { + let span = Span::new( + comment_start, + BytePos(start_pos.0 + located.comment_end as u32), + ); HANDLER.with(|handler| handler.struct_span_err(span, &message).emit()); } } @@ -264,180 +323,4 @@ fn collect_lingui_directives_from_source(source: &str, start_pos: BytePos) -> Ve } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_should_parse_multiple_keys() { - let parsed = - parse_lingui_directive(r#" lingui-set context="ctx" comment="cmt" idPrefix="p." "#) - .unwrap(); - - assert_eq!( - parsed, - Some(ParsedDirective { - reset: false, - values: DirectiveUpdate { - context: Some(DirectiveValueUpdate::Set("ctx".into())), - comment: Some(DirectiveValueUpdate::Set("cmt".into())), - id_prefix: Some(DirectiveValueUpdate::Set("p.".into())), - } - }) - ); - } - - #[test] - fn parse_should_return_none_for_non_directive_comments() { - assert_eq!(parse_lingui_directive(" some comment ").unwrap(), None); - assert_eq!(parse_lingui_directive(" i18n ").unwrap(), None); - } - - #[test] - fn parse_should_reject_invalid_syntax() { - let error = parse_lingui_directive(" lingui-set context=single ") - .expect_err("expected parser to reject invalid syntax"); - - assert!(error.contains("requires a value")); - } - - #[test] - fn parse_should_reject_unknown_params() { - let error = parse_lingui_directive(r#" lingui-set unknown="value" "#) - .expect_err("expected parser to reject unknown params"); - - assert!(error.contains("unknown param \"unknown\"")); - } - - #[test] - fn parse_should_treat_empty_strings_as_unset() { - let parsed = parse_lingui_directive(r#" lingui-set context="" comment="note" "#).unwrap(); - - assert_eq!( - parsed, - Some(ParsedDirective { - reset: false, - values: DirectiveUpdate { - context: Some(DirectiveValueUpdate::Unset), - comment: Some(DirectiveValueUpdate::Set("note".into())), - id_prefix: None, - } - }) - ); - } - - #[test] - fn collect_from_source_should_handle_crlf_block_comments() { - let directives = collect_lingui_directives_from_source( - "/* lingui-set context=\"ctx\" */\r\nconst msg = t`Hello`;\r\n", - BytePos(10), - ); - - assert_eq!( - directives, - vec![DirectiveEntry { - pos: BytePos(10), - values: DirectiveValues { - context: Some("ctx".into()), - comment: None, - id_prefix: None, - }, - }] - ); - } - - #[test] - fn collect_from_source_should_ignore_template_text_that_looks_like_comment() { - let directives = collect_lingui_directives_from_source( - "const msg = `\n// lingui-set context=\"ctx\"\n`;\n", - BytePos(10), - ); - - assert_eq!(directives, vec![]); - } - - #[test] - fn collect_should_merge_and_reset_directives() { - let directives = collect_lingui_directives_from_source( - r#" - // lingui-set context="ctx1" - // not a directive - // lingui-set comment="cmt" - // lingui-reset context="ctx2" - "#, - BytePos(10), - ); - - assert_eq!( - directives, - vec![ - DirectiveEntry { - pos: BytePos(17), - values: DirectiveValues { - context: Some("ctx1".into()), - comment: None, - id_prefix: None, - }, - }, - DirectiveEntry { - pos: BytePos(77), - values: DirectiveValues { - context: Some("ctx1".into()), - comment: Some("cmt".into()), - id_prefix: None, - }, - }, - DirectiveEntry { - pos: BytePos(111), - values: DirectiveValues { - context: Some("ctx2".into()), - comment: None, - id_prefix: None, - }, - }, - ] - ); - } - - #[test] - fn find_should_return_closest_preceding_directive() { - let directives = vec![ - DirectiveEntry { - pos: BytePos(3), - values: DirectiveValues { - context: Some("first".into()), - ..Default::default() - }, - }, - DirectiveEntry { - pos: BytePos(10), - values: DirectiveValues { - context: Some("second".into()), - ..Default::default() - }, - }, - DirectiveEntry { - pos: BytePos(20), - values: DirectiveValues { - comment: Some("third".into()), - ..Default::default() - }, - }, - ]; - - assert_eq!(find_directive_for_pos(&directives, BytePos(1)), None); - assert_eq!( - find_directive_for_pos(&directives, BytePos(7)), - Some(&DirectiveValues { - context: Some("first".into()), - ..Default::default() - }) - ); - assert_eq!( - find_directive_for_pos(&directives, BytePos(15)), - Some(&DirectiveValues { - context: Some("second".into()), - ..Default::default() - }) - ); - } -} +mod tests; diff --git a/crates/lingui_macro/src/comment_directive/source_scanner.rs b/crates/lingui_macro/src/comment_directive/source_scanner.rs deleted file mode 100644 index 94bd03f..0000000 --- a/crates/lingui_macro/src/comment_directive/source_scanner.rs +++ /dev/null @@ -1,320 +0,0 @@ -pub struct SourceComment<'a> { - pub byte_offset: usize, - pub content: &'a str, - pub kind: CommentKind, -} - -pub enum CommentKind { - Line, - Block, -} - -pub fn scan_source_comments(source: &str) -> Vec> { - let bytes = source.as_bytes(); - let mut comments = Vec::new(); - let mut index = 0usize; - - while index < bytes.len() { - match bytes[index] { - b'\'' | b'"' => { - index = skip_string_literal(bytes, index); - } - b'`' => { - index = skip_template_literal(bytes, index); - } - b'/' if bytes.get(index + 1) == Some(&b'/') => { - let comment_start = index; - let content_start = index + 2; - index = content_start; - - while index < bytes.len() && bytes[index] != b'\n' { - index += 1; - } - - comments.push(SourceComment { - byte_offset: comment_start, - content: &source[content_start..index], - kind: CommentKind::Line, - }); - } - b'/' if bytes.get(index + 1) == Some(&b'*') => { - let comment_start = index; - let content_start = index + 2; - index = content_start; - - while index < bytes.len() { - if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') { - break; - } - index += 1; - } - - let content_end = index; - if index < bytes.len() { - index += 2; - } - - comments.push(SourceComment { - byte_offset: comment_start, - content: &source[content_start..content_end], - kind: CommentKind::Block, - }); - } - _ => { - index += 1; - } - } - } - - comments -} - -fn skip_string_literal(bytes: &[u8], start: usize) -> usize { - let delim = bytes[start]; - let mut i = start + 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i = (i + 2).min(bytes.len()); - } else if bytes[i] == delim { - return i + 1; - } else { - i += 1; - } - } - i -} - -fn skip_template_literal(bytes: &[u8], start: usize) -> usize { - let mut i = start + 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i = (i + 2).min(bytes.len()); - } else if bytes[i] == b'`' { - return i + 1; - } else { - i += 1; - } - } - i -} - -#[cfg(test)] -mod tests { - use super::*; - - fn line_comments(source: &str) -> Vec<(usize, &str)> { - scan_source_comments(source) - .into_iter() - .filter(|c| matches!(c.kind, CommentKind::Line)) - .map(|c| (c.byte_offset, c.content)) - .collect() - } - - fn block_comments(source: &str) -> Vec<(usize, &str)> { - scan_source_comments(source) - .into_iter() - .filter(|c| matches!(c.kind, CommentKind::Block)) - .map(|c| (c.byte_offset, c.content)) - .collect() - } - - fn all_comments(source: &str) -> Vec<(usize, &str)> { - scan_source_comments(source) - .into_iter() - .map(|c| (c.byte_offset, c.content)) - .collect() - } - - #[test] - fn empty_source() { - assert_eq!(all_comments(""), Vec::<(usize, &str)>::new()); - } - - #[test] - fn no_comments() { - assert_eq!(all_comments("const x = 1;\nlet y = 2;"), vec![]); - } - - #[test] - fn single_line_comment() { - assert_eq!(line_comments("// hello world"), vec![(0, " hello world")]); - } - - #[test] - fn line_comment_after_code() { - assert_eq!( - line_comments("const x = 1; // inline"), - vec![(13, " inline")] - ); - } - - #[test] - fn multiple_line_comments() { - let source = "// first\n// second\ncode\n// third"; - assert_eq!( - line_comments(source), - vec![(0, " first"), (9, " second"), (24, " third")] - ); - } - - #[test] - fn single_block_comment() { - assert_eq!(block_comments("/* block */"), vec![(0, " block ")]); - } - - #[test] - fn multiline_block_comment() { - let source = "/* line1\n line2 */"; - assert_eq!(block_comments(source), vec![(0, " line1\n line2 ")]); - } - - #[test] - fn block_comment_after_code() { - assert_eq!( - block_comments("x = 1; /* note */ y = 2;"), - vec![(7, " note ")] - ); - } - - #[test] - fn ignores_comment_syntax_in_single_quoted_string() { - assert_eq!(all_comments("const x = '// not a comment';"), vec![]); - assert_eq!(all_comments("const x = '/* not a comment */';"), vec![]); - } - - #[test] - fn ignores_comment_syntax_in_double_quoted_string() { - assert_eq!(all_comments(r#"const x = "// not a comment";"#), vec![]); - assert_eq!(all_comments(r#"const x = "/* not a comment */";"#), vec![]); - } - - #[test] - fn ignores_comment_syntax_in_template_literal() { - assert_eq!(all_comments("const x = `// not a comment`;"), vec![]); - assert_eq!(all_comments("const x = `/* not a comment */`;"), vec![]); - } - - #[test] - fn handles_escaped_quotes_in_single_quoted_string() { - assert_eq!( - all_comments(r"const x = 'it\'s'; // after"), - vec![(19, " after")] - ); - } - - #[test] - fn handles_escaped_quotes_in_double_quoted_string() { - assert_eq!( - all_comments(r#"const x = "say \"hi\""; // after"#), - vec![(24, " after")] - ); - } - - #[test] - fn handles_escaped_backtick_in_template_literal() { - assert_eq!( - all_comments(r"const x = `\`template\``; // after"), - vec![(26, " after")] - ); - } - - #[test] - fn handles_backslash_at_end_of_string() { - // String ending with escape at EOF (unterminated) - assert_eq!(all_comments(r"const x = '\"), vec![]); - } - - #[test] - fn handles_backslash_at_end_of_template() { - assert_eq!(all_comments("const x = `\\"), vec![]); - } - - #[test] - fn unterminated_single_quoted_string() { - // No closing quote — scanner shouldn't panic - assert_eq!(all_comments("const x = 'unterminated // nope"), vec![]); - } - - #[test] - fn unterminated_double_quoted_string() { - assert_eq!(all_comments(r#"const x = "unterminated // nope"#), vec![]); - } - - #[test] - fn unterminated_template_literal() { - assert_eq!(all_comments("const x = `unterminated // nope"), vec![]); - } - - #[test] - fn unterminated_block_comment() { - // Block comment that never closes — content runs to end - assert_eq!( - block_comments("/* never closed"), - vec![(0, " never closed")] - ); - } - - #[test] - fn mixed_comment_types() { - let source = "// line\n/* block */\ncode // inline"; - let comments = all_comments(source); - assert_eq!( - comments, - vec![(0, " line"), (8, " block "), (25, " inline")] - ); - } - - #[test] - fn slash_not_followed_by_slash_or_star() { - // Division operator should not be mistaken for comment - assert_eq!(all_comments("const x = 10 / 2;"), vec![]); - } - - #[test] - fn empty_line_comment() { - assert_eq!(line_comments("//\ncode"), vec![(0, "")]); - } - - #[test] - fn empty_block_comment() { - assert_eq!(block_comments("/**/"), vec![(0, "")]); - } - - #[test] - fn block_comment_with_star_inside() { - assert_eq!(block_comments("/* a * b */"), vec![(0, " a * b ")]); - } - - #[test] - fn consecutive_block_comments() { - // "/* a */" = 7 bytes, so second comment starts at offset 7 - assert_eq!( - block_comments("/* a *//* b */"), - vec![(0, " a "), (7, " b ")] - ); - } - - #[test] - fn line_comment_at_eof_without_newline() { - assert_eq!(line_comments("// eof"), vec![(0, " eof")]); - } - - #[test] - fn comment_after_template_literal_with_expressions() { - // Template with ${} — the simplified scanner treats it as text until closing backtick - let source = "const x = `hello ${world}`; // after"; - assert_eq!(line_comments(source), vec![(28, " after")]); - } - - #[test] - fn multiline_template_literal_with_comment_like_content() { - let source = "const x = `\n// fake\n/* also fake */\n`;\n// real"; - assert_eq!(line_comments(source), vec![(39, " real")]); - } - - #[test] - fn string_containing_backslash_n_is_not_newline() { - // The literal text \n in a string (escaped), not a real newline - assert_eq!(all_comments("const x = '\\n'; // yes"), vec![(16, " yes")]); - } -} diff --git a/crates/lingui_macro/src/comment_directive/tests.rs b/crates/lingui_macro/src/comment_directive/tests.rs new file mode 100644 index 0000000..0d05ac0 --- /dev/null +++ b/crates/lingui_macro/src/comment_directive/tests.rs @@ -0,0 +1,405 @@ +use super::*; + +// --------------------------------------------------------------------------- +// locate_directives — the substring scanner +// --------------------------------------------------------------------------- + +/// Compact view of a located directive for assertions: (reset, params). +fn located(source: &str) -> Vec<(bool, &str)> { + locate_directives(source) + .into_iter() + .map(|d| (d.reset, d.params)) + .collect() +} + +#[test] +fn locate_line_comment_directive() { + assert_eq!( + located("// lingui-set context=\"a\"\ncode"), + vec![(false, " context=\"a\"")] + ); +} + +#[test] +fn locate_line_comment_at_eof_without_newline() { + assert_eq!( + located("// lingui-set context=\"a\""), + vec![(false, " context=\"a\"")] + ); +} + +#[test] +fn locate_block_comment_directive() { + assert_eq!( + located("/* lingui-set context=\"a\" */"), + vec![(false, " context=\"a\" ")] + ); +} + +#[test] +fn locate_jsdoc_block_comment_directive() { + assert_eq!( + located("/** lingui-set context=\"a\" */"), + vec![(false, " context=\"a\" ")] + ); +} + +#[test] +fn locate_block_comment_with_trailing_code_on_same_line() { + // This is the case the regex scanner broke on: the directive must end at + // `*/`, not swallow the rest of the line. + assert_eq!( + located("/* lingui-set context=\"a\" */ const x = 1;"), + vec![(false, " context=\"a\" ")] + ); +} + +#[test] +fn locate_jsx_expression_container_block_comment() { + // `{/* lingui-reset */}` — the only valid block-comment form inside JSX. + assert_eq!( + located("
{/* lingui-reset */}Hi
"), + vec![(true, " ")] + ); +} + +#[test] +fn locate_jsx_expression_container_set_directive() { + assert_eq!( + located("
{/* lingui-set context=\"x\" */}Hi
"), + vec![(false, " context=\"x\" ")] + ); +} + +#[test] +fn locate_rejects_directive_on_line_after_block_opener() { + // The directive must be on the opener's line; a newline between `/*` and + // the keyword means it is not recognised. + assert_eq!( + located("/*\n lingui-set context=\"a\"\n*/"), + Vec::<(bool, &str)>::new() + ); +} + +#[test] +fn locate_reset_directive() { + assert_eq!(located("// lingui-reset\ncode"), vec![(true, "")]); +} + +#[test] +fn locate_unterminated_block_comment_runs_to_eof() { + assert_eq!( + located("/* lingui-set context=\"a\""), + vec![(false, " context=\"a\"")] + ); +} + +#[test] +fn locate_word_boundary_rejects_longer_identifier() { + // `lingui-settings` / `lingui-resetter` are not directives. + assert_eq!( + located("// lingui-settings here"), + Vec::<(bool, &str)>::new() + ); + assert_eq!( + located("// lingui-resetter here"), + Vec::<(bool, &str)>::new() + ); +} + +#[test] +fn locate_requires_a_comment_opener() { + // A bare `lingui-set` not introduced by a comment is ignored. + assert_eq!( + located("const lingui_set = 1; lingui-set"), + Vec::<(bool, &str)>::new() + ); +} + +#[test] +fn locate_multiple_directives_in_order() { + assert_eq!( + located( + "// lingui-set context=\"a\"\ncode\n/* lingui-reset */\n// lingui-set comment=\"c\"" + ), + vec![ + (false, " context=\"a\""), + (true, " "), + (false, " comment=\"c\"") + ] + ); +} + +#[test] +fn locate_records_opener_start_offset() { + // Opener offset points at the `/` of the introducing comment, even when + // preceded by code (e.g. a JSX expression container). + let dirs = locate_directives("
{/* lingui-reset */}
"); + assert_eq!(dirs.len(), 1); + assert_eq!(dirs[0].opener_start, 6); // position of `/*` +} + +#[test] +fn locate_ignores_division_and_plain_comments() { + assert_eq!( + located("const x = 10 / 2; // just a note"), + Vec::<(bool, &str)>::new() + ); +} + +#[test] +fn locate_block_comment_directive_without_space_before_terminator() { + assert_eq!( + located("/* lingui-set context=\"a\"*/"), + vec![(false, " context=\"a\"")] + ); +} + +// --------------------------------------------------------------------------- +// block_comment_close +// --------------------------------------------------------------------------- + +#[test] +fn block_close_finds_terminator() { + assert_eq!(block_comment_close(b" x */", 0), Some(3)); +} + +#[test] +fn block_close_stops_at_first_terminator() { + // No string awareness: the first `*/` wins, matching JS comment lexing. + assert_eq!(block_comment_close(b" \"a */ b\" */", 0), Some(4)); +} + +#[test] +fn block_close_unterminated_is_none() { + assert_eq!(block_comment_close(b" no terminator", 0), None); +} + +// --------------------------------------------------------------------------- +// parse_lingui_directive +// --------------------------------------------------------------------------- + +#[test] +fn parse_should_parse_multiple_keys() { + let parsed = + parse_lingui_directive(false, r#" context="ctx" comment="cmt" idPrefix="p." "#).unwrap(); + + assert_eq!( + parsed, + ParsedDirective { + reset: false, + values: DirectiveUpdate { + context: Some(DirectiveValueUpdate::Set("ctx".into())), + comment: Some(DirectiveValueUpdate::Set("cmt".into())), + id_prefix: Some(DirectiveValueUpdate::Set("p.".into())), + } + } + ); +} + +#[test] +fn parse_should_accept_bare_reset() { + let parsed = parse_lingui_directive(true, "").unwrap(); + assert_eq!( + parsed, + ParsedDirective { + reset: true, + values: DirectiveUpdate::default(), + } + ); +} + +#[test] +fn parse_reset_may_carry_new_values() { + let parsed = parse_lingui_directive(true, r#" context="fresh" "#).unwrap(); + assert_eq!( + parsed, + ParsedDirective { + reset: true, + values: DirectiveUpdate { + context: Some(DirectiveValueUpdate::Set("fresh".into())), + ..Default::default() + } + } + ); +} + +#[test] +fn parse_should_reject_invalid_syntax() { + let error = parse_lingui_directive(false, " context=single ") + .expect_err("expected parser to reject invalid syntax"); + assert!(error.contains("requires a value")); +} + +#[test] +fn parse_should_reject_unknown_params() { + let error = parse_lingui_directive(false, r#" unknown="value" "#) + .expect_err("expected parser to reject unknown params"); + assert!(error.contains("unknown param \"unknown\"")); +} + +#[test] +fn parse_should_reject_set_without_params() { + let error = parse_lingui_directive(false, " ") + .expect_err("expected parser to reject set with no params"); + assert!(error.contains("requires at least one param")); +} + +#[test] +fn parse_should_treat_empty_strings_as_unset() { + let parsed = parse_lingui_directive(false, r#" context="" comment="note" "#).unwrap(); + assert_eq!( + parsed, + ParsedDirective { + reset: false, + values: DirectiveUpdate { + context: Some(DirectiveValueUpdate::Unset), + comment: Some(DirectiveValueUpdate::Set("note".into())), + id_prefix: None, + } + } + ); +} + +// --------------------------------------------------------------------------- +// collect_lingui_directives_from_source — accumulation + positions +// --------------------------------------------------------------------------- + +#[test] +fn collect_returns_empty_when_no_directive_substring() { + assert_eq!( + collect_lingui_directives_from_source("const x = 1; // hi", BytePos(1)), + vec![] + ); +} + +#[test] +fn collect_from_source_should_handle_crlf_block_comments() { + let directives = collect_lingui_directives_from_source( + "/* lingui-set context=\"ctx\" */\r\nconst msg = t`Hello`;\r\n", + BytePos(10), + ); + + assert_eq!( + directives, + vec![DirectiveEntry { + pos: BytePos(10), + values: DirectiveValues { + context: Some("ctx".into()), + comment: None, + id_prefix: None, + }, + }] + ); +} + +#[test] +fn collect_block_comment_with_trailing_code_parses_cleanly() { + // Regression for the regex scanner: trailing code after `*/` must not leak + // into the directive parameters and cause a syntax error. + let directives = collect_lingui_directives_from_source( + "{/* lingui-set context=\"a\" */}\nHi", + BytePos(1), + ); + assert_eq!(directives.len(), 1); + assert_eq!(directives[0].values.context.as_deref(), Some("a")); +} + +#[test] +fn collect_should_merge_and_reset_directives() { + let directives = collect_lingui_directives_from_source( + r#" + // lingui-set context="ctx1" + // not a directive + // lingui-set comment="cmt" + // lingui-reset context="ctx2" + "#, + BytePos(10), + ); + + let values: Vec<_> = directives.iter().map(|d| d.values.clone()).collect(); + assert_eq!( + values, + vec![ + DirectiveValues { + context: Some("ctx1".into()), + comment: None, + id_prefix: None, + }, + DirectiveValues { + context: Some("ctx1".into()), + comment: Some("cmt".into()), + id_prefix: None, + }, + DirectiveValues { + context: Some("ctx2".into()), + comment: None, + id_prefix: None, + }, + ] + ); + // Positions must be strictly increasing (binary search in find_for_pos + // relies on this). + assert!(directives.windows(2).all(|w| w[0].pos < w[1].pos)); +} + +#[test] +fn collect_reset_then_set_accumulates_from_reset() { + let directives = collect_lingui_directives_from_source( + "/* lingui-set context=\"a\" comment=\"c\" */\n/* lingui-reset */\n/* lingui-set context=\"b\" */", + BytePos(1), + ); + let last = &directives.last().unwrap().values; + assert_eq!(last.context.as_deref(), Some("b")); + assert_eq!( + last.comment, None, + "reset must have cleared the inherited comment" + ); +} + +// --------------------------------------------------------------------------- +// find_directive_for_pos +// --------------------------------------------------------------------------- + +#[test] +fn find_should_return_closest_preceding_directive() { + let directives = vec![ + DirectiveEntry { + pos: BytePos(3), + values: DirectiveValues { + context: Some("first".into()), + ..Default::default() + }, + }, + DirectiveEntry { + pos: BytePos(10), + values: DirectiveValues { + context: Some("second".into()), + ..Default::default() + }, + }, + DirectiveEntry { + pos: BytePos(20), + values: DirectiveValues { + comment: Some("third".into()), + ..Default::default() + }, + }, + ]; + + assert_eq!(find_directive_for_pos(&directives, BytePos(1)), None); + assert_eq!( + find_directive_for_pos(&directives, BytePos(7)), + Some(&DirectiveValues { + context: Some("first".into()), + ..Default::default() + }) + ); + assert_eq!( + find_directive_for_pos(&directives, BytePos(15)), + Some(&DirectiveValues { + context: Some("second".into()), + ..Default::default() + }) + ); +} diff --git a/crates/lingui_macro/tests/lingui_directive.rs b/crates/lingui_macro/tests/lingui_directive.rs index 6865119..98d5d4e 100644 --- a/crates/lingui_macro/tests/lingui_directive.rs +++ b/crates/lingui_macro/tests/lingui_directive.rs @@ -253,6 +253,29 @@ to!( "# ); +to!( + jsx_trans_with_unclosed_quotes, + LinguiOptions { + id_prefix_leader: Some(".".into()), + ..Default::default() + }, + r#" + // lingui-set idPrefix="root" + import type { MessageDescriptor } from '@lingui/core' + import { msg, t } from '@lingui/core/macro' + import { Trans } from '@lingui/react/macro' + + const X = () =>

'

+ const Y = () =>

`

+ + // lingui-set idPrefix="different" + const different = { + a: msg({ id: '.a', message: `different a` }), + b: msg({ id: '.b', message: `different b` }), + } as const satisfies Record + "# +); + to!( jsx_trans_with_directive_context, r#" @@ -416,3 +439,51 @@ function App() { } "# ); + +// --- block-comment directives inside JSX (regressions vs. the regex scanner) --- + +to!( + jsx_trans_with_block_comment_reset_in_expression_container, + r#" + import { Trans } from "@lingui/react/macro"; + /* lingui-set context="header" */ + const a = Title; + const el = ( +
+ {/* lingui-reset */} + Body +
+ ); + "# +); + +to!( + jsx_trans_with_block_comment_set_in_expression_container, + r#" + import { Trans } from "@lingui/react/macro"; + const el = ( +
+ {/* lingui-set context="section" */} + Title +
+ ); + "# +); + +to!( + jsx_block_comment_directive_with_trailing_code, + r#" + import { Trans } from "@lingui/react/macro"; + /* lingui-set context="ctx" */ const noop = 1; + const el = Hello; + "# +); + +to!( + jsdoc_block_comment_directive, + r#" + import { t } from '@lingui/core/macro'; + /** lingui-set context="jsdoc" */ + const msg = t`Hello` + "# +); diff --git a/crates/lingui_macro/tests/snapshots/lingui_directive__jsdoc_block_comment_directive.snap b/crates/lingui_macro/tests/snapshots/lingui_directive__jsdoc_block_comment_directive.snap new file mode 100644 index 0000000..1c62e42 --- /dev/null +++ b/crates/lingui_macro/tests/snapshots/lingui_directive__jsdoc_block_comment_directive.snap @@ -0,0 +1,15 @@ +--- +source: crates/lingui_macro/tests/lingui_directive.rs +--- +import { t } from '@lingui/core/macro'; +/** lingui-set context="jsdoc" */ +const msg = t`Hello` + +↓ ↓ ↓ ↓ ↓ ↓ + +import { i18n as $_i18n } from "@lingui/core"; +/** lingui-set context="jsdoc" */ const msg = $_i18n._(/*i18n*/ { + id: "AXmcdV", + message: "Hello", + context: "jsdoc" +}); diff --git a/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_block_comment_directive_with_trailing_code.snap b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_block_comment_directive_with_trailing_code.snap new file mode 100644 index 0000000..c0a2cf9 --- /dev/null +++ b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_block_comment_directive_with_trailing_code.snap @@ -0,0 +1,16 @@ +--- +source: crates/lingui_macro/tests/lingui_directive.rs +--- +import { Trans } from "@lingui/react/macro"; +/* lingui-set context="ctx" */ const noop = 1; +const el = Hello; + +↓ ↓ ↓ ↓ ↓ ↓ + +import { Trans as Trans_ } from "@lingui/react"; +/* lingui-set context="ctx" */ const noop = 1; +const el = ; diff --git a/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_reset_in_expression_container.snap b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_reset_in_expression_container.snap new file mode 100644 index 0000000..6f0c84e --- /dev/null +++ b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_reset_in_expression_container.snap @@ -0,0 +1,28 @@ +--- +source: crates/lingui_macro/tests/lingui_directive.rs +--- +import { Trans } from "@lingui/react/macro"; +/* lingui-set context="header" */ +const a = Title; +const el = ( +
+ {/* lingui-reset */} + Body +
+); + +↓ ↓ ↓ ↓ ↓ ↓ + +import { Trans as Trans_ } from "@lingui/react"; +/* lingui-set context="header" */ const a = ; +const el =
+ { /* lingui-reset */ } + +
; diff --git a/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_set_in_expression_container.snap b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_set_in_expression_container.snap new file mode 100644 index 0000000..df737ea --- /dev/null +++ b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_block_comment_set_in_expression_container.snap @@ -0,0 +1,22 @@ +--- +source: crates/lingui_macro/tests/lingui_directive.rs +--- +import { Trans } from "@lingui/react/macro"; +const el = ( +
+ {/* lingui-set context="section" */} + Title +
+); + +↓ ↓ ↓ ↓ ↓ ↓ + +import { Trans as Trans_ } from "@lingui/react"; +const el =
+ { /* lingui-set context="section" */ } + +
; diff --git a/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_unclosed_quotes.snap b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_unclosed_quotes.snap new file mode 100644 index 0000000..47d14ec --- /dev/null +++ b/crates/lingui_macro/tests/snapshots/lingui_directive__jsx_trans_with_unclosed_quotes.snap @@ -0,0 +1,36 @@ +--- +source: crates/lingui_macro/tests/lingui_directive.rs +info: + id_prefix_leader: "." +--- +// lingui-set idPrefix="root" +import type { MessageDescriptor } from '@lingui/core' +import { msg, t } from '@lingui/core/macro' +import { Trans } from '@lingui/react/macro' + +const X = () =>

'

+const Y = () =>

`

+ +// lingui-set idPrefix="different" +const different = { + a: msg({ id: '.a', message: `different a` }), + b: msg({ id: '.b', message: `different b` }), +} as const satisfies Record + +↓ ↓ ↓ ↓ ↓ ↓ + +// lingui-set idPrefix="root" +import type { MessageDescriptor } from '@lingui/core'; +const X = ()=>

'

; +const Y = ()=>

`

; +// lingui-set idPrefix="different" +const different = { + a: /*i18n*/ { + id: "different.a", + message: "different a" + }, + b: /*i18n*/ { + id: "different.b", + message: "different b" + } +} as const satisfies Record; From 9bd5e31eb9410eade96eee36d09d1bc37c691177 Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Tue, 23 Jun 2026 09:21:28 +0200 Subject: [PATCH 2/3] chore: rename LocatedDirective.opener_start to .comment_start --- crates/lingui_macro/src/comment_directive/mod.rs | 8 ++++---- crates/lingui_macro/src/comment_directive/tests.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/lingui_macro/src/comment_directive/mod.rs b/crates/lingui_macro/src/comment_directive/mod.rs index 1a8267f..f9f4c61 100644 --- a/crates/lingui_macro/src/comment_directive/mod.rs +++ b/crates/lingui_macro/src/comment_directive/mod.rs @@ -94,7 +94,7 @@ enum CommentKind { #[derive(Debug, PartialEq, Eq)] struct LocatedDirective<'a> { /// Byte offset of the comment opener (`//`, `/*` or `/**`) introducing it. - opener_start: usize, + comment_start: usize, /// Byte offset just past the comment (after `*/`, the newline, or EOF). comment_end: usize, reset: bool, @@ -133,7 +133,7 @@ fn locate_directives(source: &str) -> Vec> { } } - let Some((opener_start, kind)) = find_comment_opener(source, keyword) else { + let Some((comment_start, kind)) = find_comment_opener(source, keyword) else { continue; }; @@ -149,7 +149,7 @@ fn locate_directives(source: &str) -> Vec> { }; out.push(LocatedDirective { - opener_start, + comment_start, comment_end, reset, params, @@ -293,7 +293,7 @@ fn collect_lingui_directives_from_source(source: &str, start_pos: BytePos) -> Ve let mut accumulated = DirectiveValues::default(); for located in locate_directives(source) { - let comment_start = BytePos(start_pos.0 + located.opener_start as u32); + let comment_start = BytePos(start_pos.0 + located.comment_start as u32); match parse_lingui_directive(located.reset, located.params) { Ok(parsed) => { diff --git a/crates/lingui_macro/src/comment_directive/tests.rs b/crates/lingui_macro/src/comment_directive/tests.rs index 0d05ac0..3b63f23 100644 --- a/crates/lingui_macro/src/comment_directive/tests.rs +++ b/crates/lingui_macro/src/comment_directive/tests.rs @@ -131,12 +131,12 @@ fn locate_multiple_directives_in_order() { } #[test] -fn locate_records_opener_start_offset() { - // Opener offset points at the `/` of the introducing comment, even when - // preceded by code (e.g. a JSX expression container). +fn locate_records_comment_start_offset() { + // Comment start offset points at the `/` of the introducing comment, even + // when preceded by code (e.g. a JSX expression container). let dirs = locate_directives("
{/* lingui-reset */}
"); assert_eq!(dirs.len(), 1); - assert_eq!(dirs[0].opener_start, 6); // position of `/*` + assert_eq!(dirs[0].comment_start, 6); // position of `/*` } #[test] From ae36894a760d023687c21ce39796b53b1cf31b03 Mon Sep 17 00:00:00 2001 From: Victor Hallberg Date: Tue, 23 Jun 2026 09:56:38 +0200 Subject: [PATCH 3/3] test: increase test coverage --- .../src/comment_directive/tests.rs | 51 ++++++++++++++++--- crates/lingui_macro/tests/lingui_directive.rs | 12 +++++ ..._invalid_directive_reports_diagnostic.snap | 15 ++++++ 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 crates/lingui_macro/tests/snapshots/lingui_directive__invalid_directive_reports_diagnostic.snap diff --git a/crates/lingui_macro/src/comment_directive/tests.rs b/crates/lingui_macro/src/comment_directive/tests.rs index 3b63f23..8fc8154 100644 --- a/crates/lingui_macro/src/comment_directive/tests.rs +++ b/crates/lingui_macro/src/comment_directive/tests.rs @@ -131,12 +131,24 @@ fn locate_multiple_directives_in_order() { } #[test] -fn locate_records_comment_start_offset() { - // Comment start offset points at the `/` of the introducing comment, even - // when preceded by code (e.g. a JSX expression container). - let dirs = locate_directives("
{/* lingui-reset */}
"); - assert_eq!(dirs.len(), 1); - assert_eq!(dirs[0].comment_start, 6); // position of `/*` +fn locate_reports_full_span_and_fields() { + // Comment start points at the `/` of the introducing comment (even when + // preceded by code), and comment end points just past the closing `*/`. + assert_eq!( + locate_directives("
{/* lingui-reset */}
"), + vec![LocatedDirective { + comment_start: 6, // the `/*` + comment_end: 24, // just past `*/` + reset: true, + params: " ", + }] + ); +} + +#[test] +fn locate_ignores_lingui_prefix_not_followed_by_a_directive() { + // `lingui-` followed by neither `set` nor `reset` is not a directive. + assert_eq!(located("// lingui-format here"), Vec::<(bool, &str)>::new()); } #[test] @@ -261,6 +273,28 @@ fn parse_should_treat_empty_strings_as_unset() { ); } +#[test] +fn parse_should_reject_missing_equals() { + let error = parse_lingui_directive(false, "context") + .expect_err("expected parser to reject a key with no `=value`"); + assert!(error.contains("requires a value")); +} + +#[test] +fn parse_should_reject_empty_key() { + // A param position that does not begin with a word char yields no key. + let error = parse_lingui_directive(false, "=\"x\"") + .expect_err("expected parser to reject a missing key"); + assert!(error.contains("invalid syntax")); +} + +#[test] +fn parse_should_reject_unterminated_value() { + let error = parse_lingui_directive(false, "context=\"unterminated") + .expect_err("expected parser to reject an unterminated quoted value"); + assert!(error.contains("invalid syntax")); +} + // --------------------------------------------------------------------------- // collect_lingui_directives_from_source — accumulation + positions // --------------------------------------------------------------------------- @@ -361,6 +395,11 @@ fn collect_reset_then_set_accumulates_from_reset() { // find_directive_for_pos // --------------------------------------------------------------------------- +#[test] +fn find_returns_none_for_empty_directives() { + assert_eq!(find_directive_for_pos(&[], BytePos(5)), None); +} + #[test] fn find_should_return_closest_preceding_directive() { let directives = vec![ diff --git a/crates/lingui_macro/tests/lingui_directive.rs b/crates/lingui_macro/tests/lingui_directive.rs index 98d5d4e..6fbe826 100644 --- a/crates/lingui_macro/tests/lingui_directive.rs +++ b/crates/lingui_macro/tests/lingui_directive.rs @@ -487,3 +487,15 @@ to!( const msg = t`Hello` "# ); + +// An invalid directive is reported as a diagnostic (exercises the error branch +// of the directive collector) rather than silently ignored. +to_panic!( + invalid_directive_reports_diagnostic, + Default::default(), + r#" + import { t } from '@lingui/core/macro'; + /* lingui-set context */ + const msg = t`Hello` + "# +); diff --git a/crates/lingui_macro/tests/snapshots/lingui_directive__invalid_directive_reports_diagnostic.snap b/crates/lingui_macro/tests/snapshots/lingui_directive__invalid_directive_reports_diagnostic.snap new file mode 100644 index 0000000..0d42a49 --- /dev/null +++ b/crates/lingui_macro/tests/snapshots/lingui_directive__invalid_directive_reports_diagnostic.snap @@ -0,0 +1,15 @@ +--- +source: crates/lingui_macro/tests/lingui_directive.rs +info: {} +--- +import { t } from '@lingui/core/macro'; +/* lingui-set context */ +const msg = t`Hello` + +↓ ↓ ↓ ↓ ↓ ↓ + +error: `lingui-set` directive: "context" requires a value, e.g. context="..." + --> input.tsx:2:1 + | +2 | /* lingui-set context */ + | ^^^^^^^^^^^^^^^^^^^^^^^^