Repository: microsoft/ptvsd

Issue:
Breaking on exception opens a duplicate editor window without breakpoints
* Debug a python program with a print statement `print(xyz)`
* VS Code breaks on the exception, but it opens another instance of the same file (duplicate file)
* This other file does not have breakpoints, 
* Problem = The path to the file is different (case sensitive paths)

* Workspace = c:\Temp\python\debuggerTesting
* File being debugged = one.py
* Contents of file:
```python
print(x)
```
* Add breakpoints to the first line
* Start debugging
* VS Code breaks at the exception, 
* VS Code opens the file "C:\temp\python\debuggertesting.py"
* The breakpoint we added is missing

The problem, is PTVS is returning the path to the file with lowercase
* On the file system "Temp" directory has an upper case "T"
* PTVSD seems to be returning the path for "Temp" with lowercase "T" same applies to "debuggerTesting" (returned path contains "debuggertesting")




Discussion:
It kinda feels like a VSC bug to me - paths *are* case-insensitive on Windows, and if trying to open the same file with a path that differs only by case opens a new tab, that's wrong (and will probably surface elsewhere as well, like opening files from command line).
> It kinda feels like a VSC bug to me

Agreed, will check VS Code 

Paths might be case-insensitive on Windows, but they are also case-preserving, so if you're forcing everyone's UX to use lower-case paths just because that what you function with at a lower level you're basically making case-preservation worthless.

