0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

汎用fzfでバッファ内ファジー検索をダイナミックに

Posted at

yuki-yano/fzf-preview.vimみたいなことをしたかった

プラグインを入れずvimスクリプトのみでやったりました。
いきなりですがレポとソースはこちら(長いので折りたたみ)

vimrcで呼べる汎用fzf関数
汎用検索.vim

" ===================================================================
" junegunn/fzf.vim
" ===================================================================
" {{{
" usage
" let arguments_def = #{
"     \ title: popup title as String,
"     \ list: search targets as List,
"     \ type: format in list. f, lm, flm (file, line, msg),
"     \ eprfx: enter zone prefix text as String (0 to default value),
"     \ }
" and call like this.
" cal s:fzsearch.popup(arguments_def)

let s:fzsearch = #{bwid: 0, ewid: 0, rwid: 0, pwid: 0, tid: 0, res: [],
    \ ffdict: #{vimrc: 'vim', zshrc: 'sh', js: 'javascript', py: 'python', md: 'markdown',
        \ ts: 'typescript', tsx: 'typescriptreact',
        \ },
    \ }

let s:fzsearch.allow_exts = glob($VIMRUNTIME.'/ftplugin/*.vim')->split('\n')
            \ ->map({_,v->matchstr(v,'[^/]\+\.vim')->split('\.')[0]})

