Post

Neovim 환경 구축하기 - lazy.nvim, tokyonight 테마 설정

Neovim 환경 구축하기 - lazy.nvim, tokyonight 테마 설정

최근 다양한 CLI 및 텍스트 편집기를 활용하는데, 이 과정에서 생산성을 높이고 효율적인 환경을 갖추기 위해 Neovim 에디터에 관심을 갖게 되었습니다. Neovim은 Vim과 호환되는 동시에 Lua 기반 설정을 통한 확장성과 편의성을 제공합니다.

이번 포스팅에서는 Neovim을 간단히 소개하고, 플러그인 매니저인 lazy.nvim과 아름다운 테마인 tokyonight를 연계하여 깔끔한 개발 환경을 만드는 방법을 다뤄보겠습니다. 추가로, 위에서 제시한 설정 파일(init.lua)을 바탕으로 설정 방법을 간략히 정리하겠습니다.


1. Neovim이란?

Neovim은 Vim의 발전형 에디터로, Vimscript에 비해 더욱 현대적인 확장성과 성능을 제공합니다. 특히 Lua 언어를 통한 설정과 플러그인 관리, 그리고 내장 LSP 클라이언트 지원 등을 통해 IDE급 개발 환경을 쉽게 구축할 수 있습니다. 전통적인 Vim 사용자들에게는 익숙한 키 바인딩과 기능을 이어받으며도, 새로운 유저에게는 간결한 설정 파일 구조와 강력한 플러그인 생태계를 통해 강력한 편의성을 제공합니다.

1.1 Neovim의 특징

  • Lua 기반 설정: Vimscript 대신 Lua를 통한 설정으로, 더 직관적이고 유지보수가 쉬운 설정 파일 관리 가능
  • 내장 LSP 지원: 언어 서버 프로토콜(Language Server Protocol)을 통해 풍부한 코드 인텔리전스, 자동완성, 진단 지원 가능
  • 플러그인 생태계: Lazy.nvim, Packer, Treesitter 등 다양한 Lua 기반 플러그인 매니저와 도구 활용 용이
  • 성능 및 안정성 개선: Vim과 호환성을 유지하면서도 성능 최적화 및 비동기 기능 지원 강화

2. lazy.nvim이란?

플러그인 관리자는 Neovim 사용자에게 필수 요소 중 하나입니다. 기존에는 vim-plug, packer.nvim 등이 주류였지만, 최근에는 lazy.nvim이 빠르게 인기를 얻고 있습니다.

2.1 lazy.nvim의 특징

  • 속도 최적화: Neovim 시작 시 불필요한 플러그인 로드를 최소화하고, 이벤트나 조건에 따른 지연 로딩(laod)을 지원
  • Lua 기반 설정: Lua 테이블 형식으로 플러그인을 선언, 설정, 의존성 관리가 용이
  • 직관적인 설정 방식: require("lazy").setup({ ... }) 형태로 간단하게 플러그인 리스트와 설정을 모듈화

쉽게 말해, lazy.nvim을 사용하면 Neovim 실행 속도를 개선하고, 더 체계적으로 플러그인을 관리할 수 있습니다.


3. tokyonight 테마란?

tokyonight는 Neovim, VSCode, JetBrains IDE 등 다양한 에디터에서 사용할 수 있는 고급스럽고 차분한 다크 테마입니다. Neovim에서 tokyonight 테마를 사용하면 다음과 같은 이점을 얻을 수 있습니다.

  • 고대비 & 눈에 편한 색상 팔레트: 긴 시간의 개발에도 눈의 피로도를 줄여줌
  • 다양한 변형(theme style) 지원: night, storm, day, moon 등 다양한 스타일로 취향에 맞게 선택 가능
  • LSP, Treesitter, Statusline 등 다양한 플러그인과 호환: 에디터의 UI 요소를 테마와 자연스럽게 통일

tokyonight 테마는 단순히 색상만 변경하는 것이 아니라, 코드 가독성과 UI 일관성을 모두 챙겨주는 훌륭한 선택지입니다.


4. 준비물: 필수/권장 패키지

아래 패키지들이 있으면 본문 설정이 문제없이 동작합니다. 시스템 환경에 맞게 설치한 뒤 진행하세요.

4.1 필수 패키지

  • pynvim: Neovim Python3 provider(UltiSnips 연계)에 필요
  • git: lazy.nvim 부트스트랩, gitsigns 동작에 필요
  • ripgrep (rg): Telescope live_grep에 필요
  • 컴파일러/빌드 도구: Treesitter 컴파일용(gcc 또는 clang, make, unzip)
  • terraform: vim-terraform의 포맷(저장 시 fmt) 수행에 필요
  • tflint: nvim-lint로 Terraform 파일 저장 시 검사에 필요
  • terraform-ls: Terraform LSP(수동 설치 시). Mason으로 설치해도 무방

4.2 권장 패키지

  • fd: Telescope find_files 가속(없어도 동작, 있으면 더 빠름)
  • 클립보드 도구: clipboard=unnamedplus
    • X11: xclip 또는 xsel
    • Wayland: wl-clipboard
    • macOS: 기본 pbcopy/pbpaste로 OK
  • Nerd Font: lualine/아이콘 표시를 깔끔하게

