Repository: duckdb/duckdb (language: python)

Issue:
DuckDB uncaught exception when using SELECT with EXPLAIN
### What happens?

The latest version of the DuckDB (latest main: v1.1.4-dev2210 1a3d614f0e) crashes when running the following SQL statement: 

```sql
EXPLAIN SELECT 'any_string' IN ?;
```

Here is the stack trace from v1.1.4-dev2210 1a3d614f0e: 

```
#0  __GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:50
#1  0x0000ffffb6c41aac in __GI_abort () at abort.c:79
#2  0x0000ffffb6f048bc in __gnu_cxx::__verbose_terminate_handler() () from /lib/aarch64-linux-gnu/libstdc++.so.6
#3  0x0000ffffb6f0220c in ?? () from /lib/aarch64-linux-gnu/libstdc++.so.6
#4  0x0000ffffb6f02270 in std::terminate() () from /lib/aarch64-linux-gnu/libstdc++.so.6
#5  0x0000ffffb6f02564 in __cxa_throw () from /lib/aarch64-linux-gnu/libstdc++.so.6
#6  0x0000000000498f34 in duckdb_shell::ModeExplainRenderer::RenderRow (this=<optimized out>, result=...) at ../../tools/shell/shell_renderer.cpp:334
#7  0x0000000000469e68 in duckdb_shell::ShellState::RenderRow (this=<optimized out>, renderer=..., result=...) at ../../tools/shell/shell.cpp:1294
#8  0x000000000046e500 in duckdb_shell::ShellState::ExecutePreparedStatement (this=0xfffff8effda8, pStmt=0x14fb34d0) at ../../tools/shell/shell.cpp:1722
#9  0x000000000046ef64 in duckdb_shell::ShellState::ExecuteSQL (this=<optimized out>, zSql=0x14d62830 "EXPLAIN SELECT 'any_string' IN ?;", pzErrMsg=<optimized out>) at ../../tools/shell/shell.cpp:1788
#10 0x0000000000484a84 in duckdb_shell::ShellState::RunOneSqlLine (this=0xfffff8effda8, zSql=0x14d62830 "EXPLAIN SELECT 'any_string' IN ?;") at ../../tools/shell/shell.cpp:4318
#11 0x000000000047f3cc in duckdb_shell::ShellState::ProcessInput (this=<optimized out>) at ../../tools/shell/shell.cpp:4433
#12 0x0000000000488b48 in main (argc=1, argv=0xfffff8f000f8) at ../../tools/shell/shell.cpp:5032
```

The bug is NOT reproducible on the latest release: 19864453f7, only reproducible on the latest main. Must be introduced recently. 

### To Reproduce

### To Reproduce

1. Clone the DuckDB Git from the official repo.
2. Checkout to the latest main (1a3d614f0e).
3. Compile the DuckDB binary by using either `make reldebug` or `make debug`.
4. Run the compiled DuckDB and input the following SQL:

```
EXPLAIN SELECT 'any_string' IN ?;
```

### OS:

Ubuntu 20.04 LTS ARM 64

### DuckDB Version:

 v1.1.4-dev2210 1a3d614f0e

### DuckDB Client:

cli

### Hardware:

_No response_

### Full Name:

Yu Liang

### Affiliation:

Pennsylvania State University

### What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.

I have tested with a source build

### Did you include all relevant data sets for reproducing the issue?

Yes

### Did you include all code required to reproduce the issue?

- [X] Yes, I have

### Did you include all relevant configuration (e.g., CPU architecture, Python version, Linux distribution) to reproduce the issue?

- [X] Yes, I have


Discussion / hints:

Fix patch:
diff --git a/src/parser/parser.cpp b/src/parser/parser.cpp
index ca7e1a71145f..77ad899f1ff4 100644
--- a/src/parser/parser.cpp
+++ b/src/parser/parser.cpp
@@ -369,25 +369,36 @@ vector<SimplifiedToken> Parser::TokenizeError(const string &error_msg) {
 
 	// now iterate over the
 	bool in_quotes = false;
+	char quote_char = '\0';
 	for (idx_t i = error_start; i < error_end; i++) {
-		if (error_msg[i] == '"' || error_msg[i] == '\'') {
-			SimplifiedToken token;
-			token.start = i;
-			if (!in_quotes) {
-				token.type = SimplifiedTokenType::SIMPLIFIED_TOKEN_STRING_CONSTANT;
-				token.start++;
-			} else {
+		if (in_quotes) {
+			// in a quote - look for the quote character
+			if (error_msg[i] == quote_char) {
+				SimplifiedToken token;
+				token.start = i;
 				token.type = SimplifiedTokenType::SIMPLIFIED_TOKEN_IDENTIFIER;
+				tokens.push_back(token);
+				in_quotes = false;
+			}
+			if (StringUtil::CharacterIsNewline(error_msg[i])) {
+				// found a newline in a quote, abort the quoted state entirely
+				tokens.pop_back();
+				in_quotes = false;
 			}
+		} else if (error_msg[i] == '"' || error_msg[i] == '\'') {
+			// not quoted and found a quote - enter the quoted state
+			SimplifiedToken token;
+			token.start = i;
+			token.type = SimplifiedTokenType::SIMPLIFIED_TOKEN_STRING_CONSTANT;
+			token.start++;
 			tokens.push_back(token);
-			in_quotes = !in_quotes;
+			quote_char = error_msg[i];
+			in_quotes = true;
 		}
 	}
-	if (in_quotes && error_end < error_msg.size()) {
-		SimplifiedToken token;
-		token.start = error_end;
-		token.type = SimplifiedTokenType::SIMPLIFIED_TOKEN_IDENTIFIER;
-		tokens.push_back(token);
+	if (in_quotes) {
+		// unterminated quotes at the end of the error - pop back the quoted state
+		tokens.pop_back();
 	}
 	if (line_pos.IsValid()) {
 		SimplifiedToken token;
diff --git a/tools/shell/shell_renderer.cpp b/tools/shell/shell_renderer.cpp
index d597583b8fd7..43ba0b58e972 100644
--- a/tools/shell/shell_renderer.cpp
+++ b/tools/shell/shell_renderer.cpp
@@ -331,7 +331,7 @@ class ModeExplainRenderer : public RowRenderer {
 	void RenderRow(RowResult &result) override {
 		auto &data = result.data;
 		if (data.size() != 2) {
-			throw std::runtime_error("Invalid usage of explain renderer - must have two data points per row");
+			return;
 		}
 		if (strcmp(data[0], "logical_plan") == 0 || strcmp(data[0], "logical_opt") == 0 ||
 		    strcmp(data[0], "physical_plan") == 0) {


Test patch:
diff --git a/tools/shell/tests/test_errors.py b/tools/shell/tests/test_errors.py
index e0bbdf142950..378302826c02 100644
--- a/tools/shell/tests/test_errors.py
+++ b/tools/shell/tests/test_errors.py
@@ -63,3 +63,33 @@ def test_long_error(shell):
     result = test.run()
     result.check_stderr('\x1b[33m+(DATE, TIMESTAMP)\x1b[0m')
     result.check_stderr('\x1b[32mCAST\x1b[0m')
+
+@pytest.mark.skipif(os.name == 'nt', reason="Windows highlighting does not use shell escapes")
+def test_single_quotes_in_error(shell):
+    test = (
+        ShellTest(shell)
+        .statement(".highlight_errors on")
+        .statement("select \"I'm an error\"")
+    )
+    result = test.run()
+    result.check_stderr('"\x1b[33mI\'m an error\x1b[0m')
+
+@pytest.mark.skipif(os.name == 'nt', reason="Windows highlighting does not use shell escapes")
+def test_double_quotes_in_error(shell):
+    test = (
+        ShellTest(shell)
+        .statement(".highlight_errors on")
+        .statement("select error('''I\"m an error''')")
+    )
+    result = test.run()
+    result.check_stderr('\x1b[33mI"m an error\x1b[0m')
+
+@pytest.mark.skipif(os.name == 'nt', reason="Windows highlighting does not use shell escapes")
+def test_unterminated_quote(shell):
+    test = (
+        ShellTest(shell)
+        .statement(".highlight_errors on")
+        .statement("select error('I''m an error')")
+    )
+    result = test.run()
+    result.check_stderr('I\'m an error')
diff --git a/tools/shell/tests/test_explain.py b/tools/shell/tests/test_explain.py
new file mode 100644
index 000000000000..2f2826069fdd
--- /dev/null
+++ b/tools/shell/tests/test_explain.py
@@ -0,0 +1,15 @@
+# fmt: off
+
+import pytest
+import subprocess
+import sys
+from typing import List
+from conftest import ShellTest
+import os
+
+def test_invalid_explain(shell):
+    test = (
+        ShellTest(shell)
+        .statement("EXPLAIN SELECT 'any_string' IN ?;")
+    )
+    result = test.run()