﻿//===-- ClangFormatPackages.cs - VSPackage for clang-format ------*- C# -*-===//
//
// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
// See https://llvm.org/LICENSE.txt for license information.
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
//
//===----------------------------------------------------------------------===//
//
// This class contains a VS extension package that runs clang-format over a
// selection in a VS text editor.
//
//===----------------------------------------------------------------------===//

using EnvDTE;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using System;
using System.Collections;
using System.ComponentModel;
using System.ComponentModel.Design;
using System.IO;
using System.Runtime.InteropServices;
using System.Xml.Linq;
using System.Linq;

namespace LLVM.ClangFormat
{
    [ClassInterface(ClassInterfaceType.AutoDual)]
    [CLSCompliant(false), ComVisible(true)]
    public class OptionPageGrid : DialogPage
    {
        private string assumeFilename = "";
        private string fallbackStyle = "LLVM";
        private bool sortIncludes = false;
        private string style = "file";
        private bool formatOnSave = false;
        private string formatOnSaveFileExtensions =
            ".c;.cpp;.cxx;.cc;.tli;.tlh;.h;.hh;.hpp;.hxx;.hh;.inl;" +
            ".java;.js;.ts;.m;.mm;.proto;.protodevel;.td";

        public OptionPageGrid Clone()
        {
            // Use MemberwiseClone to copy value types.
            var clone = (OptionPageGrid)MemberwiseClone();
            return clone;
        }

        public class StyleConverter : TypeConverter
        {
            protected ArrayList values;
            public StyleConverter()
            {
                // Initializes the standard values list with defaults.
                values = new ArrayList(new string[] { "file", "Chromium", "Google", "LLVM", "Mozilla", "WebKit" });
            }

            public override bool GetStandardValuesSupported(ITypeDescriptorContext context)
            {
                return true;
            }

            public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
            {
                return new StandardValuesCollection(values);
            }

            public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
            {
                if (sourceType == typeof(string))
                    return true;

                return base.CanConvertFrom(context, sourceType);
            }

            public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
            {
                string s = value as string;
                if (s == null)
                    return base.ConvertFrom(context, culture, value);

                return value;
            }
        }

        [Category("Format Options")]
        [DisplayName("Style")]
        [Description("Coding style, currently supports:\n" +
                     "  - Predefined styles ('LLVM', 'Google', 'Chromium', 'Mozilla', 'WebKit').\n" +
                     "  - 'file' to search for a YAML .clang-format or _clang-format\n" +
                     "    configuration file.\n" +
                     "  - A YAML configuration snippet.\n\n" +
                     "'File':\n" +
                     "  Searches for a .clang-format or _clang-format configuration file\n" +
                     "  in the source file's directory and its parents.\n\n" +
                     "YAML configuration snippet:\n" +
                     "  The content of a .clang-format configuration file, as string.\n" +
                     "  Example: '{BasedOnStyle: \"LLVM\", IndentWidth: 8}'\n\n" +
                     "See also: http://clang.llvm.org/docs/ClangFormatStyleOptions.html.")]
        [TypeConverter(typeof(StyleConverter))]
        public string Style
        {
            get { return style; }
            set { style = value; }
        }

        public sealed class FilenameConverter : TypeConverter
        {
            public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
            {
                if (sourceType == typeof(string))
                    return true;

                return base.CanConvertFrom(context, sourceType);
            }

            public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
            {
                string s = value as string;
                if (s == null)
                    return base.ConvertFrom(context, culture, value);

                // Check if string contains quotes. On Windows, file names cannot contain quotes.
                // We do not accept them however to avoid hard-to-debug problems.
                // A quote in user input would end the parameter quote and so break the command invocation.
                if (s.IndexOf('\"') != -1)
                    throw new NotSupportedException("Filename cannot contain quotes");

                return value;
            }
        }

        [Category("Format Options")]
        [DisplayName("Assume Filename")]
        [Description("When reading from stdin, clang-format assumes this " +
                     "filename to look for a style config file (with 'file' style) " +
                     "and to determine the language.")]
        [TypeConverter(typeof(FilenameConverter))]
        public string AssumeFilename
        {
            get { return assumeFilename; }
            set { assumeFilename = value; }
        }

        public sealed class FallbackStyleConverter : StyleConverter
        {
            public FallbackStyleConverter()
            {
                // Add "none" to the list of styles.
                values.Insert(0, "none");
            }
        }

        [Category("Format Options")]
        [DisplayName("Fallback Style")]
        [Description("The name of the predefined style used as a fallback in case clang-format " +
                     "is invoked with 'file' style, but can not find the configuration file.\n" +
                     "Use 'none' fallback style to skip formatting.")]
        [TypeConverter(typeof(FallbackStyleConverter))]
        public string FallbackStyle
        {
            get { return fallbackStyle; }
            set { fallbackStyle = value; }
        }

        [Category("Format Options")]
        [DisplayName("Sort includes")]
        [Description("Sort touched include lines.\n\n" +
                     "See also: http://clang.llvm.org/docs/ClangFormat.html.")]
        public bool SortIncludes
        {
            get { return sortIncludes; }
            set { sortIncludes = value; }
        }