4.3 설치 예시

1
2
3
4
5
6
7
8
9
10
11
# Ubuntu/Debian
sudo apt update
sudo apt install -y git ripgrep build-essential unzip xclip
# fd가 필요한 경우
sudo apt install -y fd-find   # 실행명은 fdfind

# macOS (Homebrew)
brew install git ripgrep fd terraform tflint

# Python provider(pynvim) — init.lua가 참조하는 python3에 설치
python3 -m pip install --user -U pynvim

fzf-native 확장을 사용한다면 로컬 컴파일을 위한 make(Xcode Command Line Tools) 또는 brew install make가 필요할 수 있습니다.

4.4 설치 확인

  • Neovim에서 :checkhealth provider로 Python3 OK(pynvim) 확인
  • :checkhealth telescope에서 rg 인식 확인
  • :checkhealth nvim_treesitter로 컴파일 체인 확인
  • :Mason에서 terraform-ls, lua-language-server 상태 확인

5. 설정 예제 (init.lua)

아래는 제가 사용하고 있는 init.lua 설정 파일입니다. 이 설정에서는 lazy.nvim을 통해 tokyonight 테마와 nvim-tree, lualine 등의 플러그인을 설치 및 관리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
-- ~/.config/nvim/init.lua
-- 풀버전: 성능/UX/안정성/충돌 최소화

--------------------------------
-- 기본 옵션
--------------------------------
vim.g.mapleader = " "

vim.opt.number = true
vim.opt.autoindent = true
vim.opt.tabstop = 2
vim.opt.expandtab = true
vim.opt.shiftwidth = 2
vim.opt.smarttab = true
vim.opt.softtabstop = 2
vim.opt.termguicolors = true
vim.opt.mouse = "" -- 터미널 복사 충돌 방지
vim.opt.ignorecase = true
vim.opt.smartcase = true

-- UX 강화
vim.opt.clipboard     = "unnamedplus"
vim.opt.cursorline    = true
vim.opt.signcolumn    = "yes"
vim.opt.scrolloff     = 5
vim.opt.sidescrolloff = 8
vim.opt.splitbelow    = true
vim.opt.splitright    = true
vim.opt.updatetime    = 200
vim.opt.timeoutlen    = 400
vim.opt.undofile      = true
vim.opt.inccommand    = "split"
vim.opt.list          = true
vim.opt.listchars     = { tab = "▸ ", trail = "•", extends = ">", precedes = "<" }
vim.opt.fillchars     = { eob = " ", fold = " ", foldopen = "▾", foldclose = "▸" }
vim.opt.shortmess:append("I") -- intro 숨김
vim.opt.wrap = false

-- Python3 provider (UltiSnips/cmp 대비)
if vim.fn.executable("python3") == 1 then
  vim.g.python3_host_prog = vim.fn.exepath("python3")
end
vim.g.loaded_perl_provider = 0
vim.g.loaded_ruby_provider = 0

-- Python3 host 헬스체크 (pynvim 모듈 유무 확인)
local function has_python3_host()
  if vim.g.__python3_host_ok ~= nil then
    return vim.g.__python3_host_ok == 1
  end
  if vim.fn.executable("python3") ~= 1 then
    vim.g.__python3_host_ok = 0
    return false
  end
  local prog = vim.g.python3_host_prog or vim.fn.exepath("python3")
  local cmd = { prog, "-c", "import importlib,sys; sys.exit(0) if importlib.util.find_spec('pynvim') else sys.exit(1)" }
  vim.fn.system(cmd)
  local ok = (vim.v.shell_error == 0)
  vim.g.__python3_host_ok = ok and 1 or 0
  return ok
end

-- Git/cURL 트레이스 차단(부팅 시 불필요 로그 방지)
vim.env.GIT_TRACE = nil
vim.env.GIT_TRACE_CURL = nil
vim.env.GIT_CURL_VERBOSE = nil

local local_bin = vim.fs.normalize("~/.local/bin")
if vim.fn.isdirectory(local_bin) == 1 then
  if not vim.env.PATH:find(local_bin, 1, true) then
    vim.env.PATH = local_bin .. ":" .. vim.env.PATH
  end
end

-- UltiSnips: snipMate 디렉토리 충돌 방지. 반드시 플러그인 로딩 전에 설정
vim.g.UltiSnipsSnippetDirectories = { "UltiSnips" }

--------------------------------
-- 공용 LSP 헬퍼
--------------------------------
local function lsp_capabilities()
  local caps = vim.lsp.protocol.make_client_capabilities()
  caps.textDocument.foldingRange = {
    dynamicRegistration = false,
    lineFoldingOnly = true,
  }
  local ok, cmp_nvim_lsp = pcall(require, "cmp_nvim_lsp")
  if ok then caps = cmp_nvim_lsp.default_capabilities(caps) end
  return caps
end

