;;; lsp-pwsh.el --- client for PowerShellEditorServices  -*- lexical-binding: t; -*-

;; Copyright (C) 2019  Kien Nguyen
;; Copyright (C) 2019-2026 emacs-lsp maintainers

;; Author: kien.n.quang at gmail.com
;; Keywords: lsp

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;;

;;; Code:

(require 'f)
(require 'dash)
(require 's)
(require 'ht)

(require 'lsp-protocol)
(require 'lsp-mode)

(defgroup lsp-pwsh nil
  "LSP support for PowerShell, using the PowerShellEditorServices."
  :group 'lsp-mode
  :package-version '(lsp-mode . "6.2"))

;; PowerShell vscode flags
(defcustom lsp-pwsh-help-completion "BlockComment"
  "Controls the comment-based help completion behavior triggered by typing '##'.
Set the generated help style with \='BlockComment\=' or \='LineComment\='.
Disable the feature with \='Disabled\='."
  :type
  '(choice (const "Disabled")
           (const "BlockComment")
           (const "LineComment"))
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-script-analysis-enable t
  "Enables real-time script analysis from PowerShell Script Analyzer.
Uses the newest installed version of the PSScriptAnalyzer module or the
version bundled with this extension, if it is newer."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-script-analysis-settings-path ""
  "Specifies the path to a PowerShell Script Analyzer settings file.
To override the default settings for all projects, enter an absolute path,
or enter a path relative to your workspace."
  :type 'string
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-folding-enable t
  "Enables syntax based code folding.
When disabled, the default indentation based code folding is used."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-folding-show-last-line t
  "Shows the last line of a folded section.
Similar to the default VSCode folding style.
When disabled, the entire folded region is hidden."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-preset "Custom"
  "Sets the codeformatting options to follow the given indent style.
Sets in a way that is compatible with PowerShell syntax.
For more information about the brace styles please refer to https://github.com/PoshCode/PowerShellPracticeAndStyle/issues/81."
  :type
  '(choice (const "Custom")
           (const "Allman")
           (const "OTBS")
           (const "Stroustrup"))
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-open-brace-on-same-line t
  "Places open brace on the same line as its associated statement."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-new-line-after-open-brace t
  "Adds a newline (line break) after an open brace."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-new-line-after-close-brace t
  "Adds a newline (line break) after a closing brace."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-pipeline-indentation-style "NoIndentation"
  "Multi-line pipeline style settings."
  :type
  '(choice (const "IncreaseIndentationForFirstPipeline")
           (const "IncreaseIndentationAfterEveryPipeline")
           (const "NoIndentation"))
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-whitespace-before-open-brace t
  "Adds a space between a keyword and its associated scriptblock expression."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-whitespace-before-open-paren t
  "Adds a space between a keyword (if, elseif, while, switch, etc) and its
associated conditional expression."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-whitespace-around-operator t
  "Adds spaces before and after an operator ('=', '+', '-', etc.)."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-whitespace-after-separator t
  "Adds a space after a separator (',' and ';')."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-whitespace-inside-brace t
  "Adds a space after an opening brace ('{') and before a closing brace ('}')."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-whitespace-around-pipe t
  "Adds a space before and after the pipeline operator ('|')."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-ignore-one-line-block t
  "Does not reformat one-line code blocks, such as \"if (...) {...} else
{...}\"."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-align-property-value-pairs t
  "Align assignment statements in a hashtable or a DSC Configuration."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-code-formatting-use-correct-casing nil
  "Use correct casing for cmdlets."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-developer-editor-services-log-level "Normal"
  "Sets the log level for the PowerShell Editor Services host executable.
Valid values are \='Diagnostic\=', \='Verbose\=', \='Normal\=',
 \='Warning\=', and \='Error\='"
  :type
  '(choice (const "Diagnostic")
           (const "Verbose")
           (const "Normal")
           (const "Warning")
           (const "Error"))
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-developer-editor-services-wait-for-debugger nil
  "Launches the language service with the /waitForDebugger flag to force it to
wait for a .NET debugger to attach before proceeding."
  :type 'boolean
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-developer-feature-flags nil
  "An array of strings that enable experimental features in the PowerShell
extension."
  :type
  '(repeat string)
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(lsp-register-custom-settings
 '(("powershell.developer.featureFlags" lsp-pwsh-developer-feature-flags)
   ("powershell.developer.editorServicesWaitForDebugger" lsp-pwsh-developer-editor-services-wait-for-debugger t)
   ("powershell.codeFormatting.useCorrectCasing" lsp-pwsh-code-formatting-use-correct-casing t)
   ("powershell.codeFormatting.alignPropertyValuePairs" lsp-pwsh-code-formatting-align-property-value-pairs t)
   ("powershell.codeFormatting.ignoreOneLineBlock" lsp-pwsh-code-formatting-ignore-one-line-block t)
   ("powershell.codeFormatting.whitespaceAroundPipe" lsp-pwsh-code-formatting-whitespace-around-pipe t)
   ("powershell.codeFormatting.whitespaceInsideBrace" lsp-pwsh-code-formatting-whitespace-inside-brace t)
   ("powershell.codeFormatting.whitespaceAfterSeparator" lsp-pwsh-code-formatting-whitespace-after-separator t)
   ("powershell.codeFormatting.whitespaceAroundOperator" lsp-pwsh-code-formatting-whitespace-around-operator t)
   ("powershell.codeFormatting.whitespaceBeforeOpenParen" lsp-pwsh-code-formatting-whitespace-before-open-paren t)
   ("powershell.codeFormatting.whitespaceBeforeOpenBrace" lsp-pwsh-code-formatting-whitespace-before-open-brace t)
   ("powershell.codeFormatting.pipelineIndentationStyle" lsp-pwsh-code-formatting-pipeline-indentation-style)
   ("powershell.codeFormatting.newLineAfterCloseBrace" lsp-pwsh-code-formatting-new-line-after-close-brace t)
   ("powershell.codeFormatting.newLineAfterOpenBrace" lsp-pwsh-code-formatting-new-line-after-open-brace t)
   ("powershell.codeFormatting.openBraceOnSameLine" lsp-pwsh-code-formatting-open-brace-on-same-line t)
   ("powershell.codeFormatting.preset" lsp-pwsh-code-formatting-preset)
   ("powershell.codeFolding.showLastLine" lsp-pwsh-code-folding-show-last-line t)
   ("powershell.codeFolding.enable" lsp-pwsh-code-folding-enable t)
   ("powershell.scriptAnalysis.settingsPath" lsp-pwsh-script-analysis-settings-path)
   ("powershell.scriptAnalysis.enable" lsp-pwsh-script-analysis-enable t)
   ("powershell.helpCompletion" lsp-pwsh-help-completion)))

;; lsp-pwsh custom variables
(defcustom lsp-pwsh-ext-path (expand-file-name "pwsh" lsp-server-install-dir)
  "The path to powershell vscode extension."
  :type 'string
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-exe (or (executable-find "pwsh") (executable-find "powershell"))
  "PowerShell executable."
  :type 'string
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defcustom lsp-pwsh-dir lsp-pwsh-ext-path
  "Path to PowerShellEditorServices without last slash."
  :type 'string
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defvar lsp-pwsh-pses-script (expand-file-name "PowerShellEditorServices/Start-EditorServices.ps1"
                                               lsp-pwsh-dir)
  "Main script to start PSES.")

(defvar lsp-pwsh-log-path (expand-file-name "logs" lsp-pwsh-ext-path)
  "Path to directory where server will write log files.
Must not nil.")

(defvar lsp-pwsh--sess-id (emacs-pid))

(defun lsp-pwsh--command ()
  "Return the command to start server."
  `(,lsp-pwsh-exe "-NoProfile" "-NonInteractive" "-NoLogo"
                  ,@(if (eq system-type 'windows-nt) '("-ExecutionPolicy" "Bypass"))
                  "-OutputFormat" "Text"
                  "-File"
                  ,lsp-pwsh-pses-script
                  "-HostName" "\"Emacs Host\""
                  "-HostProfileId" "'Emacs.LSP'"
                  "-HostVersion" "9.0.0"
                  "-LogPath" ,(expand-file-name "emacs-powershell.log" lsp-pwsh-log-path)
                  "-LogLevel" ,lsp-pwsh-developer-editor-services-log-level
                  "-SessionDetailsPath" ,(expand-file-name (format "PSES-VSCode-%d" lsp-pwsh--sess-id)
                                                           lsp-pwsh-log-path)
                  ;; "-AdditionalModules" "@('PowerShellEditorServices.VSCode')"
                  "-Stdio"
                  "-BundledModulesPath" ,lsp-pwsh-dir
                  "-FeatureFlags" "@()"))

(defun lsp-pwsh--extra-init-params ()
  "Return form describing parameters for language server.")

(lsp-defun lsp-pwsh--apply-code-action-edits ((&Command :command :arguments?))
  "Handle ACTION for PowerShell.ApplyCodeActionEdits."
  (-if-let* (((&pwsh:ScriptRegion :start-line-number :end-line-number
                                  :start-column-number :end-column-number :text)
              (lsp-seq-first arguments?))
             (start-position (lsp-make-position :line (1- start-line-number)
                                                :character (1- start-column-number)))
             (end-position (lsp-make-position :line (1- end-line-number)
                                              :character (1- end-column-number)))
             (edits `[,(lsp-make-text-edit :range (lsp-make-range :start start-position
                                                                  :end end-position)
                                           :newText text)]))
      (lsp--apply-text-edits edits 'code-action)
    (lsp-send-execute-command command arguments?)))

(lsp-defun lsp-pwsh--show-code-action-document ((&Command :arguments?))
  "Handle ACTION for PowerShell.ShowCodeActionDocumentation."
  (-if-let* ((rule-raw (lsp-seq-first arguments?))
             (rule-id (if (s-prefix-p "PS" rule-raw) (substring rule-raw 2) rule-raw)))
      (browse-url
       (concat "https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/rules/"
               rule-id))
    (lsp-warn "Cannot show documentation for code action, no ruleName was supplied")))

(lsp-register-client
 (make-lsp-client
  :new-connection (lsp-stdio-connection #'lsp-pwsh--command
                                        (lambda ()
                                          (f-exists? lsp-pwsh-pses-script)))
  :activation-fn (lsp-activate-on "powershell")
  :server-id 'pwsh-ls
  :priority -1
  :initialization-options #'lsp-pwsh--extra-init-params
  :notification-handlers (ht ("powerShell/executionStatusChanged" #'ignore)
                             ("output" #'ignore))
  :action-handlers (ht ("PowerShell.ApplyCodeActionEdits"
                        #'lsp-pwsh--apply-code-action-edits)
                       ("PowerShell.ShowCodeActionDocumentation"
                        #'lsp-pwsh--show-code-action-document))
  :initialized-fn (lambda (w)
                    (with-lsp-workspace w
                      (lsp--set-configuration
                       (lsp-configuration-section "powershell")))
                    (let ((caps (lsp--workspace-server-capabilities w)))
                      (lsp:set-server-capabilities-document-range-formatting-provider? caps t)
                      (lsp:set-server-capabilities-document-formatting-provider? caps t)))
  :download-server-fn #'lsp-pwsh-setup))

(defcustom lsp-pwsh-github-asset-url
  "https://github.com/%s/%s/releases/latest/download/%s"
  "GitHub latest asset template url."
  :type 'string
  :group 'lsp-pwsh
  :package-version '(lsp-mode . "6.2"))

(defun lsp-pwsh-setup (_client callback error-callback update)
  "Downloads PowerShellEditorServices to `lsp-pwsh-dir'.
CALLBACK is called when the download finish successfully otherwise
ERROR-CALLBACK is called.
UPDATE is non-nil if it is already downloaded.
FORCED if specified with prefix argument."

  (unless (and lsp-pwsh-exe (file-executable-p lsp-pwsh-exe))
    (user-error "Use `lsp-pwsh-exe' with the value of `%s' is not a valid powershell binary"
                lsp-pwsh-exe))

  (let ((url (format lsp-pwsh-github-asset-url "PowerShell"
                     "PowerShellEditorServices" "PowerShellEditorServices.zip"))
        (temp-file (make-temp-file "ext" nil ".zip")))
    (unless (f-exists? lsp-pwsh-log-path)
      (mkdir lsp-pwsh-log-path 'create-parent))
    (unless (and (not update) (f-exists? lsp-pwsh-pses-script))
      ;; since we know it's installed, use powershell to download the file
      ;; (and avoid url.el bugginess or additional libraries)
      (when (f-exists? lsp-pwsh-dir) (delete-directory lsp-pwsh-dir 'recursive))
      (lsp-async-start-process
       callback
       error-callback
       lsp-pwsh-exe "-noprofile" "-noninteractive" "-nologo"
       "-ex" "bypass" "-command"
       "Invoke-WebRequest" "-UseBasicParsing" "-uri" url "-outfile" temp-file ";"
       "Expand-Archive" "-Path" temp-file
       "-DestinationPath" lsp-pwsh-dir))))

(lsp-consistency-check lsp-pwsh)

(provide 'lsp-pwsh)
;;; lsp-pwsh.el ends here