        [Category("Format On Save")]
        [DisplayName("Enable")]
        [Description("Enable running clang-format when modified files are saved. " +
                     "Will only format if Style is found (ignores Fallback Style)."
            )]
        public bool FormatOnSave
        {
            get { return formatOnSave; }
            set { formatOnSave = value; }
        }

        [Category("Format On Save")]
        [DisplayName("File extensions")]
        [Description("When formatting on save, clang-format will be applied only to " +
                     "files with these extensions.")]
        public string FormatOnSaveFileExtensions
        {
            get { return formatOnSaveFileExtensions; }
            set { formatOnSaveFileExtensions = value; }
        }
    }

    [PackageRegistration(UseManagedResourcesOnly = true)]
    [InstalledProductRegistration("#110", "#112", "1.0", IconResourceID = 400)]
    [ProvideMenuResource("Menus.ctmenu", 1)]
    [ProvideAutoLoad(UIContextGuids80.SolutionExists)] // Load package on solution load
    [Guid(GuidList.guidClangFormatPkgString)]
    [ProvideOptionPage(typeof(OptionPageGrid), "LLVM/Clang", "ClangFormat", 0, 0, true)]
    public sealed class ClangFormatPackage : Package
    {
        #region Package Members

        RunningDocTableEventsDispatcher _runningDocTableEventsDispatcher;

        protected override void Initialize()
        {
            base.Initialize();

            _runningDocTableEventsDispatcher = new RunningDocTableEventsDispatcher(this);
            _runningDocTableEventsDispatcher.BeforeSave += OnBeforeSave;

            var commandService = GetService(typeof(IMenuCommandService)) as OleMenuCommandService;
            if (commandService != null)
            {
                {
                    var menuCommandID = new CommandID(GuidList.guidClangFormatCmdSet, (int)PkgCmdIDList.cmdidClangFormatSelection);
                    var menuItem = new MenuCommand(MenuItemCallback, menuCommandID);
                    commandService.AddCommand(menuItem);
                }

                {
                    var menuCommandID = new CommandID(GuidList.guidClangFormatCmdSet, (int)PkgCmdIDList.cmdidClangFormatDocument);
                    var menuItem = new MenuCommand(MenuItemCallback, menuCommandID);
                    commandService.AddCommand(menuItem);
                }
            }
        }
        #endregion

        OptionPageGrid GetUserOptions()
        {
            return (OptionPageGrid)GetDialogPage(typeof(OptionPageGrid));
        }

        private void MenuItemCallback(object sender, EventArgs args)
        {
            var mc = sender as System.ComponentModel.Design.MenuCommand;
            if (mc == null)
                return;

            switch (mc.CommandID.ID)
            {
                case (int)PkgCmdIDList.cmdidClangFormatSelection:
                    FormatSelection(GetUserOptions());
                    break;

                case (int)PkgCmdIDList.cmdidClangFormatDocument:
                    FormatDocument(GetUserOptions());
                    break;
            }
        }

        private static bool FileHasExtension(string filePath, string fileExtensions)
        {
            var extensions = fileExtensions.ToLower().Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
            return extensions.Contains(Path.GetExtension(filePath).ToLower());
        }

        private void OnBeforeSave(object sender, Document document)
        {
            var options = GetUserOptions();

            if (!options.FormatOnSave)
                return;

            if (!FileHasExtension(document.FullName, options.FormatOnSaveFileExtensions))
                return;

            if (!Vsix.IsDocumentDirty(document))
                return;

            var optionsWithNoFallbackStyle = GetUserOptions().Clone();
            optionsWithNoFallbackStyle.FallbackStyle = "none";
            FormatDocument(document, optionsWithNoFallbackStyle);
        }

        /// <summary>
        /// Runs clang-format on the current selection
        /// </summary>
        private void FormatSelection(OptionPageGrid options)
        {
            IWpfTextView view = Vsix.GetCurrentView();
            if (view == null)
                // We're not in a text view.
                return;
            string text = view.TextBuffer.CurrentSnapshot.GetText();
            int start = view.Selection.Start.Position.GetContainingLine().Start.Position;
            int end = view.Selection.End.Position.GetContainingLine().End.Position;
            int length = end - start;
            
            // clang-format doesn't support formatting a range that starts at the end
            // of the file.
            if (start >= text.Length && text.Length > 0)
                start = text.Length - 1;
            string path = Vsix.GetDocumentParent(view);
            string filePath = Vsix.GetDocumentPath(view);

            RunClangFormatAndApplyReplacements(text, start, length, path, filePath, options, view);
        }

        /// <summary>
        /// Runs clang-format on the current document
        /// </summary>
        private void FormatDocument(OptionPageGrid options)
        {
            FormatView(Vsix.GetCurrentView(), options);
        }

        private void FormatDocument(Document document, OptionPageGrid options)
        {
            FormatView(Vsix.GetDocumentView(document), options);
        }