local function lsp_on_attach(_, bufnr)
  vim.api.nvim_buf_set_option(bufnr, "omnifunc", "v:lua.vim.lsp.omnifunc")
  local buf = function(mode, lhs, rhs, desc)
    vim.keymap.set(mode, lhs, rhs, { noremap = true, silent = true, buffer = bufnr, desc = desc })
  end
  buf("n", "gd", vim.lsp.buf.definition,        "Go to definition")
  buf("n", "gr", vim.lsp.buf.references,        "References")
  buf("n", "gi", vim.lsp.buf.implementation,    "Implementation")
  buf("n", "K",  vim.lsp.buf.hover,             "Hover")
  buf("n", "gs", vim.lsp.buf.signature_help,    "Signature help")
  buf("n", "<leader>rn", vim.lsp.buf.rename,    "Rename symbol")
  buf("n", "<leader>ca", vim.lsp.buf.code_action,"Code action")
  buf("n", "gl", vim.diagnostic.open_float,     "Line diagnostics")
  buf("n", "[d", vim.diagnostic.goto_prev,      "Prev diagnostic")
  buf("n", "]d", vim.diagnostic.goto_next,      "Next diagnostic")
  if vim.lsp.buf.format then
    buf("n", "<leader>cf", function() vim.lsp.buf.format({ async = true }) end, "Format buffer")
  end
end

local function configure_lsp(server, opts)
  local base = {
    on_attach = lsp_on_attach,
    capabilities = lsp_capabilities(),
  }
  vim.lsp.config(server, vim.tbl_deep_extend("force", {}, base, opts or {}))
end

local function enable_lsp(server)
  local ok, err = pcall(vim.lsp.enable, server)
  if not ok then
    vim.schedule(function()
      vim.notify(("LSP enable failed for %s: %s"):format(server, err), vim.log.levels.WARN, { title = "LSP" })
    end)
  end
end

--------------------------------
-- lazy.nvim 부트스트랩
--------------------------------
local uv = vim.uv or vim.loop
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (uv.fs_stat and uv.fs_stat(lazypath)) then
  if vim.fn.executable("git") == 1 then
    local cmd = {
      "git","-c","http.extraHeader=","-c","http.proxy=","-c","https.proxy=",
      "clone","--filter=blob:none","https://github.com/folke/lazy.nvim.git","--branch=stable",lazypath,
    }
    if vim.system then
      vim.system(cmd, { stdout = false, stderr = false }):wait()
    else
      vim.fn.system({ "sh","-c", table.concat(cmd, " ") .. " >/dev/null 2>&1" })
    end
  end
end
vim.opt.rtp:prepend(lazypath)

