@@ -117,26 +117,26 @@ pub fn parse_scip_symbol(symbol: &str) -> ScipSymbol {
117117///
118118/// Returns (field, remaining_string)
119119fn 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)
157157fn 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