15

I want to navigate between the lines of a file based on indentation. The file is structured by indentation: a line that's more indented than the previous line is a child of the previous line, a line that has the same indentation as the previous line is its sibling. I'm mostly looking for three commands:

  • Move to the next sibling, i.e. the next line with the same indentation, skipping lines that are more indented, but not skipping past a line that is less indented.
  • Move to the previous sibling, i.e. the same thing in the other direction.
  • Move to the parent, i.e. to the previous line with less indentation.

The column position of the point should not change.

These are analogs for indentation-structured data to forward-sexp, backward-sexp and backward-up-list for sexp-structured data. Indentation corresponds to program structure in languages such as Haskell and Python; these functions can be especially useful in this context but I am not looking for anything mode-specific (my primary use case is intendation-structured data inside another file format).

Coloring indentation levels can help to navigate manually with Up/Down but I want something automatic.

This super user question is similar but with weaker requirements and does not currently have answers that meet my requirements.

5
  • Does set-selective-display get you close to what you need? Commented Mar 10, 2016 at 18:37
  • 1
    @KaushalModi It's useful, and I didn't know about this, so thank you, but it isn't always what I need. Just now, I wanted to move about and see the children of the lines I was moving over. Commented Mar 10, 2016 at 18:44
  • Thanks for asking this question; I was about to ask basically the same question only less well. The only additional thing I'd like is "move to last sibling" i.e. the last line that has the same indentation, not skipping lines that are less indented. (The equivalent of repeating "move to the next sibling" until there isn't any.) Commented Sep 14, 2016 at 22:37
  • 1
    I just noticed the package indent-tools in melpa (indent-tools), which probably works for this purpose. The first commit was on 2016-May-16, about 3 months after this question was asked. Commented Mar 12, 2018 at 22:02
  • indent-tools looks almost fine but it has an issue of crossing the parent on the same level child walking gitlab.com/emacs-stuff/indent-tools/-/issues/5 Commented Jan 25, 2022 at 11:43

3 Answers 3

4

Examining the four answers available currently (two on Super User and two on this question), I see the following issues:

  • The ones on SuperUser by Stefan and Peng Bai (moving line-by-line, looking at current indentation) don't implement retaining the current column position and moving up to the parent,
  • The answer by Dan (using re-search-forward to find next line with the same indentation) skips over lines with less indentation: it doesn't know when there is no next sibling, and can therefore move to something that is not a sibling but a child of another parent… a next "cousin" perhaps.
  • The answer by Gilles (using outline-mode) doesn't retain column position and it doesn't work with lines with zero indentation ("top-level" lines). Also, looking at its code in outline.el, it too is basically going line-by-line anyway (using outline-next-visible-heading) in our case, as (almost) all lines would match the outline regexp and count as a "heading".

So, putting together some ideas of each, I have the following: move ahead line-by-line, skipping over empty and more-indented lines. If you are at equal indentation then it's the next sibling. The basic idea looks like this:

(defun indentation-get-next-sibling-line ()
  "The line number of the next sibling, or nil if there isn't any."
  (let ((wanted-indentation (current-indentation)))
    (save-excursion
      (while (and (zerop (forward-line))  ; forward-line returns 0 on success
               (or (eolp)  ; Skip past blank lines and more-indented lines
                 (> (current-indentation) wanted-indentation))))
      ;; Now we can't go further. Which case is it?
      (if (and (not (eobp)) (= (current-indentation) wanted-indentation))
        (line-number-at-pos)
        nil))))

(defun indentation-forward-to-next-sibling ()
  (interactive)
  (let ((saved-column (current-column)))
    (forward-line (- (indentation-get-next-sibling-line) (line-number-at-pos)))
    (move-to-column saved-column)))

Suitably generalized (forward/backward/up/down), what I'm using looks like the following currently:

(defun indentation-get-next-good-line (direction skip good)
  "Moving in direction `direction', and skipping over blank lines and lines that
satisfy relation `skip' between their indentation and the original indentation,
finds the first line whose indentation satisfies predicate `good'."
  (let ((starting-indentation (current-indentation))
         (lines-moved direction))
    (save-excursion
      (while (and (zerop (forward-line direction))
               (or (eolp)  ; Skip past blank lines and other skip lines
                 (funcall skip (current-indentation) starting-indentation)))
        (setq lines-moved (+ lines-moved direction)))
      ;; Now we can't go further. Which case is it?
      (if (and
            (not (eobp))
            (not (bobp))
            (funcall good (current-indentation) starting-indentation))
        lines-moved
        nil))))

(defun indentation-get-next-sibling-line ()
  "The line number of the next sibling, if any."
  (indentation-get-next-good-line 1 '> '=))

(defun indentation-get-previous-sibling-line ()
  "The line number of the previous sibling, if any"
  (indentation-get-next-good-line -1 '> '=))

(defun indentation-get-parent-line ()
  "The line number of the parent, if any."
  (indentation-get-next-good-line -1 '>= '<))

(defun indentation-get-child-line ()
  "The line number of the first child, if any."
  (indentation-get-next-good-line +1 'ignore '>))


(defun indentation-move-to-line (func preserve-column name)
  "Move the number of lines given by func. If not possible, use `name' to say so."
  (let ((saved-column (current-column))
          (lines-to-move-by (funcall func)))
    (if lines-to-move-by
      (progn
        (forward-line lines-to-move-by)
        (move-to-column (if preserve-column
                          saved-column
                          (current-indentation))))
      (message "No %s to move to." name))))