        private void FormatView(IWpfTextView view, OptionPageGrid options)
        {
            if (view == null)
                // We're not in a text view.
                return;

            string filePath = Vsix.GetDocumentPath(view);
            var path = Path.GetDirectoryName(filePath);

            string text = view.TextBuffer.CurrentSnapshot.GetText();
            if (!text.EndsWith(Environment.NewLine))
            {
                view.TextBuffer.Insert(view.TextBuffer.CurrentSnapshot.Length, Environment.NewLine);
                text += Environment.NewLine;
            }

            RunClangFormatAndApplyReplacements(text, 0, text.Length, path, filePath, options, view);
        }

        private void RunClangFormatAndApplyReplacements(string text, int offset, int length, string path, string filePath, OptionPageGrid options, IWpfTextView view)
        {
            try
            {
                string replacements = RunClangFormat(text, offset, length, path, filePath, options);
                ApplyClangFormatReplacements(replacements, view);
            }
            catch (Exception e)
            {
                var uiShell = (IVsUIShell)GetService(typeof(SVsUIShell));
                var id = Guid.Empty;
                int result;
                uiShell.ShowMessageBox(
                        0, ref id,
                        "Error while running clang-format:",
                        e.Message,
                        string.Empty, 0,
                        OLEMSGBUTTON.OLEMSGBUTTON_OK,
                        OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST,
                        OLEMSGICON.OLEMSGICON_INFO,
                        0, out result);
            }
        }

        /// <summary>
        /// Runs the given text through clang-format and returns the replacements as XML.
        /// 
        /// Formats the text range starting at offset of the given length.
        /// </summary>
        private static string RunClangFormat(string text, int offset, int length, string path, string filePath, OptionPageGrid options)
        {
            string vsixPath = Path.GetDirectoryName(
                typeof(ClangFormatPackage).Assembly.Location);

            System.Diagnostics.Process process = new System.Diagnostics.Process();
            process.StartInfo.UseShellExecute = false;
            process.StartInfo.FileName = vsixPath + "\\clang-format.exe";
            // Poor man's escaping - this will not work when quotes are already escaped
            // in the input (but we don't need more).
            string style = options.Style.Replace("\"", "\\\"");
            string fallbackStyle = options.FallbackStyle.Replace("\"", "\\\"");
            process.StartInfo.Arguments = " -offset " + offset +
                                          " -length " + length +
                                          " -output-replacements-xml " +
                                          " -style \"" + style + "\"" +
                                          " -fallback-style \"" + fallbackStyle + "\"";
            if (options.SortIncludes)
              process.StartInfo.Arguments += " -sort-includes ";
            string assumeFilename = options.AssumeFilename;
            if (string.IsNullOrEmpty(assumeFilename))
                assumeFilename = filePath;
            if (!string.IsNullOrEmpty(assumeFilename))
              process.StartInfo.Arguments += " -assume-filename \"" + assumeFilename + "\"";
            process.StartInfo.CreateNoWindow = true;
            process.StartInfo.RedirectStandardInput = true;
            process.StartInfo.RedirectStandardOutput = true;
            process.StartInfo.RedirectStandardError = true;
            if (path != null)
                process.StartInfo.WorkingDirectory = path;
            // We have to be careful when communicating via standard input / output,
            // as writes to the buffers will block until they are read from the other side.
            // Thus, we:
            // 1. Start the process - clang-format.exe will start to read the input from the
            //    standard input.
            try
            {
                process.Start();
            }
            catch (Exception e)
            {
                throw new Exception(
                    "Cannot execute " + process.StartInfo.FileName + ".\n\"" + 
                    e.Message + "\".\nPlease make sure it is on the PATH.");
            }
            // 2. We write everything to the standard output - this cannot block, as clang-format
            //    reads the full standard input before analyzing it without writing anything to the
            //    standard output.
            process.StandardInput.Write(text);
            // 3. We notify clang-format that the input is done - after this point clang-format
            //    will start analyzing the input and eventually write the output.
            process.StandardInput.Close();
            // 4. We must read clang-format's output before waiting for it to exit; clang-format
            //    will close the channel by exiting.
            string output = process.StandardOutput.ReadToEnd();
            // 5. clang-format is done, wait until it is fully shut down.
            process.WaitForExit();
            if (process.ExitCode != 0)
            {
                // FIXME: If clang-format writes enough to the standard error stream to block,
                // we will never reach this point; instead, read the standard error asynchronously.
                throw new Exception(process.StandardError.ReadToEnd());
            }
            return output;
        }

        /// <summary>
        /// Applies the clang-format replacements (xml) to the current view
        /// </summary>
        private static void ApplyClangFormatReplacements(string replacements, IWpfTextView view)
        {
            // clang-format returns no replacements if input text is empty
            if (replacements.Length == 0)
                return;

            var root = XElement.Parse(replacements);
            var edit = view.TextBuffer.CreateEdit();
            foreach (XElement replacement in root.Descendants("replacement"))
            {
                var span = new Span(
                    int.Parse(replacement.Attribute("offset").Value),
                    int.Parse(replacement.Attribute("length").Value));
                edit.Replace(span, replacement.Value);
            }
            edit.Apply();
        }
    }
}
