;;; visit-source.el --- Open file at provided position.  -*- lexical-binding: t; -*-

;; Copyright (C) 2020-2026 Pavel Popov

;; Author: Pavel Popov (https://github.com/velppa)
;; Maintainer: Pavel Popov (https://github.com/velppa)
;; Created: 2020-07-02
;; Version: 0.3.0
;; Keywords: find-file, os-integration

;; Licensed under the same terms as Emacs.
;;
;; This file is not part of GNU Emacs.

;;; Commentary:

;; Open the file referenced by text around point, optionally jumping to a
;; line and column (e.g. "./src/program.rb:34:12").  Org-mode links and
;; project-wide lookups via `rg' are also supported.
;;
;; Originally based on a snippet by u/yxhuvud (2018-02-11):
;; https://www.reddit.com/r/emacs/comments/7wjyhy/emacs_tip_findfileatpoint/du35fh4/
;; Substantially rewritten and maintained by Pavel Popov since 2020.

;; History:
;; 0.0.1 - 2018-02-11 - u/yxhuvud - initial Reddit snippet
;; 0.1.0 - 2020-07-02 - Pavel Popov - packaged as visit-source.el
;; 0.2.0 - region support, project-wide rg fallback
;; 0.3.0 - Org-link handling, trailing-punct stripping, parse/resolve split

;;; Code:

(defun visit-source-parse (s)
  "Parse string S into a list (PATH LINE COL) or nil if no match.
PATH is the matched file path; LINE and COL are integers (0 when absent).
Pure function: performs no IO."
  (when (and s (string-match
                (rx string-start
                    (group (* (not (any ?: space)))
                           (any ?/ ?.)
                           (* (not (any ?: space))))
                    (? (group ":" (+ digit)))
                    (? (group ":" (+ digit)))
                    (? ":"))
                s))
    (list (match-string 1 s)
          (if (match-string 2 s) (string-to-number (substring (match-string 2 s) 1)) 0)
          (if (match-string 3 s) (string-to-number (substring (match-string 3 s) 1)) 0))))

(defconst visit-source-delimiter-chars
  "^  \"\t\n`'=|()[]{}<>〔〕“”〈〉《》【】〖〗«»‹›·。\\`"
  "Chars considered delimiters when scanning a path around point.
First char is `^' so this string is suitable for `skip-chars-forward'.")

(defun visit-source-thing-at-point ()
  "Return the path-like string around point or in the active region.
With an active region, return the region text.  Otherwise expand
around point, stopping at characters in `visit-source-delimiter-chars'."
  (if (use-region-p)
      (buffer-substring-no-properties (region-beginning) (region-end))
    (let ((p0 (point)) p1 p2)
      (skip-chars-backward visit-source-delimiter-chars)
      (setq p1 (point))
      (goto-char p0)
      (skip-chars-forward visit-source-delimiter-chars)
      (setq p2 (point))
      (goto-char p0)
      (buffer-substring-no-properties p1 p2))))

(defconst visit-source-trailing-punct ".,;:"
  "Sentence-final punctuation chars stripped from a candidate file path.")

(defun visit-source--strip-trailing-punct (fpath)
  "Strip trailing sentence punctuation from FPATH while it makes the file resolvable.
Returns FPATH unchanged if it already exists, is nil, or no shorter prefix exists."
  (if (or (null fpath) (file-exists-p fpath))
      fpath
    (let ((p fpath))
      (while (and (> (length p) 0)
                  (memq (aref p (1- (length p)))
                        (append visit-source-trailing-punct nil))
                  (not (file-exists-p p)))
        (setq p (substring p 0 -1)))
      (if (and (not (string-empty-p p)) (file-exists-p p))
          p
        fpath))))

(defun visit-source-resolve-project-file (fpath)
  "Return FPATH, or a project-relative match found via `rg' when FPATH is missing.
If FPATH already exists, or there is no current project, FPATH is returned
unchanged.  Otherwise `rg --files' is run from the project root and the
first line containing FPATH is returned."
  (if (and fpath
           (not (file-exists-p fpath))
           (project-current))
      (let* ((root (project-root (project-current)))
             (default-directory root)
             (hit (string-trim
                   (shell-command-to-string
                    (format "rg --files | grep -E '(^|/)%s$' | head -n 1"
                            (regexp-quote fpath))))))
        (if (string-empty-p hit)
            fpath
          (expand-file-name hit root)))
    fpath))

(defun visit-source--org-link-at-point ()
  "Return file path from Org-mode link at point, or nil."
  (when (and (derived-mode-p 'org-mode) (fboundp 'org-element-context))
    (let ((el (org-element-context)))
      (when (eq (org-element-type el) 'link)
        (let ((type (org-element-property :type el))
              (path (org-element-property :path el)))
          (when (member type '("file" "fuzzy"))
            path))))))

(defun visit-source (&optional name)
  "Open file under current line.  If NAME provided, use `find-file'.

If the current line contains text like './src/program.rb:34:',
visit that file in the other window and position point on that
line. A file must either have a / or . in the filename to be
recognized.  When point is on an Org-mode link, the linked file
is visited.  A trailing dot on the path (e.g. sentence-final
punctuation) is stripped if it makes the file resolvable.
     # ./app/views/interfaces/edit.html.erb:3:fdsin
     # ./app/views/interfaces/edit.html.erb:3:in
     # ./app/views/interfaces/edit.html.erb:3
     # ./app:3
     # bar/foo"
  (interactive)
  (cond
   (name (find-file name))
   ((visit-source--org-link-at-point)
    (let ((link (visit-source--org-link-at-point)))
      (if (file-exists-p link)
          (find-file-other-window link)
        (org-open-at-point))))
   (t
    (let* ((path (visit-source-thing-at-point))
           (parsed (visit-source-parse path))
           (fpath (car parsed))
           (line-no (or (nth 1 parsed) 0))
           (col-no (or (nth 2 parsed) 0))
           (fpath (visit-source--strip-trailing-punct fpath))
           (project-file (visit-source-resolve-project-file fpath)))
      (and
       parsed
       (file-exists-p project-file)
       (find-file-other-window project-file)
       (when line-no
         (goto-char (point-min)) ;; goto-line is only for interactive use
         (forward-line (1- line-no))
         (when (> col-no 0) (forward-char (1- col-no)))
         t)
       t)))))

(provide 'visit-source)
;;; visit-source.el ends here