(defun indentation-forward-to-next-sibling ()
  "Move to the next sibling if any, retaining column position."
  (interactive "@")
  (indentation-move-to-line 'indentation-get-next-sibling-line t "next sibling"))

(defun indentation-backward-to-previous-sibling ()
  "Move to the previous sibling if any, retaining column position."
  (interactive "@")
  (indentation-move-to-line 'indentation-get-previous-sibling-line t "previous sibling"))

(defun indentation-up-to-parent ()
  "Move to the parent line if any."
  (interactive "@")
  (indentation-move-to-line 'indentation-get-parent-line nil "parent"))

(defun indentation-down-to-child ()
  "Move to the first child line if any."
  (interactive "@")
  (indentation-move-to-line 'indentation-get-child-line nil "child"))

There is still some more functionality desirable, and looking at the outline.el and reimplementing some of that may help, but I'm happy with this for now, for my purposes.

1
  • @Gilles: Thanks for the edits! Looks like (current-line) was something from misc-fns.el that I somehow have in my Aquamacs installation as part of some oneonone.el library. Commented Sep 18, 2016 at 22:50
6

This feature exists in Emacs. Outline mode describes a document as containing heading lines with a level, and has facilities to move among levels. We can define every line as a heading line with a level that reflects its indentation: set outline-regexp to the indentation. More precisely, the indentation plus the first non-whitespace character (and the beginning of the file is the uppermost level): \`\|\s-+\S-.

M-x load-libray outline RET
M-: (make-local-variable 'outline-regexp) RET
M-: (setq outline-regexp "\\`\\|\\s-+\\S-") RET
M-x outline-minor-mode RET

In Emacs 22.1–24.3 you can simplify this to:

M-x load-libray outline RET
M-1 M-x set-variable RET outline-regexp RET "\\`\\|\\s-+\\S-" RET
M-x outline-minor-mode RET

Then you can use outline motion commands:

  • C-C @ C-f (outline-forward-same-level) to move to the next sibling;
  • C-C @ C-b (outline-backward-same-level) to move to the previous sibling;
  • C-C @ C-u (outline-up-heading) to move to the parent.

One tab and one space count for the same amount of indentation. If you have a mix of tabs and spaces, set tab-width appropriately and call untabify.

If the current major mode has outline settings, they might conflict. In this case, you can use one of the many multiple major modes solution, the simplest being to create an indirect buffer and set it to Outline Major Mode. In Outline Major Mode, the default keyboard shortcuts are simpler to type: C-c C-f, etc.

3
  • This seems like it should work, but doesn't actually work for me for some reason. M-x make-local-variable RET outline-regexp RET doesn't accept that variable, and says only ` [No match]`. Am yet to look into it more carefully. Commented Sep 17, 2016 at 1:37
  • @ShreevatsaR It's an incompatible change in Emacs 24.4: outline-regexp is no longer a defcustom and can't be set interactively so easily. Commented Sep 17, 2016 at 10:43
  • Very nice, thank you. There are two minor issues: (1) If you are at the topmost level (a line with no indentation, which I guess means no match for the outline-regexp) then neither forward nor backward works, and for some reason it goes up two lines (2) when it goes to the next or previous sibling, it goes to the beginning of the line (column 0) but it would be nice to retain column. (As you specify in the question.) I guess both these may be limitations of outline mode itself. Commented Sep 17, 2016 at 16:19
4

The following three commands, minimally tested, should allow for basic navigation by indented lines. Apologies for the code repetition.

(defun ind-forward-sibling ()
  "Move forward to the next sibling line with the same indentation."
  (interactive)
  (save-match-data
    (let ((col (current-column))
          (pad (progn
                 (back-to-indentation)
                 (current-column))))
      (end-of-line 1)
      (re-search-forward (concat "^\\s-\\{"
                                 (number-to-string pad)
                                 "\\}[^ ]") nil t)
      (move-to-column col))))

(defun ind-backward-sibling ()
  "Move backward to the next sibling line with the same indentation."
  (interactive)
  (save-match-data
    (let ((col (current-column))
          (pad (progn
                 (back-to-indentation)
                 (current-column))))
      (beginning-of-line 1)
      (re-search-backward (concat "^\\s-\\{"
                                 (number-to-string pad)
                                 "\\}[^ ]") nil t)
      (move-to-column col))))

(defun ind-up-parent ()
  "Move up to parent line with less indentation."
  (interactive)
  (save-match-data
    (let ((col (current-column))
          (pad (progn
                 (back-to-indentation)
                 (current-column))))
      (when (> pad 0)
        (beginning-of-line 1)
        (re-search-backward (concat "^\\s-\\{0,"
                                    (number-to-string (1- pad))
                                    "\\}[^ ]") nil t))
      (move-to-column col))))
2
  • That's good (after the fix — I don't understand what you were trying to do with subtracting 1 to (current-column) but it causes the cursor to not move), but doesn't exactly meet my spec: moving at an indentation level moves past less-indented lines. Commented Mar 14, 2016 at 14:27
  • This doesn't work. E.g. ind-forward-sibling simply looks for the next line with the same indentation, so it skips over lines with less indentation (it goes forward even when there is no forward sibling). Commented Sep 14, 2016 at 22:27

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.