--------------------------------
-- 플러그인
--------------------------------
require("lazy").setup({
  -- Lua LS에 Neovim API 타입 주입
  { "folke/neodev.nvim", lazy = false, opts = {}, priority = 10000 },

  -- 테마
  {
    "folke/tokyonight.nvim",
    priority = 9999,
    lazy = false,
    config = function()
      ---@diagnostic disable-next-line: missing-fields
      require("tokyonight").setup({
        style = "storm",
        on_highlights = function(hl, _)
          hl.LineNr = { fg = "#00ff00" }
          hl.CursorLineNr = { fg = "#ff66ff", bold = true }
        end,
      })
      vim.cmd("colorscheme tokyonight")
    end,
  },

  -- 아이콘
  { "nvim-tree/nvim-web-devicons", lazy = true, opts = { default = true } },
  { "echasnovski/mini.icons", lazy = true, version = false, opts = {} },
  {
    "ojroques/nvim-osc52",
    event = "VeryLazy",
    config = function()
      local osc52 = require("osc52")
      osc52.setup({ silent = true })
      local function paste()
        return { vim.fn.getreg("+", 1, true), vim.fn.getregtype("+") }
      end
      vim.g.clipboard = {
        name = "osc52",
        copy = {
          ["+"] = function(lines)
            osc52.copy(table.concat(lines, "\n"))
          end,
          ["*"] = function(lines)
            osc52.copy(table.concat(lines, "\n"))
          end,
        },
        paste = {
          ["+"] = paste,
          ["*"] = paste,
        },
      }
    end,
  },

  -- 상태줄
  {
    "nvim-lualine/lualine.nvim",
    event = "VeryLazy",
    dependencies = { "nvim-tree/nvim-web-devicons" },
    config = function()
      require("lualine").setup({ options = { theme = "tokyonight", icons_enabled = true } })
    end,
  },

  -- 파일 트리
  {
    "nvim-tree/nvim-tree.lua",
    cmd = { "NvimTreeToggle", "NvimTreeFindFile", "NvimTreeRefresh", "NvimTreeFocus" },
    keys = {
      { "<C-n>",     "<cmd>NvimTreeToggle<CR>",    mode = "n", silent = true, desc = "NvimTree Toggle" },
      { "<leader>fe","<cmd>NvimTreeFindFile<CR>",  mode = "n", silent = true, desc = "NvimTree Find Current File" },
      { "<leader>ne","<cmd>NvimTreeFocus<CR>",     mode = "n", silent = true, desc = "NvimTree Focus" },
      { "<leader>fr","<cmd>NvimTreeRefresh<CR>",   mode = "n", silent = true, desc = "NvimTree Refresh" },
      { "<leader>er", function() local api=require("nvim-tree.api"); api.tree.focus(); api.fs.rename() end,
        mode="n", silent=true, desc="Explorer: Rename" },
      { "<leader>en", function() local api=require("nvim-tree.api"); api.tree.focus(); api.fs.create() end,
        mode="n", silent=true, desc="Explorer: New (file/dir)" },
      { "<leader>ed", function() local api=require("nvim-tree.api"); api.tree.focus(); api.fs.remove() end,
        mode="n", silent=true, desc="Explorer: Delete" },
      { "<leader>em", function() local api=require("nvim-tree.api"); api.tree.focus(); api.fs.rename_sub() end,
        mode="n", silent=true, desc="Explorer: Move/Rename (path)" },
    },
    dependencies = { "nvim-tree/nvim-web-devicons" },
    config = function()
      require("nvim-tree").setup({
        view = { width = 36 },
        renderer = { group_empty = true },
        filters = { dotfiles = false },
        git = { enable = true },
      })
    end,
  },

  -- 키맵 가이드
  {
    "folke/which-key.nvim",
    event = "VeryLazy",
    config = function()
      local wk = require("which-key")
      wk.setup({
        win = {
          border = "rounded",
          title = true,
          title_pos = "center",
        },
      })
      wk.add({
        { "<leader>f", group = "find" },
        { "<leader>e", group = "explorer" },
        { "<leader>c", group = "code" },
        { "<leader>s", group = "session" },
        { "<leader>x", group = "diagnostics" },
      })
    end,
  },

  -- 주석: Ctrl+/
  {
    "numToStr/Comment.nvim",
    event = { "BufReadPost", "BufNewFile" },
    config = function()
      local api = require("Comment.api")
      require("Comment").setup()
      vim.keymap.set("n", "<C-_>", function() api.toggle.linewise.current() end, { noremap = true, silent = true, desc="Toggle Comment" })
      vim.keymap.set("x", "<C-_>", function() api.toggle.linewise(vim.fn.visualmode()) end, { noremap = true, silent = true, desc="Toggle Comment (v)" })
    end,
  },

  -- Treesitter
  {
    "nvim-treesitter/nvim-treesitter",
    build = ":TSUpdate",
    event = { "BufReadPost", "BufNewFile" },
    dependencies = {
      "nvim-treesitter/nvim-treesitter-textobjects",
    },
    config = function()
      require("nvim-treesitter.configs").setup({
        ensure_installed = { "lua", "terraform", "hcl", "yaml", "json", "bash", "markdown", "go", "python" },
        highlight = { enable = true },
        indent = { enable = true, disable = { "yaml" } }, -- YAML 인덴트 비활성화
        incremental_selection = {
          enable = true,
          keymaps = {
            init_selection = "gnn",
            node_incremental = "grn",
            scope_incremental = "grc",
            node_decremental = "grm",
          },
        },
        textobjects = {
          select = {
            enable = true,
            lookahead = true,
            keymaps = {
              ["af"] = "@function.outer",
              ["if"] = "@function.inner",
              ["ac"] = "@class.outer",
              ["ic"] = "@class.inner",
            },
          },
          move = {
            enable = true,
            set_jumps = true,
            goto_next_start = {
              ["]m"] = "@function.outer",
              ["]]"] = "@class.outer",
            },
            goto_previous_start = {
              ["[m"] = "@function.outer",
              ["[["] = "@class.outer",
            },
            goto_next_end = { ["]M"] = "@function.outer" },
            goto_previous_end = { ["[M"] = "@function.outer" },
          },
        },
        auto_install = false,
      })
    end,
  },

  -- Terraform
  {
    "hashivim/vim-terraform",
    ft = { "terraform", "tf", "hcl" },
    config = function()
      vim.g.terraform_fmt_on_save = 1
      vim.g.terraform_align = 1
    end,
  },

  -- Mason
  { "mason-org/mason.nvim", build = ":MasonUpdate", opts = {} },

  -- Mason LSP bridge
  {
    "mason-org/mason-lspconfig.nvim",
    dependencies = { "mason-org/mason.nvim", "neovim/nvim-lspconfig" },
    opts = {
      ensure_installed = { "terraformls", "lua_ls", "yamlls" },
      automatic_enable = { exclude = { "terraformls" } },
    },
  },

  -- LSP 설정 (terraformls는 하단 TFENV 블록에서 구성)
  {
    "neovim/nvim-lspconfig",
    event = { "BufReadPre", "BufNewFile" },
    dependencies = { "folke/neodev.nvim", "b0o/schemastore.nvim" },
    config = function()
      vim.diagnostic.config({
        virtual_text = { spacing = 2, prefix = "●" },
        float = { border = "rounded" },
        severity_sort = true,
      })

      require("neodev").setup({})

      -- Lua
      configure_lsp("lua_ls", {
        settings = {
          Lua = {
            runtime = { version = "LuaJIT" },
            diagnostics = { globals = { "vim" } },
            workspace = { checkThirdParty = false, library = vim.api.nvim_get_runtime_file("", true) },
            telemetry = { enable = false },
          },
        },
      })

      -- YAML (SchemaStore)
      local ok_schema, schemastore = pcall(require, "schemastore")
      local yaml_schemas = {}
      if ok_schema then
        yaml_schemas = schemastore.yaml.schemas()
      end
      configure_lsp("yamlls", {
        settings = {
          yaml = {
            validate = true,
            format = { enable = true },
            schemaStore = { enable = false },
            schemas = yaml_schemas,
            keyOrdering = false,
          },
        },
      })
    end,
  },

  -- Helm 템플릿 파일타입/하이라이트
  { "towolf/vim-helm", ft = { "helm" } },

  -- TFLint + yamllint
  {
    "mfussenegger/nvim-lint",
    ft = { "terraform", "tf", "hcl", "yaml" },
    config = function()
      local lint = require("lint")
      lint.linters_by_ft = {
        terraform = { "tflint" }, tf = { "tflint" }, hcl = { "tflint" },
        yaml = { "yamllint" },
      }
      vim.api.nvim_create_autocmd("BufWritePost", {
        callback = function() require("lint").try_lint() end,
      })
    end,
  },

  -- 스니펫: UltiSnips 먼저 로드 (Python host 있을 때만)
  { "SirVer/ultisnips", event = "InsertEnter", cond = has_python3_host },
  { "honza/vim-snippets", event = "InsertEnter" },

  -- 자동 완성 (cmp + UltiSnips)
  {
    "hrsh7th/nvim-cmp",
    event = "InsertEnter",
    dependencies = {
      { "quangnguyen30192/cmp-nvim-ultisnips", cond = has_python3_host, dependencies = { "SirVer/ultisnips" } },
      "hrsh7th/cmp-buffer",
      "hrsh7th/cmp-path",
      "hrsh7th/cmp-nvim-lsp",
    },
    config = function()
      local has_py = (vim.fn.has("python3") == 1) and (vim.fn.executable("python3") == 1)
      local has_ultisnips = (vim.fn.exists("*UltiSnips#Anon") == 1)

      local cmp = require("cmp")
      cmp.setup({
        snippet = {
          expand = function(args)
            if has_ultisnips then
              vim.fn["UltiSnips#Anon"](args.body)
            elseif vim.snippet and vim.snippet.expand then
              vim.snippet.expand(args.body)
            end
          end,
        },
        mapping = cmp.mapping.preset.insert({
          ["<C-n>"] = cmp.mapping.select_next_item({ behavior = cmp.SelectBehavior.Insert }),
          ["<C-p>"] = cmp.mapping.select_prev_item({ behavior = cmp.SelectBehavior.Insert }),
          ["<CR>"]  = cmp.mapping.confirm({ select = false }),
          ["<Tab>"] = cmp.mapping(function(fallback)
            if cmp.visible() then
              cmp.select_next_item()
            elseif has_ultisnips and vim.fn["UltiSnips#CanJumpForwards"]() == 1 then
              vim.api.nvim_feedkeys(
                vim.api.nvim_replace_termcodes("<Plug>(ultisnips_jump_forward)", true, true, true),
                "m",
                true
              )
            else
              fallback()
            end
          end, { "i", "s" }),
          ["<S-Tab>"] = cmp.mapping(function(fallback)
            if cmp.visible() then
              cmp.select_prev_item()
            elseif has_ultisnips and vim.fn["UltiSnips#CanJumpBackwards"]() == 1 then
              vim.api.nvim_feedkeys(
                vim.api.nvim_replace_termcodes("<Plug>(ultisnips_jump_backward)", true, true, true),
                "m",
                true
              )
            else
              fallback()
            end
          end, { "i", "s" }),
        }),
        sources = (function()
          local s = {
            { name = "nvim_lsp" },
            { name = "buffer" },
            { name = "path" },
          }
          if has_py and has_ultisnips then
            table.insert(s, 1, { name = "ultisnips" })
          end
          return s
        end)(),
        experimental = { ghost_text = false },
      })
    end,
  },

  -- 자동 괄호 (cmp confirm 연동)
  {
    "windwp/nvim-autopairs",
    event = "InsertEnter",
    config = function()
      local autopairs = require("nvim-autopairs")
      autopairs.setup({
        check_ts = true,
        fast_wrap = {},
        disable_filetype = { "TelescopePrompt", "vim" },
      })
      local ok, cmp = pcall(require, "cmp")
      if ok then
        local cmp_autopairs = require("nvim-autopairs.completion.cmp")
        cmp.event:on("confirm_done", cmp_autopairs.on_confirm_done())
      end
      autopairs.add_rules(require("nvim-autopairs.rules.endwise-lua"))
    end,
  },

  -- Telescope
  {
    "nvim-telescope/telescope.nvim",
    branch = "0.1.x",
    dependencies = { "nvim-lua/plenary.nvim" },
    cmd = "Telescope",
    keys = {
      { "<leader>ff", "<cmd>Telescope find_files<CR>", mode="n", silent=true, desc="Find files" },
      { "<leader>fg", "<cmd>Telescope live_grep<CR>",  mode="n", silent=true, desc="Live grep" },
      { "<leader>fb", "<cmd>Telescope buffers<CR>",    mode="n", silent=true, desc="Buffers" },
      { "<leader>fh", "<cmd>Telescope help_tags<CR>",  mode="n", silent=true, desc="Help tags" },
    },
    config = function()
      require("telescope").setup({
        defaults = {
          layout_strategy = "horizontal",
          sorting_strategy = "ascending",
          layout_config = { prompt_position = "top" },
        },
      })
    end,
  },
  {
    "nvim-telescope/telescope-file-browser.nvim",
    dependencies = { "nvim-telescope/telescope.nvim" },
    keys = {
      { "<leader>fB", function()
          require("telescope").extensions.file_browser.file_browser({
            path = "%:p:h", select_buffer = true, respect_gitignore = false, hidden = true,
          })
        end, mode="n", silent=true, desc="File Browser (buffer dir)" },
    },
    config = function()
      require("telescope").load_extension("file_browser")
    end,
  },

  -- Git 변경 표시
  {
    "lewis6991/gitsigns.nvim",
    event = { "BufReadPost", "BufNewFile" },
    config = function()
      require("gitsigns").setup({
        current_line_blame = false,
        current_line_blame_opts = { delay = 500 },
      })
      local gs = package.loaded.gitsigns
      vim.keymap.set("n", "]h", gs.next_hunk, { silent = true, desc = "Next hunk" })
      vim.keymap.set("n", "[h", gs.prev_hunk, { silent = true, desc = "Prev hunk" })
      vim.keymap.set("n", "<leader>hp", gs.preview_hunk, { silent = true, desc = "Preview hunk" })
      vim.keymap.set("n", "<leader>hs", gs.stage_hunk,   { silent = true, desc = "Stage hunk" })
      vim.keymap.set("n", "<leader>hu", gs.undo_stage_hunk, { silent = true, desc = "Undo stage hunk" })
    end,
  },

  -- 진단 패널
  {
    "folke/trouble.nvim",
    cmd = { "Trouble", "TroubleToggle" },
    dependencies = { "nvim-tree/nvim-web-devicons" },
    keys = {
      { "<leader>xx", "<cmd>Trouble diagnostics toggle<CR>", mode="n", silent=true, desc="Diagnostics" },
      { "<leader>xq", "<cmd>Trouble qflist toggle<CR>",      mode="n", silent=true, desc="Quickfix" },
    },
    opts = {},
  },

  -- 세션 복원
  {
    "folke/persistence.nvim",
    event = "BufReadPre",
    config = true,
    keys = {
      { "<leader>ss", function() require("persistence").load() end,                mode="n", silent=true, desc="Session restore" },
      { "<leader>sl", function() require("persistence").load({ last = true }) end, mode="n", silent=true, desc="Session last" },
      { "<leader>sd", function() require("persistence").stop() end,                mode="n", silent=true, desc="Session stop" },
    },
  },
}, {
  checker = { enabled = false },
  change_detection = { enabled = false },
  rocks = { enabled = false },
})

