Skip to content

Commit f76666b

Browse files
sayrerclaude
andcommitted
Fix multiple roast items: autolinker, validator, C++ bindings, conformance.
- char_at_utf16_offset returns Option<char> instead of panicking - Add title attribute to URL entities with expanded_url - Implement JSON entity autolink conformance tests (was TODO) - Add Validator::with_config_and_parser_backend constructor - C++ configurationFromPath/Json return unique_ptr instead of raw pointer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eaf8b1e commit f76666b

5 files changed

Lines changed: 113 additions & 29 deletions

File tree

rust/conformance/tests/autolink.rs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use serde_derive::{Deserialize, Serialize};
66
use twitter_text::autolinker::Autolinker;
7+
use twitter_text::entity::{Entity, Type};
78
use twitter_text::ParserBackend;
89

910
/// Returns all ParserBackend variants for testing both backends.
@@ -30,7 +31,6 @@ pub struct JsonAssertion {
3031
pub expected: String,
3132
}
3233

33-
// TODO: json
3434
#[derive(Debug, PartialEq, Serialize, Deserialize)]
3535
pub struct Tests {
3636
pub usernames: Vec<Assertion>,
@@ -90,4 +90,80 @@ fn autolink() {
9090
assert_eq!(text, assertion.expected, "{}", assertion.description);
9191
}
9292
} // end for parser_backend
93+
94+
// JSON entity tests: entities are provided as pre-parsed JSON (Twitter API format)
95+
let manifest: Manifest = serde_yaml_ng::from_str(MANIFEST_YML).expect("Error parsing yaml");
96+
for assertion in manifest.tests.json {
97+
let entities_json: JsonEntities =
98+
serde_json::from_str(&assertion.json).expect("Error parsing entity JSON");
99+
let mut entities = Vec::new();
100+
for m in &entities_json.user_mentions {
101+
entities.push(Entity {
102+
t: Type::MENTION,
103+
start: m.indices[0],
104+
end: m.indices[1],
105+
value: &m.screen_name,
106+
list_slug: "",
107+
display_url: "",
108+
expanded_url: "",
109+
});
110+
}
111+
for h in &entities_json.hashtags {
112+
entities.push(Entity {
113+
t: Type::HASHTAG,
114+
start: h.indices[0],
115+
end: h.indices[1],
116+
value: &h.text,
117+
list_slug: "",
118+
display_url: "",
119+
expanded_url: "",
120+
});
121+
}
122+
for u in &entities_json.urls {
123+
entities.push(Entity {
124+
t: Type::URL,
125+
start: u.indices[0],
126+
end: u.indices[1],
127+
value: &u.url,
128+
list_slug: "",
129+
display_url: u.display_url.as_deref().unwrap_or(""),
130+
expanded_url: u.expanded_url.as_deref().unwrap_or(""),
131+
});
132+
}
133+
entities.sort_by_key(|e| e.start);
134+
135+
let autolinker = Autolinker::new(false);
136+
let result = autolinker.autolink_entities(&assertion.text, &entities);
137+
assert_eq!(result, assertion.expected, "{}", assertion.description);
138+
}
139+
}
140+
141+
#[derive(Debug, Deserialize)]
142+
struct JsonEntities {
143+
#[serde(default)]
144+
hashtags: Vec<JsonHashtag>,
145+
#[serde(default)]
146+
urls: Vec<JsonUrl>,
147+
#[serde(default)]
148+
user_mentions: Vec<JsonMention>,
149+
}
150+
151+
#[derive(Debug, Deserialize)]
152+
struct JsonHashtag {
153+
text: String,
154+
indices: Vec<i32>,
155+
}
156+
157+
#[derive(Debug, Deserialize)]
158+
struct JsonUrl {
159+
url: String,
160+
expanded_url: Option<String>,
161+
display_url: Option<String>,
162+
indices: Vec<i32>,
163+
}
164+
165+
#[derive(Debug, Deserialize)]
166+
struct JsonMention {
167+
screen_name: String,
168+
indices: Vec<i32>,
93169
}

rust/cpp-bindings/config_test.cpp

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ TEST(TwitterTextConfigurationTest, Path) {
2727
ASSERT_EQ(wr.range.start, 0);
2828
ASSERT_EQ(wr.range.end, 4351);
2929
ASSERT_EQ(wr.weight, 200);
30-
delete config;
3130
}
3231

