Skip to content

Commit 2fa3336

Browse files
quaesitor-scientiamPythonWillRulewozcode
authored
scanner: accept octal and multi-byte escapes in rune literals (fix #28880) (#28882)
* scanner: accept octal and multi-byte escapes in rune literals (fix #28880) `validate_char_literal` (added in 3b7b5ee) splits a rune literal into characters to check it holds only one, but it gave every escape other than `\x`, `\u` and `\U` a single character after the backslash, and counted every byte escape as a character of its own. So `\141` was three characters and `\xe2\x98\x85` / `\342\230\205` were three and nine, although each is one character and strings accept all of them. The rune example in doc/docs.md, which documents both forms, stopped compiling, which is one of the two `check-markdown` failures on master. An octal escape is now three octal digits, as the parser's string decoder reads it, and a byte escape holding a UTF-8 lead byte takes the continuation byte escapes that complete its character. Incomplete, invalid and overlong sequences are still rejected. Code generation needed no change: with the validator fixed, the whole docs example runs and passes under tcc and gcc. Co-Authored-By: WOZCODE <contact@withwoz.com> * scanner: accept only well-formed UTF-8 byte escapes as one rune The previous commit merged a UTF-8 lead byte escape with the escapes after it whenever each continuation byte was in 0x80..0xbf. That is not enough: the byte after E0, ED, F0 and F4 has a narrower range, so these were all accepted as one character (found by JalonSolov on #28882): `\xe0\x80\x80` overlong `\xf0\x80\x80\x80` overlong `\xed\xa0\x80` surrogate `\xf4\x90\x80\x80` above U+10FFFF The commit message of that commit and the PR description said overlong and invalid sequences stayed rejected; that only held for the C0/C1 leads. Decode the code point and apply the range `check_string_escape` already enforces for `\u`/`\U` (at most U+10FFFF, no surrogates), plus the smallest code point each length may encode, which rules out overlong forms. With leads limited to C2..F4 and continuation bytes to 80..BF, that is exactly the well-formed set of Unicode Table 3-7. The new test checks the rule rather than examples: every lead byte C0..F7, every boundary of the second-byte ranges, and a valid and an invalid final byte, in hex and octal spelling, against `encoding.utf8.validate_str`. It fails on the previous commit, and removing any one of the four checks (shortest form, surrogate, maximum, continuation byte) makes it fail on that class. Also restore the `\u0061` case in the accept-list test: it had been written into the file as a plain `a`, so `\u` was never tested. Co-Authored-By: WOZCODE <contact@withwoz.com> --------- Co-authored-by: Richard Wheeler <18647491+PythonWillRule@users.noreply.github.com> Co-authored-by: WOZCODE <contact@withwoz.com>
1 parent b0779cc commit 2fa3336

2 files changed

Lines changed: 215 additions & 6 deletions

File tree

‎vlib/v/scanner/scanner.v‎

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,12 @@ fn (mut s Scanner) validate_char_literal(start int, content_start int, content_e
217217
if s.diagnostics.len > before_errors {
218218
return
219219
}
220-
extra := match escape {
221-
`x` { 2 }
222-
`u` { 4 }
223-
`U` { 8 }
224-
else { 0 }
220+
offset = s.char_literal_escape_end(offset, content_end)
221+
// A character can be spelled as its UTF-8 bytes (`\xe2\x98\x85`, `\342\230\205`),
222+
// the same as in a string, so those escapes together are still one character.
223+
if lead := s.char_literal_escape_byte(part_start, offset) {
224+
offset = s.char_literal_utf8_escapes_end(lead, offset, content_end)
225225
}
226-
offset = int_min(content_end, offset + 2 + extra)
227226
} else {
228227
offset = int_min(content_end, offset + utf8_char_len(s.src[offset]))
229228
}
@@ -235,6 +234,113 @@ fn (mut s Scanner) validate_char_literal(start int, content_start int, content_e
235234
}
236235
}
237236

237+
// char_literal_escape_end returns where the escape that starts at the backslash at
238+
// `offset` ends. An octal escape is exactly three octal digits, which is how string
239+
// literals decode it; without three, the backslash escapes only the next character.
240+
fn (s &Scanner) char_literal_escape_end(offset int, content_end int) int {
241+
extra := match s.src[offset + 1] {
242+
`x` {
243+
2
244+
}
245+
`u` {
246+
4
247+
}
248+
`U` {
249+
8
250+
}
251+
else {
252+
if s.char_literal_octal_escape_at(offset, content_end) { 2 } else { 0 }
253+
}
254+
}
255+
return int_min(content_end, offset + 2 + extra)
256+
}
257+
258+
fn (s &Scanner) char_literal_octal_escape_at(offset int, content_end int) bool {
259+
if offset + 3 >= content_end {
260+
return false
261+
}
262+
for i in 1 .. 4 {
263+
c := s.src[offset + i]
264+
if c < `0` || c > `7` {
265+
return false
266+
}
267+
}
268+
return true
269+
}
270+
271+
// char_literal_escape_byte returns the byte that a `\xHH` or three-digit octal escape
272+
// spanning `start` to `end` stands for.
273+
fn (s &Scanner) char_literal_escape_byte(start int, end int) ?u8 {
274+
if end - start != 4 {
275+
return none
276+
}
277+
escape := s.src[start + 1]
278+
mut value := 0
279+
if escape == `x` {
280+
for i in start + 2 .. end {
281+
if !s.src[i].is_hex_digit() {
282+
return none
283+
}
284+
value = value * 16 + int(string_escape_hex_value(s.src[i]))
285+
}
286+
} else if escape >= `0` && escape <= `7` {
287+
for i in start + 1 .. end {
288+
if s.src[i] < `0` || s.src[i] > `7` {
289+
return none
290+
}
291+
value = value * 8 + int(s.src[i] - `0`)
292+
}
293+
} else {
294+
return none
295+
}
296+
if value > 0xff {
297+
return none
298+
}
299+
return u8(value)
300+
}
301+
302+
// char_literal_utf8_escapes_end extends a byte escape holding the UTF-8 lead byte `lead`
303+
// over the continuation byte escapes that complete its character, and returns where
304+
// they end. The bytes have to be one well-formed UTF-8 sequence: if they are missing, are
305+
// not continuation bytes, or spell an overlong form, a surrogate or a code point above
306+
// U+10FFFF, the escape stays on its own.
307+
fn (s &Scanner) char_literal_utf8_escapes_end(lead u8, offset int, content_end int) int {
308+
continuation_bytes := if lead >= 0xc2 && lead <= 0xdf {
309+
1
310+
} else if lead >= 0xe0 && lead <= 0xef {
311+
2
312+
} else if lead >= 0xf0 && lead <= 0xf4 {
313+
3
314+
} else {
315+
0
316+
}
317+
if continuation_bytes == 0 {
318+
return offset
319+
}
320+
mut code_point := u32(lead) & (u32(0x7f) >> (continuation_bytes + 1))
321+
mut end := offset
322+
for _ in 0 .. continuation_bytes {
323+
if end + 1 >= content_end || s.src[end] != `\\` {
324+
return offset
325+
}
326+
next_end := s.char_literal_escape_end(end, content_end)
327+
b := s.char_literal_escape_byte(end, next_end) or { return offset }
328+
if b < 0x80 || b > 0xbf {
329+
return offset
330+
}
331+
code_point = (code_point << 6) | u32(b & 0x3f)
332+
end = next_end
333+
}
334+
// The same range `check_string_escape` enforces for `\u`/`\U`, plus the smallest code
335+
// point each length may encode, which rules out overlong forms.
336+
shortest_form_minimum := [u32(0x80), 0x800, 0x10000][continuation_bytes - 1]
337+
if code_point < shortest_form_minimum || code_point > 0x10ffff
338+
|| (code_point >= 0xd800 && code_point <= 0xdfff) {
339+
return offset
340+
}
341+
return end
342+
}
343+
238344
// current_file returns current file data for Scanner.
239345
pub fn (s &Scanner) current_file() &token.File {
240346
return unsafe { s.file }

‎vlib/v/scanner/scanner_test.v‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module scanner
22

3+
import encoding.utf8
34
import v.pref
45
import v.token
56

@@ -170,3 +171,105 @@ fn test_keyword_enum_selector_inserts_semicolon() {
170171
assert scanner.scan() == expected
171172
}
172173
}
174+
175+
fn char_literal_diagnostics(source string) []string {
176+
mut files := token.FileSet.new()
177+
mut file := files.add_file('char_literal.v', source.len)
178+
file.index_lines(source)
179+
preferences := &pref.Preferences{}
180+
mut scanner := new_scanner(preferences, .normal)
181+
scanner.init(file, source)
182+
for scanner.scan() != .eof {
183+
}
184+
return scanner.diagnostics.map(it.message)
185+
}
186+
187+
// A character literal holds one character however it is spelled: a three-digit octal
188+
// escape is one byte, and the byte escapes of one UTF-8 sequence are one character, the
189+
// same as in a string.
190+
fn test_char_literal_escapes_that_spell_one_character_are_accepted() {
191+
for source in [
192+
r'`\141`',
193+
r'`\x61`',
194+
r'`\u0061`',
195+
r'`\U0001F680`',
196+
r'`\0`',
197+
r'`\xc3\xa9`',
198+
r'`\xe2\x98\x85`',
199+
r'`\342\230\205`',
200+
r'`\342\x98\205`',
201+
r'`\xf0\x9f\x9a\x80`',
202+
] {
203+
assert char_literal_diagnostics(source) == [], source
204+
}
205+
}
206+
207+
fn test_char_literal_with_more_than_one_character_is_still_rejected() {
208+
for source in [
209+
r'`\141b`',
210+
r'`\x61\x62`',
211+
// A lead byte whose continuation bytes are missing, invalid, or overlong is not
212+
// one character.
213+
r'`\xe2\x98`',
214+
r'`\xe2\x98\x41`',
215+
r'`\xc0\x80`',
216+
r'`\xc3\xa9\xa9`',
217+
] {
218+
diagnostics := char_literal_diagnostics(source)
219+
assert diagnostics.len == 1, source
220+
assert diagnostics[0].ends_with('(more than one character)'), '${source}: ${diagnostics[0]}'
221+
}
222+
}
223+
224+
fn utf8_sequence_len(lead u8) int {
225+
return if lead >= 0xf0 {
226+
4
227+
} else if lead >= 0xe0 {
228+
3
229+
} else {
230+
2
231+
}
232+
}
233+
234+
// Byte escapes are one character exactly when they spell one well-formed UTF-8 sequence,
235+
// which is decided here by `encoding.utf8`, independently of the scanner. The first
236+
// continuation byte is where the lead-specific limits live (overlong forms after `E0` and
237+
// `F0`, surrogates after `ED`, code points above U+10FFFF after `F4`), so it walks every
238+
// boundary of those ranges for every lead byte, together with a valid and an invalid final
239+
// continuation byte, in both hex and octal spelling.
240+
fn test_char_literal_byte_escapes_are_one_character_only_when_well_formed_utf8() {
241+
second_bytes := [u8(0x7f), 0x80, 0x8f, 0x90, 0x9f, 0xa0, 0xbf, 0xc0]
242+
last_bytes := [u8(0x7f), 0x80, 0xbf, 0xc0]
243+
mut sequences := [][]u8{}
244+
for lead in u8(0xc0) .. u8(0xf8) {
245+
for second in second_bytes {
246+
if utf8_sequence_len(lead) == 2 {
247+
sequences << [lead, second]
248+
continue
249+
}
250+
for last in last_bytes {
251+
mut bytes := [lead, second]
252+
for bytes.len < utf8_sequence_len(lead) - 1 {
253+
bytes << u8(0x80)
254+
}
255+
bytes << last
256+
sequences << bytes
257+
}
258+
}
259+
}
260+
for bytes in sequences {
261+
well_formed := utf8.validate_str(bytes.bytestr())
262+
hex := bytes.map('\\x${it.hex()}').join('')
263+
octal := bytes.map('\\${it:o}').join('')
264+
for spelling in [hex, octal] {
265+
source := '`${spelling}`'
266+
diagnostics := char_literal_diagnostics(source)
267+
if well_formed {
268+
assert diagnostics == [], source
269+
} else {
270+
assert diagnostics.len == 1, source
271+
assert diagnostics[0].ends_with('(more than one character)'), '${source}: ${diagnostics[0]}'
272+
}
273+
}
274+
}
275+
}

0 commit comments

Comments
 (0)