--------------------------------
-- 자동명령
--------------------------------
-- Yank 하이라이트
vim.api.nvim_create_autocmd("TextYankPost", {
  callback = function()
    vim.highlight.on_yank()
    if vim.v.event.operator == "y" then
      local reg = vim.v.event.regname
      if reg == "" or reg == "+" or reg == "*" then
        local ok, osc52 = pcall(require, "osc52")
        if ok then
          osc52.copy_register(reg == "" and "+" or reg)
        end
      end
    end
  end,
})

-- 외부 변경 자동 반영
vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {
  callback = function() if vim.fn.getcmdwintype() == "" then vim.cmd("checktime") end end,
})

-- 마지막 커서 위치 복귀
vim.api.nvim_create_autocmd("BufReadPost", {
  callback = function()
    local mark = vim.api.nvim_buf_get_mark(0, '"')
    local lcount = vim.api.nvim_buf_line_count(0)
    if mark[1] > 0 and mark[1] <= lcount then pcall(vim.api.nvim_win_set_cursor, 0, mark) end
  end,
})

-- 저장 시 트레일링 공백 제거(마크다운/커밋/Helm/Make 제외)
vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = "*",
  callback = function()
    if not vim.bo.modifiable or vim.bo.readonly then
      return
    end
    local ft = vim.bo.filetype
    if ft ~= "markdown" and ft ~= "gitcommit" and ft ~= "helm" and ft ~= "make" then
      local view = vim.fn.winsaveview()
      vim.cmd([[%s/\s\+$//e]])
      vim.fn.winrestview(view)
    end
  end,
})

-- 시작 직후 메시지라인 정리
vim.schedule(function() vim.cmd("echo ''") vim.cmd("redraw!") end)

--------------------------------
-- 전역 키매핑
--------------------------------
local map = vim.keymap.set
local opts = { noremap = true, silent = true }
local function mapd(mode, lhs, rhs, desc)
  local o = desc and vim.tbl_extend("force", opts, { desc = desc }) or opts
  map(mode, lhs, rhs, o)
end

-- 창 이동
mapd("n", "<C-h>", "<C-w>h", "Window left")
mapd("n", "<C-j>", "<C-w>j", "Window down")
mapd("n", "<C-k>", "<C-w>k", "Window up")
mapd("n", "<C-l>", "<C-w>l", "Window right")

-- 편의
mapd("n", "<leader>w", "<cmd>write<CR>", "Write buffer")
mapd("n", "<leader>q", "<cmd>quit<CR>",  "Quit window")
mapd("n", "<leader>Q", "<cmd>qa!<CR>",   "Quit all (force)")
mapd("n", "<leader>h", "<cmd>nohlsearch<CR>", "Clear highlight")

-- UI 토글
mapd("n", "<leader>un", function() vim.wo.relativenumber = not vim.wo.relativenumber end, "Toggle relative number")

-- 창 크기 조절
mapd("n", "<C-Up>",    "<cmd>resize +2<CR>", "Increase height")
mapd("n", "<C-Down>",  "<cmd>resize -2<CR>", "Decrease height")
mapd("n", "<C-Left>",  "<cmd>vertical resize -3<CR>", "Decrease width")
mapd("n", "<C-Right>", "<cmd>vertical resize +3<CR>", "Increase width")

-- 라인/블록 이동
mapd("n", "<A-j>", ":m .+1<CR>==", "Move line down")
mapd("n", "<A-k>", ":m .-1<CR>==", "Move line up")
mapd("i", "<A-j>", "<Esc>:m .+1<CR>==gi", "Move line down")
mapd("i", "<A-k>", "<Esc>:m .-1<CR>==gi", "Move line up")
mapd("v", "<A-j>", ":m '>+1<CR>gv=gv", "Move block down")
mapd("v", "<A-k>", ":m '<-2<CR>gv=gv", "Move block up")

--------------------------------
-- 파일타입별 들여쓰기 규칙
--------------------------------
vim.api.nvim_create_autocmd("FileType", {
  pattern = { "python" },
  callback = function()
    local o = vim.opt_local
    o.expandtab = true
    o.shiftwidth = 4
    o.tabstop = 4
    o.softtabstop = 4
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "go" },
  callback = function()
    local o = vim.opt_local
    o.expandtab = false   -- Go는 탭 사용
    o.tabstop = 4         -- 표시 폭
    o.shiftwidth = 0      -- 탭 폭을 따름
    o.softtabstop = 0
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "make" },
  callback = function()
    local o = vim.opt_local
    o.expandtab = false   -- Makefile은 탭 필수
    o.tabstop = 8
    o.shiftwidth = 8
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "yaml", "yml", "docker-compose", "helm" },
  callback = function()
    local o = vim.opt_local
    o.expandtab = true
    o.shiftwidth = 2
    o.tabstop = 2
    o.softtabstop = 2
  end,
})

