Skip to content

Commit b1e36dc

Browse files
Catherine Gasniermeta-codesync[bot]
authored andcommitted
symbol parsing: parse UTF-8 characters properly
Summary: The scip specification says that scheme, manager, package-name, version and escaped identifiers can contain UTF-8 characters (see [here](https://www.internalfb.com/code/fbsource/[7aae1da59854faf0dcdeef70f5665ca29deb8e41]/third-party/scip/scip.proto?lines=158-161%2C177)), so we implement that. Reviewed By: malanka Differential Revision: D85528376 fbshipit-source-id: b7910c8de8d3fe69a1ab3e2225704c017c6c8ad2
1 parent f772e29 commit b1e36dc

1 file changed

Lines changed: 138 additions & 41 deletions

File tree

  • glean/lang/scip/indexer/scip_to_glean/scip_symbol/src

glean/lang/scip/indexer/scip_to_glean/scip_symbol/src/lib.rs

Lines changed: 138 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -117,26 +117,26 @@ pub fn parse_scip_symbol(symbol: &str) -> ScipSymbol {
117117
///
118118
/// Returns (field, remaining_string)
119119
fn parse_space_escaped_field(s: &str) -> (String, &str) {
120-
let bytes = s.as_bytes();
121-
let mut i = 0;
120+
let mut chars = s.char_indices().peekable();
122121

123-
while i < bytes.len() {
124-
if bytes[i] == b' ' {
122+
while let Some((i, ch)) = chars.next() {
123+
if ch == ' ' {
125124
// Check if this is a double space (escaped space)
126-
if i + 1 < bytes.len() && bytes[i + 1] == b' ' {
127-
// Skip both spaces (this is an escaped space within the field)
128-
i += 2;
129-
} else {
130-
// Single space - this is the field delimiter
131-
return (unescape_spaces(&s[..i]), &s[i + 1..]);
125+
if let Some(&(_, next_ch)) = chars.peek() {
126+
if next_ch == ' ' {
127+
// Skip the second space (this is an escaped space within the field)
128+
chars.next();
129+
continue;
130+
}
132131
}
133-
} else {
134-
i += 1;
132+
// Single space - this is the field delimiter
133+
let rest_start = i + 1;
134+
return (unescape_spaces(&s[..i]), &s[rest_start..]);
135135
}
136136
}
137137

138138
// No delimiter found, return entire string
139-
(s.to_string(), "")
139+
(unescape_spaces(s), "")
140140
}
141141

142142
/// Unescapes double spaces in a string field.
@@ -150,54 +150,72 @@ fn unescape_spaces(s: &str) -> String {
150150
/// Parses an identifier, handling both simple and escaped identifiers.
151151
///
152152
/// According to SCIP spec:
153-
/// - Simple identifiers: contain only '_', '+', '-', '$', or alphanumeric chars
154-
/// - Escaped identifiers: surrounded by backticks, backticks escaped as ``
153+
/// - Simple identifiers: contain only '_', '+', '-', '$', or ASCII alphanumeric chars
154+
/// - Escaped identifiers: surrounded by backticks, backticks escaped as ``, can contain any UTF-8
155155
///
156156
/// Returns (unescaped_name, bytes_consumed)
157157
fn parse_identifier(s: &str, start: usize) -> (String, usize) {
158-
let bytes = s.as_bytes();
158+
let substr = &s[start..];
159+
let mut chars = substr.char_indices().peekable();
159160

160-
if start < bytes.len() && bytes[start] == b'`' {
161+
if let Some(&(_, '`')) = chars.peek() {
161162
// Escaped identifier: parse until closing backtick
162-
let mut i = start + 1;
163163
let mut result = String::new();
164+
chars.next(); // consume the opening backtick
164165

165-
while i < bytes.len() {
166-
if bytes[i] == b'`' {
166+
while let Some((byte_offset, ch)) = chars.next() {
167+
if ch == '`' {
167168
// Check if this is a double backtick (escaped backtick)
168-
if i + 1 < bytes.len() && bytes[i + 1] == b'`' {
169-
// Escaped backtick - add single backtick to result
170-
result.push('`');
171-
i += 2;
172-
} else {
173-
// End of escaped identifier
174-
return (result, i + 1 - start);
169+
if let Some(&(_, next_ch)) = chars.peek() {
170+
if next_ch == '`' {
171+
// Escaped backtick - add single backtick to result
172+
result.push('`');
173+
chars.next(); // consume the second backtick
174+
continue;
175+
}
175176
}
177+
// Single backtick - this closes the identifier
178+
// byte_offset is relative to start of substr, pointing to closing backtick
179+
// Return: byte_offset + 1 (to include the closing backtick)
180+
return (result, byte_offset + 1);
176181
} else {
177-
// Regular character
178-
result.push(bytes[i] as char);
179-
i += 1;
182+
result.push(ch);
180183
}
181184
}
182185

183186
// If we get here, no closing backtick was found
184187
// Return what we have and consume to end
185-
(result, i - start)
188+
(result, s.len() - start)
186189
} else {
187190
// Simple identifier: parse until we hit a non-identifier character
188-
let mut i = start;
189-
while i < bytes.len() {
190-
let c = bytes[i];
191-
match c {
192-
b'_' | b'+' | b'-' | b'$' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => {
193-
i += 1;
191+
// According to spec, identifier-character is ASCII only: '_' | '+' | '-' | '$' | ASCII letter or digit
192+
let mut last_valid_byte_offset = 0;
193+
194+
for (byte_offset, ch) in chars {
195+
if ch.is_ascii() {
196+
let byte = ch as u8;
197+
match byte {
198+
b'_' | b'+' | b'-' | b'$' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => {
199+
last_valid_byte_offset = byte_offset + 1;
200+
}
201+
_ => break,
194202
}
195-
_ => break,
203+
} else {
204+
// Non-ASCII character - stop parsing here (this violates spec but we handle gracefully)
205+
log::warn!(
206+
"Non-ASCII character {} found in identifier in SCIP symbol {}",
207+
ch,
208+
s
209+
);
210+
break;
196211
}
197212
}
198213

199214
// Return the slice directly (no unescaping needed for simple identifiers)
200-
(s[start..i].to_string(), i - start)
215+
(
216+
s[start..start + last_valid_byte_offset].to_string(),
217+
last_valid_byte_offset,
218+
)
201219
}
202220
}
203221

@@ -357,8 +375,14 @@ fn parse_descriptors(descriptors_str: &str) -> Vec<Descriptor> {
357375
i = new_pos;
358376
}
359377
_ => {
360-
// No recognized suffix, skip
361-
i += 1;
378+
// No recognized suffix, skip this character
379+
// We need to skip by the full UTF-8 character length to avoid
380+
// landing in the middle of a multi-byte character
381+
if let Some(ch) = descriptors_str[i..].chars().next() {
382+
i += ch.len_utf8();
383+
} else {
384+
i += 1;
385+
}
362386
}
363387
}
364388
}
@@ -706,4 +730,77 @@ mod tests {
706730
};
707731
assert_eq!(scheme, symbol_str);
708732
}
733+
734+
#[test]
735+
fn test_utf8_escaped_identifier_mixed() {
736+
// Test escaped identifier with mixed UTF-8 characters and backtick escaping
737+
let symbol = "scip . . . `hello``世界``🌍`#";
738+
let symbol = parse_scip_symbol(symbol);
739+
let ScipSymbol::Global { descriptors, .. } = symbol else {
740+
panic!("expected global symbol");
741+
};
742+
743+
assert_eq!(descriptors.len(), 1);
744+
assert_eq!(descriptors[0].name, "hello`世界`🌍");
745+
assert_eq!(descriptors[0].kind, DescriptorKind::Type);
746+
}
747+
748+
#[test]
749+
fn test_utf8_in_manager() {
750+
// Test UTF-8 characters in manager field
751+
let symbol = "scip cargo🌍 pkg v1.0 MyClass#";
752+
let symbol = parse_scip_symbol(symbol);
753+
let ScipSymbol::Global { package, .. } = symbol else {
754+
panic!("expected global symbol");
755+
};
756+
757+
assert_eq!(package.manager, Some("cargo🌍".to_string()));
758+
}
759+
760+
#[test]
761+
fn test_production_case_non_ascii_in_simple_identifier() {
762+
// Test the real production case that was causing panics
763+
// Symbol contains 'Å' (U+00C5, 2-byte UTF-8: 0xC3 0x85) in ROTÅTION
764+
// Even though this violates the spec (simple identifiers should be ASCII only),
765+
// we should handle it gracefully without panicking
766+
let symbol = "com/instagram/feed/opencarousel/consumption/OpenCarouselConstantsUtil#Companion#MEDIA_CARD_STACK_ROTÅTION.";
767+
let symbol = parse_scip_symbol(symbol);
768+
let ScipSymbol::Global {
769+
scheme,
770+
descriptors,
771+
..
772+
} = symbol
773+
else {
774+
panic!("expected global symbol");
775+
};
776+
777+
// This entire string is treated as the scheme since there's no space to delimit it
778+
assert_eq!(
779+
scheme,
780+
"com/instagram/feed/opencarousel/consumption/OpenCarouselConstantsUtil#Companion#MEDIA_CARD_STACK_ROTÅTION."
781+
);
782+
783+
// No descriptors will be parsed since the entire string is consumed as the scheme
784+
assert_eq!(descriptors.len(), 0);
785+
}
786+
787+
#[test]
788+
fn test_non_ascii_in_simple_identifier_with_proper_delimiter() {
789+
// Test that we handle non-ASCII gracefully when it appears in a simple identifier
790+
// with proper space delimiters. Uses Å (2-byte UTF-8) to test character boundary handling.
791+
// This violates the SCIP spec (simple identifiers should be ASCII only), but we handle
792+
// it gracefully to avoid panics in production.
793+
let symbol = "scip . . . ROTÅTION.";
794+
let symbol = parse_scip_symbol(symbol);
795+
let ScipSymbol::Global { descriptors, .. } = symbol else {
796+
panic!("expected global symbol");
797+
};
798+
799+
// The parser stops at non-ASCII 'Å', skips it, and continues parsing.
800+
// It parses "TION" as a separate term after skipping the invalid UTF-8 in the simple identifier.
801+
// While not ideal, this behavior avoids panics on malformed input.
802+
assert_eq!(descriptors.len(), 1);
803+
assert_eq!(descriptors[0].name, "TION");
804+
assert_eq!(descriptors[0].kind, DescriptorKind::Term);
805+
}
709806
}

0 commit comments

Comments
 (0)