We are currently marking this issue a release blocker on the PVSC side of things since I ran into this where I set a breakpoint before running the debugger and then when I do I end up with the breakpoint listed twice as it thought there was a breakpoint for `C.py` and `c.py`.
The UX should use properly cased path, of course. But in this scenario you already have one, and all it needs to do is correctly (i.e. case insensitively) match it against the path that it gets from the debugger.
I've filed an issue on VS Code for this. https://github.com/Microsoft/vscode/issues/44074
Looking at the responses, they are not going to be fixing this in VS Code any time soon (it's a known issue from Sep 2016).

Resolving patch:
diff --git a/ptvsd/pathutils.py b/ptvsd/pathutils.py
new file mode 100644
--- /dev/null
+++ b/ptvsd/pathutils.py
@@ -0,0 +1,65 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See LICENSE in the project root
+# for license information.
+
+from __future__ import print_function, with_statement, absolute_import
+
+import sys
+import os.path
+import platform
+from glob import glob
+
+
+__author__ = "Microsoft Corporation <ptvshelp@microsoft.com>"
+__version__ = "4.0.0a1"
+
+MAX_FILES_TO_CACHE = 1000
+
+class PathUnNormcase(object):
+    """Ensures path names of files are returned as they exist on the fs."""
+
+    def __init__(self):
+        self._dict = {}
+        self._enabled = False
+
+    def enable(self):
+        self._enabled = platform.system() == 'Windows'
+
+    def un_normcase(self, file_path):
+        if not self._enabled or len(file_path) == 0:
+            return file_path
+        if file_path in self._dict:
+            return self._dict[file_path]
+        file_path_to_return = self._get_actual_filename(file_path)
+        self.track_file_path_case(file_path_to_return)
+        return file_path_to_return
+
+    def track_file_path_case(self, file_path):
+        if not self._enabled:
+            return
+        if len(self._dict) > MAX_FILES_TO_CACHE:
+            self._dict.clear()
+        self._dict[file_path] = file_path
+
+    def _get_actual_filename(self, name):
+        """
+        Use glob to search for a file by building a regex.
+        Original source from https://stackoverflow.com/a/30374360/4443457
+        (Modified to match file name as well).
+        """
+
+        sep = os.path.sep
+        parts = os.path.normpath(name).split(sep)
+        dirs = parts[0:-1]
+        filename = "%s[%s]"%(parts[-1][:-1],parts[-1][-1:])
+        if dirs[0] == os.path.splitdrive(name)[0]:
+            test_name = [dirs[0].upper()]
+        else:
+            test_name = [sep + dirs[0]]
+        for d in dirs[1:]:
+            test_name += ["%s[%s]"%(d[: - 1], d[-1])]
+        path = glob(sep.join(test_name))[0]
+        res = glob(sep.join((path, filename)))
+        if not res:
+            return name
+        return res[0]
diff --git a/ptvsd/wrapper.py b/ptvsd/wrapper.py
--- a/ptvsd/wrapper.py
+++ b/ptvsd/wrapper.py
@@ -27,6 +27,7 @@
 import ptvsd.ipcjson as ipcjson
 import ptvsd.futures as futures
 import ptvsd.untangle as untangle
+from ptvsd.pathutils import PathUnNormcase
 
 
 __author__ = "Microsoft Corporation <ptvshelp@microsoft.com>"
@@ -425,7 +426,7 @@ def __init__(self, socket, pydevd, logfile=None, killonclose=True):
         self.disconnect_request_event = threading.Event()
         pydevd._vscprocessor = self
         self._closed = False
-
+        self.path_casing = PathUnNormcase()
         self.event_loop_thread = threading.Thread(target=self.loop.run_forever,
                                                   name='ptvsd.EventLoop')
         self.event_loop_thread.daemon = True
@@ -561,11 +562,11 @@ def process_launch_arguments(self):
         if self.launch_arguments is None:
             return
 
-        if self.launch_arguments.get('redirectOutput', False):
-            redirect_stdout = 'STDOUT\tSTDERR'
-        else:
-            redirect_stdout = ''
-        self.pydevd_request(pydevd_comm.CMD_REDIRECT_OUTPUT, redirect_stdout)
+        if self.launch_arguments.get('fixFilePathCase', False):
+            self.path_casing.enable()
+            
+        redirect_output = 'STDOUT\tSTDERR' if self.launch_arguments.get('redirectOutput', False) == True else ''
+        self.pydevd_request(pydevd_comm.CMD_REDIRECT_OUTPUT, redirect_output)
 
     def on_disconnect(self, request, args):
         # TODO: docstring
@@ -673,7 +674,7 @@ def on_stackTrace(self, request, args):
             key = (pyd_tid, int(xframe['id']))
             fid = self.frame_map.to_vscode(key, autogen=True)
             name = unquote(xframe['name'])
-            file = unquote(xframe['file'])
+            file = self.path_casing.un_normcase(unquote(xframe['file']))
             line = int(xframe['line'])
             stackFrames.append({
                 'id': fid,
@@ -831,6 +832,7 @@ def on_setBreakpoints(self, request, args):
         # TODO: docstring
         bps = []
         path = args['source']['path']
+        self.path_casing.track_file_path_case(path)
         src_bps = args.get('breakpoints', [])
 
         # First, we must delete all existing breakpoints in that source.
@@ -846,6 +848,7 @@ def on_setBreakpoints(self, request, args):
             line = src_bp['line']
             vsc_bpid = self.bp_map.add(
                     lambda vsc_bpid: (path, vsc_bpid))
+            self.path_casing.track_file_path_case(path)
             msg = msgfmt.format(vsc_bpid, path, line,
                                 src_bp.get('condition', None))
             self.pydevd_notify(cmd, msg)


Test patch:
diff --git a/tests/ptvsd/test_pathutils.py b/tests/ptvsd/test_pathutils.py
new file mode 100644
--- /dev/null
+++ b/tests/ptvsd/test_pathutils.py
@@ -0,0 +1,54 @@
+import os.path
+import sys
+import unittest
+import platform
+
+from ptvsd.pathutils import PathUnNormcase
+
+class PathUtilTests(unittest.TestCase):
+    def test_invalid_path_names(self):
+        tool = PathUnNormcase()
+        file_path = 'x:/this is an/invalid/file/invalid_file_.csv'
+        self.assertEqual(file_path, tool.un_normcase(file_path))
+
+    def test_empty_path_names(self):
+        tool = PathUnNormcase()
+        file_path = ''
+        self.assertEqual(file_path, tool.un_normcase(file_path))
+
+    def test_valid_names(self):
+        tool = PathUnNormcase()
+        file_path = __file__
+        self.assertEqual(file_path, tool.un_normcase(file_path))
+
+    def test_path_names_normcased(self):
+        tool = PathUnNormcase()
+        tool.enable()
+        file_path = __file__
+        self.assertEqual(file_path, tool.un_normcase(os.path.normcase(file_path)))
+
+    @unittest.skipIf(platform.system() != 'Windows', "Windows OS specific test")
+    def test_path_names_uppercase_disabled(self):
+        tool = PathUnNormcase()
+        file_path = __file__
+        self.assertNotEqual(file_path, tool.un_normcase(file_path.upper()))
+
+    @unittest.skipIf(platform.system() != 'Windows', "Windows OS specific test")
+    def test_path_names_uppercase_enabled(self):
+        tool = PathUnNormcase()
+        tool.enable()
+        file_path = __file__
+        self.assertEqual(file_path, tool.un_normcase(file_path.upper()))
+
+    @unittest.skipIf(platform.system() != 'Windows', "Windows OS specific test")
+    def test_path_names_lowercase_disabled(self):
+        tool = PathUnNormcase()
+        file_path = __file__
+        self.assertNotEqual(file_path, tool.un_normcase(file_path.lower()))
+
+    @unittest.skipIf(platform.system() != 'Windows', "Windows OS specific test")
+    def test_path_names_lowercase_enabled(self):
+        tool = PathUnNormcase()
+        tool.enable()
+        file_path = __file__
+        self.assertEqual(file_path, tool.un_normcase(file_path.lower()))