-- === TFENV + terraform-ls bootstrap (safe, idempotent) ===
if not vim.g.__tfenv_lsp_bootstrap then
  vim.g.__tfenv_lsp_bootstrap = true

  local ok_util, util = pcall(require, "lspconfig.util")
  if ok_util then

    -- Build a safe env that prefers tfenv and enables provider caching
    local function tfenv_env(base)
      local env = {}
      for k, v in pairs(base or {}) do env[k] = v end

      local home = vim.loop.os_homedir()
      -- PATH list separator
      local list_sep = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) and ";" or ":"
      local current_path = env.PATH or os.getenv("PATH") or ""

      local paths = {
        home .. "/.tfenv/shims",
        home .. "/.tfenv/bin",
      }

      -- Prepend if directory exists
      for i = #paths, 1, -1 do
        local p = paths[i]
        if vim.fn.isdirectory(p) == 1 then
          current_path = p .. list_sep .. current_path
        end
      end

      env.PATH = current_path
      env.TFENV_AUTO_INSTALL = "true"
      env.TF_PLUGIN_CACHE_DIR = home .. "/.terraform.d/plugin-cache"
      return env
    end

    -- Reusable cmd_env for terraform-ls
    local cmd_env = tfenv_env({})

    -- Root detection supports 0.12, 0.13, and 1.x layouts
    local root_dir = util.root_pattern(".terraform-version", ".tool-versions", "versions.tf", ".terraform", ".git")

    -- Configure terraform-ls to always use tfenv-backed terraform
    configure_lsp("terraformls", {
      cmd = { "terraform-ls", "serve" },
      cmd_env = cmd_env,
      filetypes = { "terraform", "terraform-vars", "hcl" },
      root_dir = root_dir,
      single_file_support = true,
      settings = {
        terraform = {
          path = "terraform",   -- tfenv shims가 해결
        },
      },
    })

    enable_lsp("terraformls")

    -- Optional inspection commands (no side effects)
    vim.api.nvim_create_user_command("TerraformEnvInfo", function()
      local out = ""
      if vim.fn.has("wsl") == 1 then
        local ok, handle = pcall(io.popen, "bash -lc 'command -v terraform && terraform version | head -n1'")
        if ok and handle then
          out = handle:read("*a") or ""
          handle:close()
        end
      else
        local ok2, handle2 = pcall(io.popen, "terraform version | head -n1 2>&1")
        if ok2 and handle2 then
          out = handle2:read("*a") or ""
          handle2:close()
        end
      end
      if out == "" then out = "terraform not found in PATH" end
      vim.notify(out, vim.log.levels.INFO, { title = "TerraformEnvInfo" })
    end, {})

    vim.api.nvim_create_user_command("TerraformSetEnv", function()
      local new_env = tfenv_env({ PATH = os.getenv("PATH") })
      vim.env.PATH = new_env.PATH
      vim.env.TFENV_AUTO_INSTALL = new_env.TFENV_AUTO_INSTALL
      vim.env.TF_PLUGIN_CACHE_DIR = new_env.TF_PLUGIN_CACHE_DIR
      vim.notify("tfenv environment exported for this Neovim session", vim.log.levels.INFO, { title = "TerraformSetEnv" })
    end, {})
  end
