Repository: beeware/briefcase

Issue:
Giving a formal name with a character such as ', /, \, causes briefcase new to crash
### Describe the bug

When you execute `briefcase new`  and set the name to / you get an IndexError: string index out of range.  

This is also true for some other characters such as /, \ , ", ', and ,
It appears to effect all of that class of characters due to the way the class name is created.

The problem appears to be in config.py make_class_name:
    
    # Normalize to NFKC form, then remove any character that isn't
    # in the allowed categories, or is the underscore character;
    # Capitalize the resulting word.
    class_name = "".join(
        ch
        for ch in unicodedata.normalize("NFKC", formal_name)
        if unicodedata.category(ch) in xid_continue or ch in {"_"}
    )

This causes the name to be stripped of all characters, making the string length zero.

This causes an attempt to make a class name to fail

    # If the first character isn't in the 'start' character set,
    # and it isn't already an underscore, prepend an underscore.
    if unicodedata.category(class_name[0]) not in xid_start and class_name[0] != "_":
        class_name = f"_{class_name}"

    return class_name

### Steps to reproduce

1. Run `briefcase new`
2. When prompted Formal Name [Hello World]: enter \


### Expected behavior

Validation should force you to create a legal name, or additionally prompt you to create proper class names.

### Screenshots

_No response_

### Environment

- Operating System: Linux
- Python version: 3.11
- Software versions:
  - Briefcase: 0.3.19.dev26+g96734be6.d20240520
  - Toga:
  - ...


### Logs

_No response_

### Additional context

_No response_


Discussion:
I'm looking into this
I have a fix developed. Essentially added a validator function to the input. Working on the pull request process now.



Patch:
diff --git a/changes/1810.bugfix.rst b/changes/1810.bugfix.rst
new file mode 100644
index 000000000..24150ad2f
--- /dev/null
+++ b/changes/1810.bugfix.rst
@@ -0,0 +1,1 @@
+The formal name of an app is now validated.
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index ef52b0409..1a8e6dffa 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -164,6 +164,26 @@ def make_app_name(self, formal_name):
             # use a dummy app name as the suggestion.
             return "myapp"
 
+    def validate_formal_name(self, candidate):
+        """Determine if the formal name is valid.
+
+        A formal name is valid if it contains at least one identifier character.
+
+        :param candidate: The candidate name
+        :returns: True the formal name is valid.
+        :raises: ValueError if the name is not a valid formal name.
+        """
+        if not make_class_name(candidate):  # Check whether a class name may be derived
+            raise ValueError(
+                self.input.textwrap(
+                    f"{candidate!r} is not a valid formal name.\n"
+                    "\n"
+                    "Formal names must include at least one valid Python identifier character."
+                )
+            )
+
+        return True
+
     def _validate_existing_app_name(self, candidate):
         """Perform internal validation preventing the use of pre-existing app names.
 
@@ -459,6 +479,7 @@ def build_app_context(self, project_overrides: dict[str, str]) -> dict[str, str]
             ),
             variable="formal name",
             default="Hello World",
+            validator=self.validate_formal_name,
             override_value=project_overrides.pop("formal_name", None),
         )
 
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 34d50800a..bf158cfce 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -73,7 +73,11 @@ def make_class_name(formal_name):
 
     # If the first character isn't in the 'start' character set,
     # and it isn't already an underscore, prepend an underscore.
-    if unicodedata.category(class_name[0]) not in xid_start and class_name[0] != "_":
+    if (
+        class_name
+        and unicodedata.category(class_name[0]) not in xid_start
+        and class_name[0] != "_"
+    ):
         class_name = f"_{class_name}"
 
     return class_name


Test patch:
diff --git a/tests/commands/new/test_validate_formal_name.py b/tests/commands/new/test_validate_formal_name.py
new file mode 100644
index 000000000..3daa39a54
--- /dev/null
+++ b/tests/commands/new/test_validate_formal_name.py
@@ -0,0 +1,48 @@
+import pytest
+
+
+@pytest.mark.parametrize(
+    "name",
+    [
+        # Various forms of capitalization and alphanumeric
+        "Hello World",
+        "helloworld",
+        "helloWorld",
+        "hello42world",
+        "42helloworld",
+        # Names that include punctuation
+        "hello_world",
+        "hello-world",
+        "_helloworld",
+        "/helloworld",
+        "Hello / World!",
+        # Internationalized names that can be unicode-simplified
+        "Hallo Vögel",
+        "Bonjour Garçon",
+        # Internationalized names that cannot be unicode-simplified
+        "你好 世界!",
+    ],
+)
+def test_valid_formal_name(new_command, name):
+    """Test that valid formal names are accepted."""
+    assert new_command.validate_formal_name(name)
+
+
+@pytest.mark.parametrize(
+    "name",
+    [
+        "",  # Empty
+        " ",  # Just a space
+        "\t",  # Other whitespace characters
+        "/",  # Just a slash
+        "'",
+        "\\",
+        "/'\\",  # Multiple invalid characters
+    ],
+)
+def test_invalid_formal_name(new_command, name, tmp_path):
+    """Test that invalid app names are rejected."""
+    (tmp_path / "existing").mkdir()
+
+    with pytest.raises(ValueError):
+        new_command.validate_formal_name(name)