Skip to content

Commit 5bfbb3b

Browse files
authored
Normalize source newlines before warnings, compilation and diagnostics (#9793)
* Normalize physical source newlines before parser warnings and compilation Share universal-newline translation across byte decoding, compiler entry points and syntax diagnostics. Keep LF-only input borrowed and preserve original compile audit bytes. Cover literal values, docstrings, warnings and diagnostic consistency. Assisted-by: OpenAI Codex:GPT-6.1 * Mark normalized source result as must-use Assisted-by: OpenAI Codex:GPT-6.1 --------- Co-authored-by: youknowdot <youknowdot@users.noreply.github.com>
1 parent 8e6b60e commit 5bfbb3b

6 files changed

Lines changed: 118 additions & 14 deletions

File tree

‎crates/compiler-core/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ pub mod bytecode;
99
pub mod frozen;
1010
pub mod marshal;
1111
mod mode;
12+
mod source;
1213
mod source_location;
1314
pub mod varint;
1415

1516
pub use mode::Mode;
17+
pub use source::normalize_newlines;
1618
pub use source_location::{AstLocationError, AstLocations, SourceLocationRange};
1719

1820
pub use ruff_source_file::{

‎crates/compiler-core/src/source.rs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use alloc::{borrow::Cow, string::String};
2+
3+
/// Translate physical source line endings before tokenization, including those
4+
/// inside literals, as CPython's `_PyTokenizer_translate_newlines` does.
5+
#[must_use]
6+
pub fn normalize_newlines(source: &str) -> Cow<'_, str> {
7+
let Some(first_cr) = source.find('\r') else {
8+
return Cow::Borrowed(source);
9+
};
10+
let mut normalized = String::with_capacity(source.len());
11+
normalized.push_str(&source[..first_cr]);
12+
let mut rest = &source[first_cr..];
13+
while let Some(cr) = rest.find('\r') {
14+
normalized.push_str(&rest[..cr]);
15+
normalized.push('\n');
16+
rest = &rest[cr + 1..];
17+
if let Some(after_lf) = rest.strip_prefix('\n') {
18+
rest = after_lf;
19+
}
20+
}
21+
normalized.push_str(rest);
22+
Cow::Owned(normalized)
23+
}
24+
25+
#[cfg(test)]
26+
mod tests {
27+
use super::*;
28+
29+
#[test]
30+
fn borrow_lf_source() {
31+
let source = "print('한글')\n";
32+
assert!(matches!(normalize_newlines(source), Cow::Borrowed(s) if s == source));
33+
}
34+
35+
#[test]
36+
fn physical_line_endings() {
37+
assert_eq!(
38+
normalize_newlines("a\r\nb\rc\n\rd\r\r\n"),
39+
"a\nb\nc\n\nd\n\n"
40+
);
41+
assert_eq!(normalize_newlines("'\\r'\r"), "'\\r'\n");
42+
}
43+
}

‎crates/compiler/src/lib.rs‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2111,13 +2111,7 @@ pub fn compile(
21112111
source_path: &str,
21122112
opts: CompileOpts,
21132113
) -> Result<CodeObject, CompileError> {
2114-
// TODO: do this less hacky; ruff's parser should translate a CRLF line
2115-
// break in a multiline string into just an LF in the parsed value
2116-
#[cfg(windows)]
2117-
let source = source.replace("\r\n", "\n");
2118-
#[cfg(windows)]
2119-
let source = source.as_str();
2120-
2114+
let source = core::normalize_newlines(source);
21212115
let source_file = SourceFileBuilder::new(source_path, source).finish();
21222116
_compile(source_file, mode, opts)
21232117
// let index = LineIndex::from_source_text(source);
@@ -2448,9 +2442,7 @@ pub fn compile_with_syntax_warning_handler<'a>(
24482442
opts: CompileOpts,
24492443
syntax_warning_handler: &'a mut compile::SyntaxWarningHandler<'a>,
24502444
) -> Result<CodeObject, CompileError> {
2451-
let source = source.replace("\r\n", "\n");
2452-
#[cfg(windows)]
2453-
let source = source.as_str();
2445+
let source = core::normalize_newlines(source);
24542446

24552447
let source_file = SourceFileBuilder::new(source_path, source).finish();
24562448
_compile_with_syntax_warning_handler(source_file, mode, opts, Some(syntax_warning_handler))
@@ -2461,6 +2453,7 @@ pub fn compile_symtable(
24612453
mode: Mode,
24622454
source_path: &str,
24632455
) -> Result<symboltable::SymbolTable, CompileError> {
2456+
let source = core::normalize_newlines(source);
24642457
let source_file = SourceFileBuilder::new(source_path, source).finish();
24652458
_compile_symtable(source_file, mode)
24662459
}

‎crates/vm/src/vm/compile.rs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ impl VmCompileError {
8181

8282
impl CompileWarningError {
8383
fn into_pyexception(self, vm: &VirtualMachine, source: Option<&str>) -> PyBaseExceptionRef {
84+
let source = source.map(compiler::core::normalize_newlines);
85+
let source = source.as_deref();
8486
if !self
8587
.exception
8688
.fast_isinstance(vm.ctx.exceptions.syntax_warning)
@@ -463,10 +465,9 @@ impl VirtualMachine {
463465
};
464466
// Normalize the source before parsing so literal values, warning ranges,
465467
// and diagnostic text all refer to the same universal-newline input.
466-
Ok(if decoded.contains('\r') {
467-
decoded.replace("\r\n", "\n").replace('\r', "\n")
468-
} else {
469-
decoded
468+
Ok(match compiler::core::normalize_newlines(&decoded) {
469+
alloc::borrow::Cow::Borrowed(_) => decoded,
470+
alloc::borrow::Cow::Owned(normalized) => normalized,
470471
})
471472
}
472473

@@ -722,6 +723,8 @@ impl VirtualMachine {
722723
original_filename: &Py<PyStr>,
723724
module: Option<&Py<PyStr>>,
724725
) -> Result<compiler::codegen::symboltable::SymbolTable, VmCompileError> {
726+
let source = compiler::core::normalize_newlines(source);
727+
let source = source.as_ref();
725728
#[cfg(feature = "parser")]
726729
{
727730
let start = match mode {
@@ -815,6 +818,8 @@ impl VirtualMachine {
815818
) -> Result<PyRef<PyCode>, VmCompileError> {
816819
#[cfg(not(feature = "parser"))]
817820
let _ = (module, emitted_escapes);
821+
let source = compiler::core::normalize_newlines(source);
822+
let source = source.as_ref();
818823
#[cfg(feature = "parser")]
819824
{
820825
self.emit_standard_parser_warnings(

‎crates/vm/src/vm/vm_new.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ impl VirtualMachine {
510510
return self.new_recursion_error(error.to_string());
511511
}
512512

513+
let source = source.map(crate::compiler::core::normalize_newlines);
514+
let source = source.as_deref();
515+
513516
let incomplete_or_syntax = |allow| -> &'static Py<crate::builtins::PyType> {
514517
if allow {
515518
self.ctx.exceptions.incomplete_input_error

‎extra_tests/snippets/builtin_compile.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,61 @@ def _expect_syntax(source, mode):
250250
raise AssertionError("initial eval indentation was accepted")
251251

252252
assert eval(compile("(\n 1\n)", "<test>", "eval")) == 1
253+
254+
# Physical CR and CRLF are translated before lexing, even inside literals.
255+
# Keep explicit backslash-r escapes distinct from physical line endings.
256+
for ending in ("\r", "\r\n", "\n\r", "\r\r\n"):
257+
normalized = ending.replace("\r\n", "\n").replace("\r", "\n")
258+
for prefix in ("", "r", "b", "f"):
259+
source = prefix + '"""a' + ending + 'b"""'
260+
expected = "a" + normalized + "b"
261+
if prefix == "b":
262+
expected = expected.encode()
263+
assert eval(source) == expected
264+
assert eval(source.encode()) == expected
265+
assert eval(compile(source, "<newlines>", "eval")) == expected
266+
tree = ast.parse(source, mode="eval")
267+
assert eval(compile(tree, "<newlines>", "eval")) == expected
268+
namespace = {}
269+
exec("value = " + source, namespace)
270+
assert namespace["value"] == expected
271+
assert eval('t"""a' + ending + 'b"""').strings == ("a" + normalized + "b",)
272+
namespace = {}
273+
exec('def f():\r """a' + ending + ' b"""\r pass', namespace)
274+
assert namespace["f"].__doc__ == "a" + normalized + "b"
275+
assert eval('"a\\' + ending[:1] + 'b"') == "ab"
276+
assert eval(r'"a\rb"') == "a\rb"
277+
assert eval(r'r"a\rb"') == r"a\rb"
278+
279+
# Warnings and SyntaxError text must use the same source as the parser.
280+
import warnings
281+
282+
for source in ('"""a\r\\q"""', '"""a\r\\q""" + )', "1\r+", '"a\rb"'):
283+
results = []
284+
for text in (source, source.replace("\r", "\n")):
285+
with warnings.catch_warnings(record=True) as captured:
286+
warnings.simplefilter("always")
287+
try:
288+
eval(text)
289+
except SyntaxError as exc:
290+
result = (exc.msg, exc.lineno, exc.offset, exc.text)
291+
else:
292+
result = None
293+
results.append((result, [(str(w.message), w.lineno) for w in captured]))
294+
assert results[0] == results[1], (source, results)
295+
296+
# Audit hooks see the caller's bytes, not the parser's translated buffer.
297+
import sys
298+
299+
newline_audit_events = []
300+
newline_audit_source = '"""audit\rsource"""'
301+
302+
303+
def record_newline_compile(event, args):
304+
if event == "compile" and args[0] == newline_audit_source.encode():
305+
newline_audit_events.append(args[0])
306+
307+
308+
sys.addaudithook(record_newline_compile)
309+
assert eval(newline_audit_source) == "audit\nsource"
310+
assert newline_audit_events == [newline_audit_source.encode()]

0 commit comments

Comments
 (0)