end
-- === End TFENV + terraform-ls bootstrap ===

5.1 적용 방법

  1. Neovim 설치
    • Ubuntu: sudo apt install neovim
    • macOS(Homebrew): brew install neovim
      Neovim 버전은 0.8 이상을 권장합니다.
  2. 환경 디렉토리 생성
    1
    
    mkdir -p ~/.config/nvim
    

    이후 위의 init.lua 파일을 ~/.config/nvim/init.lua 경로에 저장합니다.

  3. Neovim 실행
    1
    
    nvim
    

    첫 실행 시 init.lua 내 스크립트가 동작하며, lazy.nvim이 없으면 자동으로 clone하여 설치합니다. 이후 지정한 플러그인들이 자동 설치됩니다.

  4. 테마 & 플러그인 동작 확인
    tokyonight 테마가 적용된 UI를 확인하고, Ctrl + n으로 NvimTree를 열어보거나 상태라인(lualine)의 아이콘, 색상이 잘 나오는지 테스트합니다.

6. 추가 설정/개선 포인트

  • which-key: 리더키 기반 단축키 힌트를 제공해 학습 곡선을 낮춥니다.
  • gitsigns: 변경/추가/삭제 라인 표시로 Git 작업 가시성을 높입니다.
  • indent-blankline(ibl): 들여쓰기 가이드 표시로 가독성을 높입니다.
  • telescope-fzf-native: Telescope 검색 성능을 향상합니다(make 필요).
  • todo-comments: TODO/FIXME/HACK 등 주석을 일관되게 하이라이트합니다.
  • fidget: LSP 초기화/분석 진행 상태를 UI로 보여줍니다.