fu! s:fzsearch.popup(v) abort
    if empty(a:v.list) || (len(a:v.list) == 1 && empty(a:v.list[0]))
        cal s:echoE('no data')
        retu
    endif

    let self.list = a:v.list
    let self.type = a:v.type
    " TODO depends on type
    let self.prr = a:v.eprfx
    let self.wd = ''
    let self.wda = []
    let self.ridx = 0
    let self.max = 30
    let self.mode = 'Fuzzy'
    let self.pr = '['.self.mode.']'.(self.prr ?? '>>')
    let self.res = a:v.list[0:self.max]

    let self.bwid = popup_create([], #{title: ' '.a:v.title.' ',
        \ zindex: 50, mapping: 0, scrollbar: 0,
        \ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzBWin'],
        \ minwidth: &columns*9/12, maxwidth: &columns*9/12,
        \ minheight: &lines/2+6, maxheight: &lines/2+6,
        \ line: &lines/4-2, col: &columns/8+1,
        \ })

    let self.ewid = popup_create(self.pr, #{title: ' Search Mode: <Tab> | ClearText: <C-w> ',
        \ zindex: 100, mapping: 0,
        \ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzEWin'],
        \ minwidth: &columns/3, maxwidth: &columns/3,
        \ minheight: 1, maxheight: 1,
        \ line: &lines*3/4+2, col: &columns/7+1,
        \ filter: function(self.fil, [#{wd: []}]),
        \ })
    cal matchaddpos('FzEWin', [[1, 1, len(self.pr)]],  16, -1, #{window: self.ewid})
    cal matchadd('DarkRed', '\[.*\]', 17, -1, #{window: self.ewid})

    let self.rwid = popup_menu(self.res, #{title: ' Choose: <C-n/p> <CR> | QuickFix: <C-q> ',
        \ zindex: 99, mapping: 0, scrollbar: 1,
        \ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzWin'],
        \ minwidth: &columns/3, maxwidth: &columns/3,
        \ minheight: &lines/2, maxheight: &lines/2,
        \ pos: 'topleft', line: &lines/4, col: &columns/7,
        \ callback: 'Fzsearch_confirm',
        \ filter: function(self.jk, [0]),
        \ })

    " result list syntax
    if self.type == 'lm'
        cal setbufvar(winbufnr(self.rwid), '&filetype', &filetype)
    elseif self.type == 'flm'
        cal setbufvar(winbufnr(self.rwid), '&filetype', 'txt')
    endif

    " get preview file
    let path = bufname('%')
    if self.type =~ 'flm'
        let path = split(self.res[0], '|')[0]
    elseif self.type =~ 'f'
        let path = substitute(self.res[0], '\~', $HOME, 'g')
    endif
    let read = ['Cannot open file.', 'please check this file path.', path]
    try
        let read = readfile(path)
    catch
    endtry

    " preview
    let self.pwid = popup_menu(read, #{title: ' File Preview | Scroll: <C-d/u> ',
        \ zindex: 98, mapping: 0, scrollbar: 1,
        \ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzWin'],
        \ minwidth: &columns/3, maxwidth: &columns/3,
        \ minheight: &lines/2+3, maxheight: &lines/2+3,
        \ pos: 'topleft', line: &lines/4, col: &columns/2+1,
        \ firstline: 1,
        \ filter: function(self.pv, [0]),
        \ })

    " get preview ext for syntax highlight
    let ext = matchstr(path, '[^\.]\+$')
    let ext = ext !~ '^[a-zA-Z0-9]\+$' ? '' : ext
    let ext = get(self.ffdict, ext, ext)
    cal setbufvar(winbufnr(self.pwid), '&filetype', self.type =~ 'f' ? ext : &filetype)

    " first line
    let lnm = 1
    if self.type == 'flm'
        let sep = split(self.res[0], '|')
        let lnm = len(sep) < 2 ? 1 : split(sep[1], ' ')[0]
    elseif self.type == 'lm'
        let lnm = split(self.res[0], ':')[0]
    endif
    " cursor line top from 10row
    cal popup_setoptions(self.pwid, #{firstline: (lnm-10 > 0 ? lnm-10 : 1)})
    cal win_execute(self.pwid, 'exe '.lnm)
endf

" on confirm
fu! Fzsearch_confirm(wid, idx) abort
    if a:idx == -1
        cal s:echoE('cancel')
        cal s:fzsearch.finalize()
        retu
    endif
    let result = s:fzsearch.res[a:idx-1]

    if s:fzsearch.type == 'f'
        exe 'e '.result
    elseif s:fzsearch.type == 'lm'
        let l = split(result, ':')[0]
        cal s:echoI('jump to '.l)
        exe l
        if s:fzsearch.mode == 'Simply'
            let @/ = s:fzsearch.wd
            norm! n
            cal s:quickhl.set()
        endif
    elseif s:fzsearch.type == 'flm'
        let sep = split(result, '|')
        let fnm = substitute(sep[0], $HOME, '~', 'g')
        let lnm = len(sep) < 2 ? 1 : split(sep[1], ' ')[0]
        " if quickfix window
        if expand('%')->empty()
            wincmd w
        endif
        exe 'e '.fnm
        exe lnm
        cal s:echoI('jump to '.fnm.' line:'.lnm)
    endif
    cal s:fzsearch.finalize()
endf

" create quickfix
fu! s:fzsearch.quickfix() abort
    let tmp = &errorformat
    let resource = self.res
    let ef = '%f: %l: %m'

    if self.type == 'lm'
        let fnm = expand('%')
        let resource = deepcopy(self.res)->map({_,v->fnm.': '.v})
    elseif self.type == 'f'
        let resource = deepcopy(self.res)->map({_,v->v.': 1: open'})
    elseif self.type == 'flm'
        " sample
        " filename|100 col 10-15|message
        " want-> filename, 100, message
        let resource = deepcopy(self.res)->map({_,v
                    \ ->split(v,'|')[0].': '
                    \ .split(split(v,'|')[1], ' ')[0].': '
                    \ .join(split(v,'|')[2:], '')})
    endif

    let &errorformat = ef
    cgetexpr resource | cw
    let &errorformat = tmp
    cal s:fzsearch.finalize()
endf

" finalize
fu! s:fzsearch.finalize() abort
    let s:fzsearch.list = []
    let s:fzsearch.res = []
    cal s:runcat.stop()
    cal popup_clear()
endf

" preview scroll
fu! s:fzsearch.scroll(wid, vector) abort
    if self.tid
        retu
    endif
    cal timer_stop(self.tid)
    let vec = a:vector ? "\<C-n>" : "\<C-p>"
    let self.tid = timer_start(10, { -> popup_filter_menu(a:wid, vec) }, #{repeat: -1})
    let delay = 600
    cal timer_start(delay, self.scstop)
endf

fu! s:fzsearch.scstop(_) abort
    cal timer_stop(self.tid)
    let self.tid = 0
endf

" preview draw update
fu! s:fzsearch.pvupd() abort
    let win = winbufnr(self.pwid)

    if empty(self.res) && self.type == 'lm'
        retu
    endif
    if self.type == 'lm'
        let lnm = split(self.res[self.ridx], ':')[0]
        cal popup_setoptions(self.pwid, #{firstline: (lnm-10 > 0 ? lnm-10 : 1)})
        cal win_execute(self.pwid, 'exe '.lnm)
        retu
    endif

    " del
    sil! cal deletebufline(win, 1, getbufinfo(win)[0].linecount)
    " put
    if empty(self.res)
        retu
    endif
    let path = self.type == 'flm'
                \ ? split(self.res[self.ridx], '|')[0]
                \ : substitute(self.res[self.ridx], '\~', $HOME, 'g')
    let read = ['Cannot open file.', 'please check this file path.', path]
    try
        let read = readfile(path)
    catch
    endtry
    cal setbufline(win, 1, read)
    " syntax
    let ext = matchstr(path, '[^\.]\+$')
    let ext = ext !~ '^[a-zA-Z0-9]\+$' ? '' : ext
    let ext = get(self.ffdict, ext, ext)
    cal setbufvar(win, '&filetype', ext)
    " line
    let sep = split(self.res[self.ridx], '|')
    let lnm = len(sep) < 2 ? 1 : split(sep[1], ' ')[0]
    cal popup_setoptions(self.pwid, #{firstline: (lnm-10 > 0 ? lnm-10 : 1)})
    cal win_execute(self.pwid, 'exe '.lnm)
endf

" search result update
fu! s:fzsearch.list_upd() abort
    " upd match result list
    let filterd = empty(self.wd) ? self.list :
                \ self.mode == 'Fuzzy' ? matchfuzzy(self.list, self.wd)
                \ : copy(self.list)->filter({_,v->match(v, self.wd)!=-1})
    ""let filterd = copy(self.list)->filter({_,v->match(v, self.wd)!=-1})
    let self.res = filterd[0:self.max]
    let self.ridx = len(self.res)-1 < self.ridx ? len(self.res)-1 : self.ridx
    cal setbufline(winbufnr(self.ewid), 1, self.pr . self.wd)
    cal deletebufline(winbufnr(self.rwid), 1, self.max)
    echo ''
    cal setbufline(winbufnr(self.rwid), 1, self.res)
    " highlight match char
    cal clearmatches(self.rwid)
    if !empty(self.wd)
        cal matchadd('FzMatch', self.mode == 'Fuzzy'
                    \ ? printf('\c[%s]', escape(self.wd, '\[\]\-\.\*'))
                    \ : '\c'.self.wd,
                    \ 16, -1, #{window: self.rwid})
    endif
    " upd preview
    cal self.pvupd()
endf

fu! s:fzsearch.togglemode() abort
    let self.mode = self.mode == 'Fuzzy' ? 'Simply' : 'Fuzzy'
    let self.pr = '['.self.mode.']'.(self.prr ?? '>>')
    cal clearmatches(self.ewid)
    cal matchaddpos('FzEWin', [[1, 1, len(self.pr)]],  16, -1, #{window: self.ewid})
    cal matchadd('DarkRed', '\[.*\]', 17, -1, #{window: self.ewid})
    cal self.list_upd()
endf

" enter search word
fu! s:fzsearch.fil(ctx, wid, key) abort
    if a:key is# "\<Esc>"
        cal popup_close(self.bwid)
        cal popup_close(self.pwid)
        cal popup_close(self.ewid)
        cal feedkeys("\<C-c>")
        retu 1
    elseif a:key is# "\<C-n>" || a:key is# "\<C-p>" || a:key is# "\<CR>"
        " only largest zindex window is active.
        cal popup_setoptions(self.rwid, #{zindex: 100})
        cal popup_setoptions(self.ewid, #{zindex: 99})
        cal popup_setoptions(self.pwid, #{zindex: 98})
        cal feedkeys(a:key)
        retu 1
    elseif a:key is# "\<C-d>" || a:key is# "\<C-u>"
        cal popup_setoptions(self.pwid, #{zindex: 100})
        cal popup_setoptions(self.ewid, #{zindex: 99})
        cal popup_setoptions(self.rwid, #{zindex: 98})
        cal feedkeys(a:key)
        retu 1
    elseif a:key is# "\<Tab>"
        cal self.togglemode()
        retu 1
    elseif a:key is# "\<C-q>"
        cal self.quickfix()
        cal feedkeys("\<Esc>")
        retu 1
    " TODO fzf.vim copy shold D-v only or Shift Insert. C-v want split
    " TODO fzf.vim add feature: refresh cache (or re search)
    elseif a:key is# "\<C-v>" || a:key is# "\<D-v>"
        for i in range(0, strlen(@+)-1)
            cal add(self.wda, strpart(@+, i, 1))
        endfor
    " TODO see self
    elseif a:key is# "\<BS>" && !empty(self.wda)
        unlet self.wda[len(self.wda)-1]
    elseif a:key is# "\<BS>" && len(self.wda) == 0
        " noop
        retu 1
    elseif a:key is# "\<C-w>"
        let self.wda = []
    elseif strtrans(a:key) == "<80><fd>`"
        " noop (for polyglot bug adhoc)
        retu 1
    else
        cal add(self.wda, a:key)
    endif

    let self.wd = join(self.wda, '')
    cal self.list_upd()
    retu a:key is# "x" || a:key is# "\<Space>" ? 1 : popup_filter_menu(a:wid, a:key)
endf

" choose result
fu! s:fzsearch.jk(_, wid, key) abort
    if a:key is# "\<Esc>"
        cal popup_close(self.bwid)
        cal popup_close(self.pwid)
        cal popup_close(self.ewid)
        cal feedkeys("\<C-c>")
    elseif a:key is# "\<C-n>"
        let self.ridx = self.ridx == len(self.res)-1 ? len(self.res)-1 : self.ridx+1
        cal self.pvupd()
        retu popup_filter_menu(a:wid, a:key)
    elseif a:key is# "\<C-p>"
        let self.ridx = self.ridx ? self.ridx-1 : 0
        cal self.pvupd()
        retu popup_filter_menu(a:wid, a:key)
    elseif a:key is# "\<CR>"
        cal popup_close(self.ewid)
        cal popup_close(self.pwid)
        cal popup_close(self.bwid)
        retu popup_filter_menu(a:wid, empty(self.res) ? "\<C-c>" : a:key)
    elseif a:key is# "\<C-d>" || a:key is# "\<C-u>"
        cal popup_setoptions(self.pwid, #{zindex: 100})
        cal popup_setoptions(self.ewid, #{zindex: 99})
        cal popup_setoptions(self.rwid, #{zindex: 98})
        cal feedkeys(a:key)
    else
        cal popup_setoptions(self.ewid, #{zindex: 100})
        cal popup_setoptions(self.rwid, #{zindex: 99})
        cal popup_setoptions(self.pwid, #{zindex: 98})
        cal feedkeys(a:key)
    endif
    retu 1
endf

" scroll preview
fu! s:fzsearch.pv(_, wid, key) abort
    if a:key is# "\<Esc>"
        cal popup_close(self.bwid)
        cal popup_close(self.pwid)
        cal popup_close(self.ewid)
        cal feedkeys("\<C-c>")
    elseif a:key is# "\<C-d>"
        cal self.scroll(a:wid, 1)
    elseif a:key is# "\<C-u>"
        cal self.scroll(a:wid, 0)
    elseif a:key is# "\<C-n>" || a:key is# "\<C-p>" || a:key is# "\<CR>"
        cal popup_setoptions(self.rwid, #{zindex: 100})
        cal popup_setoptions(self.ewid, #{zindex: 99})
        cal popup_setoptions(self.pwid, #{zindex: 98})
        cal feedkeys(a:key)
    else
        cal popup_setoptions(self.ewid, #{zindex: 100})
        cal popup_setoptions(self.rwid, #{zindex: 99})
        cal popup_setoptions(self.pwid, #{zindex: 98})
        cal feedkeys(a:key)
    endif
    retu 1
endf

" =====================
fu! s:fzf_histories()
    cal s:fzsearch.popup(#{title: 'Histories', list: execute('ol')->split('\n')->map({_,v -> split(v, ': ')[1]}),
        \ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf

fu! s:fzf_buffers()
    cal s:fzsearch.popup(#{title: 'Buffers', list: execute('ls')->split('\n')->map({_,v -> split(v, '"')[1]})
            \ ->filter({_,v -> v != '[No Name]' && v != '[無名]' && v !~ '!.*'}),
        \ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf
" =====================

aug FzColor
    au!
    au ColorScheme * hi FzMatch cterm=BOLD cterm=underline ctermfg=196 ctermbg=237
    au ColorScheme * hi FzWin ctermfg=114 ctermbg=237
    au ColorScheme * hi FzBWin cterm=BOLD ctermfg=145 ctermbg=238
    au ColorScheme * hi FzEWin ctermfg=39 ctermbg=237
aug END

noremap <silent><Plug>(fzf-histories) :<C-u>cal <SID>fzf_histories()<CR>
noremap <silent><Plug>(fzf-buffers) :<C-u>cal <SID>fzf_buffers()<CR>
" }}}

使い方

検索にはSimpleモードとFuzzyモードがあり
Simpleの場合は入力文字そのままを、Fuzzyの場合はあいまい検索します。

Gitのls-filesやヒストリー、バッファリスト、バッファ内文字列やQuickFix内など
とにかく文字列のリストを渡せばこのポップアップで検索、プレビューできます。
(batコマンドがなくとも色がつきます。vimのバッファをただ開いているだけだから。)
fzsearch1.gif

検索結果をQuickFixに渡し、取っておくことも可能。ポップアップの弱点を解消してます。
また、QuickFix内をポップアップで検索してQuickFixにもどしてt、無限ループもできます。
fzsearch2.gif

バッファ内、もしくはQuickFixのバッファ内を検索する

" Fuzzy search current file {{{
fu! s:currentSearch() abort
    let buf = getline(1, line('$'))
    " quickfix currentSearch
    " empty buffer -> no data
    if expand('%')->empty()
        cal s:fzsearch.popup(#{title: 'QuickFix', list: buf, type: 'flm', eprfx: 0})
        retu
    endif
    " buffer currentSearch
    cal s:fzsearch.popup(#{title: 'Current', list: map(buf, {i,v->i+1.': '.v}), type: 'lm', eprfx: 0})
endf

noremap <silent><Plug>(current-search) :<C-u>cal <SID>currentSearch()<CR>
" }}}

ヒストリーを検索

fu! s:fzf_histories()
    cal s:fzsearch.popup(#{title: 'Histories', list: execute('ol')->split('\n')->map({_,v -> split(v, ': ')[1]}),
        \ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf

バッファを検索

fu! s:fzf_buffers()
    cal s:fzsearch.popup(#{title: 'Buffers', list: execute('ls')->split('\n')->map({_,v -> split(v, '"')[1]})
            \ ->filter({_,v -> v != '[No Name]' && v != '[無名]' && v !~ '!.*'}),
        \ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf

ファイル、Git ls-filesを検索

これには工夫が必要。(ちょっとTODO残ってますが。。。)
gitのリポジトリフォルダである場合はgit ls-files結果を渡せばいいですが
そうでない場合は、つまりfzfコマンドをやりたい場合は難しいです。

今回はどうしてもfindコマンドでやりたかったので(プラグイン不要だから)、
最下層までの検索をあきらめ、1階層ずつ非同期プロセスで検索し、結果を逐次渡してます。
gitプロジェクト以外で使わなきゃいいだけの話。

let s:fzf = #{cache: [], maxdepth: 4, gcache: [],
    \ not_path_arr: [
         \'"*/.**/*"',
         \'"*node_modules/*"', '"*target/*"'
         \'"*Applications/*"', '"*AppData/*"', '"*Library/*"',
         \'"*Music/*"', '"*Pictures/*"', '"*Movies/*"', '"*Videos/*"'
         \'"*OneDrive/*"',
    \ ],
\}

" TODO fzf delete
fu! TestFzfMaxDepth(n) abort
    let s:fzf.maxdepth = a:n
endf
" TODO fzf maxdepth かえるコマンド作るか

let s:fzf.postfix = ' -type f -not -path '.join(s:fzf.not_path_arr, ' -not -path ')
let s:fzf.searched = getcwd()
let s:fzf.get_gitls = {-> system('git ls-files -c')->split('\n')->filter({_,v->!empty(v)}) }
let s:fzf.get_file_d1 = {-> system('find . -mindepth 1 -maxdepth 1'.s:fzf.postfix)->split('\n')->filter({_,v->!empty(v)}) }
let s:fzf.get_file = { v -> 'find . -mindepth '.v.' -maxdepth '.v.s:fzf.postfix }

fu! s:fzf.files() abort
    let pwd = getcwd()
    let chk = system('git status')
    let self.is_git = v:shell_error ? 0 : 1

    " moved or first
    if stridx(pwd, self.searched) == -1
                "\ || (self.is_git && empty(self.gcache))
                \ || (self.is_git)
                \ || (!self.is_git && empty(self.cache))
        let self.searched = pwd

        if self.is_git
            " TODO fzf no need cache?
            let self.gcache = self.get_gitls()
            cal s:echoI('cache: git ls-files -c')
        else
            let self.cache = self.get_file_d1()
            cal self.asyncfind()
        endif
    endif

    cal s:fzsearch.popup(#{title: self.is_git ? 'Project Files' : 'Files',
        \ list: self.is_git ? self.gcache : self.cache,
        \ type: 'f', eprfx: '['.substitute(pwd, $HOME, '~', 'g').']>>'})
endf

fu! s:fzf.asyncfind() abort
    cal s:runcat.start()
    let self.notwid = popup_notification('find files in ['.s:fzf.searched.'] and caching ...', #{zindex: 51, line: &lines, col: 5})
    let self.jobcnt = self.maxdepth-1
    let self.endjobcnt = 0
    for depth in range(2, self.maxdepth)
        cal job_start(self.get_file(depth), #{out_cb: self.asyncfind_start, close_cb: self.asyncfind_end})
    endfor
endf

fu! s:fzf.asyncfind_start(ch, msg) abort
    cal add(self.cache, a:msg)
endf

fu! s:fzf.asyncfind_end(ch) abort
    let s:fzsearch.list = self.cache
    cal s:fzsearch.list_upd()

    let self.endjobcnt += 1
    if self.endjobcnt == self.jobcnt
        cal s:runcat.stop()
        cal popup_close(self.notwid)
        cal popup_notification('find files cached !', #{zindex: 51, line: &lines, col: 5})
    endif
endf

fu! s:fzfexe() abort
    cal s:fzf.files()
endf
noremap <silent><Plug>(fzf-smartfiles) :<C-u>cal <SID>fzfexe()<CR>

仕組み

  • ポップアップを4枚つくる(1枚は下敷き)
  • 入力文字を描画し、検索結果を絞り、ヒット文字を赤くする
  • 入力キーに応じて操作ポップアップを変えるため、ポップアップのzindexを変える
    (C-n/pで検索結果ウィンドウ、C-d/uでプレビューウィンドウ、その他全てで入力ウィンドウ)
  • 検索対象がファイルなのか、行に飛ぶ物なのか(マーク一覧、バッファ内)で分岐
  • ファイル検索の場合、ファイル名の拡張子に応じてシンタックスハイライトをプレビューに
  • 行検索の場合、検索結果とプレビュー両方にシンタックスハイライト
  • 決定されたらジャンプ。これもファイルなのか行なのかでコマンドを変える

ざっくり説明ですがこんな感じです。
QuickFixに対応するため、検索結果リストは'%f: %l: %m'のフォーマットで
ファイル名:行番号:テキストのリストとなるように統一しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?