Repository: spyder-ide/spyder

Issue:
Editor doesn't support hanging indentation
## Description of your problem

**What steps will reproduce the problem?**
1. In Editor, type 
   `long_function_name(`
   then press Enter.

**What is the expected output? What do you see instead?**
I wish there is an option for hanging indent (of a fixed number of spaces), rather than vertical alignment. That is, I want an option for:

```
# Hanging indent
def long_function_name(
        var_one, var_two, var_three,
        var_four):
    print(var_one)
```

rather than:

```
# Vertical alignment
def long_function_name(
                       var_one, var_two, var_three,
                       var_four):
    print(var_one)
```

Currently the vertical alignment seems to be the only option. But it saves little space when there is a big indentation. PEP also recommends both styles. 

If I could wish for more, I would suggest having the hanging indent of twice the amount for one level (e.g., 4 x 2 = 8 spaces), so as to disambiguate it with subsequent indents that are not continuations, as I copied above from the second example of the PEP: https://www.python.org/dev/peps/pep-0008/#indentation. 

Interestingly, hanging indent (of twice the amount for one level) is also the style of the MATLAB editor, so users converting from MATLAB would find it more familiar. It could be important since Spyder is the IDE of choice for many previous MATLAB users.

**Please provide any additional information below**
## Versions and main components
- Spyder Version: 3.0.0
- Python Version: 2.7.12
- Operating system: Mac OS X Sierra (10.12)
## Dependencies

Please go to the menu entry `Help > Optional Dependencies` (or
`Help > Dependencies`), press the button `Copy to clipboard`
and paste the contents below:

jedi >=0.8.1     :  0.9.0 (OK)
matplotlib >=1.0 :  1.5.3 (OK)
nbconvert >=4.0  :  4.2.0 (OK)
numpy >=1.7      :  1.11.1 (OK)
pandas >=0.13.1  :  0.18.1 (OK)
pep8 >=0.6       :  1.7.0 (OK)
psutil >=0.3     :  4.3.0 (OK)
pyflakes >=0.5.0 :  1.2.3 (OK)
pygments >=2.0   :  2.1.3 (OK)
pylint >=0.25    :  1.6.4 (OK)
qtconsole >=4.2.0:  4.2.1 (OK)
rope >=0.9.4     :  0.9.4 (OK)
sphinx >=0.6.6   :  1.4.1 (OK)
sympy >=0.7.3    :  1.0 (OK)



Discussion:
Spyder kind of does what you want. If you manually indent your first variables like this

```
def long_function_name(
        var_one, var_two, var_three,
```

then the coming lines will respect your initial indentation.

---

If you want to improve this, you're welcome to send us a pull request to implement it because we don't have time to do it :-)

it used to work like @yulkang wants. From pep8 (from what I recall, at least), vertical alignment should be used only if there is something right after the open parenthesis.

``` python
# 1: OK
def long_function_name(
    var_one, var_two, var_three)

# 2: OK
def long_function_name(var_one,
                       var_two,
                       var_three)

# 3: NOT OK
def long_function_name(var_one,
    var_two,
    var_three)

# 4: NOT OK
def long_function_name(
                       var_one,
                       var_two,
                       var_three)
```

Spyder is currently doing 4 instead of 1.

I made a preliminary version and made a pull request:
https://github.com/yulkang/spyder/tree/hangingindent

It has an option for hanging indent at menu - python - Preferences - Editor - Advanced settings - Hanging indentation. It is unchecked by default, but when checked, allows hanging indentation, as follows:

```
def long_function_name(
        var1, var2):
    print("This works. Yeah!")

if long_function_name(
        var1, var2):
    print("also works")
```

The case I found not working is the following, where the subsequent line is indented one step too much:

```
if long_array_name[
        var1]:
            print("this does not work yet")
```

But such cases are rarer for me, so it would be useful even as it stands now. I haven't found why this happens, and I would appreciate help.

I checked the following and they work as expected, too:
- the new option is saved after quitting, and
- the default behavior, vertical alignment, is recovered when the new option is unchecked.

Please let me know if there are other issues. 