아래 스니펫은 위 플러그인을 require("lazy").setup({ ... }) 내부에 추가할 때 참조용입니다.

1
2
3
4
5
6
7
8
-- 추가 플러그인 예시 (lazy.nvim spec)
{ "folke/which-key.nvim", event = "VeryLazy", opts = {} },
{ "lewis6991/gitsigns.nvim", event = { "BufReadPre", "BufNewFile" }, opts = {} },
{ "lukas-reineke/indent-blankline.nvim", main = "ibl", event = { "BufReadPost", "BufNewFile" }, opts = {} },
{ "nvim-telescope/telescope-fzf-native.nvim", build = "make", cond = function() return vim.fn.executable("make") == 1 end,
  config = function() require("telescope").load_extension("fzf") end },
{ "folke/todo-comments.nvim", event = { "BufReadPost", "BufNewFile" }, dependencies = { "nvim-lua/plenary.nvim" }, opts = {} },
{ "j-hui/fidget.nvim", tag = "legacy", event = "LspAttach", opts = {} },

관련 글

  1. Neovim 환경 구축하기 - lazy.nvim, tokyonight 테마 설정 (현재 글)

Reference

  • Neovim: https://neovim.io
  • lazy.nvim: https://github.com/folke/lazy.nvim
  • tokyonight.nvim: https://github.com/folke/tokyonight.nvim
  • nvim-tree: https://github.com/nvim-tree/nvim-tree.lua
  • lualine.nvim: https://github.com/nvim-lualine/lualine.nvim
  • telescope.nvim: https://github.com/nvim-telescope/telescope.nvim
  • telescope-fzf-native: https://github.com/nvim-telescope/telescope-fzf-native.nvim
  • gitsigns.nvim: https://github.com/lewis6991/gitsigns.nvim
  • indent-blankline.nvim(ibl): https://github.com/lukas-reineke/indent-blankline.nvim
  • todo-comments.nvim: https://github.com/folke/todo-comments.nvim
  • fidget.nvim: https://github.com/j-hui/fidget.nvim

궁금하신 점이나 추가해야 할 부분은 댓글이나 아래의 링크를 통해 문의해주세요.
Written with KKamJi

This post is licensed under CC BY 4.0 by the author.