3332
TEST(TwitterTextConfigurationTest, Json) {
@@ -47,7 +46,6 @@ TEST(TwitterTextConfigurationTest, Json) {
4746
ASSERT_EQ(wr.range.start, 0);
4847
ASSERT_EQ(wr.range.end, 4351);
4948
ASSERT_EQ(wr.weight, 200);
50-
delete config;
5149
}
5250

5351
TEST(TwitterTextConfigurationTest, Version) {

rust/cpp-bindings/twitter.h

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ class TwitterTextConfiguration {
1212
config(config)
1313
{}
1414

15-
// TODO: these are fallible, so the return type should change
16-
static TwitterTextConfiguration* configurationFromPath(std::string path) {
17-
return new TwitterTextConfiguration(configuration_from_path(path));
15+
static std::unique_ptr<TwitterTextConfiguration> configurationFromPath(std::string path) {
16+
return std::make_unique<TwitterTextConfiguration>(configuration_from_path(path));
1817
}
1918

20-
static TwitterTextConfiguration* configurationFromJson(std::string json) {
21-
return new TwitterTextConfiguration(configuration_from_json(json));
19+
static std::unique_ptr<TwitterTextConfiguration> configurationFromJson(std::string json) {
20+
return std::make_unique<TwitterTextConfiguration>(configuration_from_json(json));
2221
}
2322

2423
int32_t getVersion() {

rust/twitter-text/src/autolinker.rs

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,15 @@ use crate::extractor::{Extract, Extractor, ParserBackend};
88
use std::borrow::Cow;
99

1010
/// Get the character at a given UTF-16 offset in a string.
11-
/// Panics if the offset is out of bounds or doesn't align with a character boundary.
12-
fn char_at_utf16_offset(text: &str, utf16_offset: i32) -> char {
11+
fn char_at_utf16_offset(text: &str, utf16_offset: i32) -> Option<char> {
1312
let mut current_utf16 = 0i32;
1413
for c in text.chars() {
1514
if current_utf16 == utf16_offset {
16-
return c;
15+
return Some(c);
1716
}
1817
current_utf16 += c.len_utf16() as i32;
1918
}
20-
panic!(
21-
"UTF-16 offset {} not found in text of length {} (UTF-16 length {})",
22-
utf16_offset,
23-
text.len(),
24-
current_utf16
25-
);
19+
None
2620
}
2721

2822
type Attributes = Vec<(String, String)>;
@@ -270,7 +264,7 @@ impl<'a> Autolinker<'a> {
270264
}
271265

272266
fn link_to_hashtag(&self, entity: &Entity, text: &str, buf: &mut String) {
273-
let hash_char = char_at_utf16_offset(text, entity.get_start());
267+
let hash_char = char_at_utf16_offset(text, entity.get_start()).unwrap_or('#');
274268
let hashtag = entity.get_value();
275269
let mut attrs: Attributes = Vec::new();
276270
attrs.push((HREF.to_string(), self.hashtag_url_base.to_owned() + hashtag));
@@ -297,7 +291,7 @@ impl<'a> Autolinker<'a> {
297291

298292
fn link_to_mention_and_list(&self, entity: &Entity, text: &str, buf: &mut String) {
299293
let mut mention = String::from(entity.get_value());
300-
let at_char = char_at_utf16_offset(text, entity.get_start());
294+
let at_char = char_at_utf16_offset(text, entity.get_start()).unwrap_or('@');
301295
let mut attrs: Attributes = Vec::new();
302296

303297
if entity.get_type() == entity::Type::MENTION && !entity.get_list_slug().is_empty() {
@@ -416,6 +410,9 @@ impl<'a> Autolinker<'a> {
416410

417411
let mut attrs: Attributes = Vec::new();
418412
attrs.push((HREF.to_string(), String::from(url)));
413+
if !entity.get_expanded_url().is_empty() {
414+
attrs.push((TITLE.to_string(), String::from(entity.get_expanded_url())));
415+
}
419416
if !self.url_class.is_empty() {
420417
attrs.push((CLASS.to_string(), String::from(self.url_class)));
421418
}
@@ -830,22 +827,23 @@ mod tests {
830827
#[test]
831828
fn test_char_at_utf16_offset() {
832829
// ASCII text - UTF-16 offset equals char index
833-
assert_eq!(char_at_utf16_offset("hello", 0), 'h');
834-
assert_eq!(char_at_utf16_offset("hello", 4), 'o');
830+
assert_eq!(char_at_utf16_offset("hello", 0), Some('h'));
831+
assert_eq!(char_at_utf16_offset("hello", 4), Some('o'));
832+
assert_eq!(char_at_utf16_offset("hello", 99), None);
835833

836834
// Emoji at start (🔥 is U+1F525, takes 2 UTF-16 code units)
837835
let text = "🔥hello";
838-
assert_eq!(char_at_utf16_offset(text, 0), '🔥');
839-
assert_eq!(char_at_utf16_offset(text, 2), 'h'); // After emoji (2 UTF-16 units)
840-
assert_eq!(char_at_utf16_offset(text, 3), 'e');
836+
assert_eq!(char_at_utf16_offset(text, 0), Some('🔥'));
837+
assert_eq!(char_at_utf16_offset(text, 2), Some('h')); // After emoji (2 UTF-16 units)
838+
assert_eq!(char_at_utf16_offset(text, 3), Some('e'));
841839

842840
// Multiple emoji
843841
let text = "🔥🔥🔥 @test";
844-
assert_eq!(char_at_utf16_offset(text, 0), '🔥');
845-
assert_eq!(char_at_utf16_offset(text, 2), '🔥');
846-
assert_eq!(char_at_utf16_offset(text, 4), '🔥');
847-
assert_eq!(char_at_utf16_offset(text, 6), ' ');
848-
assert_eq!(char_at_utf16_offset(text, 7), '@');
842+
assert_eq!(char_at_utf16_offset(text, 0), Some('🔥'));
843+
assert_eq!(char_at_utf16_offset(text, 2), Some('🔥'));
844+
assert_eq!(char_at_utf16_offset(text, 4), Some('🔥'));
845+
assert_eq!(char_at_utf16_offset(text, 6), Some(' '));
846+
assert_eq!(char_at_utf16_offset(text, 7), Some('@'));
849847
}
850848

851849
#[test]

rust/twitter-text/src/validator.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ impl Validator {
5353
}
5454
}
5555

56+
pub fn with_config_and_parser_backend(
57+
config: twitter_text_config::Configuration,
58+
parser_backend: ParserBackend,
59+
) -> Validator {
60+
Validator {
61+
short_url_length: 23,
62+
short_url_length_https: 23,
63+
config,
64+
extractor: Extractor::new(),
65+
parser_backend,
66+
}
67+
}
68+
5669
pub fn is_valid_tweet(&self, s: &str) -> bool {
5770
parse_with_parser_backend(s, &self.config, false, self.parser_backend).is_valid
5871
}

0 commit comments

Comments
 (0)