|
| 1 | +//! Exact native equivalent of the possessive Python `MODEL(...)` header regular expression. |
| 2 | +
|
| 3 | +/// Code-point offsets of one matched header: header start, header end, and SQL start. |
| 4 | +pub(crate) type HeaderMatchOffsets = (usize, usize, usize); |
| 5 | + |
| 6 | +const MODEL_KEYWORD: &[u8] = b"MODEL"; |
| 7 | + |
| 8 | +pub(crate) fn match_one(text: &str) -> Option<HeaderMatchOffsets> { |
| 9 | + let bytes = text.as_bytes(); |
| 10 | + let mut index = skip_whitespace(text, 0); |
| 11 | + if !bytes[index..].starts_with(MODEL_KEYWORD) { |
| 12 | + return None; |
| 13 | + } |
| 14 | + index = skip_whitespace(text, index + MODEL_KEYWORD.len()); |
| 15 | + if bytes.get(index) != Some(&b'(') { |
| 16 | + return None; |
| 17 | + } |
| 18 | + let header_start = index + 1; |
| 19 | + let header_end = scan_header_body(text, header_start); |
| 20 | + if bytes.get(header_end) != Some(&b')') { |
| 21 | + return None; |
| 22 | + } |
| 23 | + index = skip_whitespace(text, header_end + 1); |
| 24 | + if bytes.get(index) != Some(&b';') { |
| 25 | + return None; |
| 26 | + } |
| 27 | + let sql_start = skip_whitespace(text, index + 1); |
| 28 | + Some(code_point_offsets( |
| 29 | + text, |
| 30 | + header_start, |
| 31 | + header_end, |
| 32 | + sql_start, |
| 33 | + )) |
| 34 | +} |
| 35 | + |
| 36 | +/// Consume the possessive header body and return the byte offset where it stops. |
| 37 | +fn scan_header_body(text: &str, start: usize) -> usize { |
| 38 | + let bytes = text.as_bytes(); |
| 39 | + let mut index = start; |
| 40 | + while let Some(&byte) = bytes.get(index) { |
| 41 | + index = match byte { |
| 42 | + b'"' | b'\'' => quoted_string_end(text, index, byte) |
| 43 | + .unwrap_or_else(|| unclosed_quote_end(text, index)), |
| 44 | + b')' => { |
| 45 | + let after = skip_whitespace(text, index + 1); |
| 46 | + if bytes.get(after) == Some(&b';') { |
| 47 | + return index; |
| 48 | + } |
| 49 | + index + 1 |
| 50 | + } |
| 51 | + b'\\' => match escape_end(text, index) { |
| 52 | + Some(end) => end, |
| 53 | + None => return index, |
| 54 | + }, |
| 55 | + _ => index + 1, |
| 56 | + }; |
| 57 | + } |
| 58 | + index |
| 59 | +} |
| 60 | + |
| 61 | +/// `"(?:[^"\\]|\\.)*+"`: the closing quote's end, or `None` when the string never closes. |
| 62 | +fn quoted_string_end(text: &str, open: usize, quote: u8) -> Option<usize> { |
| 63 | + let bytes = text.as_bytes(); |
| 64 | + let mut index = open + 1; |
| 65 | + while let Some(&byte) = bytes.get(index) { |
| 66 | + if byte == quote { |
| 67 | + return Some(index + 1); |
| 68 | + } |
| 69 | + if byte == b'\\' { |
| 70 | + index = escape_end(text, index)?; |
| 71 | + } else { |
| 72 | + index += 1; |
| 73 | + } |
| 74 | + } |
| 75 | + None |
| 76 | +} |
| 77 | + |
| 78 | +/// `["'](?:[^\\)]|\\.)*+`: an unclosed quote runs to the next `)` or unescapable backslash. |
| 79 | +fn unclosed_quote_end(text: &str, open: usize) -> usize { |
| 80 | + let bytes = text.as_bytes(); |
| 81 | + let mut index = open + 1; |
| 82 | + while let Some(&byte) = bytes.get(index) { |
| 83 | + match byte { |
| 84 | + b')' => return index, |
| 85 | + b'\\' => match escape_end(text, index) { |
| 86 | + Some(end) => index = end, |
| 87 | + None => return index, |
| 88 | + }, |
| 89 | + _ => index += 1, |
| 90 | + } |
| 91 | + } |
| 92 | + index |
| 93 | +} |
| 94 | + |
| 95 | +/// `\\.` with DOTALL: a backslash plus one following code point, if any. |
| 96 | +fn escape_end(text: &str, backslash: usize) -> Option<usize> { |
| 97 | + text[backslash + 1..] |
| 98 | + .chars() |
| 99 | + .next() |
| 100 | + .map(|escaped| backslash + 1 + escaped.len_utf8()) |
| 101 | +} |
| 102 | + |
| 103 | +fn skip_whitespace(text: &str, start: usize) -> usize { |
| 104 | + let mut index = start; |
| 105 | + for character in text[start..].chars() { |
| 106 | + if !is_python_whitespace(character) { |
| 107 | + break; |
| 108 | + } |
| 109 | + index += character.len_utf8(); |
| 110 | + } |
| 111 | + index |
| 112 | +} |
| 113 | + |
| 114 | +/// Python's `str.isspace()`, which `\s` uses for `str` patterns. |
| 115 | +fn is_python_whitespace(character: char) -> bool { |
| 116 | + matches!( |
| 117 | + character, |
| 118 | + '\t'..='\r' |
| 119 | + | '\u{1c}'..='\u{20}' |
| 120 | + | '\u{85}' |
| 121 | + | '\u{a0}' |
| 122 | + | '\u{1680}' |
| 123 | + | '\u{2000}'..='\u{200a}' |
| 124 | + | '\u{2028}' |
| 125 | + | '\u{2029}' |
| 126 | + | '\u{202f}' |
| 127 | + | '\u{205f}' |
| 128 | + | '\u{3000}' |
| 129 | + ) |
| 130 | +} |
| 131 | + |
| 132 | +fn code_point_offsets( |
| 133 | + text: &str, |
| 134 | + header_start: usize, |
| 135 | + header_end: usize, |
| 136 | + sql_start: usize, |
| 137 | +) -> HeaderMatchOffsets { |
| 138 | + if text.is_ascii() { |
| 139 | + return (header_start, header_end, sql_start); |
| 140 | + } |
| 141 | + let header_start_chars = text[..header_start].chars().count(); |
| 142 | + let header_end_chars = header_start_chars + text[header_start..header_end].chars().count(); |
| 143 | + let sql_start_chars = header_end_chars + text[header_end..sql_start].chars().count(); |
| 144 | + (header_start_chars, header_end_chars, sql_start_chars) |
| 145 | +} |
0 commit comments