From 3e00ddce38e7847cbdfc5734226ca88eeb2c9128 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Fri, 10 Nov 2023 21:53:35 +0530 Subject: [PATCH] Preserve trailing semicolon for Notebooks (#8590) ## Summary This PR updates the formatter to preserve trailing semicolon for Jupyter Notebooks. The motivation behind the change is that semicolons in notebooks are typically used to hide the output, for example when plotting. This is highlighted in the linked issue. The conditions required as to when the trailing semicolon should be preserved are: 1. It should be a top-level statement which is last in the module. 2. For statement, it can be either assignment, annotated assignment, or augmented assignment. Here, the target should only be a single identifier i.e., multiple assignments or tuple unpacking isn't considered. 3. For expression, it can be any. ## Test Plan Add a new integration test in `ruff_cli`. The test notebook basically acts as a document as to which trailing semicolons are to be preserved. fixes: #8254 --- .../test/fixtures/trailing_semicolon.ipynb | 413 +++++++++++++++++ crates/ruff_cli/tests/format.rs | 429 ++++++++++++++++++ .../src/comments/format.rs | 10 +- crates/ruff_python_formatter/src/context.rs | 28 +- .../src/expression/mod.rs | 4 +- .../src/expression/parentheses.rs | 6 +- .../src/statement/stmt_ann_assign.rs | 10 + .../src/statement/stmt_assign.rs | 14 +- .../src/statement/stmt_aug_assign.rs | 14 +- .../src/statement/stmt_expr.rs | 15 +- .../src/statement/suite.rs | 27 +- 11 files changed, 945 insertions(+), 25 deletions(-) create mode 100644 crates/ruff_cli/resources/test/fixtures/trailing_semicolon.ipynb diff --git a/crates/ruff_cli/resources/test/fixtures/trailing_semicolon.ipynb b/crates/ruff_cli/resources/test/fixtures/trailing_semicolon.ipynb new file mode 100644 index 0000000000..f6427ba81b --- /dev/null +++ b/crates/ruff_cli/resources/test/fixtures/trailing_semicolon.ipynb @@ -0,0 +1,413 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "4f8ce941-1492-4d4e-8ab5-70d733fe891a", + "metadata": {}, + "outputs": [], + "source": [ + "%config ZMQInteractiveShell.ast_node_interactivity=\"last_expr_or_assign\"" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "721ec705-0c65-4bfb-9809-7ed8bc534186", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Assignment statement without a semicolon\n", + "x = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "de50e495-17e5-41cc-94bd-565757555d7e", + "metadata": {}, + "outputs": [], + "source": [ + "# Assignment statement with a semicolon\n", + "x = 1;\n", + "x = 1;" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "39e31201-23da-44eb-8684-41bba3663991", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Augmented assignment without a semicolon\n", + "x += 1" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6b73d3dd-c73a-4697-9e97-e109a6c1fbab", + "metadata": {}, + "outputs": [], + "source": [ + "# Augmented assignment without a semicolon\n", + "x += 1;\n", + "x += 1; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2a3e5b86-aa5b-46ba-b9c6-0386d876f58c", + "metadata": {}, + "outputs": [], + "source": [ + "# Multiple assignment without a semicolon\n", + "x = y = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "07f89e51-9357-4cfb-8fc5-76fb75e35949", + "metadata": {}, + "outputs": [], + "source": [ + "# Multiple assignment with a semicolon\n", + "x = y = 1;\n", + "x = y = 1;" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c22b539d-473e-48f8-a236-625e58c47a00", + "metadata": {}, + "outputs": [], + "source": [ + "# Tuple unpacking without a semicolon\n", + "x, y = 1, 2" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "12c87940-a0d5-403b-a81c-7507eb06dc7e", + "metadata": {}, + "outputs": [], + "source": [ + "# Tuple unpacking with a semicolon (irrelevant)\n", + "x, y = 1, 2;\n", + "x, y = 1, 2; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "5a768c76-6bc4-470c-b37e-8cc14bc6caf4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Annotated assignment statement without a semicolon\n", + "x: int = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "21bfda82-1a9a-4ba1-9078-74ac480804b5", + "metadata": {}, + "outputs": [], + "source": [ + "# Annotated assignment statement without a semicolon\n", + "x: int = 1;\n", + "x: int = 1; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "09929999-ff29-4d10-ad2b-e665af15812d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Assignment expression without a semicolon\n", + "(x := 1)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "32a83217-1bad-4f61-855e-ffcdb119c763", + "metadata": {}, + "outputs": [], + "source": [ + "# Assignment expression with a semicolon\n", + "(x := 1);\n", + "(x := 1); # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "61b81865-277e-4964-b03e-eb78f1f318eb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x = 1\n", + "# Expression without a semicolon\n", + "x" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "974c29be-67e1-4000-95fa-6ca118a63bad", + "metadata": {}, + "outputs": [], + "source": [ + "x = 1\n", + "# Expression with a semicolon\n", + "x;" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "cfeb1757-46d6-4f13-969f-a283b6d0304f", + "metadata": {}, + "outputs": [], + "source": [ + "class Point:\n", + " def __init__(self, x, y):\n", + " self.x = x\n", + " self.y = y\n", + "\n", + "\n", + "p = Point(0, 0);" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "2ee7f1a5-ccfe-4004-bfa4-ef834a58da97", + "metadata": {}, + "outputs": [], + "source": [ + "# Assignment statement where the left is an attribute access doesn't\n", + "# print the value.\n", + "p.x = 1;" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "3e49370a-048b-474d-aa0a-3d1d4a73ad37", + "metadata": {}, + "outputs": [], + "source": [ + "data = {}\n", + "\n", + "# Neither does the subscript node\n", + "data[\"foo\"] = 1;" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "d594bdd3-eaa9-41ef-8cda-cf01bc273b2d", + "metadata": {}, + "outputs": [], + "source": [ + "if (x := 1):\n", + " # It should be the top level statement\n", + " x" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "e532f0cf-80c7-42b7-8226-6002fcf74fb6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Parentheses with comments\n", + "(\n", + " x := 1 # comment\n", + ") # comment" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "473c5d62-871b-46ed-8a34-27095243f462", + "metadata": {}, + "outputs": [], + "source": [ + "# Parentheses with comments\n", + "(\n", + " x := 1 # comment\n", + "); # comment" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "8c3c2361-f49f-45fe-bbe3-7e27410a8a86", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Hello world!'" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\"\"\"Hello world!\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "23dbe9b5-3f68-4890-ab2d-ab0dbfd0712a", + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Hello world!\"\"\"; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "3ce33108-d95d-4c70-83d1-0d4fd36a2951", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'x = 1'" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x = 1\n", + "f\"x = {x}\"" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "654a4a67-de43-4684-824a-9451c67db48f", + "metadata": {}, + "outputs": [], + "source": [ + "x = 1\n", + "f\"x = {x}\";\n", + "f\"x = {x}\"; # comment\n", + "# comment" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (ruff-playground)", + "language": "python", + "name": "ruff-playground" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/crates/ruff_cli/tests/format.rs b/crates/ruff_cli/tests/format.rs index 30b33c690a..c4527e9a79 100644 --- a/crates/ruff_cli/tests/format.rs +++ b/crates/ruff_cli/tests/format.rs @@ -818,3 +818,432 @@ fn test_diff_stdin_formatted() { ----- stderr ----- "###); } + +#[test] +fn test_notebook_trailing_semicolon() { + let fixtures = Path::new("resources").join("test").join("fixtures"); + let unformatted = fs::read(fixtures.join("trailing_semicolon.ipynb")).unwrap(); + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.ipynb"]) + .arg("-") + .pass_stdin(unformatted), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "4f8ce941-1492-4d4e-8ab5-70d733fe891a", + "metadata": {}, + "outputs": [], + "source": [ + "%config ZMQInteractiveShell.ast_node_interactivity=\"last_expr_or_assign\"" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "721ec705-0c65-4bfb-9809-7ed8bc534186", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Assignment statement without a semicolon\n", + "x = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "de50e495-17e5-41cc-94bd-565757555d7e", + "metadata": {}, + "outputs": [], + "source": [ + "# Assignment statement with a semicolon\n", + "x = 1\n", + "x = 1;" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "39e31201-23da-44eb-8684-41bba3663991", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Augmented assignment without a semicolon\n", + "x += 1" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6b73d3dd-c73a-4697-9e97-e109a6c1fbab", + "metadata": {}, + "outputs": [], + "source": [ + "# Augmented assignment without a semicolon\n", + "x += 1\n", + "x += 1; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2a3e5b86-aa5b-46ba-b9c6-0386d876f58c", + "metadata": {}, + "outputs": [], + "source": [ + "# Multiple assignment without a semicolon\n", + "x = y = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "07f89e51-9357-4cfb-8fc5-76fb75e35949", + "metadata": {}, + "outputs": [], + "source": [ + "# Multiple assignment with a semicolon\n", + "x = y = 1\n", + "x = y = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c22b539d-473e-48f8-a236-625e58c47a00", + "metadata": {}, + "outputs": [], + "source": [ + "# Tuple unpacking without a semicolon\n", + "x, y = 1, 2" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "12c87940-a0d5-403b-a81c-7507eb06dc7e", + "metadata": {}, + "outputs": [], + "source": [ + "# Tuple unpacking with a semicolon (irrelevant)\n", + "x, y = 1, 2\n", + "x, y = 1, 2 # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "5a768c76-6bc4-470c-b37e-8cc14bc6caf4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Annotated assignment statement without a semicolon\n", + "x: int = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "21bfda82-1a9a-4ba1-9078-74ac480804b5", + "metadata": {}, + "outputs": [], + "source": [ + "# Annotated assignment statement without a semicolon\n", + "x: int = 1\n", + "x: int = 1; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "09929999-ff29-4d10-ad2b-e665af15812d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Assignment expression without a semicolon\n", + "(x := 1)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "32a83217-1bad-4f61-855e-ffcdb119c763", + "metadata": {}, + "outputs": [], + "source": [ + "# Assignment expression with a semicolon\n", + "(x := 1)\n", + "(x := 1); # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "61b81865-277e-4964-b03e-eb78f1f318eb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x = 1\n", + "# Expression without a semicolon\n", + "x" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "974c29be-67e1-4000-95fa-6ca118a63bad", + "metadata": {}, + "outputs": [], + "source": [ + "x = 1\n", + "# Expression with a semicolon\n", + "x;" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "cfeb1757-46d6-4f13-969f-a283b6d0304f", + "metadata": {}, + "outputs": [], + "source": [ + "class Point:\n", + " def __init__(self, x, y):\n", + " self.x = x\n", + " self.y = y\n", + "\n", + "\n", + "p = Point(0, 0);" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "2ee7f1a5-ccfe-4004-bfa4-ef834a58da97", + "metadata": {}, + "outputs": [], + "source": [ + "# Assignment statement where the left is an attribute access doesn't\n", + "# print the value.\n", + "p.x = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "3e49370a-048b-474d-aa0a-3d1d4a73ad37", + "metadata": {}, + "outputs": [], + "source": [ + "data = {}\n", + "\n", + "# Neither does the subscript node\n", + "data[\"foo\"] = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "d594bdd3-eaa9-41ef-8cda-cf01bc273b2d", + "metadata": {}, + "outputs": [], + "source": [ + "if x := 1:\n", + " # It should be the top level statement\n", + " x" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "e532f0cf-80c7-42b7-8226-6002fcf74fb6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Parentheses with comments\n", + "(\n", + " x := 1 # comment\n", + ") # comment" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "473c5d62-871b-46ed-8a34-27095243f462", + "metadata": {}, + "outputs": [], + "source": [ + "# Parentheses with comments\n", + "(\n", + " x := 1 # comment\n", + "); # comment" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "8c3c2361-f49f-45fe-bbe3-7e27410a8a86", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Hello world!'" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\"\"\"Hello world!\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "23dbe9b5-3f68-4890-ab2d-ab0dbfd0712a", + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Hello world!\"\"\"; # comment\n", + "# comment" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "3ce33108-d95d-4c70-83d1-0d4fd36a2951", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'x = 1'" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x = 1\n", + "f\"x = {x}\"" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "654a4a67-de43-4684-824a-9451c67db48f", + "metadata": {}, + "outputs": [], + "source": [ + "x = 1\n", + "f\"x = {x}\"\n", + "f\"x = {x}\"; # comment\n", + "# comment" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (ruff-playground)", + "language": "python", + "name": "ruff-playground" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 + } + + ----- stderr ----- + "###); +} diff --git a/crates/ruff_python_formatter/src/comments/format.rs b/crates/ruff_python_formatter/src/comments/format.rs index a492ef1012..abf4833dac 100644 --- a/crates/ruff_python_formatter/src/comments/format.rs +++ b/crates/ruff_python_formatter/src/comments/format.rs @@ -323,7 +323,7 @@ pub(crate) struct FormatEmptyLines { impl Format> for FormatEmptyLines { fn fmt(&self, f: &mut Formatter) -> FormatResult<()> { match f.context().node_level() { - NodeLevel::TopLevel => match self.lines { + NodeLevel::TopLevel(_) => match self.lines { 0 | 1 => write!(f, [hard_line_break()]), 2 => write!(f, [empty_line()]), _ => match f.options().source_type() { @@ -519,9 +519,9 @@ pub(crate) fn empty_lines_before_trailing_comments<'a>( ) -> FormatEmptyLinesBeforeTrailingComments<'a> { // Black has different rules for stub vs. non-stub and top level vs. indented let empty_lines = match (f.options().source_type(), f.context().node_level()) { - (PySourceType::Stub, NodeLevel::TopLevel) => 1, + (PySourceType::Stub, NodeLevel::TopLevel(_)) => 1, (PySourceType::Stub, _) => 0, - (_, NodeLevel::TopLevel) => 2, + (_, NodeLevel::TopLevel(_)) => 2, (_, _) => 1, }; @@ -573,9 +573,9 @@ pub(crate) fn empty_lines_after_leading_comments<'a>( ) -> FormatEmptyLinesAfterLeadingComments<'a> { // Black has different rules for stub vs. non-stub and top level vs. indented let empty_lines = match (f.options().source_type(), f.context().node_level()) { - (PySourceType::Stub, NodeLevel::TopLevel) => 1, + (PySourceType::Stub, NodeLevel::TopLevel(_)) => 1, (PySourceType::Stub, _) => 0, - (_, NodeLevel::TopLevel) => 2, + (_, NodeLevel::TopLevel(_)) => 2, (_, _) => 1, }; diff --git a/crates/ruff_python_formatter/src/context.rs b/crates/ruff_python_formatter/src/context.rs index 01caed9e68..f92825fe95 100644 --- a/crates/ruff_python_formatter/src/context.rs +++ b/crates/ruff_python_formatter/src/context.rs @@ -19,7 +19,7 @@ impl<'a> PyFormatContext<'a> { options, contents, comments, - node_level: NodeLevel::TopLevel, + node_level: NodeLevel::TopLevel(TopLevelStatementPosition::Other), } } @@ -68,12 +68,21 @@ impl Debug for PyFormatContext<'_> { } } -/// What's the enclosing level of the outer node. +/// The position of a top-level statement in the module. #[derive(Copy, Clone, Debug, Eq, PartialEq, Default)] +pub(crate) enum TopLevelStatementPosition { + /// This is the last top-level statement in the module. + Last, + /// Any other top-level statement. + #[default] + Other, +} + +/// What's the enclosing level of the outer node. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub(crate) enum NodeLevel { /// Formatting statements on the module level. - #[default] - TopLevel, + TopLevel(TopLevelStatementPosition), /// Formatting the body statements of a [compound statement](https://docs.python.org/3/reference/compound_stmts.html#compound-statements) /// (`if`, `while`, `match`, etc.). @@ -86,6 +95,12 @@ pub(crate) enum NodeLevel { ParenthesizedExpression, } +impl Default for NodeLevel { + fn default() -> Self { + Self::TopLevel(TopLevelStatementPosition::Other) + } +} + impl NodeLevel { /// Returns `true` if the expression is in a parenthesized context. pub(crate) const fn is_parenthesized(self) -> bool { @@ -94,6 +109,11 @@ impl NodeLevel { NodeLevel::Expression(Some(_)) | NodeLevel::ParenthesizedExpression ) } + + /// Returns `true` if this is the last top-level statement in the module. + pub(crate) const fn is_last_top_level_statement(self) -> bool { + matches!(self, NodeLevel::TopLevel(TopLevelStatementPosition::Last)) + } } /// Change the [`NodeLevel`] of the formatter for the lifetime of this struct diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index 707963f1ec..019546e596 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -135,7 +135,9 @@ impl FormatRule> for FormatExpr { } } else { let level = match f.context().node_level() { - NodeLevel::TopLevel | NodeLevel::CompoundStatement => NodeLevel::Expression(None), + NodeLevel::TopLevel(_) | NodeLevel::CompoundStatement => { + NodeLevel::Expression(None) + } saved_level @ (NodeLevel::Expression(_) | NodeLevel::ParenthesizedExpression) => { saved_level } diff --git a/crates/ruff_python_formatter/src/expression/parentheses.rs b/crates/ruff_python_formatter/src/expression/parentheses.rs index e68a5a473e..75e0ee3fe0 100644 --- a/crates/ruff_python_formatter/src/expression/parentheses.rs +++ b/crates/ruff_python_formatter/src/expression/parentheses.rs @@ -252,7 +252,7 @@ pub(crate) enum InParenthesesOnlyLineBreak { impl<'ast> Format> for InParenthesesOnlyLineBreak { fn fmt(&self, f: &mut Formatter>) -> FormatResult<()> { match f.context().node_level() { - NodeLevel::TopLevel | NodeLevel::CompoundStatement | NodeLevel::Expression(None) => { + NodeLevel::TopLevel(_) | NodeLevel::CompoundStatement | NodeLevel::Expression(None) => { match self { InParenthesesOnlyLineBreak::SoftLineBreak => Ok(()), InParenthesesOnlyLineBreak::SoftLineBreakOrSpace => space().fmt(f), @@ -319,7 +319,7 @@ pub(super) fn write_in_parentheses_only_group_start_tag(f: &mut PyFormatter) { // Unconditionally group the content if it is not enclosed by an optional parentheses group. f.write_element(FormatElement::Tag(tag::Tag::StartGroup(tag::Group::new()))); } - NodeLevel::Expression(None) | NodeLevel::TopLevel | NodeLevel::CompoundStatement => { + NodeLevel::Expression(None) | NodeLevel::TopLevel(_) | NodeLevel::CompoundStatement => { // No group } } @@ -334,7 +334,7 @@ pub(super) fn write_in_parentheses_only_group_end_tag(f: &mut PyFormatter) { // Unconditionally group the content if it is not enclosed by an optional parentheses group. f.write_element(FormatElement::Tag(tag::Tag::EndGroup)); } - NodeLevel::Expression(None) | NodeLevel::TopLevel | NodeLevel::CompoundStatement => { + NodeLevel::Expression(None) | NodeLevel::TopLevel(_) | NodeLevel::CompoundStatement => { // No group } } diff --git a/crates/ruff_python_formatter/src/statement/stmt_ann_assign.rs b/crates/ruff_python_formatter/src/statement/stmt_ann_assign.rs index f22457cc4b..cb5f5fa745 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_ann_assign.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_ann_assign.rs @@ -2,9 +2,11 @@ use ruff_formatter::write; use ruff_python_ast::StmtAnnAssign; use crate::comments::{SourceComment, SuppressionKind}; + use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::Parenthesize; use crate::prelude::*; +use crate::statement::trailing_semicolon; #[derive(Default)] pub struct FormatStmtAnnAssign; @@ -36,6 +38,14 @@ impl FormatNodeRule for FormatStmtAnnAssign { )?; } + if f.options().source_type().is_ipynb() + && f.context().node_level().is_last_top_level_statement() + && target.is_name_expr() + && trailing_semicolon(item.into(), f.context().source()).is_some() + { + token(";").fmt(f)?; + } + Ok(()) } diff --git a/crates/ruff_python_formatter/src/statement/stmt_assign.rs b/crates/ruff_python_formatter/src/statement/stmt_assign.rs index 475a21a58b..7a8a5fd2be 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_assign.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_assign.rs @@ -6,6 +6,7 @@ use crate::context::{NodeLevel, WithNodeLevel}; use crate::expression::parentheses::{Parentheses, Parenthesize}; use crate::expression::{has_own_parentheses, maybe_parenthesize_expression}; use crate::prelude::*; +use crate::statement::trailing_semicolon; #[derive(Default)] pub struct FormatStmtAssign; @@ -40,7 +41,18 @@ impl FormatNodeRule for FormatStmtAssign { item, Parenthesize::IfBreaks )] - ) + )?; + + if f.options().source_type().is_ipynb() + && f.context().node_level().is_last_top_level_statement() + && rest.is_empty() + && first.is_name_expr() + && trailing_semicolon(item.into(), f.context().source()).is_some() + { + token(";").fmt(f)?; + } + + Ok(()) } fn is_suppressed( diff --git a/crates/ruff_python_formatter/src/statement/stmt_aug_assign.rs b/crates/ruff_python_formatter/src/statement/stmt_aug_assign.rs index df15aaa121..65260c5fec 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_aug_assign.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_aug_assign.rs @@ -2,9 +2,11 @@ use ruff_formatter::write; use ruff_python_ast::StmtAugAssign; use crate::comments::{SourceComment, SuppressionKind}; + use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::Parenthesize; use crate::prelude::*; +use crate::statement::trailing_semicolon; use crate::{AsFormat, FormatNodeRule}; #[derive(Default)] @@ -28,7 +30,17 @@ impl FormatNodeRule for FormatStmtAugAssign { space(), maybe_parenthesize_expression(value, item, Parenthesize::IfBreaks) ] - ) + )?; + + if f.options().source_type().is_ipynb() + && f.context().node_level().is_last_top_level_statement() + && target.is_name_expr() + && trailing_semicolon(item.into(), f.context().source()).is_some() + { + token(";").fmt(f)?; + } + + Ok(()) } fn is_suppressed( diff --git a/crates/ruff_python_formatter/src/statement/stmt_expr.rs b/crates/ruff_python_formatter/src/statement/stmt_expr.rs index ae2b45bad2..c0a6361b71 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_expr.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_expr.rs @@ -2,9 +2,11 @@ use ruff_python_ast as ast; use ruff_python_ast::{Expr, Operator, StmtExpr}; use crate::comments::{SourceComment, SuppressionKind}; + use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::Parenthesize; use crate::prelude::*; +use crate::statement::trailing_semicolon; #[derive(Default)] pub struct FormatStmtExpr; @@ -14,10 +16,19 @@ impl FormatNodeRule for FormatStmtExpr { let StmtExpr { value, .. } = item; if is_arithmetic_like(value) { - maybe_parenthesize_expression(value, item, Parenthesize::Optional).fmt(f) + maybe_parenthesize_expression(value, item, Parenthesize::Optional).fmt(f)?; } else { - value.format().fmt(f) + value.format().fmt(f)?; } + + if f.options().source_type().is_ipynb() + && f.context().node_level().is_last_top_level_statement() + && trailing_semicolon(item.into(), f.context().source()).is_some() + { + token(";").fmt(f)?; + } + + Ok(()) } fn is_suppressed( diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index b0dee5f58c..beac36ffb9 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -8,7 +8,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::comments::{ leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments, }; -use crate::context::{NodeLevel, WithNodeLevel}; +use crate::context::{NodeLevel, TopLevelStatementPosition, WithNodeLevel}; use crate::expression::string::StringLayout; use crate::prelude::*; use crate::statement::stmt_expr::FormatStmtExpr; @@ -49,8 +49,19 @@ impl Default for FormatSuite { impl FormatRule> for FormatSuite { fn fmt(&self, statements: &Suite, f: &mut PyFormatter) -> FormatResult<()> { + let mut iter = statements.iter(); + let Some(first) = iter.next() else { + return Ok(()); + }; + let node_level = match self.kind { - SuiteKind::TopLevel => NodeLevel::TopLevel, + SuiteKind::TopLevel => NodeLevel::TopLevel( + iter.clone() + .next() + .map_or(TopLevelStatementPosition::Last, |_| { + TopLevelStatementPosition::Other + }), + ), SuiteKind::Function | SuiteKind::Class | SuiteKind::Other => { NodeLevel::CompoundStatement } @@ -62,11 +73,6 @@ impl FormatRule> for FormatSuite { let f = &mut WithNodeLevel::new(node_level, f); - let mut iter = statements.iter(); - let Some(first) = iter.next() else { - return Ok(()); - }; - // Format the first statement in the body, which often has special formatting rules. let first = match self.kind { SuiteKind::Other => { @@ -165,6 +171,11 @@ impl FormatRule> for FormatSuite { let mut preceding_comments = comments.leading_dangling_trailing(preceding); while let Some(following) = iter.next() { + if self.kind == SuiteKind::TopLevel && iter.clone().next().is_none() { + f.context_mut() + .set_node_level(NodeLevel::TopLevel(TopLevelStatementPosition::Last)); + } + let following_comments = comments.leading_dangling_trailing(following); let needs_empty_lines = if is_class_or_function_definition(following) { @@ -351,7 +362,7 @@ impl FormatRule> for FormatSuite { .map_or(preceding.end(), |comment| comment.slice().end()); match node_level { - NodeLevel::TopLevel => match lines_after(end, source) { + NodeLevel::TopLevel(_) => match lines_after(end, source) { 0 | 1 => hard_line_break().fmt(f)?, 2 => empty_line().fmt(f)?, _ => match source_type {