├── Makefile ├── ftplugin └── haskell.vim ├── README.md ├── .github └── workflows │ └── test.yml ├── runtest.vim ├── LICENSE ├── indent └── haskell.vim ├── test └── test_indent.vim └── autoload └── haskell.vim /Makefile: -------------------------------------------------------------------------------- 1 | VIM := vim 2 | unexport VIM 3 | VIMFLAGS := $(if $(filter nvim,$(VIM)),--headless,--not-a-term) 4 | 5 | all: test 6 | 7 | TESTS := $(wildcard test/test_*.vim) 8 | 9 | $(TESTS): 10 | $(VIM) --clean $(VIMFLAGS) -u runtest.vim "$@" || { cat testlog; false; } 11 | 12 | test: $(TESTS) 13 | 14 | .PHONY: all test $(TESTS) 15 | -------------------------------------------------------------------------------- /ftplugin/haskell.vim: -------------------------------------------------------------------------------- 1 | " Vim filetype plugin 2 | " Language: Haskell 3 | " Author: Axel Forsman 4 | 5 | if exists('b:did_ftplugin') | finish | endif 6 | let b:did_ftplugin = 1 7 | 8 | setlocal tabstop=8 shiftwidth=2 expandtab 9 | setlocal comments=:--,s:{-,e:-} commentstring=--\ %s 10 | 11 | let b:undo_ftplugin = 'setlocal tabstop< shiftwidth< expandtab< 12 | \ comments< commentstring<' 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |
4 | 5 | # vim-haskell ![test](https://github.com/axelf4/vim-haskell/workflows/test/badge.svg) 6 | Haskell support for Vim that provides Tab-cycle indentation 7 | 8 | ![demo-indent] 9 | 10 | ## Usage 11 | 12 | By default CtrlT/-D are mapped in Insert mode 13 | to go to the next and previous indentation points respectively. 14 | 15 | *Vim support: Requires Vim 8.2+ or Neovim 0.5+* 16 | 17 | [demo-indent]: ../extra/demo-indent.gif 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | include: 11 | - vim-url: https://github.com/vim/vim-appimage/releases/download/v8.2.1145/GVim-v8.2.1145.glibc2.15-x86_64.AppImage 12 | vim-flags: --not-a-term 13 | - vim-url: https://github.com/neovim/neovim/releases/download/nightly/nvim.appimage 14 | vim-flags: --headless 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Install Vim 18 | run: | 19 | curl -L --output vim ${{ matrix.vim-url }} 20 | chmod +x vim 21 | - name: Run tests 22 | run: make test VIM=./vim VIMFLAGS=${{ matrix.vim-flags }} 23 | -------------------------------------------------------------------------------- /runtest.vim: -------------------------------------------------------------------------------- 1 | set nocompatible 2 | syntax enable 3 | filetype plugin indent on 4 | let s:test_file = expand('%') 5 | let s:messages = [] 6 | 7 | function s:CheckErrors() abort 8 | if empty(v:errors) | return | endif 9 | call add(s:messages, s:test_file .. ':1:Error') 10 | for s:error in v:errors 11 | call add(s:messages, s:error) 12 | endfor 13 | call writefile(s:messages, 'testlog') 14 | cquit! 15 | endfunction 16 | 17 | try 18 | execute 'cd' fnamemodify(resolve(expand(':p')), ':h') 19 | set runtimepath^=. 20 | 21 | source % 22 | " Query list of functions matching ^Test_ 23 | let s:tests = map(split(execute('function /^Test_'), "\n"), 'matchstr(v:val, ''^function \zs\k\+\ze()'')') 24 | 25 | for s:test_function in s:tests 26 | %bwipeout! 27 | call add(s:messages, 'Test ' .. s:test_function) 28 | echo 'Test' s:test_function 29 | execute 'call' s:test_function '()' 30 | call s:CheckErrors() 31 | endfor 32 | catch 33 | call add(v:errors, "Uncaught exception: " .. v:exception .. " at " .. v:throwpoint) 34 | call s:CheckErrors() 35 | endtry 36 | 37 | call writefile(s:messages, 'testlog') 38 | quit! 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Axel Forsman 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 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /indent/haskell.vim: -------------------------------------------------------------------------------- 1 | " Vim indent file 2 | " Language: Haskell 3 | 4 | if exists('b:did_indent') | finish | endif 5 | let b:did_indent = 1 6 | 7 | setlocal indentexpr=GetHaskellIndent() indentkeys=0},0;,0),0],0,,!^F,o,O 8 | 9 | if !hasmapto('HaskellIndentN', 'i') 10 | imap HaskellIndentN 11 | endif 12 | if !hasmapto('HaskellIndentP', 'i') 13 | imap HaskellIndentP 14 | endif 15 | inoremap HaskellIndentN CycleIndentExpr(1) 16 | inoremap HaskellIndentP CycleIndentExpr(-1) 17 | 18 | let b:undo_indent = 'setlocal indentexpr< indentkeys< 19 | \| iunmap HaskellIndentN| iunmap HaskellIndentP 20 | \| iunmap | iunmap ' 21 | 22 | if exists("*GetHaskellIndent") | finish | endif 23 | 24 | let s:indent_dir = 0 25 | 26 | " Set direction for indent cycling and return RHS for indenting. 27 | " 28 | " Note: Leaving Insert mode on blank line would reset indent. 29 | function s:CycleIndentExpr(dir) abort 30 | let s:indent_dir = a:dir 31 | return mode() ==# 'i' ? "\" : '==' 32 | endfunction 33 | 34 | let s:skip_ai = 0 35 | 36 | function GetHaskellIndent() abort 37 | " Neovim indents twice on ^F... 38 | if s:skip_ai 39 | let s:skip_ai = 0 40 | return -1 41 | endif 42 | if s:indent_dir && has('nvim') | let s:skip_ai = 1 | endif 43 | 44 | let prevIndent = indent(s:indent_dir ? v:lnum : prevnonblank(v:lnum)) 45 | let indentations = haskell#Parse() 46 | 47 | let [dir, s:indent_dir] = [s:indent_dir, 0] 48 | if dir >= 0 49 | for indent in indentations 50 | if indent > prevIndent | return indent | endif 51 | endfor 52 | return indentations[-1] 53 | else 54 | for indent in reverse(indentations) 55 | if indent < prevIndent | return indent | endif 56 | endfor 57 | return indentations[-1] " List was reversed in-place 58 | endif 59 | endfunction 60 | -------------------------------------------------------------------------------- /test/test_indent.vim: -------------------------------------------------------------------------------- 1 | function s:Test(text, lines) abort 2 | %bwipeout! 3 | set filetype=haskell 4 | call setline(1, a:text + ['']) 5 | 6 | for assertion in a:lines 7 | call cursor(assertion.lnum, 1) 8 | call assert_equal(assertion.points, uniq(haskell#Parse())) 9 | endfor 10 | endfunction 11 | 12 | function Test_BasicExpr() abort 13 | let text =<< trim END 14 | foo = 15 | END 16 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}]) 17 | endfunction 18 | 19 | function Test_Where() abort 20 | let text =<< trim END 21 | foo = bar 22 | where bar = 4 23 | END 24 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}, 25 | \ {'lnum': 3, 'points': [0, 8, 10]}]) 26 | 27 | let text =<< trim END 28 | foo = 1 29 | where 30 | x = 1; z = 3 31 | y = 4 32 | z foo 33 | END 34 | call s:Test(text, [{'lnum': 3, 'points': [0, 4]}, 35 | \ {'lnum': 6, 'points': [0, 7, 9]}]) 36 | endfunction 37 | 38 | function Test_MultipleDecls() abort 39 | let text =<< trim END 40 | foo = 4 41 | 42 | bar = 4 43 | test 44 | END 45 | call s:Test(text, [{'lnum': 1, 'points': [0]}, 46 | \ {'lnum': 2, 'points': [0, 2]}, 47 | \ {'lnum': 4, 'points': [0, 2]}]) 48 | endfunction 49 | 50 | function Test_WhereAndLet() abort 51 | let text =<< trim END 52 | foo = 1 53 | where 54 | y = let 55 | baz = 6 56 | in bar * baz 57 | z foo 58 | END 59 | call s:Test(text, 60 | \ [{'lnum': 7, 'points': [0, 7, 9]}]) 61 | endfunction 62 | 63 | function Test_String() abort 64 | let text =<< trim END 65 | foo = let 66 | "bar \ 67 | END 68 | call s:Test(text, [{'lnum': 3, 'points': [0, 2, 4]}]) 69 | endfunction 70 | 71 | function Test_LocalDeclaration() abort 72 | let text =<< trim END 73 | foo = [x | let a, b :: Int; a = 1; b = 2 74 | 75 | , x <- [a..b]] 76 | END 77 | call s:Test(text, [{'lnum': 2, 'points': [0, 2, 15, 17]}, 78 | \ {'lnum': 3, 'points': [0, 2]}, 79 | \ {'lnum': 4, 'points': [0, 2]}]) 80 | endfunction 81 | 82 | function Test_PatternMatch() abort 83 | let text =<< trim END 84 | foo = let 85 | (x, y) = 86 | END 87 | call s:Test(text, [{'lnum': 3, 'points': [0, 2, 4]}]) 88 | endfunction 89 | 90 | function Test_NestedLayoutCtxs() abort 91 | let text =<< trim END 92 | foo = 93 | case 3 of 94 | _ -> let x = 1 95 | END 96 | call s:Test(text, [{'lnum': 4, 'points': [0, 2, 4, 6, 13, 15]}]) 97 | endfunction 98 | 99 | function Test_CharLiteral() abort 100 | let text =<< trim END 101 | foo = ',' 102 | END 103 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}]) 104 | endfunction 105 | 106 | function Test_Comment() abort 107 | let text =<< trim END 108 | foo = -- let x = 42 109 | END 110 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}]) 111 | endfunction 112 | 113 | function Test_OnlyComment() abort 114 | let text =<< trim END 115 | -- x 116 | END 117 | call s:Test(text, [{'lnum': 2, 'points': [0]}]) 118 | endfunction 119 | 120 | function Test_ExplicitLayoutCtx() abort 121 | let text =<< trim END 122 | foo = x where { 123 | x = y 124 | ; y = z } 125 | END 126 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}, 127 | \ {'lnum': 3, 'points': [0, 2]}, 128 | \ {'lnum': 4, 'points': [0]}]) 129 | 130 | let text =<< trim END 131 | foo = x where 132 | { 133 | } 134 | END 135 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}, 136 | \ {'lnum': 3, 'points': [0, 2]}, 137 | \ {'lnum': 4, 'points': [0]}]) 138 | endfunction 139 | 140 | function Test_EmptyPair() abort 141 | let text =<< trim END 142 | foo = () + [] + True {} 143 | END 144 | call s:Test(text, [{'lnum': 2, 'points': [0, 2]}]) 145 | endfunction 146 | 147 | function Test_InsertMode() abort 148 | set filetype=haskell 149 | call setline(1, ['foo = let x = 0']) 150 | 151 | execute "normal o\y = 1" 152 | call assert_equal(' y = 1', getline(2)) 153 | 154 | " Assert that indent on blank line is deleted upon leaving Insert mode 155 | execute "normal S\" 156 | call assert_equal('', getline(2)) 157 | endfunction 158 | -------------------------------------------------------------------------------- /autoload/haskell.vim: -------------------------------------------------------------------------------- 1 | const s:endtoken = -1 2 | " End of a layout list 3 | const s:layoutEnd = -2 4 | " A new item in a layout list 5 | const s:layoutItem = -3 6 | const [s:value, 7 | \ s:operator, 8 | \ s:comma, s:semicolon, s:lbrace, s:rbrace, 9 | \ s:lparen, s:rparen, s:lbracket, s:rbracket, 10 | \ s:if, s:then, s:else, s:let, s:in, s:do, s:case, s:of, s:where] 11 | \ = range(1, 19) 12 | 13 | " Regex for matching tokens. 14 | " Note: Vim regexes only supports nine sub-Patterns... 15 | " 16 | " Keywords 17 | let s:search_pat = '\C\(if\|then\|else\|let\|in\|do\|case\|of\|where\)[[:alnum:]''_]\@!' 18 | " Values 19 | let s:search_pat ..= '\|\(''\%(\\.\|[^'']\)\+''\|[[:alnum:]''_]\+\|"\%(\\\_s\+\\\?\|\\\S\|[^"]\)*\%("\|\_$\)\)' 20 | " Special single-character symbols 21 | let s:search_pat ..= '\|\([,;(){}[\]]\)' 22 | " Operators 23 | let s:search_pat ..= '\|\([-:!#$%&*+./<=>?@\\\\^|~`]\+\)' 24 | 25 | const s:str2Tok = { 26 | \ 'if': s:if, 'then': s:then, 'else': s:else, 'let': s:let, 'in': s:in, 27 | \ 'do': s:do, 'case': s:case, 'of': s:of, 'where': s:where, 28 | \ ',': s:comma, ';': s:semicolon, '{': s:lbrace, '}': s:rbrace, 29 | \ '(': s:lparen, ')': s:rparen, '[': s:lbracket, ']': s:rbracket, 30 | \ } 31 | 32 | " Lex the next token and move the cursor to its start. 33 | " Returns "s:endtoken" if no token was found. 34 | function s:LexToken(stopline, at_cursor) abort 35 | let at_cursor = a:at_cursor 36 | while 1 37 | let match = search(s:search_pat, (at_cursor ? 'c' : '') .. 'pWz', a:stopline, 0) 38 | if match && synIDattr(synID(line('.'), col('.'), 1), 'name') =~# 'Comment$' 39 | let at_cursor = 0 40 | continue 41 | endif 42 | return match == 2 ? s:str2Tok[expand('')] 43 | \ : match == 3 ? s:value 44 | \ : match == 4 ? s:str2Tok[getline('.')[col('.') - 1]] 45 | \ : match == 5 ? s:operator 46 | \ : s:endtoken 47 | endwhile 48 | endfunction 49 | 50 | " Parse around the cursor and return possible indentation points. 51 | " 52 | " May move the cursor. 53 | function haskell#Parse() abort 54 | let parser = {'token': v:null, 'nextToken': v:null, 55 | \ 'currentLine': 1, 'currentCol': 1, 56 | \ 'initial_line': line('.'), 57 | \ 'layoutCtx': 0, 58 | \ 'indentations': [0], 59 | \ } 60 | 61 | " Move to first line with zero indentation 62 | normal! 0 63 | while search('^\S\|\%^', 'bW', 0, 0) 64 | \ && synIDattr(synID(line('.'), 1, 1), 'name') =~# 'Comment$\|String$' 65 | endwhile 66 | 67 | function parser.next() abort 68 | if line('.') >= self.initial_line | let self.token = s:endtoken | endif 69 | if self.token is s:endtoken | return s:endtoken | endif 70 | 71 | " If has pending token: Return it 72 | if self.nextToken isnot v:null 73 | let self.token = self.nextToken 74 | let self.nextToken = v:null 75 | return self.token 76 | endif 77 | 78 | let prevLine = self.currentLine 79 | " Lex the next token and jump to its start 80 | let self.token = s:LexToken(self.initial_line, self.token is v:null) 81 | 82 | if line('.') < self.initial_line 83 | let [self.currentLine, self.currentCol] = [line('.'), col('.')] 84 | 85 | " Layout rule if implicit layout is active 86 | if prevLine < self.currentLine && self.layoutCtx > 0 87 | let layoutIndent = self.layoutCtx 88 | 89 | let self.nextToken = self.token 90 | if self.currentCol < layoutIndent 91 | let self.token = s:layoutEnd 92 | elseif self.currentCol == layoutIndent 93 | let self.token = s:layoutItem 94 | else 95 | let self.nextToken = v:null 96 | endif 97 | endif 98 | endif 99 | 100 | return self.token 101 | endfunction 102 | 103 | let parser.token = parser.next() 104 | 105 | let result = s:TopLevel(parser) 106 | return sort(parser.indentations, 'n') 107 | endfunction 108 | 109 | " Parser return statuses. 110 | " 111 | " - "s:retNone" means no token got parsed 112 | " - "s:retOk" means parser consumed (maybe partially) at least one token 113 | const [s:retNone, s:retOk, s:retFinished] = range(3) 114 | 115 | function s:Token(token) abort 116 | let dict = {} 117 | function dict.fn(p) abort closure 118 | if a:p.token is a:token 119 | call a:p.next() 120 | return s:retOk 121 | endif 122 | return s:retNone 123 | endfunction 124 | return dict.fn 125 | endfunction 126 | 127 | function s:FromDict(dict) abort 128 | let dict = {} 129 | function dict.fn(p) abort closure 130 | let Parser = get(a:dict, a:p.token, v:null) 131 | return Parser is v:null ? s:retNone : Parser(a:p) 132 | endfunction 133 | return dict.fn 134 | endfunction 135 | 136 | function s:Seq(...) abort 137 | let alts = a:000 138 | let dict = {} 139 | function dict.fn(p) abort closure 140 | let first = 1 141 | for Alt in alts 142 | let result = Alt(a:p) 143 | if result != s:retOk | return first ? s:retNone : s:retOk | endif 144 | let first = 0 145 | endfor 146 | return s:retOk 147 | endfunction 148 | return dict.fn 149 | endfunction 150 | 151 | function s:Many(Parser) abort 152 | let dict = {} 153 | function dict.fn(p) closure 154 | let first = 1 155 | while 1 156 | let result = a:Parser(a:p) 157 | if result == s:retNone | return first ? s:retNone : s:retOk | endif 158 | let first = 0 159 | endwhile 160 | endfunction 161 | return dict.fn 162 | endfunction 163 | 164 | function s:Lazy(Cb) abort 165 | let Parser = v:null 166 | let dict = {} 167 | function dict.fn(p) abort closure 168 | if Parser is v:null | let Parser = a:Cb() | endif 169 | return Parser(a:p) 170 | endfunction 171 | return dict.fn 172 | endfunction 173 | 174 | function s:Opt(Parser) abort 175 | let dict = {} 176 | function dict.fn(p) abort closure 177 | call a:Parser(a:p) 178 | return s:retOk 179 | endfunction 180 | return dict.fn 181 | endfunction 182 | 183 | function s:Layout(Item) abort 184 | let dict = {} 185 | function dict.fn(p) abort closure 186 | let prevLayoutCtx = a:p.layoutCtx 187 | 188 | if a:p.token == s:endtoken || a:p.initial_line == line('.') 189 | call add(a:p.indentations, indent(a:p.currentLine) + shiftwidth()) 190 | return s:retOk 191 | elseif a:p.token is s:lbrace 192 | let [a:p.layoutCtx, startIndent] = [0, col('.') - 1] 193 | let res = s:AddIndent(s:Seq(s:Token(s:lbrace), s:Sep(a:Item, s:semicolon)))(a:p) 194 | if res == s:retOk && a:p.token == s:rbrace && line('.') == a:p.initial_line 195 | call add(a:p.indentations, startIndent) 196 | endif 197 | else 198 | " Store indentation column of the enclosing layout context 199 | let layoutCtx = a:p.currentCol " FIXME: Handle tabs 200 | let a:p.layoutCtx = layoutCtx 201 | 202 | while a:Item(a:p) == s:retOk 203 | let current = a:p.token 204 | if current == s:endtoken 205 | call add(a:p.indentations, layoutCtx - 1) 206 | return s:retOk 207 | endif 208 | if current == s:layoutEnd || current == s:layoutItem || current == s:semicolon 209 | call a:p.next() 210 | endif 211 | if !(current == s:layoutItem || current == s:semicolon) 212 | break 213 | endif 214 | endwhile 215 | endif 216 | 217 | let a:p.layoutCtx = prevLayoutCtx 218 | return s:retOk 219 | endfunction 220 | return dict.fn 221 | endfunction 222 | 223 | function s:Sep(Parser, sep) abort 224 | let dict = {} 225 | function dict.fn(p) abort closure 226 | let result = a:Parser(a:p) 227 | if result == s:retNone | return s:retNone | endif 228 | 229 | while a:p.token == a:sep 230 | call a:p.next() 231 | 232 | let result = a:Parser(a:p) 233 | if result == s:retNone | break | endif 234 | endwhile 235 | 236 | return s:retOk 237 | endfunction 238 | return dict.fn 239 | endfunction 240 | 241 | function s:AddIndent(Parser) abort 242 | let dict = {} 243 | function dict.fn(p) abort closure 244 | let startIndent = max([indent(a:p.currentLine), a:p.layoutCtx - 1]) 245 | let result = a:Parser(a:p) 246 | if result == s:retOk && a:p.token == s:endtoken 247 | call add(a:p.indentations, startIndent + shiftwidth()) 248 | endif 249 | return result 250 | endfunction 251 | return dict.fn 252 | endfunction 253 | 254 | const [s:Expr, s:Decl] = [s:Lazy({-> s:Expression}), s:Lazy({-> s:Declaration})] 255 | 256 | const s:expression_list = { 257 | \ s:value: s:Token(s:value), 258 | \ s:operator: s:Token(s:operator), 259 | \ s:let: s:Seq(s:Token(s:let), s:Layout(s:Decl), s:Token(s:in), s:Expr), 260 | \ s:if: s:Seq(s:Token(s:if), s:Expr, s:Token(s:then), s:Expr, s:Token(s:else), s:Expr), 261 | \ s:do: s:Seq(s:Token(s:do), s:Layout(s:Expr)), 262 | \ s:case: s:Seq(s:Token(s:case), s:Expr, s:Token(s:of), s:Layout(s:Expr)), 263 | \ s:lparen: s:Seq(s:Token(s:lparen), s:Opt(s:Sep(s:Expr, s:comma)), s:Token(s:rparen)), 264 | \ s:lbracket: s:Seq(s:Token(s:lbracket), s:Opt(s:Sep(s:Expr, s:comma)), s:Token(s:rbracket)), 265 | \ s:lbrace: s:Seq(s:Token(s:lbrace), s:Opt(s:Sep(s:Expr, s:comma)), s:Token(s:rbrace)), 266 | \ } 267 | 268 | const s:Expression = s:AddIndent(s:Many(s:FromDict(s:expression_list))) 269 | const s:Declaration = s:Seq(s:Opt(s:AddIndent(s:Sep(s:Token(s:value), s:comma))), 270 | \ s:Expression, s:Opt(s:Seq(s:Token(s:where), s:Layout(s:Decl)))) 271 | 272 | " Parse topdecls. 273 | const s:TopLevel = s:Declaration 274 | --------------------------------------------------------------------------------