├── .codecov.yaml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── measure.go ├── measure_test.go ├── measurement.go ├── measurement_test.go ├── parse.go ├── parse_test.go ├── vim.go └── vim_test.go /.codecov.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.codecov.com/docs/commit-status#disabling-a-status 2 | coverage: 3 | status: 4 | project: off 5 | patch: off 6 | 7 | comment: false 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | unit-tests: 6 | name: Unit tests 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: '>=1.23.0' 16 | - uses: rhysd/action-setup-vim@v1 17 | - uses: rhysd/action-setup-vim@v1 18 | with: 19 | neovim: true 20 | - run: go build 21 | env: 22 | CGO_ENABLED: '0' 23 | - run: go test -v -race -coverprofile coverage.txt 24 | - name: Upload coverage to codecov 25 | uses: codecov/codecov-action@v4 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-go@v5 13 | with: 14 | go-version: '>=1.23.0' 15 | - uses: goreleaser/goreleaser-action@v6 16 | with: 17 | version: '~> v2' 18 | args: release --clean 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vim-startuptime 2 | /coverage.txt 3 | /release 4 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | draft: false 5 | prerelease: true 6 | 7 | builds: 8 | - id: macos 9 | goos: [darwin] 10 | goarch: [amd64, arm64] 11 | env: 12 | - CGO_ENABLED=0 13 | 14 | - id: linux 15 | goos: [linux] 16 | goarch: [386, arm, amd64, arm64] 17 | env: 18 | - CGO_ENABLED=0 19 | 20 | - id: windows 21 | goos: [windows] 22 | goarch: [amd64, arm64] 23 | env: 24 | - CGO_ENABLED=0 25 | 26 | - id: freebsd 27 | goos: [freebsd] 28 | goarch: [386, amd64] 29 | env: 30 | - CGO_ENABLED=0 31 | 32 | archives: 33 | - <<: &archives_defaults 34 | files: 35 | - README.md 36 | - LICENSE 37 | id: nix 38 | builds: [macos, linux, freebsd] 39 | format: tar.gz 40 | - <<: *archives_defaults 41 | id: windows 42 | builds: [windows] 43 | format: zip 44 | 45 | changelog: 46 | disable: true 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # [v1.3.2](https://github.com/rhysd/vim-startuptime/releases/tag/v1.3.2) - 12 Feb 2024 3 | 4 | - Fix a parse error when using `vim-startuptime` with https://github.com/neovim/neovim/commit/8e739af064dec28886694aa448f60a570acd2173 or later. ([#4](https://github.com/rhysd/vim-startuptime/issues/4)) 5 | - The parser was broken by [the recent Neovim's change](https://github.com/neovim/neovim/pull/26790 to `--startuptime` output format). 6 | 7 | [Changes][v1.3.2] 8 | 9 | 10 | 11 | # [v1.3.1](https://github.com/rhysd/vim-startuptime/releases/tag/v1.3.1) - 06 Dec 2022 12 | 13 | - Fix Vim does not exit on Windows by adding `-e` option (thanks [@mattn](https://github.com/mattn), [#3](https://github.com/rhysd/vim-startuptime/issues/3)) 14 | - Replace newlines in error messages with spaces and avoid capitalizing error messages following the Go's error message convention 15 | - Remove deprecated `io/util` imports 16 | 17 | [Changes][v1.3.1] 18 | 19 | 20 | 21 | # [v1.3.0](https://github.com/rhysd/vim-startuptime/releases/tag/v1.3.0) - 11 Jul 2022 22 | 23 | - Support Lua `require('...')` as script source. It was recently [introduced to Neovim](https://github.com/neovim/neovim/pull/19267). ([#2](https://github.com/rhysd/vim-startuptime/issues/2)) 24 | 25 | [Changes][v1.3.0] 26 | 27 | 28 | 29 | # [v1.2.0](https://github.com/rhysd/vim-startuptime/releases/tag/v1.2.0) - 03 Apr 2022 30 | 31 | - Add `-verbose` flag to output the progress of measurements to stderr 32 | - Add support for [LunarVim](https://www.lunarvim.org/) (thanks [@runar-rkmedia](https://github.com/runar-rkmedia), [#1](https://github.com/rhysd/vim-startuptime/issues/1)) 33 | - Build binaries with the latest Go compiler 1.18.0 34 | 35 | [Changes][v1.2.0] 36 | 37 | 38 | 39 | # [v1.1.0](https://github.com/rhysd/vim-startuptime/releases/tag/v1.1.0) - 12 Feb 2020 40 | 41 | - New: `-warmup {count}` option was added for warm-up phase. Before collecting the results, `vim-startuptime` runs the Vim/Neovim process `{count}` times 42 | - Dev: Migrate CI to GitHub Actions 43 | 44 | [Changes][v1.1.0] 45 | 46 | 47 | 48 | # [v1.0.0](https://github.com/rhysd/vim-startuptime/releases/tag/v1.0.0) - 06 Jun 2018 49 | 50 | First release :tada: 51 | 52 | [Changes][v1.0.0] 53 | 54 | 55 | [v1.3.2]: https://github.com/rhysd/vim-startuptime/compare/v1.3.1...v1.3.2 56 | [v1.3.1]: https://github.com/rhysd/vim-startuptime/compare/v1.3.0...v1.3.1 57 | [v1.3.0]: https://github.com/rhysd/vim-startuptime/compare/v1.2.0...v1.3.0 58 | [v1.2.0]: https://github.com/rhysd/vim-startuptime/compare/v1.1.0...v1.2.0 59 | [v1.1.0]: https://github.com/rhysd/vim-startuptime/compare/v1.0.0...v1.1.0 60 | [v1.0.0]: https://github.com/rhysd/vim-startuptime/tree/v1.0.0 61 | 62 | 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | the MIT License 2 | 3 | Copyright (c) 2018 rhysd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 17 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 20 | THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Better `vim --startuptime` 2 | ========================== 3 | [![CI Badge][]][CI] 4 | [![Codecov Status][]][Codecov] 5 | 6 | `vim-startuptime` is a small Go program to measure startup time of Vim or Neovim. This program aims 7 | to be an alternative of `--startuptime` option of Vim, which measures the startup time metrics to 8 | allow vimmers to optimize Vim's startup. 9 | 10 | After warm-up, `vim-startuptime` runs `vim --startuptime` multiple times internally and collects the 11 | metrics from the results (e.g. average time for loading each plugin's scripts). The summary calculated 12 | from the measurements is output to stdout. 13 | 14 | Tested on Linux, Mac and Windows with both Vim and Neovim. 15 | 16 | 17 | 18 | ## Installation 19 | 20 | Download an executable from [a release page](https://github.com/rhysd/vim-startuptime/releases). 21 | 22 | If you want to install the latest stable version, please run following command. Go toolchain is 23 | necessary for running the command. 24 | 25 | ```sh 26 | go install github.com/rhysd/vim-startuptime@latest 27 | ``` 28 | 29 | 30 | 31 | ## Requirements 32 | 33 | - `vim` 7.4.1444 or later (for `--not-a-term` startup option) 34 | - `nvim` 35 | 36 | 37 | 38 | ## Usage 39 | 40 | Just run the command with no argument. 41 | 42 | ```sh 43 | vim-startuptime 44 | ``` 45 | 46 | By default, it tries to run `vim` and `:quit` immediately 10 times, collects the results and outputs 47 | a summary of them to stdout. 48 | 49 | ``` 50 | Extra options: [] 51 | Measured: 10 times 52 | 53 | Total Average: 189.954400 msec 54 | Total Max: 198.062000 msec 55 | Total Min: 183.966000 msec 56 | 57 | AVERAGE MAX MIN 58 | ------------------------------- 59 | 98.532900 102.605000 94.275000: $HOME/.vimrc 60 | 51.859600 56.937000 49.897000: opening buffers 61 | 17.027900 18.810000 16.277000: /Users/rhysd/.vim/bundle/vim-color-spring-night/colors/spring-night.vim 62 | 11.878900 13.153000 10.567000: /Users/rhysd/.vim/bundle/vim-smartinput/autoload/smartinput.vim 63 | 9.407600 11.710000 8.606000: /usr/local/Cellar/macvim/HEAD-0db36ff_1/MacVim.app/Contents/Resources/vim/runtime/filetype.vim 64 | 65 | ...(snip) 66 | 67 | 0.009100 0.012000 0.007000: window checked 68 | 0.009000 0.012000 0.008000: inits 3 69 | 0.003000 0.005000 0.002000: clipboard setup 70 | 0.002600 0.004000 0.002000: editing files in windows 71 | ``` 72 | 73 | If you want to run with Neovim, please specify `-vimpath` option as follows: 74 | 75 | ``` 76 | $ vim-startuptime -vimpath nvim 77 | ``` 78 | 79 | If you want to give some options or arguments to underlying `vim` command executions, please specify 80 | them after `--` argument in command line as follows: 81 | 82 | ``` 83 | $ vim-startuptime -- --cmd DoSomeCommand path/to/some/file 84 | ``` 85 | 86 | Please see `-help` option to know the command options. 87 | 88 | 89 | 90 | ## What's Next after running `vim-startuptime`? 91 | 92 | By running `vim-startuptime`, now you know which script file takes time to run. What you should do 93 | next is `:profile`. 94 | 95 | ```sh 96 | vim --cmd 'profile start profile.log' --cmd 'profile! file /path/to/slow_script.vim' -c quit 97 | ``` 98 | 99 | Profiled results are dumped to `profile.log`. Please check it. In log file, `:set ft=vim` would help 100 | you analyze the results. 101 | Please see `:help profile` for more details. 102 | 103 | 104 | 105 | ## (Maybe) TODO 106 | 107 | - Add more metrics like median 108 | - Temporarily isolate CPU for running Vim if possible 109 | 110 | 111 | 112 | ## License 113 | 114 | Distributed under [the MIT License](./LICENSE). 115 | 116 | 117 | 118 | [CI Badge]: https://github.com/rhysd/vim-startuptime/actions/workflows/ci.yml/badge.svg 119 | [CI]: https://github.com/rhysd/vim-startuptime/actions/workflows/ci.yml 120 | [Codecov Status]: https://codecov.io/gh/rhysd/vim-startuptime/branch/master/graph/badge.svg 121 | [Codecov]: https://codecov.io/gh/rhysd/vim-startuptime 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rhysd/vim-startuptime 2 | 3 | go 1.16 4 | 5 | require github.com/google/go-cmp v0.6.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | ) 9 | 10 | type options struct { 11 | count uint 12 | vimPath string 13 | script bool 14 | extraArgs []string 15 | warmup uint 16 | verbose bool 17 | } 18 | 19 | const usageHeader = `Usage: vim-startuptime [flags] [-- VIMARGS...] 20 | 21 | vim-startuptime is a command which provides better --startuptime option of Vim 22 | or Neovim. It starts Vim with --startuptime multiple times, collects the 23 | results and outputs summary of the measurements to stdout. 24 | 25 | Flags:` 26 | 27 | func parseOptions(out io.Writer, args []string) (*options, int) { 28 | fs := flag.NewFlagSet(args[0], flag.ContinueOnError) 29 | fs.SetOutput(out) 30 | 31 | o := &options{} 32 | fs.UintVar(&o.count, "count", 10, "How many times measure startup time") 33 | fs.StringVar(&o.vimPath, "vimpath", "vim", "Command to run Vim or Neovim") 34 | fs.BoolVar(&o.script, "script", false, "Only collects script loading times") 35 | fs.UintVar(&o.warmup, "warmup", 1, "How many times start Vim at warm-up phase") 36 | fs.BoolVar(&o.verbose, "verbose", false, "Verbose output to stderr while measurements") 37 | fs.Usage = func() { 38 | fmt.Fprintln(out, usageHeader) 39 | fs.PrintDefaults() 40 | } 41 | 42 | if err := fs.Parse(args[1:]); err != nil { 43 | if err == flag.ErrHelp { 44 | return nil, 0 45 | } 46 | fmt.Fprintf(out, "error while parsing command line arguments: %s\n", err) 47 | return nil, 1 48 | } 49 | 50 | o.extraArgs = fs.Args() 51 | return o, -1 52 | } 53 | 54 | func measure(opts *options, out io.Writer) error { 55 | collected, err := collectMeasurements(opts) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | summary, err := summarizeStartuptime(collected, opts.verbose) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | fmt.Fprintf(out, "Extra options: %v\n", opts.extraArgs) 66 | fmt.Fprintf(out, "Measured: %d times\n\n", opts.count) 67 | summary.print(out) 68 | return nil 69 | } 70 | 71 | func main() { 72 | opts, code := parseOptions(os.Stderr, os.Args) 73 | if code >= 0 { 74 | os.Exit(code) 75 | } 76 | 77 | if err := measure(opts, os.Stdout); err != nil { 78 | fmt.Fprintln(os.Stderr, err) 79 | os.Exit(1) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | func TestParseOptionsOK(t *testing.T) { 13 | var b bytes.Buffer 14 | 15 | o, s := parseOptions(&b, []string{"vim-startuptime", "-count", "3", "-vimpath", "nvim", "-script", "-warmup", "5", "-verbose", "--", "--foo"}) 16 | if s >= 0 { 17 | t.Fatal("unexpected exit with status:", s) 18 | } 19 | 20 | want := &options{ 21 | count: 3, 22 | vimPath: "nvim", 23 | script: true, 24 | extraArgs: []string{"--foo"}, 25 | warmup: 5, 26 | verbose: true, 27 | } 28 | 29 | if !cmp.Equal(o, want, cmp.AllowUnexported(options{})) { 30 | t.Fatal(cmp.Diff(o, want, cmp.AllowUnexported(options{}))) 31 | } 32 | 33 | stderr := b.String() 34 | if stderr != "" { 35 | t.Fatalf("Unexpected stderr output %q", stderr) 36 | } 37 | } 38 | 39 | func TestParseOptionsUnknownFlag(t *testing.T) { 40 | var b bytes.Buffer 41 | 42 | _, s := parseOptions(&b, []string{"vim-startuptime", "-foo"}) 43 | if s <= 0 { 44 | t.Fatal("unexpected status:", s) 45 | } 46 | 47 | stderr := b.String() 48 | if !strings.Contains(stderr, "flag provided but not defined: -foo") { 49 | t.Fatal("unexpected error output to stderr:", stderr) 50 | } 51 | } 52 | 53 | func TestParseOptionsHelpOutput(t *testing.T) { 54 | var b bytes.Buffer 55 | 56 | _, s := parseOptions(&b, []string{"vim-startuptime", "-help"}) 57 | if s != 0 { 58 | t.Fatal("unexpected status:", s) 59 | } 60 | 61 | stderr := b.String() 62 | if !strings.HasPrefix(stderr, "Usage: vim-startuptime [flags] [-- VIMARGS...]") { 63 | t.Fatal("unexpected help output to stderr:", stderr) 64 | } 65 | } 66 | 67 | func TestRunMeasurementMainOK(t *testing.T) { 68 | o := &options{ 69 | count: 1, 70 | vimPath: "vim", 71 | script: false, 72 | extraArgs: []string{}, 73 | warmup: 0, 74 | verbose: false, 75 | } 76 | 77 | var b bytes.Buffer 78 | if err := measure(o, &b); err != nil { 79 | t.Fatal(err) 80 | } 81 | 82 | out := b.String() 83 | for _, want := range []string{ 84 | "Extra options: []", 85 | "Measured: 1 times", 86 | "Total Average:", 87 | "Total Max:", 88 | "Total Min:", 89 | } { 90 | if !strings.Contains(out, want) { 91 | t.Errorf("output should contain %q but got %q", want, out) 92 | } 93 | } 94 | } 95 | 96 | func TestErrorWhileRunningMain(t *testing.T) { 97 | o := &options{ 98 | count: 1, 99 | vimPath: "this-executable-does-not-exist", 100 | script: false, 101 | extraArgs: []string{}, 102 | warmup: 0, 103 | verbose: false, 104 | } 105 | 106 | err := measure(o, io.Discard) 107 | if !strings.Contains(err.Error(), `failed to run "this-executable-does-not-exist"`) { 108 | t.Fatal("error was unexpected:", err) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /measure.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "time" 8 | ) 9 | 10 | type collectedMeasurements struct { 11 | total []time.Duration 12 | entries map[string][]time.Duration 13 | } 14 | 15 | func collectMeasurements(opts *options) (*collectedMeasurements, error) { 16 | if opts.verbose { 17 | fmt.Fprintf(os.Stderr, "Running warm-up phase %d times\n", opts.warmup) 18 | } 19 | for i := uint(0); i < opts.warmup; i++ { 20 | if err := runVim(opts.vimPath, opts.extraArgs); err != nil { 21 | return nil, fmt.Errorf("error while warm-up: %w", err) 22 | } 23 | } 24 | 25 | dir, err := os.MkdirTemp("", "__vim_startuptime_") 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to make a temporary directory: %w", err) 28 | } 29 | defer os.RemoveAll(dir) 30 | 31 | if opts.verbose { 32 | fmt.Fprintf(os.Stderr, "Running %q with arguments %v %d times at %q\n", opts.vimPath, opts.extraArgs, opts.count, dir) 33 | } 34 | collected := &collectedMeasurements{entries: map[string][]time.Duration{}} 35 | for id := 0; id < int(opts.count); id++ { 36 | f, err := runVimStartuptime(opts.vimPath, dir, id, opts.extraArgs) 37 | if err != nil { 38 | return nil, err 39 | } 40 | m, err := parseStartuptime(f) 41 | if err != nil { 42 | f.Close() 43 | return nil, err 44 | } 45 | collected.total = append(collected.total, m.elapsedTotal) 46 | for _, e := range m.entries { 47 | if !opts.script || e.script { 48 | collected.entries[e.name] = append(collected.entries[e.name], e.total) 49 | } 50 | } 51 | f.Close() 52 | } 53 | 54 | return collected, nil 55 | } 56 | 57 | func summarizeEntry(name string, ds []time.Duration) entrySummary { 58 | total := time.Duration(0) 59 | min := ds[0] 60 | max := ds[0] 61 | for _, d := range ds { 62 | total += d 63 | if d < min { 64 | min = d 65 | } 66 | if d > max { 67 | max = d 68 | } 69 | } 70 | average := time.Duration(total.Nanoseconds()/int64(len(ds))) * time.Nanosecond 71 | return entrySummary{name, average, max, min} 72 | } 73 | 74 | func summarizeStartuptime(collected *collectedMeasurements, verbose bool) (*measurementSummary, error) { 75 | if verbose { 76 | fmt.Fprintf(os.Stderr, "Calculating summary of collected %d results\n", len(collected.entries)) 77 | } 78 | 79 | summary := &measurementSummary{} 80 | summary.sortedEntries = make([]entrySummary, 0, len(collected.entries)) 81 | if len(collected.total) == 0 { 82 | return nil, fmt.Errorf("no total time was collected") 83 | } 84 | summary.total = summarizeEntry("Total", collected.total) 85 | for n, ds := range collected.entries { 86 | if len(ds) == 0 { 87 | return nil, fmt.Errorf("no profile was collected for '%s'", n) 88 | } 89 | summary.sortedEntries = append(summary.sortedEntries, summarizeEntry(n, ds)) 90 | } 91 | 92 | // Sort in decending order by duration 93 | sort.Slice(summary.sortedEntries, func(i, j int) bool { 94 | return summary.sortedEntries[i].average > summary.sortedEntries[j].average 95 | }) 96 | 97 | if verbose { 98 | fmt.Fprintln(os.Stderr, "Calculated summary. Printing...") 99 | } 100 | return summary, nil 101 | } 102 | -------------------------------------------------------------------------------- /measure_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | var measureCmpOpts = cmp.AllowUnexported(collectedMeasurements{}, measurementSummary{}, entrySummary{}) 12 | 13 | func TestCollectMeasurementsOK(t *testing.T) { 14 | for _, path := range []string{"vim", "nvim"} { 15 | t.Run(path, func(t *testing.T) { 16 | opts := &options{ 17 | count: 2, 18 | vimPath: path, 19 | warmup: 1, 20 | extraArgs: []string{"-N", "-u", "NONE"}, 21 | } 22 | collected, err := collectMeasurements(opts) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | if len(collected.total) != 2 { 27 | t.Error("2 total times should be collected but", len(collected.total)) 28 | } 29 | if len(collected.entries) == 0 { 30 | t.Error("Collected entries are empty") 31 | } 32 | for s, ds := range collected.entries { 33 | if len(ds) < 2 { 34 | t.Error("Source time for", s, " should be collected twice but", ds) 35 | } 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestCollectMeasurementsVimStartError(t *testing.T) { 42 | for _, path := range []string{"vim", "nvim"} { 43 | t.Run(path, func(t *testing.T) { 44 | opts := &options{count: 2, vimPath: path, extraArgs: []string{"--foo"}} 45 | _, err := collectMeasurements(opts) 46 | if err == nil { 47 | t.Fatal("No error occurred") 48 | } 49 | if !strings.Contains(err.Error(), "failed to run \""+path+"\" with args [--foo") { 50 | t.Fatal("Unexpected error:", err.Error()) 51 | } 52 | }) 53 | } 54 | } 55 | 56 | func TestCollectMeasurementsVimWarmupError(t *testing.T) { 57 | opts := &options{warmup: 1, count: 1, vimPath: "vim", extraArgs: []string{"--foo"}} 58 | _, err := collectMeasurements(opts) 59 | if err == nil { 60 | t.Fatal("No error occurred") 61 | } 62 | if !strings.Contains(err.Error(), "error while warm-up") { 63 | t.Fatal("Unexpected error:", err.Error()) 64 | } 65 | } 66 | 67 | func TestSummarizeStartuptime(t *testing.T) { 68 | collected := &collectedMeasurements{ 69 | total: []time.Duration{ 70 | time.Duration(10000), 71 | time.Duration(20000), 72 | time.Duration(30000), 73 | }, 74 | entries: map[string][]time.Duration{ 75 | "/foo/bar": { 76 | time.Duration(110), 77 | time.Duration(130), 78 | time.Duration(120), 79 | }, 80 | "$VIM/vimrc": { 81 | time.Duration(1500), 82 | time.Duration(1400), 83 | time.Duration(1300), 84 | }, 85 | }, 86 | } 87 | want := &measurementSummary{ 88 | total: entrySummary{ 89 | "Total", 90 | time.Duration(20000), 91 | time.Duration(30000), 92 | time.Duration(10000), 93 | }, 94 | sortedEntries: []entrySummary{ 95 | { 96 | "$VIM/vimrc", 97 | time.Duration(1400), 98 | time.Duration(1500), 99 | time.Duration(1300), 100 | }, 101 | { 102 | "/foo/bar", 103 | time.Duration(120), 104 | time.Duration(130), 105 | time.Duration(110), 106 | }, 107 | }, 108 | } 109 | 110 | have, err := summarizeStartuptime(collected, false) 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | if !cmp.Equal(want, have, measureCmpOpts) { 115 | t.Fatal(cmp.Diff(want, have, measureCmpOpts)) 116 | } 117 | } 118 | 119 | func TestSummarizeStartuptimeError(t *testing.T) { 120 | for _, tc := range []struct { 121 | what string 122 | collected *collectedMeasurements 123 | msg string 124 | }{ 125 | { 126 | "no total time", 127 | &collectedMeasurements{ 128 | total: []time.Duration{}, 129 | entries: map[string][]time.Duration{ 130 | "/foo/bar": {time.Duration(110)}, 131 | }, 132 | }, 133 | "no total time was collected", 134 | }, 135 | { 136 | "no entry profile result", 137 | &collectedMeasurements{ 138 | total: []time.Duration{time.Duration(110)}, 139 | entries: map[string][]time.Duration{ 140 | "$VIM/vimrc": {}, 141 | }, 142 | }, 143 | "no profile was collected for '$VIM/vimrc'", 144 | }, 145 | } { 146 | t.Run(tc.what, func(t *testing.T) { 147 | _, err := summarizeStartuptime(tc.collected, false) 148 | if err == nil { 149 | t.Fatal("Error should happen") 150 | } 151 | msg := err.Error() 152 | if !strings.Contains(msg, tc.msg) { 153 | t.Fatalf("Unexpected error '%s', it should contain '%s'", msg, tc.msg) 154 | } 155 | }) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /measurement.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "math" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type measurementEntry struct { 12 | script bool 13 | elapsed time.Duration 14 | total time.Duration 15 | self time.Duration 16 | name string 17 | } 18 | 19 | type measurement struct { 20 | elapsedTotal time.Duration 21 | entries []*measurementEntry 22 | } 23 | 24 | type entrySummary struct { 25 | name string 26 | average time.Duration 27 | max time.Duration 28 | min time.Duration 29 | } 30 | 31 | type measurementSummary struct { 32 | total entrySummary 33 | sortedEntries []entrySummary 34 | } 35 | 36 | func alignFloatColumn(data []float64, header string) []string { 37 | intPart := 0 38 | for _, f := range data { 39 | d := int(math.Log10(f)) + 1 40 | if d > intPart { 41 | intPart = d 42 | } 43 | } 44 | width := intPart + 1 /*'.'*/ + 6 /*decimal part*/ 45 | 46 | aligned := make([]string, 0, len(data)+1) 47 | 48 | if len(header) < width { 49 | header = fmt.Sprintf("%s%s", strings.Repeat(" ", width-len(header)), header) 50 | } 51 | aligned = append(aligned, header) 52 | 53 | for _, f := range data { 54 | s := fmt.Sprintf("%.6f", f) 55 | if len(s) < width { 56 | s = fmt.Sprintf("%s%s", strings.Repeat(" ", width-len(s)), s) 57 | } 58 | aligned = append(aligned, s) 59 | } 60 | 61 | return aligned 62 | } 63 | 64 | func (summary *measurementSummary) print(w io.Writer) { 65 | fmt.Fprintf(w, "Total Average: %f msec\n", summary.total.average.Seconds()*1000) 66 | fmt.Fprintf(w, "Total Max: %f msec\n", summary.total.max.Seconds()*1000) 67 | fmt.Fprintf(w, "Total Min: %f msec\n\n", summary.total.min.Seconds()*1000) 68 | 69 | averages := make([]float64, 0, len(summary.sortedEntries)) 70 | maxes := make([]float64, 0, len(summary.sortedEntries)) 71 | mins := make([]float64, 0, len(summary.sortedEntries)) 72 | for _, e := range summary.sortedEntries { 73 | averages = append(averages, e.average.Seconds()*1000) 74 | maxes = append(maxes, e.max.Seconds()*1000) 75 | mins = append(mins, e.min.Seconds()*1000) 76 | } 77 | averageColumn := alignFloatColumn(averages, "AVERAGE") 78 | maxColumn := alignFloatColumn(maxes, "MAX") 79 | minColumn := alignFloatColumn(mins, "MIN") 80 | 81 | fmt.Fprintf(w, "%s %s %s\n", averageColumn[0], maxColumn[0], minColumn[0]) 82 | fmt.Fprintln(w, strings.Repeat("-", len(averageColumn[0])+1+len(maxColumn[0])+1+len(minColumn[0])+1)) 83 | for i, e := range summary.sortedEntries { 84 | fmt.Fprintf(w, "%s %s %s: %s\n", averageColumn[i+1], maxColumn[i+1], minColumn[i+1], e.name) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /measurement_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | func TestPrintSummary(t *testing.T) { 13 | for _, tc := range []struct { 14 | what string 15 | summary *measurementSummary 16 | want []string 17 | }{ 18 | { 19 | "different number of digits in entries", 20 | &measurementSummary{ 21 | entrySummary{ 22 | "Total", 23 | time.Duration(200) * time.Millisecond, 24 | time.Duration(300) * time.Millisecond, 25 | time.Duration(100) * time.Millisecond, 26 | }, 27 | []entrySummary{ 28 | { 29 | "/foo/bar.vim", 30 | time.Duration(12345) * time.Microsecond, 31 | time.Duration(13334) * time.Microsecond, 32 | time.Duration(11112) * time.Microsecond, 33 | }, 34 | { 35 | "$VIM/vimrc", 36 | time.Duration(1234) * time.Microsecond, 37 | time.Duration(1334) * time.Microsecond, 38 | time.Duration(1112) * time.Microsecond, 39 | }, 40 | }, 41 | }, 42 | []string{ 43 | "Total Average: 200.000000 msec", 44 | "Total Max: 300.000000 msec", 45 | "Total Min: 100.000000 msec", 46 | "", 47 | " AVERAGE MAX MIN", 48 | "------------------------------", 49 | "12.345000 13.334000 11.112000: /foo/bar.vim", 50 | " 1.234000 1.334000 1.112000: $VIM/vimrc", 51 | }, 52 | }, 53 | { 54 | "same number of digits in entries", 55 | &measurementSummary{ 56 | entrySummary{ 57 | "Total", 58 | time.Duration(200) * time.Millisecond, 59 | time.Duration(1000) * time.Millisecond, 60 | time.Duration(10) * time.Millisecond, 61 | }, 62 | []entrySummary{ 63 | { 64 | "/foo/bar.vim", 65 | time.Duration(5678) * time.Microsecond, 66 | time.Duration(7890) * time.Microsecond, 67 | time.Duration(1234) * time.Microsecond, 68 | }, 69 | { 70 | "$VIM/vimrc", 71 | time.Duration(1234) * time.Microsecond, 72 | time.Duration(2345) * time.Microsecond, 73 | time.Duration(1000) * time.Microsecond, 74 | }, 75 | }, 76 | }, 77 | []string{ 78 | "Total Average: 200.000000 msec", 79 | "Total Max: 1000.000000 msec", 80 | "Total Min: 10.000000 msec", 81 | "", 82 | " AVERAGE MAX MIN", 83 | "---------------------------", 84 | "5.678000 7.890000 1.234000: /foo/bar.vim", 85 | "1.234000 2.345000 1.000000: $VIM/vimrc", 86 | }, 87 | }, 88 | } { 89 | t.Run(tc.what, func(t *testing.T) { 90 | var buf bytes.Buffer 91 | tc.summary.print(&buf) 92 | lines := strings.Split(buf.String(), "\n") 93 | 94 | if lines[len(lines)-1] != "" { 95 | t.Error("Output does not end with newline") 96 | } 97 | lines = lines[:len(lines)-1] 98 | 99 | if !cmp.Equal(lines, tc.want) { 100 | t.Fatal(cmp.Diff(lines, tc.want)) 101 | } 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func parseErrorAt(lineno uint, format string, args ...interface{}) error { 13 | s := fmt.Sprintf("parse error at line:%d: %s", lineno, format) 14 | return fmt.Errorf(s, args...) 15 | } 16 | 17 | func parseDuration(s string, lineno uint) (time.Duration, error) { 18 | s = strings.TrimSuffix(s, ":") 19 | f, err := strconv.ParseFloat(s, 64) 20 | if err != nil { 21 | return time.Duration(0), parseErrorAt(lineno, "cannot parse field '%s' as millisec duration", s) 22 | } 23 | return time.Duration(f*1000) * time.Microsecond, nil 24 | } 25 | 26 | func parseStartuptimeEntity(line string, lineno uint) (*measurementEntry, error) { 27 | e := &measurementEntry{} 28 | 29 | ss := strings.Fields(line) 30 | if len(ss) <= 2 { 31 | return nil, parseErrorAt(lineno, "lack of fields: '%s'", line) 32 | } 33 | 34 | d, err := parseDuration(ss[0], lineno) 35 | if err != nil { 36 | return nil, err 37 | } 38 | e.elapsed = d 39 | 40 | d, err = parseDuration(ss[1], lineno) 41 | if err != nil { 42 | return nil, err 43 | } 44 | e.total = d 45 | 46 | if strings.HasSuffix(ss[1], ":") { 47 | e.script = false 48 | e.self = time.Duration(0) 49 | e.name = strings.Join(ss[2:], " ") 50 | return e, nil 51 | } 52 | 53 | e.script = true 54 | if len(ss) < 4 { 55 | return nil, parseErrorAt(lineno, "failed to parse script measurement line '%s'. too few fields", line) 56 | } 57 | 58 | d, err = parseDuration(ss[2], lineno) 59 | if err != nil { 60 | return nil, err 61 | } 62 | e.self = d 63 | 64 | if ss[3] == "sourcing" { 65 | e.name = strings.Join(ss[4:], " ") 66 | if e.name == "" { 67 | return nil, parseErrorAt(lineno, "failed to parse script measurement line '%s'. script name is missing", line) 68 | } 69 | } else if strings.HasPrefix(ss[3], "require(") { 70 | e.name = strings.Join(ss[3:], " ") 71 | } else { 72 | return nil, parseErrorAt(lineno, "'sourcing' token or 'require(...)' token is expected but got '%s'", ss[3]) 73 | } 74 | 75 | return e, nil 76 | } 77 | 78 | func parseStartuptime(file *os.File) (*measurement, error) { 79 | m := &measurement{} 80 | 81 | s := bufio.NewScanner(file) 82 | l := uint(1) 83 | for s.Scan() { 84 | if l < 7 { 85 | // Skip header 86 | l++ 87 | continue 88 | } 89 | t := s.Text() 90 | if t == "" { 91 | // Neovim appends an extra empty line at the end of input (#4) 92 | continue 93 | } 94 | e, err := parseStartuptimeEntity(t, l) 95 | if err != nil { 96 | return nil, err 97 | } 98 | m.entries = append(m.entries, e) 99 | l++ 100 | } 101 | 102 | if len(m.entries) == 0 { 103 | return nil, fmt.Errorf("broken --startuptime output while parsing file %s. no entry was parsed", file.Name()) 104 | } 105 | m.elapsedTotal = m.entries[len(m.entries)-1].elapsed 106 | 107 | return m, nil 108 | } 109 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | var parseCmpOpts = cmp.AllowUnexported(measurement{}, measurementEntry{}) 14 | 15 | func TestParseOK(t *testing.T) { 16 | tmpfile, err := os.CreateTemp("", "__test_parse_ok") 17 | if err != nil { 18 | panic(err) 19 | } 20 | tmpname := tmpfile.Name() 21 | tmpfile.Close() 22 | defer func() { 23 | os.Remove(tmpname) 24 | }() 25 | 26 | header := ` 27 | 28 | times in msec 29 | clock self+sourced self: sourced script 30 | clock elapsed: other lines 31 | 32 | ` 33 | 34 | for _, tc := range []struct { 35 | what string 36 | lines []string 37 | expected *measurement 38 | }{ 39 | { 40 | "system only", 41 | []string{ 42 | "000.008 000.008: --- VIM STARTING ---", 43 | "000.190 000.182: Allocated generic buffers", 44 | }, 45 | &measurement{ 46 | time.Duration(190) * time.Microsecond, 47 | []*measurementEntry{ 48 | { 49 | false, 50 | time.Duration(8) * time.Microsecond, 51 | time.Duration(8) * time.Microsecond, 52 | time.Duration(0), 53 | "--- VIM STARTING ---", 54 | }, 55 | { 56 | false, 57 | time.Duration(190) * time.Microsecond, 58 | time.Duration(182) * time.Microsecond, 59 | time.Duration(0), 60 | "Allocated generic buffers", 61 | }, 62 | }, 63 | }, 64 | }, 65 | { 66 | "script only", 67 | []string{ 68 | "012.696 000.429 000.429: sourcing /foo/bar.vim", 69 | "013.270 001.106 000.677: sourcing $VIM/vimrc", 70 | }, 71 | &measurement{ 72 | time.Duration(13270) * time.Microsecond, 73 | []*measurementEntry{ 74 | { 75 | true, 76 | time.Duration(12696) * time.Microsecond, 77 | time.Duration(429) * time.Microsecond, 78 | time.Duration(429) * time.Microsecond, 79 | "/foo/bar.vim", 80 | }, 81 | { 82 | true, 83 | time.Duration(13270) * time.Microsecond, 84 | time.Duration(1106) * time.Microsecond, 85 | time.Duration(677) * time.Microsecond, 86 | "$VIM/vimrc", 87 | }, 88 | }, 89 | }, 90 | }, 91 | { 92 | "mixed", 93 | []string{ 94 | "198.369 000.161 000.161: sourcing /foo/bar.vim", 95 | "198.465 001.679: BufEnter autocommands", 96 | "198.467 000.002: editing files in windows", 97 | "200.107 001.135 001.135: sourcing $VIM/vimrc", 98 | }, 99 | &measurement{ 100 | time.Duration(200107) * time.Microsecond, 101 | []*measurementEntry{ 102 | { 103 | true, 104 | time.Duration(198369) * time.Microsecond, 105 | time.Duration(161) * time.Microsecond, 106 | time.Duration(161) * time.Microsecond, 107 | "/foo/bar.vim", 108 | }, 109 | { 110 | false, 111 | time.Duration(198465) * time.Microsecond, 112 | time.Duration(1679) * time.Microsecond, 113 | time.Duration(0), 114 | "BufEnter autocommands", 115 | }, 116 | { 117 | false, 118 | time.Duration(198467) * time.Microsecond, 119 | time.Duration(2) * time.Microsecond, 120 | time.Duration(0), 121 | "editing files in windows", 122 | }, 123 | { 124 | true, 125 | time.Duration(200107) * time.Microsecond, 126 | time.Duration(1135) * time.Microsecond, 127 | time.Duration(1135) * time.Microsecond, 128 | "$VIM/vimrc", 129 | }, 130 | }, 131 | }, 132 | }, 133 | { 134 | "lua for neovim (#2)", 135 | []string{ 136 | "021.963 000.003: parsing arguments", 137 | "023.515 000.278 000.278: require('vim.shared')", 138 | "023.699 000.101 000.101: require('vim._meta')", 139 | }, 140 | &measurement{ 141 | time.Duration(23699) * time.Microsecond, 142 | []*measurementEntry{ 143 | { 144 | false, 145 | time.Duration(21963) * time.Microsecond, 146 | time.Duration(3) * time.Microsecond, 147 | time.Duration(0), 148 | "parsing arguments", 149 | }, 150 | { 151 | true, 152 | time.Duration(23515) * time.Microsecond, 153 | time.Duration(278) * time.Microsecond, 154 | time.Duration(278) * time.Microsecond, 155 | "require('vim.shared')", 156 | }, 157 | { 158 | true, 159 | time.Duration(23699) * time.Microsecond, 160 | time.Duration(101) * time.Microsecond, 161 | time.Duration(101) * time.Microsecond, 162 | "require('vim._meta')", 163 | }, 164 | }, 165 | }, 166 | }, 167 | } { 168 | t.Run(tc.what, func(t *testing.T) { 169 | content := []byte(header + strings.Join(tc.lines, "\n") + "\n") 170 | if err := os.WriteFile(tmpname, content, 0644); err != nil { 171 | panic(err) 172 | } 173 | 174 | f, err := os.Open(tmpname) 175 | if err != nil { 176 | panic(err) 177 | } 178 | defer f.Close() 179 | 180 | m, err := parseStartuptime(f) 181 | if err != nil { 182 | t.Fatal(err) 183 | } 184 | 185 | if !cmp.Equal(m, tc.expected, parseCmpOpts) { 186 | t.Fatal(cmp.Diff(m, tc.expected, parseCmpOpts)) 187 | } 188 | }) 189 | } 190 | } 191 | 192 | func TestParseErrors(t *testing.T) { 193 | tmpfile, err := os.CreateTemp("", "__test_parse_errors") 194 | if err != nil { 195 | panic(err) 196 | } 197 | tmpname := tmpfile.Name() 198 | tmpfile.Close() 199 | defer func() { 200 | os.Remove(tmpname) 201 | }() 202 | 203 | header := []string{ 204 | "", 205 | "", 206 | "times in msec", 207 | "clock self+sourced self: sourced script", 208 | "clock elapsed: other lines", 209 | "", 210 | } 211 | 212 | for _, tc := range []struct { 213 | what string 214 | lines []string 215 | msg string 216 | line uint 217 | }{ 218 | { 219 | what: "empty file", 220 | lines: []string{""}, 221 | msg: "broken --startuptime output while parsing file", 222 | }, 223 | { 224 | what: "invalid float at elapsed time", 225 | lines: append(header, "00-.008 000.008: --- VIM STARTING ---"), 226 | msg: "cannot parse field '00-.008' as millisec duration", 227 | line: 7, 228 | }, 229 | { 230 | what: "invalid float at self+source", 231 | lines: append(header, "000.008 000.a08: --- VIM STARTING ---"), 232 | msg: "cannot parse field '000.a08' as millisec duration", 233 | line: 7, 234 | }, 235 | { 236 | what: "invalid float at self", 237 | lines: append(header, "198.369 000.161 000.!61: sourcing /foo/bar.vim"), 238 | msg: "cannot parse field '000.!61' as millisec duration", 239 | line: 7, 240 | }, 241 | { 242 | what: "script name is not existing", 243 | lines: append(header, "198.369 000.161 000.161: sourcing"), 244 | msg: "script name is missing", 245 | line: 7, 246 | }, 247 | { 248 | what: "empty description", 249 | lines: append(header, "198.369 000.161 000.161:"), 250 | msg: "too few fields", 251 | line: 7, 252 | }, 253 | { 254 | what: "'sourcing' is missing", 255 | lines: append(header, "198.369 000.161 000.161: /foo/bar.vim foo"), 256 | msg: "'sourcing' token or 'require(...)' token is expected but got '/foo/bar.vim'", 257 | line: 7, 258 | }, 259 | } { 260 | t.Run(tc.what, func(t *testing.T) { 261 | content := []byte(strings.Join(tc.lines, "\n") + "\n") 262 | if err := os.WriteFile(tmpname, content, 0644); err != nil { 263 | panic(err) 264 | } 265 | 266 | f, err := os.Open(tmpname) 267 | if err != nil { 268 | panic(err) 269 | } 270 | defer f.Close() 271 | 272 | _, err = parseStartuptime(f) 273 | if err == nil { 274 | t.Fatal("Error did not happen:", tc.msg) 275 | } 276 | 277 | msg := err.Error() 278 | if !strings.Contains(msg, tc.msg) { 279 | t.Fatalf("Unexpected error. '%s' is not in '%s'", tc.msg, msg) 280 | } 281 | if tc.line != 0 && !strings.Contains(msg, fmt.Sprintf("parse error at line:%d:", tc.line)) { 282 | t.Fatal("Error should occur at line", tc.line, "(error:", msg, ")") 283 | } 284 | }) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /vim.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | func isNeovimPath(vimpath string) bool { 14 | suff := strings.TrimSuffix(vimpath, ".exe") 15 | if strings.HasSuffix(suff, "nvim") { 16 | return true 17 | } 18 | if strings.HasSuffix(suff, "lvim") { 19 | return true 20 | } 21 | return false 22 | } 23 | 24 | func runVim(vimpath string, extra []string, args ...string) error { 25 | a := make([]string, 0, len(extra)+3+len(args)) 26 | a = append(a, extra...) 27 | if isNeovimPath(vimpath) { 28 | a = append(a, "--headless") 29 | } else { 30 | a = append(a, "--not-a-term") 31 | } 32 | if runtime.GOOS == "windows" { 33 | if p, err := exec.LookPath(vimpath); err == nil { 34 | if filepath.Base(p) == "vim.exe" { 35 | a = append(a, "-e") 36 | } 37 | } 38 | } 39 | a = append(a, "-c", "qall!") 40 | a = append(a, args...) 41 | 42 | cmd := exec.Command(vimpath, a...) 43 | out, err := cmd.CombinedOutput() 44 | if err != nil { 45 | for i, b := range out { 46 | if b == '\n' || b == '\r' { 47 | out[i] = ' ' 48 | } 49 | } 50 | return fmt.Errorf("failed to run %q with args %v: %w. Output: %s", vimpath, a, err, string(out)) 51 | } 52 | return nil 53 | } 54 | 55 | func runVimStartuptime(vimpath, tmpdir string, id int, extra []string) (*os.File, error) { 56 | outfile := filepath.Join(tmpdir, strconv.Itoa(id)) 57 | if err := runVim(vimpath, extra, "--startuptime", outfile); err != nil { 58 | return nil, err 59 | } 60 | 61 | f, err := os.Open(outfile) 62 | if err != nil { 63 | return nil, fmt.Errorf("could not open --startuptime result file '%s': %w", outfile, err) 64 | } 65 | return f, nil 66 | } 67 | -------------------------------------------------------------------------------- /vim_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestRunVimOK(t *testing.T) { 12 | for _, exe := range []string{"vim", "nvim"} { 13 | t.Run(exe, func(t *testing.T) { 14 | dir, err := os.MkdirTemp("", "__vim_run_test_") 15 | if err != nil { 16 | panic(err) 17 | } 18 | defer os.RemoveAll(dir) 19 | 20 | f, err := runVimStartuptime(exe, dir, 3, []string{"-u", "NONE", "-N"}) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | defer f.Close() 25 | 26 | _, fname := filepath.Split(f.Name()) 27 | if fname != "3" { 28 | t.Error("Invalid result file name", fname, "Wanted '3'") 29 | } 30 | bytes, err := io.ReadAll(f) 31 | if err != nil { 32 | t.Fatal("Cannot open startup result file", err) 33 | } 34 | content := string(bytes) 35 | if !strings.Contains(content, "--- "+strings.ToUpper(exe)+" STARTING ---") { 36 | t.Error("Invalid result file:", content) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestRunVimError(t *testing.T) { 43 | for _, exe := range []string{"vim", "nvim"} { 44 | t.Run(exe, func(t *testing.T) { 45 | dir, err := os.MkdirTemp("", "__vim_run_test_error_") 46 | if err != nil { 47 | panic(err) 48 | } 49 | defer os.RemoveAll(dir) 50 | 51 | _, err = runVimStartuptime(exe, dir, 3, []string{"--foo", "--bar"}) 52 | if err == nil { 53 | t.Fatal("Invalid extra args should cause an error") 54 | } 55 | want := "failed to run \"" + exe + "\" with args [--foo --bar" 56 | if !strings.Contains(err.Error(), want) { 57 | t.Errorf("Wanted %q but got %q", want, err) 58 | } 59 | }) 60 | } 61 | } 62 | --------------------------------------------------------------------------------