@yulkang, thanks a lot for looking at it. I'd like to say two things:
1. Please create a pull request with your work so we can review it and merge it :-)
2. Please don't add a new option for this (we have enough options already). Let's make this the default behavior ;-)


Resolving patch:
diff --git a/spyder/widgets/sourcecode/codeeditor.py b/spyder/widgets/sourcecode/codeeditor.py
--- a/spyder/widgets/sourcecode/codeeditor.py
+++ b/spyder/widgets/sourcecode/codeeditor.py
@@ -1912,25 +1912,40 @@ def fix_indent(self, forward=True, comment_or_string=False):
         cursor = self.textCursor()
         block_nb = cursor.blockNumber()
         # find the line that contains our scope
-        diff = 0
+        diff_paren = 0
+        diff_brack = 0
+        diff_curly = 0
         add_indent = False
         prevline = None
         for prevline in range(block_nb-1, -1, -1):
             cursor.movePosition(QTextCursor.PreviousBlock)
             prevtext = to_text_string(cursor.block().text()).rstrip()
-            if (self.is_python_like() and not prevtext.strip().startswith('#') \
-              and prevtext) or prevtext:
-                if prevtext.strip().endswith(')'):
+            
+            if ((self.is_python_like() 
+                 and not prevtext.strip().startswith('#')
+                 and prevtext) 
+                or prevtext):
+                    
+                if (prevtext.strip().endswith(')')
+                    or prevtext.strip().endswith(']')
+                    or prevtext.strip().endswith('}')):
+                
                     comment_or_string = True  # prevent further parsing
+                    
                 elif prevtext.strip().endswith(':') and self.is_python_like():
                     add_indent = True
                     comment_or_string = True
-                if prevtext.count(')') > prevtext.count('('):
-                    diff = prevtext.count(')') - prevtext.count('(')
-                    continue
-                elif diff:
-                    diff += prevtext.count(')') - prevtext.count('(')
-                    if not diff:
+                if (prevtext.count(')') > prevtext.count('(')):
+                    diff_paren = prevtext.count(')') - prevtext.count('(')
+                elif (prevtext.count(']') > prevtext.count('[')):
+                    diff_brack = prevtext.count(']') - prevtext.count('[')
+                elif (prevtext.count('}') > prevtext.count('{')):
+                    diff_curly = prevtext.count('}') - prevtext.count('{')
+                elif diff_paren or diff_brack or diff_curly:
+                    diff_paren += prevtext.count(')') - prevtext.count('(')
+                    diff_brack += prevtext.count(']') - prevtext.count('[')
+                    diff_curly += prevtext.count('}') - prevtext.count('{')
+                    if not (diff_paren or diff_brack or diff_curly):
                         break
                 else:
                     break
@@ -1963,25 +1978,33 @@ def fix_indent(self, forward=True, comment_or_string=False):
                 else:
                     correct_indent -= len(self.indent_chars)
             elif len(re.split(r'\(|\{|\[', prevtext)) > 1:
-                rlmap = {")":"(", "]":"[", "}":"{"}
-                for par in rlmap:
-                    i_right = prevtext.rfind(par)
-                    if i_right != -1:
-                        prevtext = prevtext[:i_right]
-                        for _i in range(len(prevtext.split(par))):
-                            i_left = prevtext.rfind(rlmap[par])
-                            if i_left != -1:
-                                prevtext = prevtext[:i_left]
-                            else:
-                                break
+                # Hanging indent
+                # find out if the last one is (, {, or []})
+                if re.search(r'[\(|\{|\[]\s*$', prevtext) is not None:
+                    if self.indent_chars == '\t':
+                        correct_indent += 8
+                    else:
+                        correct_indent += len(self.indent_chars) * 2
                 else:
-                    if prevtext.strip():
-                        if len(re.split(r'\(|\{|\[', prevtext)) > 1:
-                            #correct indent only if there are still opening brackets
-                            prevexpr = re.split(r'\(|\{|\[', prevtext)[-1]
-                            correct_indent = len(prevtext)-len(prevexpr)
+                    rlmap = {")":"(", "]":"[", "}":"{"}
+                    for par in rlmap:
+                        i_right = prevtext.rfind(par)
+                        if i_right != -1:
+                            prevtext = prevtext[:i_right]
+                            for _i in range(len(prevtext.split(par))):
+                                i_left = prevtext.rfind(rlmap[par])
+                                if i_left != -1:
+                                    prevtext = prevtext[:i_left]
+                                else:
+                                    break
                     else:
-                        correct_indent = len(prevtext)
+                        if prevtext.strip():
+                            if len(re.split(r'\(|\{|\[', prevtext)) > 1:
+                                #correct indent only if there are still opening brackets
+                                prevexpr = re.split(r'\(|\{|\[', prevtext)[-1]
+                                correct_indent = len(prevtext)-len(prevexpr)
+                            else:
+                                correct_indent = len(prevtext)
 
         if (forward and indent >= correct_indent) or \
            (not forward and indent <= correct_indent):


Test patch:
diff --git a/spyder/widgets/sourcecode/tests/test_autoindent.py b/spyder/widgets/sourcecode/tests/test_autoindent.py
--- a/spyder/widgets/sourcecode/tests/test_autoindent.py
+++ b/spyder/widgets/sourcecode/tests/test_autoindent.py
@@ -55,16 +55,44 @@ def test_brackets_alone():
     assert text == "def function():\n    print []\n    ", repr(text)
 
 
-def test_open_parenthesis():
-    text = get_indent_fix("open_parenthesis(\n")
-    assert text == "open_parenthesis(\n                 ", repr(text)
-
-
 def test_simple_def():
     text = get_indent_fix("def function():\n")
     assert text == "def function():\n    ", repr(text)
 
 
+def test_open_parenthesis():
+    # An open parenthesis with no item is followed by a hanging indent
+    text = get_indent_fix("open_parenthesis(\n")
+    assert text == "open_parenthesis(\n        ", repr(text)
+
+def test_open_bracket():
+    # An open bracket with no item is followed by a hanging indent
+    text = get_indent_fix("open_bracket[\n")
+    assert text == "open_bracket[\n        ", repr(text)
+    
+def test_open_curly():
+    # An open curly bracket with no item is followed by a hanging indent
+    text = get_indent_fix("open_curly{\n")
+    assert text == "open_curly{\n        ", repr(text)
+    
+def test_align_on_parenthesis():
+    # An open parenthesis with one or more item is followed by an indent
+    # up to the parenthesis.
+    text = get_indent_fix("parenthesis_w_item = (1,\n")
+    assert text == "parenthesis_w_item = (1,\n                      ", repr(text)    
+
+def test_align_on_bracket():
+    # An open bracket with one or more item is followed by an indent
+    # up to the parenthesis.
+    text = get_indent_fix("bracket_w_item = (1,\n")
+    assert text == "bracket_w_item = (1,\n                  ", repr(text)    
+    
+def test_align_on_curly():
+    # An open curly bracket with one or more item is followed by an indent
+    # up to the parenthesis.
+    text = get_indent_fix("curly_w_item = (1,\n")
+    assert text == "curly_w_item = (1,\n                ", repr(text)    
+
 # --- Failing tests
 # -----------------------------------------------------------------------------
 @pytest.mark.xfail
@@ -84,15 +112,15 @@ def test_def_with_unindented_comment():
          "test with indented comment"),
         ("def function():\n\tprint []\n", "def function():\n\tprint []\n\t",
          "test brackets alone"),
-        ("\na = {\n", "\na = {\n\t ", "indentation after opening bracket"),
+        ("\na = {\n", "\na = {\n\t\t", "indentation after opening bracket"),
         ("def function():\n", "def function():\n\t", "test simple def"),
+        ("open_parenthesis(\n", "open_parenthesis(\n\t\t",
+         "open parenthesis"),
 
         # Failing test
         pytest.mark.xfail(
             ("def function():\n# Comment\n", "def function():\n# Comment\n\t",
              "test_def_with_unindented_comment")),
-        pytest.mark.xfail(("open_parenthesis(\n", "open_parenthesis(\n\t\t\t\ŧ ",
-                           "open parenthesis")),
     ])
 def test_indentation_with_tabs(text_input, expected, test_text):
     text = get_indent_fix(text_input, indent_chars="\t")