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?

個人用sas用vimインストーラ

0
Last updated at Posted at 2026-07-19

【依存ゼロ】管理者権限のない企業LinuxでVimをSAS専用IDEにする(導入編)

この記事について

社内のLinuxサーバでSAS 9.3を使っているが、管理者権限がなく ~/ 配下しか
編集できない——そんな環境でも、Vimだけで SAS開発を完結させるための設定一式
「SASIDE」です。

  • 外部依存ゼロ: Node.js / Python / Lua / LSP / Tree-sitter / ネットワーク不要
  • root不要: ~/.vim にファイルを置くだけ
  • git / curl / apt 不要: 1本のシェルスクリプトを実行するだけ
  • sas program.txt 運用に対応: 拡張子が .txt でも中身からSASと自動判定

会社PCがGitHubにアクセスできなくても、この記事のコードをコピペするだけ
導入できるよう、インストーラを1本のテキストにまとめてあります。

関連記事:

  • 使い方編(実際のワークフロー)
  • リファレンス編(全コマンド・全オプション)

できること

カテゴリ 機能
実行 F5 で 保存→SAS実行→ログ表示→ERROR/WARNINGをquickfixへ
部分実行 ビジュアル選択して F5選択範囲だけ\rbカーソル位置のステップだけ実行
構文チェック \rcobs=0 実行。巨大データでも数秒でタイプミス検出
ログ解析 色分け表示、ERROR/WARNING/NOTE抽出、危険NOTE検出\lr
構文 SAS 9.3対応ハイライト(%macro・PROC SQL・マクロ変数・日付定数)
インデント DATA/PROC/DO/%MACRO のネストと括弧内を賢く字下げ、F9で一括整形
補完 SAS関数/PROC/オプション辞書 + マクロ変数 + データセット名を Tab で補完
ナビゲート Explorer(F2) / Navigator(F3) / Outline(F4) / データセット・マクロ一覧
入力支援 括弧・クオート自動補完、スニペット(Ctrl+j)、テンプレート生成
その他 プロジェクト検索、TODO一覧、ブックマーク、セッション保存

特に推したい2機能

危険NOTE検出(\lr — SASで本当に怖いのはERRORではなく、
NOTE: Variable X is uninitialized
NOTE: MERGE statement has more than one data set with repeats of BY values のように
エラーにならず黙って誤った結果を出すNOTEです。これらのパターンを辞書化し、
「なぜ危険か」の理由付きでquickfixに一覧化します。

構文チェック(\rc-obs 0 -noreplace を付けて実行し、データを1件も
処理せずに構文だけ検証します。数百万件を読むプログラムでも、タイプミスの発覚が
「10分後」から「数秒後」になります。


動作要件

  • Linux + Vim 8.0以降(Huge版推奨)
  • +job があればSASを非同期実行、無ければ自動で同期実行にフォールバック
  • 管理者権限は不要

Vim 7.4系でも大半は動きますが、Included patches: が 2200 未満だと
一部機能(ログ→quickfix連携、スニペット展開)が動きません。
vim --version | head -2 で確認してください。


導入方法

Step 1. この記事末尾のインストーラ全文をコピー

インストーラ全文」のコードブロック右上のコピーボタンで、
4000行超でもワンクリックで全文コピーできます。

Step 2. 会社PCでテキストファイルとして保存

メモ帳などに貼り付け、saside_install.txt として保存します。
.sh だとメール等でブロックされやすいので、まず .txt が無難)

Step 3. WinSCPでサーバへ転送

転送モードを「テキスト」にすると改行が自動でLinux形式に変換されます。

Step 4. サーバ上で実行

# バイナリ転送だった場合の保険(改行コードをLinux形式へ)
tr -d '\r' < saside_install.txt > saside_install.sh

# 実行(sudoは不要。sudoで実行しないでください)
sh saside_install.sh

~/.vim に約50ファイルが展開されます。既存ファイルは ~/.vim_backup_日時/
自動退避
されるので、何度実行しても安全です。

Step 5. vimrc を反映

# ~/.vimrc がまだ無い場合
cp ~/.vim/vimrc.example ~/.vimrc

# 既にある場合は中身を確認して追記(重複行に注意)
cat ~/.vim/vimrc.example >> ~/.vimrc

Step 6. ヘルプの索引を生成(初回のみ)

vim -c 'helptags ~/.vim/doc' -c 'q'

以後 :help saside が引けます。

Step 7. 動作確認

:edit test.sas
:set filetype?      " → filetype=sas
:SasBlockInfo       " ブロック情報が出ればOK

SASの実体がPATHに無い場合は ~/.vimrc の末尾で指定します。

let g:saside_sas_cmd = '/opt/sas/SASFoundation/9.3/sas'

よくあるトラブル

インストール後、Vim起動時にエラーが出る / 一部ファイルの中身が途中から始まっている
コピペや転送の途中で行が欠落した場合に起きます。このインストーラは展開後に
全ファイルの行数・バイト数を自動照合
し、欠落があれば NG: ファイル名
表示して停止します。その表示が出たら、記事のコードブロックを(コピーボタンで)
もう一度コピーし直してやり直してください。巨大なブロックはメモ帳や
クリップボード経由で稀に行が欠落するため、整合性チェック: 全 N ファイル OK
出るまで再実行するのが確実です。

:syntax onE484 エラーが出る
この種の環境は $VIMRUNTIME が壊れている(~/.vim を指している)ことが多く、
~/.vim/syntax/syntax.vim などの配線ファイルが必須です。インストーラに同梱済み
なので、展開できていれば解消します。

ペーストするとVimが固まる
括弧補完などが1文字ごとに発動するためです。貼り付け前に F11 を押して
貼り付けモードにすると一瞬で貼れます(貼り終えたら再度 F11)。
そもそもファイルはWinSCPで転送する想定なので、Vimへの直接ペーストは不要です。

Permission denied で止まる
~/.vim 内にroot所有のファイルが残っている場合に起きます。
chown -R $USER:$USER ~/.vim で自分の所有に戻してから再実行してください。

ログが文字化けする
~/.vimrcset fileencodings=... の順序を環境に合わせます。
EUC-JP環境なら euc-jp を前方に。


アンインストール / 元に戻す

~/.vim_backup_日時/ に元のファイルが入っています。丸ごと戻したい場合は
そのディレクトリの内容を ~/.vim に上書きコピーしてください。


ライセンス・注意事項

MIT License。自由に改変・再配布できます。

社内利用者の方へ: このスクリプト自体に社内情報は含まれませんが、
~/.vimrc や辞書ファイルに社内のサーバパス・ライブラリ名・データセット名を
追記した場合は、公開前に必ず除去してください。また、外部で作成したスクリプトを
社内サーバで実行することについては、事前に情報システム部門や上長へ確認することを
おすすめします(このインストーラは全文が人間に読めるプレーンテキストで、外部通信も
一切ないため、内容レビューによる承認を得やすい形にしてあります)。


インストーラ全文

以下を丸ごとコピーし、saside_install.txt として保存してください。

#!/bin/sh
# saside_install.sh — SAS Vim環境 セルフエクストラクト・インストーラ(自己検証付き)
# 使い方: sh saside_install.sh   (~/.vim に展開。既存ファイルは自動退避)
# 注意: sudoは不要です($HOME/.vim にしか書き込みません)
#
# ★このインストーラは展開後に各ファイルの行数・バイト数を照合します。
#   コピペや転送で内容が欠落すると「NG」として一覧表示されるので、
#   その場合は記事のコードを再コピーしてやり直してください。
set -e
TARGET="$HOME/.vim"
BACKUP="$HOME/.vim_backup_$(date +%Y%m%d_%H%M%S)"
count=0
write_file() {
  path="$TARGET/$1"; dir=$(dirname "$path"); mkdir -p "$dir"
  if [ -f "$path" ]; then mkdir -p "$BACKUP/$(dirname "$1")"; cp -p "$path" "$BACKUP/$1"; fi
  cat > "$path"; count=$((count + 1))
}
echo "SAS Vim環境をインストールします: $TARGET"
write_file 'autoload/sas.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/autoload/sas.vim
" sas#xxx() が初めて呼ばれた時にのみ読み込まれる(遅延ロード)。

function! sas#run(path) abort
  let logf = fnamemodify(a:path, ':r') . '.log'
  let lstf = fnamemodify(a:path, ':r') . '.lst'
  let cmd = printf('%s -sysin %s -log %s -print %s -nosplash',
        \ g:sas_command, shellescape(a:path), shellescape(logf), shellescape(lstf))
  echom "Running: " . cmd
  call system(cmd)
  call sas#quickfixlog(logf)
endfunction

function! sas#openlog(path) abort
  if filereadable(a:path)
    execute 'split ' . fnameescape(a:path)
    setlocal filetype=saslog
  else
    echohl WarningMsg | echo "log not found: " . a:path | echohl None
  endif
endfunction

function! sas#openlst(path) abort
  if filereadable(a:path)
    execute 'split ' . fnameescape(a:path)
    setlocal filetype=saslst
  else
    echohl WarningMsg | echo "lst not found: " . a:path | echohl None
  endif
endfunction

function! sas#quickfixlog(path) abort
  if !filereadable(a:path)
    echohl WarningMsg | echo "log not found: " . a:path | echohl None
    return
  endif
  let save_efm = &errorformat
  setlocal errorformat=ERROR\ %n-%.%#:\ %m,ERROR:\ %m,WARNING:\ %m
  execute 'cgetfile ' . fnameescape(a:path)
  let &errorformat = save_efm
  copen
endfunction

" :SasFormat / <F9> 用。IntelliJの「コードの整形」に相当する
" 安全な範囲の自動整形(完全なAST解析はしていない点に注意)。
"   1) gg=G でブロック構造に基づく再インデント(indent/sas.vim を使用)
"   2) 各行の行末の余分な空白を除去
"   3) 3行以上連続する空行を1行に圧縮
function! sas#format() abort
  let view = winsaveview()
  normal! gg=G
  keepjumps keeppatterns %s/\s\+$//e
  keepjumps keeppatterns %s/\n\{3,}/\r\r/e
  call winrestview(view)
  echo "SasFormat: 整形しました"
endfunction

" スマート % : SASのブロック対応(DO/END, %DO/%END, %MACRO/%MEND,
" DATA・PROC/RUN・QUIT)の間をジャンプする。どのブロックキーワード上でも
" なければ通常の % (括弧ジャンプ)として振る舞う
function! sas#matchjump() abort
  let line = getline('.')

  let pat_do    = '\c\<do\>\%(\s\+until\|\s\+while\)\?.\{-};\s*$'
  let pat_end   = '\c^\s*end\s*;'
  let pat_pdo   = '\c%do\>'
  let pat_pend  = '\c^\s*%end\s*;'
  let pat_macro = '\c^\s*%macro\>'
  let pat_mend  = '\c^\s*%mend\>'
  let pat_dp    = '\c^\s*\%(data\|proc\)\>'
  let pat_rq    = '\c^\s*\%(run\|quit\)\s*;'

  if line =~ pat_macro
    call search('\c^\s*%mend\>', 'W')
  elseif line =~ pat_mend
    call searchpair(pat_macro, '', pat_mend, 'bW')
  elseif line =~ pat_pend
    call searchpair(pat_pdo, '', pat_pend, 'bW')
  elseif line =~ pat_pdo
    " カーソルを %do キーワード上に移してから対応する %end を探す
    " (キーワードより手前にカーソルがあると searchpair が対応を誤るため)
    call search('\c%do\>', 'c', line('.'))
    call searchpair(pat_pdo, '', pat_pend, 'W')
  elseif line =~ pat_end
    call searchpair(pat_do, '', pat_end, 'bW')
  elseif line =~ pat_do
    " カーソルを do キーワード上に移してから対応する end を探す
    call search('\c\<do\>', 'c', line('.'))
    call searchpair(pat_do, '', pat_end, 'W')
  elseif line =~ pat_dp
    call search(pat_rq, 'W')
  elseif line =~ pat_rq
    call search(pat_dp, 'bW')
  else
    normal! %
  endif
endfunction
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/bookmark.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/bookmark.vim
" Module:      bookmark — 名前付きブックマーク(JSON永続化)
" Description: {file, lnum, text, name} を ~/.vim/.state/bookmarks.json に
"              保存する。一覧は quickfix に流す。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:marks = []
let s:loaded = 0

function! s:file() abort
  return saside#util#StateDir() . '/bookmarks.json'
endfunction

function! s:load() abort
  if s:loaded
    return
  endif
  let s:loaded = 1
  if filereadable(s:file())
    try
      let s:marks = json_decode(join(readfile(s:file()), ''))
    catch
      call saside#util#Warn('bookmarks.json が壊れています(初期化)')
      let s:marks = []
    endtry
  endif
endfunction

function! s:save() abort
  call writefile([json_encode(s:marks)], s:file())
endfunction

" 現在行をブックマークに追加する
function! saside#bookmark#Add(...) abort
  call s:load()
  let name = a:0 && a:1 !=# '' ? a:1 : input('ブックマーク名: ')
  call add(s:marks, {
        \ 'file': expand('%:p'),
        \ 'lnum': line('.'),
        \ 'text': getline('.'),
        \ 'name': name,
        \ })
  call s:save()
  call saside#event#Emit('BookmarkChanged', {})
  call saside#util#Info('追加: ' . name . ' (' . len(s:marks) . '件)')
endfunction

" ブックマーク一覧を quickfix に表示する
function! saside#bookmark#List() abort
  call s:load()
  if empty(s:marks)
    call saside#util#Info('ブックマークはありません')
    return
  endif
  let items = map(copy(s:marks), {_, m -> {
        \ 'filename': m.file, 'lnum': m.lnum,
        \ 'text': '[' . m.name . '] ' . m.text}})
  call setqflist([], ' ', {'title': 'SASIDE bookmarks', 'items': items})
  botright cwindow
endfunction

" 全ブックマークを削除する
function! saside#bookmark#Clear() abort
  call s:load()
  let s:marks = []
  call s:save()
  call saside#event#Emit('BookmarkChanged', {})
  call saside#util#Info('全ブックマークを削除しました')
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/complete.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/complete.vim
" Module:      complete — SAS補完エンジン(omnifunc)
" Description: 候補源は4系統:
"                1) dict/sas_*.dict(関数/PROC/ODS/FORMAT/オプション/マクロ。
"                   "語<TAB>説明" 形式。初回のみロードしキャッシュ)
"                2) parser インデックス(マクロ変数・ユーザーマクロ・
"                   データセット名)
"                3) parser#Keywords()(ステートメント)
"                4) バッファ既出語
"              '&'開始→マクロ変数、'%'開始→マクロ系に自動で切り替わる。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:dicts = {}          " category -> [{word, desc}]
let s:loaded = 0

let s:DICT_FILES = {
      \ 'func':  'sas_functions.dict',
      \ 'proc':  'sas_procs.dict',
      \ 'ods':   'sas_ods.dict',
      \ 'fmt':   'sas_formats.dict',
      \ 'opt':   'sas_options.dict',
      \ 'macro': 'sas_macros.dict',
      \ }

" 辞書をロードする(runtimepath の dict/ から。初回のみ)
function! s:load_dicts() abort
  if s:loaded
    return
  endif
  let s:loaded = 1
  for [cat, fname] in items(s:DICT_FILES)
    let s:dicts[cat] = []
    let found = globpath(&runtimepath, 'dict/' . fname, 0, 1)
    if empty(found)
      continue
    endif
    for line in readfile(found[0])
      if line =~# '^\s*#' || line =~# '^\s*$'
        continue
      endif
      let parts = split(line, "\t")
      call add(s:dicts[cat],
            \ {'word': parts[0], 'desc': get(parts, 1, '')})
    endfor
  endfor
endfunction

" 語の説明を辞書から引く(help モジュールが利用)。無ければ ''
function! saside#complete#Describe(word) abort
  call s:load_dicts()
  let w = tolower(a:word)
  for cat in keys(s:dicts)
    for e in s:dicts[cat]
      if tolower(e.word) ==# w || tolower(e.word) ==# '%' . w
        return '[' . cat . '] ' . e.word
              \ . (e.desc !=# '' ? ' — ' . e.desc : '')
      endif
    endfor
  endfor
  return ''
endfunction

" omnifunc 本体
function! saside#complete#Omni(findstart, base) abort
  if a:findstart
    let line = getline('.')
    let start = col('.') - 1
    while start > 0 && line[start - 1] =~ '[[:alnum:]_&%]'
      let start -= 1
    endwhile
    return start
  endif

  call s:load_dicts()
  let idx = saside#parser#Index()
  let base = a:base
  let items = []

  " ---- &xxx : マクロ変数 ----
  if base =~# '^&'
    let prefix = strpart(base, 1)
    for v in idx.macrovars
      if prefix ==# '' || v =~? '^' . escape(prefix, '\')
        call add(items, {'word': '&' . v . '.', 'abbr': '&' . v,
              \ 'menu': '[mvar]'})
      endif
    endfor
    return items
  endif

  " ---- %xxx : マクロ構文・ユーザーマクロ ----
  if base =~# '^%'
    for e in s:dicts.macro
      if e.word =~? '^' . escape(base, '\')
        call add(items, {'word': e.word, 'menu': '[macro]', 'info': e.desc})
      endif
    endfor
    for m in idx.macros
      let cand = '%' . m.name
      if cand =~? '^' . escape(base, '\')
        call add(items, {'word': cand,
              \ 'menu': '[user] (' . join(m.args, ',') . ')'})
      endif
    endfor
    return items
  endif

  " ---- 通常語 ----
  let pat = '^' . escape(base, '\')
  let seen = {}
  for kw in saside#parser#Keywords().statement
    if kw =~? pat
      call add(items, {'word': kw, 'menu': '[stmt]'})
      let seen[kw] = 1
    endif
  endfor
  for [cat, entries] in items(s:dicts)
    if cat ==# 'macro'
      continue
    endif
    for e in entries
      if e.word =~? pat && !has_key(seen, tolower(e.word))
        call add(items, {'word': e.word, 'menu': '[' . cat . ']',
              \ 'info': e.desc})
        let seen[tolower(e.word)] = 1
      endif
    endfor
  endfor
  " データセット名(& を含むものは除外して実名のみ)
  for d in idx.datasets
    if d.name =~? pat && d.name !~# '&' && !has_key(seen, tolower(d.name))
      call add(items, {'word': d.name, 'menu': '[ds]'})
      let seen[tolower(d.name)] = 1
    endif
  endfor
  " バッファ既出語
  let cur = line('.')
  for lnum in range(1, line('$'))
    if lnum == cur
      continue
    endif
    for w in split(getline(lnum), '\W\+')
      if strlen(w) >= 3 && w =~# '^\a' && w =~? pat
            \ && !has_key(seen, tolower(w))
        call add(items, {'word': w, 'menu': '[buf]'})
        let seen[tolower(w)] = 1
      endif
    endfor
  endfor
  return items
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/dataset.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/dataset.vim
" Module:      dataset — Dataset Browser(navigator の表示モード委譲)
" =============================================================================
function! saside#dataset#Toggle() abort
  call saside#navigator#Toggle('dataset')
endfunction
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/event.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/event.vim
" Module:      event — SASIDE pub/sub イベント基盤
" Description: User autocmd を薄くラップしたイベント機構。
"              発火データは g:saside_event に格納する(Vim8のUser autocmdは
"              引数を渡せないための定石)。再帰発火は1段までに制限する。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:emit_depth = 0
const s:MAX_DEPTH = 2

" イベントを発火する。
"   name: 'IndexUpdated' など PascalCase
"   data: 任意のdict。ハンドラは g:saside_event で参照する
function! saside#event#Emit(name, data) abort
  if s:emit_depth >= s:MAX_DEPTH
    call saside#util#Warn('event loop detected, dropped: ' . a:name)
    return v:false
  endif
  let s:emit_depth += 1
  " ネストしたEmit(ハンドラ内からの発火)が g:saside_event を上書きしても、
  " 外側イベントの後続ハンドラが正しいデータを受け取れるよう退避・復元する
  let had_prev = exists('g:saside_event')
  let prev = had_prev ? g:saside_event : {}
  try
    let g:saside_event = extend({'name': a:name}, a:data)
    if exists('#User#saside:' . a:name)
      execute 'doautocmd <nomodeline> User saside:' . a:name
    endif
  finally
    if had_prev
      let g:saside_event = prev
    else
      unlet! g:saside_event
    endif
    let s:emit_depth -= 1
  endtry
  return v:true
endfunction

" イベントを購読する(after/plugin からのユーザー拡張用ヘルパ)。
"   name:   イベント名
"   Fn:     funcref または関数名文字列。g:saside_event を引数に呼ばれる
"
" 実装メモ: funcref(特にlambda)を文字列化してautocmdに埋め込むと、
" 参照が残らずGCされて E700 になる。script-local のレジストリに保持し、
" ディスパッチ関数経由で呼び出す。
let s:handlers = {}
let s:handler_seq = 0

function! saside#event#On(name, Fn) abort
  let s:handler_seq += 1
  let key = a:name . '#' . s:handler_seq
  let s:handlers[key] = a:Fn
  execute 'augroup saside_user_' . a:name
  execute 'autocmd User saside:' . a:name
        \ . ' call saside#event#_Dispatch(' . string(key) . ')'
  execute 'augroup END'
  return key
endfunction

" 内部ディスパッチャ(公開名だがユーザーが直接呼ぶものではない)
function! saside#event#_Dispatch(key) abort
  if has_key(s:handlers, a:key)
    call call(s:handlers[a:key], [g:saside_event])
  endif
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/explorer.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/explorer.vim
" Module:      explorer — Project Explorer(左ペイン・軽量ツリー)
" Description: readdir()(無ければglob())によるディレクトリツリー。
"              ファイル/ディレクトリの作成・削除・コピー・リネーム・移動を
"              Vim組込み関数のみで行う(外部コマンド不使用)。
" Keymaps(バッファ内):
"   Enter/o 開く・ディレクトリ展開/折畳   a 新規ファイル   A 新規ディレクトリ
"   D 削除(確認あり)   r リネーム   m 移動   c コピー
"   u ルートを親へ   C ルートをカーソル下のディレクトリへ   R 再読込   q 閉じる
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:root = ''
let s:expanded = {}

" Explorerを開く(ルート未設定なら現在ファイルのディレクトリ)
function! saside#explorer#Open(...) abort
  if a:0 && a:1 !=# ''
    let s:root = fnamemodify(a:1, ':p')
  elseif s:root ==# ''
    let base = expand('%:p:h')
    let s:root = fnamemodify(base !=# '' ? base : getcwd(), ':p')
  endif
  call saside#layout#Open('explorer')
  let bufnr = saside#util#Scratch('explorer')
  call s:render(bufnr)
  call s:setup_buffer()
  return v:true
endfunction

function! saside#explorer#Toggle() abort
  if saside#layout#Win('explorer') != -1
    call saside#layout#Close('explorer')
  else
    call saside#explorer#Open()
  endif
endfunction

" -----------------------------------------------------------------------------
" 描画
" -----------------------------------------------------------------------------

" dir直下のエントリを [ディレクトリ...] + [ファイル...] で返す
function! s:list(dir) abort
  if exists('*readdir')
    let names = readdir(a:dir)
  else
    " glob フォールバック(ドットファイルは省略)
    let names = map(glob(a:dir . '/*', 1, 1), "fnamemodify(v:val, ':t')")
  endif
  let dirs = []
  let files = []
  for n in sort(names)
    if n =~# '^\.' | continue | endif
    if isdirectory(a:dir . '/' . n)
      call add(dirs, n)
    else
      call add(files, n)
    endif
  endfor
  return [dirs, files]
endfunction

function! s:render(bufnr) abort
  let lines = ['[' . fnamemodify(s:root, ':~') . ']']
  let paths = ['']
  call s:render_dir(s:root, 0, lines, paths)
  call saside#util#SetLines(a:bufnr, lines)
  call setbufvar(a:bufnr, 'saside_paths', paths)
endfunction

function! s:render_dir(dir, depth, lines, paths) abort
  let [dirs, files] = s:list(a:dir)
  let pad = repeat('  ', a:depth)
  for d in dirs
    let full = a:dir . '/' . d
    let open = get(s:expanded, full, 0)
    call add(a:lines, pad . (open ? '▾ ' : '▸ ') . d . '/')
    call add(a:paths, full)
    if open
      call s:render_dir(full, a:depth + 1, a:lines, a:paths)
    endif
  endfor
  for f in files
    call add(a:lines, pad . '  ' . f)
    call add(a:paths, a:dir . '/' . f)
  endfor
endfunction

function! s:refresh() abort
  let bufnr = bufnr('saside://explorer')
  if bufnr != -1
    call s:render(bufnr)
  endif
endfunction

" カーソル行のパスを返す(ヘッダ行は '')
function! s:cursor_path() abort
  return get(get(b:, 'saside_paths', []), line('.') - 1, '')
endfunction

" -----------------------------------------------------------------------------
" 操作
" -----------------------------------------------------------------------------

function! saside#explorer#Activate() abort
  let path = s:cursor_path()
  if path ==# '' | return | endif
  if isdirectory(path)
    let s:expanded[path] = !get(s:expanded, path, 0)
    call s:refresh()
  else
    call win_gotoid(saside#layout#Editor())
    execute 'edit ' . fnameescape(path)
  endif
endfunction

function! saside#explorer#NewFile() abort
  let dir = s:context_dir()
  let name = input('新規ファイル名: ')
  if name ==# '' | return | endif
  let path = dir . '/' . name
  if filereadable(path) || isdirectory(path)
    call saside#util#Error('既に存在します: ' . name)
    return
  endif
  call writefile([], path)
  call s:refresh()
endfunction

function! saside#explorer#NewDir() abort
  let dir = s:context_dir()
  let name = input('新規ディレクトリ名: ')
  if name ==# '' | return | endif
  call mkdir(dir . '/' . name, 'p')
  let s:expanded[dir . '/' . name] = 1
  call s:refresh()
endfunction

function! saside#explorer#Delete() abort
  let path = s:cursor_path()
  if path ==# '' | return | endif
  if confirm('削除しますか?  ' . fnamemodify(path, ':t'),
        \ "&Yes\n&No", 2) != 1
    return
  endif
  let flag = isdirectory(path) ? 'rf' : ''
  if delete(path, flag) != 0
    call saside#util#Error('削除に失敗: ' . path)
  endif
  call s:refresh()
endfunction

function! saside#explorer#Rename() abort
  let path = s:cursor_path()
  if path ==# '' | return | endif
  let name = input('リネーム: ', fnamemodify(path, ':t'))
  if name ==# '' || name ==# fnamemodify(path, ':t') | return | endif
  let dest = fnamemodify(path, ':h') . '/' . name
  if rename(path, dest) != 0
    call saside#util#Error('リネームに失敗')
  endif
  call s:refresh()
endfunction

function! saside#explorer#Move() abort
  let path = s:cursor_path()
  if path ==# '' | return | endif
  let dest_dir = input('移動先ディレクトリ: ', fnamemodify(path, ':h'), 'dir')
  if dest_dir ==# '' || !isdirectory(dest_dir)
    call saside#util#Error('ディレクトリがありません')
    return
  endif
  if rename(path, dest_dir . '/' . fnamemodify(path, ':t')) != 0
    call saside#util#Error('移動に失敗')
  endif
  call s:refresh()
endfunction

function! saside#explorer#Copy() abort
  let path = s:cursor_path()
  if path ==# '' || isdirectory(path)
    call saside#util#Warn('ファイルのみコピーできます')
    return
  endif
  let name = input('コピー先ファイル名: ', fnamemodify(path, ':t'))
  if name ==# '' | return | endif
  let dest = fnamemodify(path, ':h') . '/' . name
  if filereadable(dest)
    call saside#util#Error('既に存在します')
    return
  endif
  " バイナリ安全なコピー(外部コマンド不使用)
  call writefile(readfile(path, 'b'), dest, 'b')
  call s:refresh()
endfunction

function! saside#explorer#RootUp() abort
  let s:root = fnamemodify(s:root, ':h:h') . '/'
  call s:refresh()
endfunction

function! saside#explorer#RootHere() abort
  let path = s:cursor_path()
  if isdirectory(path)
    let s:root = fnamemodify(path, ':p')
    call s:refresh()
  endif
endfunction

" コンテキストディレクトリ(カーソルがディレクトリならそこ、ファイルなら親)
function! s:context_dir() abort
  let path = s:cursor_path()
  if path ==# ''
    return fnamemodify(s:root, ':p:h')
  endif
  return isdirectory(path) ? path : fnamemodify(path, ':h')
endfunction

function! s:setup_buffer() abort
  if get(b:, 'saside_exp_ready', 0)
    return
  endif
  let b:saside_exp_ready = 1
  setlocal cursorline
  nnoremap <buffer> <silent> <CR> :call saside#explorer#Activate()<CR>
  nnoremap <buffer> <silent> o    :call saside#explorer#Activate()<CR>
  nnoremap <buffer> <silent> a    :call saside#explorer#NewFile()<CR>
  nnoremap <buffer> <silent> A    :call saside#explorer#NewDir()<CR>
  nnoremap <buffer> <silent> D    :call saside#explorer#Delete()<CR>
  nnoremap <buffer> <silent> r    :call saside#explorer#Rename()<CR>
  nnoremap <buffer> <silent> m    :call saside#explorer#Move()<CR>
  nnoremap <buffer> <silent> c    :call saside#explorer#Copy()<CR>
  nnoremap <buffer> <silent> u    :call saside#explorer#RootUp()<CR>
  nnoremap <buffer> <silent> C    :call saside#explorer#RootHere()<CR>
  nnoremap <buffer> <silent> R    :call <SID>refresh_map()<CR>
  nnoremap <buffer> <silent> q    :call saside#layout#Close('explorer')<CR>
  syntax match SasideExpDir /^.*[▸▾].*$/
  highlight default link SasideExpDir Directory
endfunction

function! s:refresh_map() abort
  call s:refresh()
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/format.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/format.vim
" Module:      format — コード整形
" Description: 1) indent/sas.vim による全体再インデント
"              2) 行末空白の除去
"              3) 3行以上の連続空行を1行へ圧縮
"              4) g:saside_format_upcase=1 のとき予約語を大文字化
"                 (文字列/コメント内は syntax 情報で保護する)
"              全処理は1回のundoで戻せる。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

function! saside#format#Format() abort
  let view = winsaveview()
  " undo を1塊にする
  let ul_save = &l:undolevels
  try
    normal! gg=G
    keepjumps keeppatterns silent! %s/\s\+$//e
    keepjumps keeppatterns silent! %s/\n\{3,}/\r\r/e
    if get(g:, 'saside_format_upcase', 0)
      call s:upcase_buffer()
    endif
  finally
    let &l:undolevels = ul_save
    call winrestview(view)
  endtry
  call saside#util#Info('整形しました')
endfunction

" 予約語(ステートメント語のみ)を大文字化する。
" 各マッチ位置の syntax グループを調べ、文字列/コメントはスキップする。
function! s:upcase_buffer() abort
  let words = saside#parser#Keywords().statement
  let pat = '\c\<\%(' . join(words, '\|') . '\)\>'
  for lnum in range(1, line('$'))
    let line = getline(lnum)
    let out = ''
    let pos = 0
    while 1
      let [str, mstart, mend] = matchstrpos(line, pat, pos)
      if mstart == -1
        let out .= strpart(line, pos)
        break
      endif
      let out .= strpart(line, pos, mstart - pos)
      " syntax で保護判定(syntaxが無効な環境ではそのまま大文字化)
      let syn = synIDattr(synIDtrans(synID(lnum, mstart + 1, 1)), 'name')
      if syn ==# 'String' || syn ==# 'Comment' || syn ==# 'Constant'
        let out .= str
      else
        let out .= toupper(str)
      endif
      let pos = mend
    endwhile
    if out !=# line
      call setline(lnum, out)
    endif
  endfor
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/help.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/help.vim
" Module:      help — SAS関数/PROC/オプションの辞書ヘルプ
" Description: dict/ の説明フィールドを K キー/:SasHelp から引く。
"              +popupwin ならカーソル位置にポップアップ、無ければ echo。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

function! saside#help#Lookup(word) abort
  let word = a:word !=# '' ? a:word : expand('<cword>')
  if word ==# ''
    return
  endif
  let desc = saside#complete#Describe(word)
  if desc ==# ''
    call saside#util#Info('辞書に無い語: ' . word)
    return
  endif
  if exists('*popup_atcursor')
    call popup_atcursor(desc, {'padding': [0, 1, 0, 1], 'moved': 'any'})
  else
    echo desc
  endif
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/layout.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/layout.vim
" Module:      layout — ウィンドウレイアウト管理
" Description: 各ウィンドウに w:saside_role の役割タグを付けて管理する。
"              役割: 'explorer'(左) / 'navigator'(右) / 'log'(下)。
"              役割なしウィンドウが「エディタ」である。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

" 指定roleのウィンドウIDを返す(無ければ -1)
function! saside#layout#Win(role) abort
  for nr in range(1, winnr('$'))
    if getwinvar(nr, 'saside_role', '') ==# a:role
      return win_getid(nr)
    endif
  endfor
  return -1
endfunction

" エディタ(役割なし)ウィンドウのIDを返す
function! saside#layout#Editor() abort
  for nr in range(1, winnr('$'))
    if getwinvar(nr, 'saside_role', '') ==# ''
      return win_getid(nr)
    endif
  endfor
  return win_getid(winnr())
endfunction

" roleのペインを開いて移動する(既にあれば移動のみ)。winidを返す。
function! saside#layout#Open(role) abort
  let id = saside#layout#Win(a:role)
  if id != -1
    call win_gotoid(id)
    return id
  endif
  if a:role ==# 'log'
    execute 'botright ' . get(g:, 'saside_log_height', 10) . 'split'
  elseif a:role ==# 'explorer'
    execute 'topleft vertical ' . get(g:, 'saside_explorer_width', 28) . 'split'
  else
    execute 'botright vertical ' . get(g:, 'saside_navigator_width', 34) . 'split'
  endif
  let w:saside_role = a:role
  setlocal winfixwidth winfixheight
  call saside#event#Emit('LayoutChanged', {'role': a:role, 'action': 'open'})
  return win_getid()
endfunction

" roleのペインを閉じる
function! saside#layout#Close(role) abort
  let id = saside#layout#Win(a:role)
  if id == -1
    return v:false
  endif
  let cur = win_getid()
  call win_gotoid(id)
  " 最後の1枚は閉じられない
  if winnr('$') > 1
    close
  endif
  if cur != id && win_gotoid(cur) != 1
    call win_gotoid(saside#layout#Editor())
  endif
  call saside#event#Emit('LayoutChanged', {'role': a:role, 'action': 'close'})
  return v:true
endfunction

" IDE一括レイアウト(左Explorer + 右Navigator + 下Log)。
" フォーカスはエディタに戻す。
"
" 重要: 各パネルを開く前に必ずエディタ(SASバッファ)へ戻る。
" Navigator は「現在ウィンドウのfiletypeがsasか」で対象バッファを判定するため、
" 別パネル(explorer等 ft≠sas)の中から呼ぶと対象を見失って開けない。
function! saside#layout#Ide() abort
  let ed = saside#layout#Editor()

  " Navigator を最初に、必ずSASバッファがカレントな状態で開く
  call win_gotoid(ed)
  call saside#navigator#Show('nav')

  call win_gotoid(ed)
  call saside#explorer#Open()

  " ログはファイルがある場合のみ(log#Open内でフォーカスは自己復元)
  call win_gotoid(ed)
  silent! call saside#log#Open()

  call win_gotoid(ed)
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/log.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/log.vim
" Module:      log — SASログビューア
" Description: 下ペインにログを表示し、ERROR/WARNING/NOTE の抽出と
"              quickfix連携を行う。色分けは syntax/saslog.vim に委譲。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

" 対象ログのパスを決める(引数 > 直近実行 > カレントファイル.log)
function! s:target(args) abort
  if len(a:args) > 0 && a:args[0] !=# ''
    return fnamemodify(a:args[0], ':p')
  endif
  if exists('*saside#run#Last') && !empty(saside#run#Last())
    return saside#run#Last().log
  endif
  return expand('%:p:r') . '.log'
endfunction

" ログを下ペインに開く。フォーカスは呼び出し元へ戻す。
function! saside#log#Open(...) abort
  let path = s:target(a:000)
  if !filereadable(path)
    call saside#util#Warn('ログがありません: ' . path)
    return v:false
  endif
  let cur = win_getid()
  call saside#layout#Open('log')
  execute 'silent edit! ' . fnameescape(path)
  setlocal autoread nowrap
  setlocal filetype=saslog
  normal! G
  call win_gotoid(cur)
  return v:true
endfunction

" .lst(プロシジャ出力)を下ペインに開く
function! saside#log#Lst(...) abort
  let path = len(a:000) > 0 && a:000[0] !=# ''
        \ ? fnamemodify(a:000[0], ':p')
        \ : (exists('*saside#run#Last') && !empty(saside#run#Last())
        \    ? saside#run#Last().lst : expand('%:p:r') . '.lst')
  if !filereadable(path)
    call saside#util#Warn('lstがありません: ' . path)
    return v:false
  endif
  let cur = win_getid()
  call saside#layout#Open('log')
  execute 'silent edit! ' . fnameescape(path)
  setlocal autoread nowrap filetype=saslst
  call win_gotoid(cur)
  return v:true
endfunction

" ログペインをトグルする
function! saside#log#Toggle() abort
  if saside#layout#Win('log') != -1
    call saside#layout#Close('log')
  else
    call saside#log#Open()
  endif
endfunction

" ログをパースして quickfix に流す。kind: 'error'|'warning'|'note'|'all'
function! saside#log#Quickfix(...) abort
  let kind = len(a:000) > 0 ? a:000[0] : 'all'
  let path = s:target(len(a:000) > 1 ? [a:000[1]] : [])
  if !filereadable(path)
    call saside#util#Warn('ログがありません: ' . path)
    return 0
  endif
  let pats = {
        \ 'error':   '^\d*\s*ERROR\%(\s\d\+-\d\+\)\?:',
        \ 'warning': '^\d*\s*WARNING:',
        \ 'note':    '^\d*\s*NOTE:',
        \ }
  let pat = kind ==# 'all'
        \ ? pats.error . '\|' . pats.warning
        \ : get(pats, kind, pats.error)
  let items = []
  let lnum = 0
  for line in readfile(path)
    let lnum += 1
    if line =~# pat
      call add(items, {'filename': path, 'lnum': lnum, 'text': line})
    endif
  endfor
  call setqflist([], ' ', {'title': 'SAS log [' . kind . ']', 'items': items})
  if !empty(items)
    botright cwindow
  else
    cclose
    call saside#util#Info('該当なし (' . kind . ')')
  endif
  return len(items)
endfunction

" -----------------------------------------------------------------------------
" 危険NOTE検出(SASログスキャナ)
" -----------------------------------------------------------------------------
"
" SASで最も怖いのは ERROR ではなく「NOTE で警告されるだけで処理が続行し、
" 黙って誤った結果を出す」類の事象である。ここではその代表的パターンを
" 辞書化し、理由付きで quickfix に出す。
" g:saside_log_risk_patterns に [{'pat':正規表現, 'desc':説明}] を足せば拡張可。
let s:RISK_PATTERNS = [
      \ {'pat': 'uninitialized',
      \  'desc': '変数が未初期化(変数名のタイプミス・定義漏れの可能性)'},
      \ {'pat': 'Invalid \%(numeric \|character \)\?data',
      \  'desc': '不正なデータ(型変換に失敗し欠損値になっている)'},
      \ {'pat': 'MERGE statement has more than one data set with repeats of BY values',
      \  'desc': 'MERGEでBY値が重複(意図しない結合・件数増加の典型例)'},
      \ {'pat': 'Division by zero',
      \  'desc': 'ゼロ除算(結果が欠損値になっている)'},
      \ {'pat': 'W\.D format was too small',
      \  'desc': '桁あふれ(表示が丸められ・欠けている)'},
      \ {'pat': 'Character values have been converted to numeric',
      \  'desc': '文字→数値の暗黙変換(意図しない型混在)'},
      \ {'pat': 'Numeric values have been converted to character',
      \  'desc': '数値→文字の暗黙変換(意図しない型混在)'},
      \ {'pat': 'Missing values were generated',
      \  'desc': '演算の結果として欠損値が発生'},
      \ {'pat': 'Mathematical operations could not be performed',
      \  'desc': '数学演算を実行できず欠損値が発生'},
      \ {'pat': 'has 0 observations',
      \  'desc': '出力が0件(抽出条件・結合条件の誤りの可能性)'},
      \ {'pat': 'SAS went to a new line when INPUT statement reached past the end of a line',
      \  'desc': 'INPUTが行をまたいだ(読み込み位置がずれている)'},
      \ {'pat': 'LOST CARD',
      \  'desc': '入力データの欠落(レコード構造の不整合)'},
      \ {'pat': 'Multiple lengths were specified for the variable',
      \  'desc': '変数長の不一致(文字列が切り捨てられる恐れ)'},
      \ {'pat': 'Truncated record',
      \  'desc': 'レコードが切り捨てられた'},
      \ ]

" ログから危険NOTEを抽出して quickfix に出す
function! saside#log#Risk(...) abort
  let path = s:target(a:000)
  if !filereadable(path)
    call saside#util#Warn('ログがありません: ' . path)
    return 0
  endif
  let patterns = s:RISK_PATTERNS + get(g:, 'saside_log_risk_patterns', [])
  let items = []
  let lnum = 0
  for line in readfile(path)
    let lnum += 1
    for p in patterns
      if line =~? p.pat
        call add(items, {'filename': path, 'lnum': lnum, 'type': 'W',
              \ 'text': '[' . p.desc . '] ' . saside#util#Trim(line)})
        break
      endif
    endfor
  endfor
  call setqflist([], ' ', {'title': 'SAS log 危険NOTE', 'items': items})
  if !empty(items)
    botright cwindow
    call saside#util#Warn('要確認の危険NOTE: ' . len(items) . '件')
  else
    cclose
    call saside#util#Info('危険NOTEはありません')
  endif
  return len(items)
endfunction

" RunFinished イベントハンドラ(plugin/saside.vim から配線される)
function! saside#log#OnRunFinished(ev) abort
  let exit = get(a:ev, 'exit', -1)
  let path = get(a:ev, 'log', '')
  call saside#log#Open(path)
  let n = saside#log#Quickfix('all', path)
  " 危険NOTEは件数だけ数えて知らせる(quickfixはERROR/WARNINGを優先表示)
  let risk = s:count_risk(path)
  if exit == 0 && n == 0 && risk == 0
    call saside#util#Info('SAS正常終了 (exit 0)')
  elseif exit == 0 && n == 0
    call saside#util#Warn(printf(
          \ 'SAS終了 exit=0 ですが危険NOTEが %d件あります(:SasLogRisk で確認)',
          \ risk))
  else
    call saside#util#Warn(printf(
          \ 'SAS終了 exit=%d / ERROR+WARNING %d件 / 危険NOTE %d件', exit, n, risk))
  endif
endfunction

" 危険NOTEの件数だけを数える(quickfixを書き換えない)
function! s:count_risk(path) abort
  if !filereadable(a:path)
    return 0
  endif
  let patterns = s:RISK_PATTERNS + get(g:, 'saside_log_risk_patterns', [])
  let n = 0
  for line in readfile(a:path)
    for p in patterns
      if line =~? p.pat
        let n += 1
        break
      endif
    endfor
  endfor
  return n
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/macro.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/macro.vim
" Module:      macro — Macro Browser(navigator の表示モード委譲)
" =============================================================================
function! saside#macro#Toggle() abort
  call saside#navigator#Toggle('macro')
endfunction
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/navigator.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/navigator.vim
" Module:      navigator — 右ペインのコード構造ビュー(共通描画基盤)
" Description: parser のインデックスを4つのモードで表示する。
"                nav     : 種別ごとにグループ化(SAS Navigator)
"                outline : ファイル出現順の平坦リスト(Outline)
"                dataset : 入出力データセット一覧(Dataset Browser)
"                macro   : マクロ定義一覧(Macro Browser)
"              Enterでソース位置へジャンプ。IndexUpdatedで自動再描画。
"              outline/dataset/macro モジュールは本モジュールの thin wrapper。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:mode = 'nav'
let s:src = -1                 " 表示対象のSASバッファ

let s:NAV_ORDER = ['macro', 'data', 'proc', 'libname', 'filename',
      \            'options', 'ods', 'title', 'footnote', 'label']

" ペインを開いて指定モードで描画する
function! saside#navigator#Show(mode) abort
  let s:mode = a:mode
  if &filetype ==# 'sas'
    let s:src = bufnr('%')
  endif
  if s:src == -1 || !bufexists(s:src)
    call saside#util#Warn('SASバッファがありません')
    return v:false
  endif
  call saside#layout#Open('navigator')
  let bufnr = saside#util#Scratch('navigator')
  call s:render(bufnr)
  call s:setup_buffer()
  return v:true
endfunction

" トグル(同モードで開いていれば閉じる)
function! saside#navigator#Toggle(mode) abort
  if saside#layout#Win('navigator') != -1 && s:mode ==# a:mode
    call saside#layout#Close('navigator')
  else
    call saside#navigator#Show(a:mode)
  endif
endfunction

" 再描画(ペインが開いている時のみ)
function! saside#navigator#Refresh() abort
  let winid = saside#layout#Win('navigator')
  if winid == -1
    return
  endif
  let bufnr = bufnr('saside://navigator')
  if bufnr != -1
    call s:render(bufnr)
  endif
endfunction

" IndexUpdated イベントハンドラ(plugin から配線)
function! saside#navigator#OnIndexUpdated(ev) abort
  if get(a:ev, 'bufnr', -1) ==# s:src
    call saside#navigator#Refresh()
  endif
endfunction

" 現在行の項目へジャンプする
function! saside#navigator#Jump() abort
  let items = get(b:, 'saside_items', [])
  let item = get(items, line('.') - 1, {})
  if empty(item)
    return
  endif
  call win_gotoid(saside#layout#Editor())
  if bufnr('%') != s:src
    execute 'silent buffer ' . s:src
  endif
  call cursor(item.lnum, 1)
  normal! zz
  if get(g:, 'saside_nav_stay', 0)
    call win_gotoid(saside#layout#Win('navigator'))
  endif
endfunction

" -----------------------------------------------------------------------------
" 描画
" -----------------------------------------------------------------------------

function! s:render(bufnr) abort
  let idx = saside#parser#Index(s:src)
  let lines = []
  let items = []      " lines と平行。ヘッダ行は {}
  let title = {'nav': 'NAVIGATOR', 'outline': 'OUTLINE',
        \      'dataset': 'DATASETS', 'macro': 'MACROS'}[s:mode]
  call add(lines, printf('%s  [%s]', title, fnamemodify(bufname(s:src), ':t')))
  call add(items, {})

  if s:mode ==# 'nav'
    for type in s:NAV_ORDER
      let group = filter(copy(idx.blocks), 'v:val.type ==# type')
      if empty(group)
        continue
      endif
      call add(lines, '▸ ' . toupper(type) . ' (' . len(group) . ')')
      call add(items, {})
      for b in group
        call add(lines, printf('    %-28s :%d', strpart(s:name(b), 0, 28), b.lnum))
        call add(items, b)
      endfor
    endfor
  elseif s:mode ==# 'outline'
    for b in idx.blocks
      call add(lines, printf('  %-8s %-24s :%d',
            \ b.type, strpart(s:name(b), 0, 24), b.lnum))
      call add(items, b)
    endfor
  elseif s:mode ==# 'dataset'
    for io in ['out', 'in']
      let group = filter(copy(idx.datasets), 'v:val.io ==# io')
      call add(lines, '▸ ' . (io ==# 'out' ? 'OUTPUT' : 'INPUT')
            \ . ' (' . len(group) . ')')
      call add(items, {})
      for d in group
        call add(lines, printf('    %-22s via %-6s :%d',
              \ strpart(d.name, 0, 22), d.via, d.lnum))
        call add(items, d)
      endfor
    endfor
  elseif s:mode ==# 'macro'
    for m in idx.macros
      call add(lines, printf('  %%%s(%s) :%d',
            \ m.name, join(m.args, ', '), m.lnum))
      call add(items, m)
    endfor
    if empty(idx.macros)
      call add(lines, '  (マクロ定義なし)')
      call add(items, {})
    endif
  endif

  call saside#util#SetLines(a:bufnr, lines)
  call setbufvar(a:bufnr, 'saside_items', items)
endfunction

" ブロックの表示名(空なら種別で代用)
function! s:name(b) abort
  return a:b.name !=# '' ? a:b.name : '(' . a:b.type . ')'
endfunction

" ナビゲータバッファのローカル設定(初回のみ有効化される)
function! s:setup_buffer() abort
  if get(b:, 'saside_nav_ready', 0)
    return
  endif
  let b:saside_nav_ready = 1
  setlocal cursorline
  nnoremap <buffer> <silent> <CR> :call saside#navigator#Jump()<CR>
  nnoremap <buffer> <silent> q    :call saside#layout#Close('navigator')<CR>
  nnoremap <buffer> <silent> r    :call saside#navigator#Refresh()<CR>
  nnoremap <buffer> <silent> 1    :call saside#navigator#Show('nav')<CR>
  nnoremap <buffer> <silent> 2    :call saside#navigator#Show('outline')<CR>
  nnoremap <buffer> <silent> 3    :call saside#navigator#Show('dataset')<CR>
  nnoremap <buffer> <silent> 4    :call saside#navigator#Show('macro')<CR>
  " 簡易ハイライト
  syntax match SasideNavHeader /^[▸A-Z].*/
  syntax match SasideNavLnum   /:\d\+$/
  highlight default link SasideNavHeader Title
  highlight default link SasideNavLnum   LineNr
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/outline.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/outline.vim
" Module:      outline — Outline ビュー(navigator の表示モード委譲)
" =============================================================================
function! saside#outline#Toggle() abort
  call saside#navigator#Toggle('outline')
endfunction
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/parser.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/parser.vim
" Module:      parser — SASIDE の中核。SASコードのインデックス生成
" Description: バッファを1パス走査し、DATA/PROC/%MACRO/LIBNAME/ODS等の
"              「ブロック一覧」、入出力データセット一覧、マクロ定義、
"              マクロ変数を抽出した dict(コードインデックス)を返す。
"              Navigator/Outline/Dataset/Macro/StatusLine/補完のすべてが
"              このインデックスだけを参照する(単一パーサ・複数ビュー)。
"
" 設計メモ:
"   - 正規表現ベースの行スキャナである。/* */ 複数行コメントは状態機械で
"     スキップする。文字列内のキーワード誤検出は文字列除去で回避する。
"   - キャッシュは b:saside_index に保持し b:changedtick で無効化判定する。
"   - 完全な構文解析ではない。SASの全構文網羅より「実務コードで外さない」
"     ことを優先している(既知の限界は doc/saside.txt に明記)。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

" -----------------------------------------------------------------------------
" 公開API
" -----------------------------------------------------------------------------

" コードインデックスを返す(キャッシュ有効)。
function! saside#parser#Index(...) abort
  let bufnr = a:0 ? a:1 : bufnr('%')
  let tick = getbufvar(bufnr, 'changedtick', -1)
  let cache = getbufvar(bufnr, 'saside_index', {})
  if !empty(cache) && get(cache, 'tick', -2) ==# tick
    return cache
  endif
  let idx = s:scan(getbufline(bufnr, 1, '$'))
  let idx.tick = tick
  let idx.bufnr = bufnr
  call setbufvar(bufnr, 'saside_index', idx)
  " イベントはtimerで遅延発火する。Index()はステータスライン評価などの
  " textlock文脈(テキスト/ウィンドウ変更禁止)から呼ばれ得るため、
  " その場でEmitするとハンドラのUI更新が E565 になる
  if has('timers')
    call timer_start(0,
          \ {-> saside#event#Emit('IndexUpdated', {'bufnr': bufnr})})
  else
    call saside#event#Emit('IndexUpdated', {'bufnr': bufnr})
  endif
  return idx
endfunction

" キャッシュ済みインデックスを返す(解析は一切しない読み取り専用版)。
" 未解析なら空dict。textlock文脈(statusline等)からはこちらを使うこと。
function! saside#parser#Cached(...) abort
  let bufnr = a:0 ? a:1 : bufnr('%')
  return getbufvar(bufnr, 'saside_index', {})
endfunction

" キャッシュを破棄する(次回 Index() で必ず再解析)
function! saside#parser#Invalidate(...) abort
  let bufnr = a:0 ? a:1 : bufnr('%')
  call setbufvar(bufnr, 'saside_index', {})
endfunction

" 指定行を含む最内ブロックを返す(無ければ空dict)。statusline用。
function! saside#parser#BlockAt(lnum, ...) abort
  let bufnr = a:0 ? a:1 : bufnr('%')
  let best = {}
  for b in saside#parser#Index(bufnr).blocks
    if b.lnum <= a:lnum && a:lnum <= b.end
      " より内側(開始行が大きい)のブロックを優先する
      if empty(best) || b.lnum >= best.lnum
        let best = b
      endif
    endif
  endfor
  return best
endfunction

" 予約語テーブルを返す(format/complete が共有。カテゴリ→語リスト)
function! saside#parser#Keywords() abort
  return s:keywords
endfunction

" -----------------------------------------------------------------------------
" 定数
" -----------------------------------------------------------------------------

let s:keywords = {
      \ 'statement': ['data','proc','run','quit','set','merge','update',
      \   'modify','by','where','output','input','infile','file','if','then',
      \   'else','do','end','until','while','select','when','otherwise',
      \   'return','array','retain','drop','keep','rename','length','format',
      \   'informat','label','title','footnote','options','libname',
      \   'filename','call','put','putlog','cards','datalines','attrib','ods'],
      \ 'macro': ['%macro','%mend','%let','%put','%if','%then','%else','%do',
      \   '%end','%to','%until','%while','%global','%local','%return',
      \   '%include','%sysfunc','%sysevalf','%eval','%scan','%substr',
      \   '%index','%length','%upcase','%str','%nrstr','%bquote','%quote'],
      \ }

" ブロック終端とみなす文(run; / quit;)
let s:re_close   = '\c^\s*\%(run\|quit\)\s*;'
" 単一行で完結する宣言系ステートメント
let s:re_oneline = '\c^\s*\(libname\|filename\|options\|title\d*\|footnote\d*\|ods\|%include\)\>\s*\([^;]*\)'
" ラベル(goto先): name: 形式。ただし予約語や代入と紛れないよう先頭のみ
let s:re_label   = '^\s*\(\a\w*\)\s*:\s*$'

" -----------------------------------------------------------------------------
" スキャナ本体
" -----------------------------------------------------------------------------

" 行リストを走査してインデックスを構築する
function! s:scan(lines) abort
  let idx = {'blocks': [], 'datasets': [], 'macros': [], 'macrovars': []}
  let mvars = {}

  " 状態
  let in_comment = 0        " /* */ コメント内
  let cur = {}              " 現在開いている data/proc ブロック
  let macro_stack = []      " ネスト中の %macro ブロック
  let in_sql = 0            " PROC SQL 内(FROM/JOIN抽出用)

  let lnum = 0
  for raw in a:lines
    let lnum += 1
    " withstr: コメント除去済み・文字列リテラルは保持(symput抽出用)
    " line:    さらに文字列の中身を潰したもの(構造マッチ用)
    let [withstr, in_comment] = s:strip_noise(raw, in_comment)
    let line = s:squash_strings(withstr)
    if line =~ '^\s*$'
      continue
    endif

    " ---- %macro / %mend (data/procより先に判定)----
    let m = matchlist(line, '\c^\s*%macro\s\+\(\w\+\)\s*\%((\([^)]*\))\)\?')
    if !empty(m)
      let blk = {'type': 'macro', 'name': m[1], 'lnum': lnum, 'end': lnum,
            \    'args': s:parse_args(m[2])}
      call add(idx.blocks, blk)
      call add(idx.macros, blk)
      call add(macro_stack, blk)
      " マクロ引数もマクロ変数として補完対象に載せる
      for a in blk.args
        let mvars[a] = 1
      endfor
      continue
    endif
    if line =~ '\c^\s*%mend\>'
      if !empty(macro_stack)
        let macro_stack[-1].end = lnum
        call remove(macro_stack, -1)
      endif
      continue
    endif

    " ---- %let ----
    let m = matchlist(line, '\c%let\s\+\(\w\+\)')
    if !empty(m)
      let mvars[m[1]] = 1
    endif
    " ---- call symput / symputx (文字列を保持した withstr からマッチ)----
    let m = matchlist(withstr, '\ccall\s\+symputx\?\s*(\s*[''"]\(\w\+\)[''"]')
    if !empty(m)
      let mvars[m[1]] = 1
    endif
    " ---- select ... into :var ----
    let m = matchlist(line, '\cinto\s*:\s*\(\w\+\)')
    if !empty(m)
      let mvars[m[1]] = 1
    endif

    " ---- DATAステップ開始 ----
    let m = matchlist(line, '\c^\s*data\s\+\([^;(]\+\)')
    if !empty(m) && line !~ '\c^\s*data\s*='
      call s:close_block(cur, lnum - 1)
      let names = s:split_ds_names(m[1])
      let cur = {'type': 'data', 'name': join(names, ' '), 'lnum': lnum,
            \    'end': lnum}
      call add(idx.blocks, cur)
      for n in names
        call add(idx.datasets,
              \ {'name': n, 'io': 'out', 'via': 'data', 'lnum': lnum})
      endfor
      let in_sql = 0
      continue
    endif
    " data _null_; のような ( や ; 直結パターン
    if line =~ '\c^\s*data\s*;'
      call s:close_block(cur, lnum - 1)
      let cur = {'type': 'data', 'name': '(unnamed)', 'lnum': lnum, 'end': lnum}
      call add(idx.blocks, cur)
      let in_sql = 0
      continue
    endif

    " ---- PROC開始 ----
    let m = matchlist(line, '\c^\s*proc\s\+\(\w\+\)')
    if !empty(m)
      call s:close_block(cur, lnum - 1)
      let cur = {'type': 'proc', 'name': tolower(m[1]), 'lnum': lnum,
            \    'end': lnum}
      call add(idx.blocks, cur)
      let in_sql = (tolower(m[1]) ==# 'sql')
      " proc xxx data=yyy の入力データセット
      let dm = matchlist(line, '\cdata\s*=\s*\([A-Za-z_&][A-Za-z0-9_.&]*\)')
      if !empty(dm)
        call add(idx.datasets,
              \ {'name': dm[1], 'io': 'in', 'via': 'proc', 'lnum': lnum})
      endif
      continue
    endif

    " ---- run; / quit; でブロックを閉じる ----
    if line =~ s:re_close
      call s:close_block(cur, lnum)
      let cur = {}
      let in_sql = 0
      continue
    endif

    " ---- ブロック内の入力データセット ----
    let m = matchlist(line, '\c^\s*\(set\|merge\|update\|modify\)\s\+\([^;]*\)')
    if !empty(m)
      for n in s:split_ds_names(m[2])
        call add(idx.datasets,
              \ {'name': n, 'io': 'in', 'via': tolower(m[1]), 'lnum': lnum})
      endfor
      continue
    endif

    " ---- PROC SQL: create table / from / join ----
    if in_sql
      let m = matchlist(line, '\c\<create\s\+table\s\+\([A-Za-z_&][A-Za-z0-9_.&]*\)')
      if !empty(m)
        call add(idx.datasets,
              \ {'name': m[1], 'io': 'out', 'via': 'create', 'lnum': lnum})
      endif
      let m = matchlist(line, '\c\<from\s\+\([A-Za-z_&][A-Za-z0-9_.&]*\)')
      if !empty(m)
        call add(idx.datasets,
              \ {'name': m[1], 'io': 'in', 'via': 'from', 'lnum': lnum})
      endif
      let m = matchlist(line, '\c\<join\s\+\([A-Za-z_&][A-Za-z0-9_.&]*\)')
      if !empty(m)
        call add(idx.datasets,
              \ {'name': m[1], 'io': 'in', 'via': 'join', 'lnum': lnum})
      endif
    endif

    " ---- 単一行宣言(libname/filename/options/title/footnote/ods)----
    let m = matchlist(line, s:re_oneline)
    if !empty(m)
      call add(idx.blocks, {'type': tolower(substitute(m[1], '\d\+$', '', '')),
            \ 'name': saside#util#Trim(m[2]), 'lnum': lnum, 'end': lnum})
      continue
    endif

    " ---- ラベル ----
    let m = matchlist(line, s:re_label)
    if !empty(m)
      call add(idx.blocks,
            \ {'type': 'label', 'name': m[1], 'lnum': lnum, 'end': lnum})
    endif
  endfor

  " ファイル末尾まで閉じられなかったブロックの後始末
  call s:close_block(cur, lnum)
  for blk in macro_stack
    let blk.end = lnum
  endfor

  let idx.macrovars = sort(keys(mvars))
  return idx
endfunction

" -----------------------------------------------------------------------------
" 内部ヘルパ
" -----------------------------------------------------------------------------

" コメントを行から除去する(文字列リテラルは保持する)。
" 戻り値: [処理済みの行, 次行に持ち越す in_comment 状態]
function! s:strip_noise(line, in_comment) abort
  let line = a:line
  let in_comment = a:in_comment
  if in_comment
    let pos = stridx(line, '*/')
    if pos == -1
      return ['', 1]
    endif
    let line = strpart(line, pos + 2)
    let in_comment = 0
  endif
  " 同一行で閉じる /* */ を除去(複数個対応)
  let line = substitute(line, '/\*.\{-}\*/', ' ', 'g')
  " 行内で開いたまま閉じない /* は以降を捨てて持ち越す
  let pos = stridx(line, '/*')
  if pos != -1
    let line = strpart(line, 0, pos)
    let in_comment = 1
  endif
  " 行頭 * コメント(; まで。閉じ ; が無ければ行全体を捨てる)
  if line =~ '^\s*\*'
    let line = substitute(line, '^\s*\*[^;]*;\?', '', '')
  endif
  return [line, in_comment]
endfunction

" 文字列リテラルの中身を '@' に潰す(構造マッチでのキーワード誤検出防止)
function! s:squash_strings(line) abort
  let line = substitute(a:line, "'[^']*'", "'@'", 'g')
  return substitute(line, '"[^"]*"', '"@"', 'g')
endfunction

" %macro の引数リストを分解する("a=, b, c=x" → ['a','b','c'])
function! s:parse_args(argstr) abort
  let args = []
  for part in split(a:argstr, ',')
    let name = matchstr(part, '\w\+')
    if name !=# ''
      call add(args, name)
    endif
  endfor
  return args
endfunction

" "work.a raw.b(keep=x)" のようなデータセット名並びを分解する
function! s:split_ds_names(str) abort
  let names = []
  " 括弧内のオプションを除去してから空白区切りで分解
  let clean = substitute(a:str, '([^)]*)', '', 'g')
  for tok in split(clean, '\s\+')
    let tok = saside#util#Trim(tok)
    " データセット名として妥当なものだけ(key=value の残骸などを除外)。
    " & を含むのはマクロ変数展開名(例: work_l.&outdata.)を許容するため
    if tok =~# '^[A-Za-z_&][A-Za-z0-9_.&]*$' && tok !~? '^\%(end\|nobs\|key\)$'
      call add(names, tok)
    endif
  endfor
  return names
endfunction

" 開いているブロックの終端行を確定する
function! s:close_block(blk, lnum) abort
  if !empty(a:blk) && a:lnum >= a:blk.lnum
    let a:blk.end = a:lnum
  endif
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/run.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/run.vim
" Module:      run — SASバッチ実行
" Description: 保存→実行→終了コード判定→イベント発火。実行方式は
"              +job(非同期)→ system(同期)の順で自動選択する。
"              端末で対話的に実行したい場合は RunTerm()(+terminal時のみ)。
" Events:      RunStarted {file, mode} / RunFinished {exit, log, lst}
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:job = v:null
let s:status = 'idle'          " 'idle' | 'running' | 'done:N'
let s:last = {}                " {'file':..,'log':..,'lst':..}

" 直近の実行情報を返す(logモジュールが利用)
function! saside#run#Last() abort
  return s:last
endfunction

" 現在の実行状態を返す
function! saside#run#Status() abort
  return s:status
endfunction

" 構文チェック実行(obs=0)。
" データを1件も処理せずに構文だけ検証するSASの定石。巨大データを読む
" プログラムでも数秒でタイプミスを検出できる。-noreplace により永久
" データセットの上書きも防ぐ。
function! saside#run#Check() abort
  let args = get(g:, 'saside_check_args', ['-obs', '0', '-noreplace'])
  call saside#util#Info('構文チェック実行中(' . join(args, ' ') . ')')
  return saside#run#Execute('', args)
endfunction

" SASをバッチ実行する。
"   a:1 対象ファイル(省略/空なら現在バッファ)
"   a:2 追加コマンドライン引数のリスト(省略可)
function! saside#run#Execute(...) abort
  if s:status ==# 'running'
    call saside#util#Warn('SAS実行中です(中断は :SasAbort)')
    return {'started': v:false}
  endif
  let file = a:0 && a:1 !=# '' ? fnamemodify(a:1, ':p') : expand('%:p')
  if file ==# ''
    call saside#util#Error('実行対象ファイルがありません')
    return {'started': v:false}
  endif
  " 現在バッファ対象なら未保存変更を保存
  if file ==# expand('%:p') && &modified
    silent update
  endif
  let base = fnamemodify(file, ':r')
  let logf = base . '.log'
  let lstf = base . '.lst'
  let s:last = {'file': file, 'log': logf, 'lst': lstf}
  let extra = a:0 >= 2 ? a:2 : []
  let cmd = [g:saside_sas_cmd, '-sysin', file, '-log', logf, '-print', lstf]
        \ + g:saside_sas_args + extra

  if saside#util#Capabilities().job
    let s:status = 'running'
    call saside#event#Emit('RunStarted', {'file': file, 'mode': 'job'})
    let s:job = job_start(cmd, {'exit_cb': function('s:on_exit')})
    if job_status(s:job) ==# 'fail'
      let s:status = 'idle'
      call saside#util#Error('起動に失敗: ' . g:saside_sas_cmd)
      return {'started': v:false}
    endif
    call saside#util#Info('SAS実行開始: ' . fnamemodify(file, ':t'))
    return {'started': v:true, 'mode': 'job'}
  endif

  " 同期フォールバック
  call saside#event#Emit('RunStarted', {'file': file, 'mode': 'sync'})
  call system(join(map(copy(cmd), 'shellescape(v:val)'), ' '))
  let s:status = 'done:' . v:shell_error
  call saside#event#Emit('RunFinished',
        \ {'exit': v:shell_error, 'log': logf, 'lst': lstf})
  return {'started': v:true, 'mode': 'sync'}
endfunction

" -----------------------------------------------------------------------------
" 部分実行(選択範囲 / カーソル位置のブロック)
" -----------------------------------------------------------------------------

" 選択範囲を一時ファイルに書き出して実行する。
"   一時ファイルは元ファイルと同じディレクトリに作る(相対パスのLIBNAMEや
"   %include が元と同じ条件で解決されるようにするため)。
"   g:saside_run_preamble=1(既定)なら、選択範囲より前にある
"   LIBNAME/FILENAME/OPTIONS/%LET を自動で先頭に付ける(部分実行でも
"   ライブラリ参照やマクロ変数が解決できるようにするため)。
function! saside#run#ExecuteRange(line1, line2) abort
  let lines = getline(a:line1, a:line2)
  if empty(filter(copy(lines), 'v:val !~ "^\\s*$"'))
    call saside#util#Error('選択範囲が空です')
    return {'started': v:false}
  endif

  let src = expand('%:p')
  let dir = src !=# '' ? fnamemodify(src, ':h') : getcwd()
  let base = src !=# '' ? fnamemodify(src, ':t:r') : 'untitled'
  let tmp = dir . '/' . base . '_sel.sas'

  let body = []
  if get(g:, 'saside_run_preamble', 1)
    let body += s:preamble(a:line1)
  endif
  let body += lines

  if writefile(body, tmp) != 0
    call saside#util#Error('一時ファイルを作成できません: ' . tmp)
    return {'started': v:false}
  endif
  call saside#util#Info(printf('部分実行: %d-%d行 (%d行送信)',
        \ a:line1, a:line2, len(body)))
  return saside#run#Execute(tmp)
endfunction

" カーソル位置のブロック(DATA/PROC/%MACRO)だけを実行する
function! saside#run#ExecuteBlock() abort
  let blk = saside#parser#BlockAt(line('.'))
  if empty(blk)
    call saside#util#Warn('カーソル位置にDATA/PROCブロックがありません')
    return {'started': v:false}
  endif
  call saside#util#Info(printf('ブロック実行: %s %s (L%d-%d)',
        \ toupper(blk.type), blk.name, blk.lnum, blk.end))
  return saside#run#ExecuteRange(blk.lnum, blk.end)
endfunction

" 指定行より前にある LIBNAME/FILENAME/OPTIONS/%LET を集める
function! s:preamble(before_lnum) abort
  let out = []
  if a:before_lnum <= 1
    return out
  endif
  for line in getline(1, a:before_lnum - 1)
    if line =~? '^\s*\%(libname\|filename\|options\)\>'
          \ || line =~? '^\s*%let\>'
      call add(out, line)
    endif
  endfor
  if !empty(out)
    call insert(out, '/* --- saside: auto preamble (前方のLIBNAME/OPTIONS/%LET) --- */')
    call add(out, '/* --- end preamble --- */')
    call add(out, '')
  endif
  return out
endfunction

" +terminal 環境では端末ウィンドウ内で実行する(進行が見える)
function! saside#run#ExecuteTerm(...) abort
  if !has('terminal')
    return saside#run#Execute(a:0 ? a:1 : '')
  endif
  let file = a:0 && a:1 !=# '' ? fnamemodify(a:1, ':p') : expand('%:p')
  if file ==# expand('%:p') && &modified
    silent update
  endif
  let base = fnamemodify(file, ':r')
  let s:last = {'file': file, 'log': base . '.log', 'lst': base . '.lst'}
  let cmd = [g:saside_sas_cmd, '-sysin', file,
        \    '-log', s:last.log, '-print', s:last.lst] + g:saside_sas_args
  botright call term_start(cmd, {'term_rows': get(g:, 'saside_log_height', 10),
        \ 'exit_cb': function('s:on_exit')})
  let s:status = 'running'
  call saside#event#Emit('RunStarted', {'file': file, 'mode': 'term'})
  return {'started': v:true, 'mode': 'term'}
endfunction

" 実行中ジョブを停止する
function! saside#run#Abort() abort
  if s:status !=# 'running' || type(s:job) != v:t_job
    call saside#util#Info('実行中のジョブはありません')
    return v:false
  endif
  call job_stop(s:job)
  return v:true
endfunction

" ジョブ終了コールバック
function! s:on_exit(job, exit) abort
  let s:status = 'done:' . a:exit
  call saside#event#Emit('RunFinished',
        \ {'exit': a:exit, 'log': get(s:last, 'log', ''),
        \  'lst': get(s:last, 'lst', '')})
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/search.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/search.vim
" Module:      search — プロジェクト横断検索(vimgrep ラッパ)
" Description: プロジェクトルートは g:saside_project_root >
"              .sasproject マーカーの上方探索 > 現在ファイルのディレクトリ。
"              対象は **/*.sas と **/*.txt(会社運用に合わせる)。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

" プロジェクトルートを返す
function! saside#search#Root() abort
  if get(g:, 'saside_project_root', '') !=# ''
    return fnamemodify(g:saside_project_root, ':p')
  endif
  let marker = findfile('.sasproject', expand('%:p:h') . ';')
  if marker !=# ''
    return fnamemodify(marker, ':p:h')
  endif
  let dir = expand('%:p:h')
  return dir !=# '' ? dir : getcwd()
endfunction

function! s:globs() abort
  let root = fnameescape(saside#search#Root())
  return root . '/**/*.sas ' . root . '/**/*.txt'
endfunction

" パターンでプロジェクト検索
function! saside#search#Grep(pattern) abort
  if a:pattern ==# ''
    return
  endif
  try
    execute 'noautocmd vimgrep /' . escape(a:pattern, '/') . '/gj ' . s:globs()
  catch /E480/
    call saside#util#Info('該当なし: ' . a:pattern)
    return
  endtry
  botright cwindow
endfunction

" データセット名の使用箇所を検索(定義/入出力の行に絞る)
function! saside#search#Dataset(name) abort
  let name = a:name !=# '' ? a:name : expand('<cword>')
  call saside#search#Grep(
        \ '\c\<\%(data\|set\|merge\|update\|modify\|from\|join\|table\)\>'
        \ . '[^;]*\<' . name . '\>')
endfunction

" マクロの定義と呼び出しを検索
function! saside#search#Macro(name) abort
  let name = a:name !=# '' ? a:name : expand('<cword>')
  let name = substitute(name, '^%', '', '')
  call saside#search#Grep('%\%(macro\s\+\)\?' . name . '\>')
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/session.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/session.vim
" Module:      session — セッション保存/復元
" Description: :mksession のラッパ。保存先は ~/.vim/.state/。
"              SASIDEの特殊ペイン(scratchバッファ)はセッションに含めず、
"              復元後に開いていたロール情報から再構築する。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

function! s:path(name) abort
  return saside#util#StateDir() . '/' . a:name . '.session.vim'
endfunction

function! s:meta(name) abort
  return saside#util#StateDir() . '/' . a:name . '.session.json'
endfunction

function! saside#session#Save(...) abort
  let name = a:0 && a:1 !=# '' ? a:1 : 'default'
  " 開いているSASIDEペインを記録してから閉じる(scratchはmksession不能)
  let roles = []
  for role in ['explorer', 'navigator', 'log']
    if saside#layout#Win(role) != -1
      call add(roles, role)
      call saside#layout#Close(role)
    endif
  endfor
  let sso = &sessionoptions
  set sessionoptions=blank,buffers,curdir,folds,tabpages,winsize
  try
    execute 'mksession! ' . fnameescape(s:path(name))
    call writefile([json_encode({'roles': roles})], s:meta(name))
  finally
    let &sessionoptions = sso
  endtry
  " 閉じたペインを開き直す
  for role in roles
    call s:reopen(role)
  endfor
  call saside#util#Info('セッション保存: ' . name)
endfunction

function! saside#session#Load(...) abort
  let name = a:0 && a:1 !=# '' ? a:1 : 'default'
  if !filereadable(s:path(name))
    call saside#util#Error('セッションがありません: ' . name)
    return v:false
  endif
  execute 'silent! source ' . fnameescape(s:path(name))
  if filereadable(s:meta(name))
    try
      let meta = json_decode(join(readfile(s:meta(name)), ''))
      for role in get(meta, 'roles', [])
        call s:reopen(role)
      endfor
    catch
    endtry
  endif
  call win_gotoid(saside#layout#Editor())
  call saside#util#Info('セッション復元: ' . name)
  return v:true
endfunction

function! saside#session#ListNames() abort
  return map(glob(saside#util#StateDir() . '/*.session.vim', 1, 1),
        \ "fnamemodify(v:val, ':t:r:r')")
endfunction

function! s:reopen(role) abort
  if a:role ==# 'explorer'
    call saside#explorer#Open()
  elseif a:role ==# 'navigator'
    call saside#navigator#Show('nav')
  elseif a:role ==# 'log'
    silent! call saside#log#Open()
  endif
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/snippet.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/snippet.vim
" Module:      snippet — スニペット展開
" Description: dict/snippets.txt を読み、挿入モードの <C-j> で
"              「カーソル直前の語がスニペットキーなら展開 /
"                そうでなければ次の <+placeholder+> へジャンプ」する。
"              プレースホルダはジャンプ時に削除され、そこから入力を続ける。
" File format (dict/snippets.txt):
"              snippet キー 説明
"              本文行...(次の 'snippet ' 行まで)
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:snips = {}
let s:loaded = 0

function! s:load() abort
  if s:loaded
    return
  endif
  let s:loaded = 1
  let found = globpath(&runtimepath, 'dict/snippets.txt', 0, 1)
  if empty(found)
    return
  endif
  let key = ''
  for line in readfile(found[0])
    let m = matchlist(line, '^snippet\s\+\(\S\+\)\%(\s\+\(.*\)\)\?$')
    if !empty(m)
      let key = m[1]
      let s:snips[key] = {'desc': m[2], 'body': []}
    elseif key !=# '' && line !~# '^\s*#'
      call add(s:snips[key].body, line)
    endif
  endfor
  " 末尾の空行を除去
  for k in keys(s:snips)
    while !empty(s:snips[k].body) && s:snips[k].body[-1] =~# '^\s*$'
      call remove(s:snips[k].body, -1)
    endwhile
  endfor
endfunction

" スニペット一覧(テンプレート/ドキュメント用)
function! saside#snippet#List() abort
  call s:load()
  return map(sort(keys(s:snips)),
        \ {_, k -> printf('%-12s %s', k, s:snips[k].desc)})
endfunction

" <C-j> 本体: 展開 or プレースホルダジャンプ
function! saside#snippet#ExpandOrJump() abort
  call s:load()
  let line = getline('.')
  let col = col('.')
  let before = strpart(line, 0, col - 1)
  let key = matchstr(before, '\w\+$')
  if key !=# '' && has_key(s:snips, key)
    call s:expand(key, line, col, before)
  else
    call saside#snippet#JumpNext()
  endif
  return ''
endfunction

function! s:expand(key, line, col, before) abort
  let snip = s:snips[a:key]
  let lnum = line('.')
  let indent = matchstr(a:line, '^\s*')
  let head = strpart(a:before, 0, len(a:before) - len(a:key))
  let tail = strpart(a:line, a:col - 1)
  let body = copy(snip.body)
  if empty(body)
    return
  endif
  " 1行目はキーの位置に流し込み、2行目以降は同じインデントで挿入する
  call setline(lnum, head . body[0])
  if len(body) > 1
    call append(lnum, map(body[1:], {_, l -> l ==# '' ? '' : indent . l}))
  endif
  " 末尾に元の後続テキストを繋げる
  if tail !=# ''
    let last = lnum + len(body) - 1
    call setline(last, getline(last) . tail)
  endif
  call cursor(lnum, max([1, len(head)]))
  call saside#snippet#JumpNext()
endfunction

" 次の <+placeholder+> を探して削除し、カーソルをそこへ置く
function! saside#snippet#JumpNext() abort
  let pos = searchpos('<+[^+>]*+>', 'cW')
  if pos[0] == 0
    return v:false
  endif
  let [lnum, col] = pos
  let line = getline(lnum)
  let ph = matchstr(line, '<+[^+>]*+>', col - 1)
  let name = matchstr(ph, '<+\zs[^+>]*\ze+>')
  call setline(lnum,
        \ strpart(line, 0, col - 1) . strpart(line, col - 1 + len(ph)))
  call cursor(lnum, col)
  if name !=# ''
    echo '[snippet] ' . name
  endif
  return v:true
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/statusline.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/statusline.vim
" Module:      statusline — 現在ブロック名・実行状態付きステータスライン
" Description: SASバッファに setlocal statusline を適用する。
"              表示: ファイル | 変更 | 現在ブロック(DATA/PROC名) |
"                    実行状態 | ft/enc | 行:桁/割合
"
" 重要: statusline の %! 式は再描画中(textlock)に評価されるため、
"       この中から解析やイベント発火に到達してはならない(E565になる)。
"       Build() は parser#Cached() による読み取り専用参照だけを行い、
"       初回解析は Enable() が timer で予約する。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:run_flag = ''

" FileType sas から呼ばれる(plugin/saside.vim が配線)
function! saside#statusline#Enable() abort
  setlocal statusline=%!saside#statusline#Build()
  " 初回インデックスを textlock の外で構築しておく
  if has('timers')
    let bufnr = bufnr('%')
    call timer_start(50,
          \ {-> bufexists(bufnr) ? saside#parser#Index(bufnr) : 0})
  endif
endfunction

" 実行イベントで状態フラグを更新する
function! saside#statusline#OnRun(ev) abort
  if a:ev.name ==# 'RunStarted'
    let s:run_flag = '[SAS RUNNING]'
  else
    let exit = get(a:ev, 'exit', -1)
    let s:run_flag = exit == 0 ? '[SAS OK]' : '[SAS EXIT ' . exit . ']'
  endif
  redrawstatus!
endfunction

" statusline 文字列を構築する(読み取り専用。解析は行わない)
function! saside#statusline#Build() abort
  let block = ''
  if &filetype ==# 'sas'
    let idx = saside#parser#Cached()
    if !empty(idx)
      let lnum = line('.')
      let best = {}
      for b in get(idx, 'blocks', [])
        if b.lnum <= lnum && lnum <= b.end
              \ && (empty(best) || b.lnum >= best.lnum)
          let best = b
        endif
      endfor
      if !empty(best)
        let block = ' [' . toupper(best.type)
              \ . (best.name !=# '' ? ' ' . best.name : '') . ']'
      endif
    endif
  endif
  return '%f %m%r' . s:esc(block) . ' ' . s:esc(s:run_flag)
        \ . ' %= %y %{&fileencoding} %l:%c %p%%'
endfunction

" statusline の % を無害化する
function! s:esc(s) abort
  return substitute(a:s, '%', '%%', 'g')
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/template.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/template.vim
" Module:      template — :SasNew {kind} でコード骨格を挿入
" Description: snippet と同じプレースホルダ記法 <+name+> を使う。
"              挿入後は snippet#JumpNext で最初のプレースホルダへ移動する。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

let s:templates = {
      \ 'data': [
      \   'data <+out+>;',
      \   '    set <+in+>;',
      \   '    <+statements+>',
      \   'run;',
      \ ],
      \ 'sql': [
      \   'proc sql;',
      \   '    create table <+out+> as',
      \   '    select <+columns+>',
      \   '    from <+in+>',
      \   '    where <+condition+>;',
      \   'quit;',
      \ ],
      \ 'sort': [
      \   'proc sort data=<+in+> out=<+out+>;',
      \   '    by <+keys+>;',
      \   'run;',
      \ ],
      \ 'means': [
      \   'proc means data=<+in+> n mean std min max maxdec=2;',
      \   '    class <+class+>;',
      \   '    var <+vars+>;',
      \   'run;',
      \ ],
      \ 'freq': [
      \   'proc freq data=<+in+>;',
      \   '    tables <+vars+> / missing;',
      \   'run;',
      \ ],
      \ 'macro': [
      \   '%macro <+name+>(<+args+>);',
      \   '    <+body+>',
      \   '%mend <+name2+>;',
      \ ],
      \ 'header': [
      \   '/*============================================================',
      \   '  Program : <+program+>',
      \   '  Purpose : <+purpose+>',
      \   '  Author  : <+author+>',
      \   '  Date    : <+date+>',
      \   '============================================================*/',
      \ ],
      \ }

" 利用可能な種別(コマンド補完用)
function! saside#template#Kinds(...) abort
  return join(sort(keys(s:templates)), "\n")
endfunction

" テンプレートをカーソル行の下に挿入する
function! saside#template#New(kind) abort
  if !has_key(s:templates, a:kind)
    call saside#util#Error('未知のテンプレート: ' . a:kind
          \ . '(' . join(sort(keys(s:templates)), '/') . ')')
    return v:false
  endif
  let lnum = line('.')
  call append(lnum, s:templates[a:kind])
  call cursor(lnum + 1, 1)
  call saside#snippet#JumpNext()
  return v:true
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/todo.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/todo.vim
" Module:      todo — TODO/FIXME/BUG 一覧
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

function! saside#todo#List() abort
  call saside#search#Grep('\C\<\%(TODO\|FIXME\|BUG\)\>')
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/saside/util.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        autoload/saside/util.vim
" Module:      util — SASIDE 共通ユーティリティ
" Description: メッセージ表示・状態ディレクトリ・スクラッチバッファ生成など、
"              全モジュールが共有する最小限の道具箱。他モジュールに依存しない。
" Maintainer:  SASIDE
" License:     Vim License
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

" -----------------------------------------------------------------------------
" メッセージ表示(統一プレフィックス付き)
" -----------------------------------------------------------------------------

" 情報メッセージを表示する
function! saside#util#Info(msg) abort
  echo '[saside] ' . a:msg
endfunction

" 警告を :messages に残る形で表示する
function! saside#util#Warn(msg) abort
  echohl WarningMsg
  echomsg '[saside] ' . a:msg
  echohl None
endfunction

" エラーを :messages に残る形で表示する
function! saside#util#Error(msg) abort
  echohl ErrorMsg
  echomsg '[saside] ' . a:msg
  echohl None
endfunction

" -----------------------------------------------------------------------------
" 状態ディレクトリ(セッション・ブックマーク等の永続化先)
" -----------------------------------------------------------------------------

" 状態ディレクトリの絶対パスを返す(無ければ作成する)
function! saside#util#StateDir() abort
  let dir = expand(get(g:, 'saside_state_dir', '~/.vim/.state'))
  if !isdirectory(dir)
    call mkdir(dir, 'p', 0700)
  endif
  return dir
endfunction

" -----------------------------------------------------------------------------
" スクラッチバッファ
" -----------------------------------------------------------------------------

" saside://{role} という名前の使い捨てバッファを現在ウィンドウに用意する。
" 既存なら再利用する。バッファ番号を返す。
function! saside#util#Scratch(role) abort
  let name = 'saside://' . a:role
  let bufnr = bufnr(name)
  if bufnr == -1
    execute 'silent edit ' . fnameescape(name)
    setlocal buftype=nofile bufhidden=hide noswapfile nobuflisted
    setlocal nonumber norelativenumber nowrap
    let bufnr = bufnr('%')
  else
    execute 'silent buffer ' . bufnr
  endif
  return bufnr
endfunction

" スクラッチバッファの内容を丸ごと置き換える(modifiable制御込み)
function! saside#util#SetLines(bufnr, lines) abort
  call setbufvar(a:bufnr, '&modifiable', 1)
  " deletebufline は Vim 8.1.0039+。無い環境では対象バッファで実行する
  if exists('*deletebufline')
    silent call deletebufline(a:bufnr, 1, '$')
    call setbufline(a:bufnr, 1, a:lines)
  else
    let cur = bufnr('%')
    execute 'silent buffer ' . a:bufnr
    silent %delete _
    call setline(1, a:lines)
    execute 'silent buffer ' . cur
  endif
  call setbufvar(a:bufnr, '&modifiable', 0)
endfunction

" -----------------------------------------------------------------------------
" 汎用
" -----------------------------------------------------------------------------

" 文字列から前後空白を除く(Vim 8.0互換のtrim代替)
function! saside#util#Trim(s) abort
  return substitute(a:s, '^\s*\|\s*$', '', 'g')
endfunction

" 機能の有無をまとめて調べる(run モジュールの実行方式判定用)
function! saside#util#Capabilities() abort
  return {
        \ 'job':      has('job') && has('channel'),
        \ 'terminal': has('terminal'),
        \ 'timers':   has('timers'),
        \ }
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'autoload/sasomni.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/autoload/sasomni.vim
" SAS用オムニ補完エンジン(IntelliJのCtrl+Space相当)
"
" 補完対象:
"   1) SASキーワード(ステートメント/PROC名/関数/マクロ構文)
"   2) バッファ内で定義されたマクロ変数(%let, %macroの引数, call symput)
"      -> "&" で始めると &var. 形式で補完
"   3) バッファ内で定義されたユーザーマクロ(%macro xxx)
"      -> "%" で始めると %xxx 形式で補完
"   4) バッファ内に既出の識別子(データセット名・変数名など)
"
" ftplugin側で  setlocal omnifunc=sasomni#Complete  を設定して使う

" ============================================================
" 静的候補(メニューに種別を表示する)
" ============================================================
let s:statements = [
      \ 'data','proc','run','quit','set','merge','update','modify','by',
      \ 'where','output','input','infile','file','if','then','else','do',
      \ 'end','until','while','select','when','otherwise','return','array',
      \ 'retain','drop','keep','rename','length','format','informat','label',
      \ 'title','footnote','options','libname','filename','call','put',
      \ 'putlog','cards','datalines','attrib','ods'
      \ ]

let s:procs = [
      \ 'sql','means','summary','freq','print','report','sort','transpose',
      \ 'datasets','contents','append','compare','corr','univariate','rank',
      \ 'sgplot','gplot','tabulate','export','import','copy','reg','glm',
      \ 'logistic','genmod','mixed','format','http','json'
      \ ]

let s:functions = [
      \ 'substr','scan','compress','upcase','lowcase','propcase','trim',
      \ 'ltrim','strip','left','right','translate','tranwrd','index','find',
      \ 'verify','input','put','sum','mean','std','n','nmiss','round','ceil',
      \ 'floor','int','mod','abs','sqrt','exp','log','intck','intnx',
      \ 'datepart','timepart','today','date','time','year','month','day',
      \ 'mdy','hms','symget','symput','symputx','exist','open','close',
      \ 'lag','dif','coalesce','coalescec','catx','cats','cat','length'
      \ ]

let s:macrostmts = [
      \ '%macro','%mend','%let','%put','%if','%then','%else','%do','%end',
      \ '%to','%until','%while','%global','%local','%return','%abort',
      \ '%include','%sysfunc','%sysevalf','%eval','%scan','%substr',
      \ '%index','%length','%upcase','%lowcase','%str','%nrstr','%bquote',
      \ '%nrbquote','%quote','%unquote','%superq','%symexist','%goto'
      \ ]

let s:sqlkws = [
      \ 'select','from','group by','having','order by','create table',
      \ 'inner join','left join','right join','full join','on','as',
      \ 'distinct','union','case','between','like','is null','exists',
      \ 'count','avg','min','max','calculated','noprint','insert into'
      \ ]

" ============================================================
" バッファスキャン
" ============================================================

" %let / %macro引数 / call symput で定義されたマクロ変数名を収集
function! s:ScanMacroVars() abort
  let vars = {}
  for line in getline(1, '$')
    " %let name = ...
    let m = matchlist(line, '\c%let\s\+\(\w\+\)')
    if !empty(m)
      let vars[m[1]] = 1
    endif
    " call symput('name', ...) / symputx
    let m = matchlist(line, '\ccall\s\+symputx\?\s*(\s*[''"]\(\w\+\)[''"]')
    if !empty(m)
      let vars[m[1]] = 1
    endif
    " %macro name(p1=, p2, p3=x); の引数
    let m = matchlist(line, '\c%macro\s\+\w\+\s*(\(.\{-}\)[);]')
    if !empty(m)
      for p in split(m[1], ',')
        let pname = matchstr(p, '\w\+')
        if pname !=# ''
          let vars[pname] = 1
        endif
      endfor
    endif
    " select ... into :name (PROC SQL)
    let m = matchlist(line, '\cinto\s*:\s*\(\w\+\)')
    if !empty(m)
      let vars[m[1]] = 1
    endif
  endfor
  return keys(vars)
endfunction

" %macro で定義されたユーザーマクロ名を収集
function! s:ScanUserMacros() abort
  let macros = {}
  for line in getline(1, '$')
    let m = matchlist(line, '\c%macro\s\+\(\w\+\)')
    if !empty(m)
      let macros[m[1]] = 1
    endif
  endfor
  return keys(macros)
endfunction

" バッファ内の識別子(3文字以上)を収集
function! s:ScanBufferWords(prefix) abort
  let words = {}
  let cur_lnum = line('.')
  for lnum in range(1, line('$'))
    if lnum == cur_lnum
      continue
    endif
    for w in split(getline(lnum), '\W\+')
      if strlen(w) >= 3 && w =~# '^\a'
        let words[w] = 1
      endif
    endfor
  endfor
  return keys(words)
endfunction

" ============================================================
" 本体 (omnifunc)
" ============================================================
function! sasomni#Complete(findstart, base) abort
  if a:findstart
    let line = getline('.')
    let start = col('.') - 1
    " & % も語の一部として遡る(&cutoff / %sysfunc の補完のため)
    while start > 0 && line[start - 1] =~ '[[:alnum:]_&%]'
      let start -= 1
    endwhile
    return start
  endif

  let base = a:base
  let items = []

  " ---- &xxx : マクロ変数補完 ----
  if base =~# '^&'
    let prefix = strpart(base, 1)
    for v in sort(s:ScanMacroVars())
      if prefix ==# '' || v =~? '^' . escape(prefix, '\')
        call add(items, {'word': '&' . v . '.', 'abbr': '&' . v, 'menu': '[macro var]'})
      endif
    endfor
    return items
  endif

  " ---- %xxx : マクロ構文・ユーザーマクロ補完 ----
  if base =~# '^%'
    for kw in s:macrostmts
      if kw =~? '^' . escape(base, '\')
        call add(items, {'word': kw, 'menu': '[macro stmt]'})
      endif
    endfor
    for mname in sort(s:ScanUserMacros())
      let cand = '%' . mname
      if cand =~? '^' . escape(base, '\')
        call add(items, {'word': cand, 'menu': '[user macro]'})
      endif
    endfor
    return items
  endif

  " ---- 通常の識別子補完 ----
  let pat = '^' . escape(base, '\')
  for kw in s:statements
    if kw =~? pat
      call add(items, {'word': kw, 'menu': '[statement]'})
    endif
  endfor
  for kw in s:procs
    if kw =~? pat
      call add(items, {'word': kw, 'menu': '[proc]'})
    endif
  endfor
  for kw in s:functions
    if kw =~? pat
      call add(items, {'word': kw, 'menu': '[function]'})
    endif
  endfor
  for kw in s:sqlkws
    if kw =~? pat
      call add(items, {'word': kw, 'menu': '[sql]'})
    endif
  endfor
  " バッファ内既出語(重複は除く)
  let seen = {}
  for it in items
    let seen[tolower(it.word)] = 1
  endfor
  for w in sort(s:ScanBufferWords(base))
    if w =~? pat && !has_key(seen, tolower(w))
      call add(items, {'word': w, 'menu': '[buffer]'})
    endif
  endfor
  return items
endfunction
SASIDE_INSTALLER_EOF
write_file 'colors/sasdark.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/colors/sasdark.vim
" syntax_sas_ultimate.vim が使う全ハイライトグループに対応した専用カラースキーム。
" ランタイムの colors/ が使えない環境でも単体で完結する。
"
" グループ対応表(syntax/sas.vim側):
"   Comment    <- sasComment                    コメント
"   String     <- sasString                     文字列
"   Constant   <- sasDateConst/HexConst/BitConst/Number  数値・日付・16進・2進定数
"   Special    <- sasFormat, sasAutoVar          フォーマット表記・自動変数
"   Identifier <- sasMacroVar                    マクロ変数 &var
"   PreProc    <- sasMacroCall, sasMacroDecl     マクロ呼び出し・宣言(%macro %let)
"   PreCondit  <- sasMacroCond                    マクロ制御構文(%if %do %end)
"   Function   <- sasMacroFunc, sasFunction       マクロ関数・データステップ関数
"   Statement  <- sasStatement, sasSqlKw, sasCallStmt  主要ステートメント/SQL/CALL
"   Conditional<- sasConditional                  if/then/else/select/when
"   Repeat     <- sasRepeat                       do/end/until/while
"   Operator   <- sasOperator                     eq ne lt gt in and or 等
"   Type       <- sasProcName                     PROC名

hi clear
if exists("syntax_on")
  syntax reset
endif
let g:colors_name = "sasdark"
set background=dark

" ---- 基本 ----
hi Normal       ctermfg=252 ctermbg=234 guifg=#d0d0d0 guibg=#1c1c1c

" ---- コメント / 文字列 ----
hi Comment      ctermfg=244 cterm=italic guifg=#888888 gui=italic
hi String       ctermfg=150 guifg=#afd787

" ---- 数値・日付・16進・2進定数 ----
hi Constant     ctermfg=180 guifg=#e8b589
hi Number       ctermfg=180 guifg=#e8b589

" ---- フォーマット表記・自動変数(_N_ 等) ----
hi Special      ctermfg=203 guifg=#ff5f5f

" ---- マクロ変数 &var ----
hi Identifier   ctermfg=111 guifg=#87afff

" ---- マクロ呼び出し・宣言 (%macro %mend %let) ----
hi PreProc      ctermfg=170 guifg=#d787d7 cterm=bold gui=bold

" ---- マクロ制御構文 (%if %then %do %end) ----
hi PreCondit    ctermfg=141 guifg=#af87ff cterm=bold gui=bold

" ---- マクロ関数・データステップ関数 (%scan / substr / put 等) ----
hi Function     ctermfg=79  guifg=#5fd7af

" ---- 主要ステートメント (data/proc/run/quit, SQL, call) ----
hi Statement    ctermfg=214 guifg=#ffaf00 cterm=bold gui=bold

" ---- 制御構文 if/then/else/select/when/otherwise ----
hi Conditional  ctermfg=208 guifg=#ff8700 cterm=bold gui=bold

" ---- 繰り返し do/end/until/while ----
hi Repeat       ctermfg=178 guifg=#d7af00 cterm=bold gui=bold

" ---- 演算子 eq/ne/lt/gt/in/and/or/not ----
hi Operator     ctermfg=182 guifg=#c9a6d6

" ---- PROC 名 ----
hi Type         ctermfg=81  guifg=#5fd7ff

" ---- メッセージ類 ----
hi WarningMsg   ctermfg=220 guifg=#ffd700 cterm=bold gui=bold
hi ErrorMsg     ctermfg=196 guifg=#ff0000 cterm=bold gui=bold

" ---- UI ----
hi LineNr       ctermfg=240 guifg=#585858
hi CursorLine   ctermbg=236 guibg=#2e2e2e
hi Visual       ctermbg=239 guibg=#4e4e4e
hi Pmenu        ctermfg=252 ctermbg=236 guifg=#d0d0d0 guibg=#2e2e2e
hi PmenuSel     ctermfg=234 ctermbg=214 guifg=#1c1c1c guibg=#ffaf00
SASIDE_INSTALLER_EOF
write_file 'dict/sas_formats.dict' <<'SASIDE_INSTALLER_EOF'
# formats / informats
best12.	数値の汎用表示
8.	幅8の数値
comma12.	3桁区切り
comma12.2	3桁区切り+小数2桁
dollar12.2	$付き3桁区切り
percent8.2	パーセント表示
z6.	前ゼロ埋め
yymmdd10.	2024-01-31形式
yymmddn8.	20240131形式
yymmdds10.	2024/01/31形式
date9.	31JAN2024形式
ddmmyy10.	31/01/2024形式
mmddyy10.	01/31/2024形式
datetime19.	日時表示
time8.	hh:mm:ss
monyy7.	JAN2024
year4.	年のみ
qtr1.	四半期
$char20.	文字(空白保持)
$20.	文字幅20
$upcase20.	大文字化して表示
$hex.	16進表示
SASIDE_INSTALLER_EOF
write_file 'dict/sas_functions.dict' <<'SASIDE_INSTALLER_EOF'
# SAS functions  (word<TAB>description)
substr	部分文字列の抽出/置換 substr(s,pos<,len>)
scan	区切り文字で分割してn番目の語を返す
compress	指定文字を除去 compress(s<,chars,mod>)
compbl	連続空白を1つに圧縮
upcase	大文字化
lowcase	小文字化
propcase	先頭のみ大文字化
trim	末尾空白除去
strip	前後空白除去
left	左詰め
right	右詰め
length	文字列長(末尾空白除く)
lengthn	文字列長(空文字は0)
cat	文字列連結(空白保持)
cats	文字列連結(前後空白除去)
catx	区切り文字付き連結 catx(sep,...)
tranwrd	部分文字列の置換 tranwrd(s,from,to)
translate	文字単位の置換
index	部分文字列の位置
indexc	文字集合の位置
find	部分文字列検索(開始位置/修飾子指定可)
findc	文字集合検索
verify	集合外文字の位置
anyalpha	英字の位置
anydigit	数字の位置
anyspace	空白の位置
reverse	文字列反転
repeat	文字列の繰り返し
input	文字→数値/日付変換 input(s,informat.)
put	数値/日付→文字変換 put(x,format.)
inputn	実行時informat指定の変換
putn	実行時format指定の変換
sum	合計(欠損を無視)
mean	平均(欠損を無視)
median	中央値
std	標準偏差
var	分散
min	最小値
max	最大値
n	非欠損数
nmiss	欠損数
range	範囲(max-min)
round	丸め round(x<,unit>)
ceil	切り上げ
floor	切り捨て
int	整数部
mod	剰余
abs	絶対値
sign	符号
sqrt	平方根
exp	指数
log	自然対数
log2	底2の対数
log10	常用対数
today	今日の日付(SAS日付値)
date	today と同じ
time	現在時刻
datetime	現在日時
year	日付→年
month	日付→月
day	日付→日
qtr	日付→四半期
week	日付→週番号
weekday	日付→曜日(1=日)
hour	時刻→時
minute	時刻→分
second	時刻→秒
mdy	年月日→SAS日付値 mdy(m,d,y)
ymd	(mdy を使用)
hms	時分秒→SAS時刻値
dhms	日付+時分秒→日時値
datepart	日時値→日付部分
timepart	日時値→時刻部分
intck	期間の境界数 intck('unit',from,to)
intnx	期間の加算 intnx('unit',date,n<,align>)
datdif	日数差 datdif(d1,d2,basis)
yrdif	年数差 yrdif(d1,d2,basis)
lag	前行の値(キュー方式に注意)
dif	差分 x-lag(x)
coalesce	最初の非欠損値(数値)
coalescec	最初の非欠損値(文字)
missing	欠損なら1
ifn	条件演算(数値) ifn(cond,t,f<,m>)
ifc	条件演算(文字)
symget	マクロ変数の値を取得
symput	(callルーチン)マクロ変数へ格納
symputx	(callルーチン)前後空白除去して格納
exist	データセットの存在確認
open	データセットを開く
close	データセットを閉じる
attrn	データセットの数値属性 attrn(id,'NOBS')
attrc	データセットの文字属性
varnum	変数の位置番号
varname	位置番号→変数名
vartype	変数の型(N/C)
fetch	次の行を読む
fetchobs	指定行を読む
libref	ライブラリ参照の確認
pathname	librefの物理パス
resolve	マクロ式を実行時解決
dequote	引用符除去
quote	引用符付与
rand	乱数 rand('uniform')等
ranuni	一様乱数(旧)
probnorm	標準正規分布関数
probit	正規分位関数
SASIDE_INSTALLER_EOF
write_file 'dict/sas_macros.dict' <<'SASIDE_INSTALLER_EOF'
# macro statements/functions
%macro	マクロ定義開始
%mend	マクロ定義終了
%let	マクロ変数代入
%put	ログ出力(%put _all_ で全変数)
%if	条件分岐(%then %do ... %end)
%then	%ifの帰結
%else	%ifの否定側
%do	ブロック開始/反復(%do i=1 %to n)
%end	ブロック終了
%to	反復の上限
%by	反復の刻み
%until	後判定ループ
%while	前判定ループ
%global	グローバルマクロ変数宣言
%local	ローカルマクロ変数宣言
%return	マクロから抜ける
%abort	処理中断
%goto	ラベルへ分岐
%include	外部ファイルの取り込み
%sysfunc	データステップ関数をマクロで使う
%sysevalf	浮動小数の評価
%eval	整数の評価
%scan	n番目の語
%qscan	クォート版scan
%substr	部分文字列
%qsubstr	クォート版substr
%index	位置検索
%length	文字列長
%upcase	大文字化
%qupcase	クォート版upcase
%lowcase	小文字化(autocallマクロ)
%trim	末尾空白除去(autocall)
%left	先頭空白除去(autocall)
%cmpres	連続空白圧縮(autocall)
%str	マクロクォート(; % 等を文字として扱う)
%nrstr	& % も解決しないクォート
%bquote	実行時値のクォート
%nrbquote	&も解決しないbquote
%quote	bquoteの旧版
%unquote	クォート解除
%superq	変数を解決せずに取得(最強のクォート)
%symexist	マクロ変数の存在確認
%symglobl	グローバルか判定
%symlocal	ローカルか判定
%sysget	環境変数取得
SASIDE_INSTALLER_EOF
write_file 'dict/sas_ods.dict' <<'SASIDE_INSTALLER_EOF'
# ODS statements/destinations
listing	リスト出力(既定の出力先)
html	HTML出力
pdf	PDF出力
rtf	RTF(Word)出力
excel	Excel出力(新しめの環境)
tagsets.excelxp	Excel XML出力(9.3世代の定番)
csv	CSV出力
output	出力オブジェクト→データセット化
trace	出力オブジェクト名の確認
select	出力オブジェクトの選択
exclude	出力オブジェクトの除外
graphics	ODSグラフィックスon/off
close	出力先を閉じる
_all_	全出力先(ods _all_ close)
style	出力スタイル指定 style=
file	出力ファイル指定 file=
path	出力パス指定
noproctitle	プロシジャタイトル非表示
escapechar	インラインフォーマット用エスケープ
SASIDE_INSTALLER_EOF
write_file 'dict/sas_options.dict' <<'SASIDE_INSTALLER_EOF'
# system options
mprint	マクロ展開後のコードをログ出力
mlogic	マクロ実行ロジックをログ出力
symbolgen	マクロ変数解決をログ出力
merror	未定義マクロ呼び出しを警告
nomprint	mprint無効化
obs=	処理する最大オブザベーション数
firstobs=	処理開始オブザベーション
nodate	出力に日付を出さない
date	出力に日付を出す
nonumber	ページ番号を出さない
number	ページ番号を出す
nocenter	左詰め出力
center	中央寄せ出力
linesize=	1行の幅 (ls=)
pagesize=	1ページの行数 (ps=)
missing=	欠損値の表示文字
fmterr	未定義フォーマットをエラーに
nofmterr	未定義フォーマットを警告に留める
errors=	同一エラーの最大表示回数
source	ソースをログに出す
nosource	ソースをログに出さない
source2	include先ソースもログに出す
notes	NOTEを出す
nonotes	NOTEを抑止(デバッグ後は戻すこと)
fullstimer	詳細なリソース使用量をログ出力
stimer	簡易リソース表示
compress=	データセット圧縮 (yes/binary)
reuse=	圧縮領域の再利用
work=	WORKライブラリの場所
sortsize=	ソートに使うメモリ
memsize=	最大メモリ
validvarname=	変数名規則 (v7/upcase/any)
yearcutoff=	2桁年の解釈基準年
SASIDE_INSTALLER_EOF
write_file 'dict/sas_procs.dict' <<'SASIDE_INSTALLER_EOF'
# SAS procedures
sql	SQL処理(結合・集計・ビュー)
sort	並べ替え(by/nodupkey/out=)
means	基本統計量(n mean std min max)
summary	meansのprint無し版
freq	度数集計・クロス表・カイ二乗
univariate	分布の詳細統計・パーセンタイル
corr	相関係数
print	データセットの一覧出力
report	体裁付きレポート
tabulate	多次元集計表
transpose	行列転置(by/id/var)
datasets	データセット管理(delete/copy/modify)
contents	データセットの構造表示
append	データセットの追記
compare	データセットの比較
rank	順位付け
standard	標準化(旧)
stdize	標準化
format	ユーザー定義フォーマット作成
export	外部ファイルへ出力(csv等)
import	外部ファイル取込
copy	ライブラリ間コピー
reg	線形回帰
glm	一般線形モデル
logistic	ロジスティック回帰
genmod	一般化線形モデル
mixed	混合モデル
phreg	Cox比例ハザード
lifetest	生存時間解析
factor	因子分析
cluster	階層クラスタ分析
fastclus	k-meansクラスタ
arima	時系列ARIMA
timeseries	時系列集計
gplot	散布図/折れ線(旧グラフ)
gchart	棒/円グラフ(旧グラフ)
sgplot	統計グラフ(新)
sgpanel	パネルグラフ
boxplot	箱ひげ図
catalog	カタログ管理
options	オプション一覧表示
printto	ログ/出力の出力先変更
fcmp	ユーザー定義関数作成
SASIDE_INSTALLER_EOF
write_file 'dict/snippets.txt' <<'SASIDE_INSTALLER_EOF'
# SASIDE snippets  (snippet key description / body until next 'snippet')
snippet datastep DATAステップ骨格
data <+out+>;
    set <+in+>;
    <+statements+>
run;
snippet sql PROC SQL骨格
proc sql;
    create table <+out+> as
    select <+columns+>
    from <+in+>;
quit;
snippet sortds PROC SORT
proc sort data=<+in+> out=<+out+>;
    by <+keys+>;
run;
snippet meansds PROC MEANS
proc means data=<+in+> n mean std min max maxdec=2;
    var <+vars+>;
run;
snippet freqds PROC FREQ
proc freq data=<+in+>;
    tables <+vars+>;
run;
snippet macrodef マクロ定義
%macro <+name+>(<+args+>);
    <+body+>
%mend;
snippet doloop DOループ
do <+i+> = 1 to <+n+>;
    <+body+>
end;
snippet selwhen SELECT-WHEN
select (<+var+>);
    when (<+v1+>) <+action1+>;
    otherwise <+action2+>;
end;
snippet importcsv CSV取込
proc import datafile="<+path+>" out=<+out+> dbms=csv replace;
    getnames=yes;
run;
snippet exportcsv CSV出力
proc export data=<+in+> outfile="<+path+>" dbms=csv replace;
run;
SASIDE_INSTALLER_EOF
write_file 'doc/saside.txt' <<'SASIDE_INSTALLER_EOF'
*saside.txt*    Pure Vim Script SAS IDE                              *saside*

                SASIDE — SAS 9.3 向け Vim IDE(外部依存ゼロ)

==============================================================================
目次                                                        *saside-contents*

    1. 概要 ................................ |saside-intro|
    2. インストール ........................ |saside-install|
    3. コマンド ............................ |saside-commands|
    4. キーマップ .......................... |saside-mappings|
    5. オプション .......................... |saside-options|
    6. 既知の限界 .......................... |saside-limitations|

==============================================================================
1. 概要                                                        *saside-intro*

SASIDE は Linux 上の SAS 9.3 バッチ開発を Vim だけで完結させる IDE です。
純粋な Vim Script のみで実装され、Node.js / Python / LSP / ネットワーク
アクセスを一切必要としません。~/.vim 配下へのコピーだけで動作します。

中核は「コードインデックス」です。編集中の SAS コードを1パスで解析し、
DATA/PROC/%MACRO/データセット/マクロ変数を抽出します。Navigator・Outline・
Dataset Browser・Macro Browser・ステータスライン・補完のすべてが、この
単一のインデックスを共有します。

==============================================================================
2. インストール                                              *saside-install*

WinSCP 等で本一式を ~/.vim/ へコピーし、Vim を再起動してください。
ヘルプを有効にするには一度だけ実行します: >

    :helptags ~/.vim/doc
<
会社の実行運用(`sas program.txt`)に合わせる場合は vimrc に: >

    let g:saside_sas_cmd = 'sas'      " フルパス指定も可
<
==============================================================================
3. コマンド                                                 *saside-commands*

実行・ログ ~
    :SasRun [file]        保存して SAS をバッチ実行(既定 F5)
    :{range}SasRunSelection  選択範囲だけを実行(ビジュアルモードで F5)
    :SasRunBlock          カーソル位置のDATA/PROCブロックだけ実行 (\rb)
    :SasCheck             obs=0 構文チェック実行(データ処理せず検証)(\rc)
    :SasRunTerm [file]    +terminal 環境では端末内で実行
    :SasAbort             実行中ジョブの停止
    :SasLog [file]        ログを下ペインに表示(F6 でトグル)
    :SasLst [file]        .lst を下ペインに表示 (F7)
    :SasQuickfix          ERROR+WARNING を quickfix へ (F8)
    :SasLogErrors         ERROR のみ抽出
    :SasLogWarnings       WARNING のみ抽出
    :SasLogNotes          NOTE のみ抽出
    :SasLogRisk [file]    危険NOTE(黙って誤結果を出す類)を抽出 (\lr)

ビュー ~
    :SasExplorer          左: ファイルツリー (F2)
    :SasNav               右: Navigator (F3)
    :SasOutline           右: Outline (F4)
    :SasDatasets          右: Dataset Browser
    :SasMacros            右: Macro Browser
    :SasIde               一括 IDE レイアウト (F12)

編集支援 ~
    :SasFormat            再インデント+空白整理(F9。
                          g:saside_format_upcase=1 で予約語大文字化)
    :SasNew {kind}        テンプレート挿入 (data/sql/sort/means/freq/
                          macro/header)
    :SasHelp [word]       辞書ヘルプ(SASバッファでは K でも可)

検索 ~
    :SasGrep {pat}        プロジェクト横断検索 (\sg)
    :SasFindDs [name]     データセット使用箇所検索 (\sd)
    :SasFindMacro [name]  マクロ定義/呼び出し検索 (\sm)
    :SasTodo              TODO/FIXME/BUG 一覧 (F10)

その他 ~
    :SasBookmark [name]   現在行をブックマーク (\bb)
    :SasBookmarks         ブックマーク一覧 (\bl)
    :SasBookmarkClear     全削除
    :SasSessionSave [n]   セッション保存
    :SasSessionLoad [n]   セッション復元
    :SasBlockInfo         カーソル行のブロック情報
    :SasIndex             コードインデックスのダンプ(デバッグ用)

==============================================================================
4. キーマップ                                               *saside-mappings*

既定割当(g:saside_no_default_maps=1 で無効化、<Plug> で個別再割当可):

    F2   Explorer          F3  Navigator       F4   Outline
    F5   SAS実行           F6  ログトグル      F7   .lst表示
    {Visual}F5  選択範囲だけ実行       \rb  カーソル位置のブロックだけ実行
    F8   Quickfix          F9  整形            F10  TODO一覧
    F12  IDEレイアウト
    <C-j> (挿入)  スニペット展開 / 次プレースホルダ
    K    (SAS)    辞書ヘルプ
    \rc  構文チェック(obs=0)      \lr  危険NOTE抽出
    \sg \sd \sm   検索系    \bb \bl  ブックマーク

Navigator 内: Enter=ジャンプ q=閉じる r=更新 1/2/3/4=モード切替
Explorer 内:  Enter/o=開く a=新規 A=新規Dir D=削除 r=改名 m=移動
              c=コピー u=親へ C=ルート変更 R=更新 q=閉じる

==============================================================================
5. オプション                                                *saside-options*

    g:saside_sas_cmd         SAS実行コマンド(既定 'sas')
    g:saside_sas_args        追加引数リスト(既定 [])
    g:saside_run_preamble    部分実行時に前方のLIBNAME/OPTIONS/%LETを
                             自動で先頭に付ける(既定 1)
    g:saside_check_args      :SasCheck の引数
                             (既定 ['-obs','0','-noreplace'])
    g:saside_log_risk_patterns  危険NOTEパターンの追加
                             [{'pat':正規表現,'desc':説明}, ...]
    g:saside_txt_detect      *.txt のSAS自動判定(既定 1)
    g:saside_txt_threshold   判定に必要なSAS特徴行数(既定 3)
    g:saside_index_maxlines  リアルタイム解析の上限行数(既定 20000)
    g:saside_debounce_ms     解析デバウンス(既定 300)
    g:saside_explorer_width  左ペイン幅(既定 28)
    g:saside_navigator_width 右ペイン幅(既定 34)
    g:saside_log_height      下ペイン高(既定 10)
    g:saside_nav_stay        ジャンプ後もNavigatorに留まる(既定 0)
    g:saside_format_upcase   整形時に予約語を大文字化(既定 0)
    g:saside_session_auto    終了時に自動セッション保存(既定 0)
    g:saside_project_root    プロジェクトルート固定(既定 自動検出。
                             .sasproject ファイルでもマーク可)
    g:saside_state_dir       状態保存先(既定 ~/.vim/.state)
    g:saside_no_default_maps 既定キー割当を無効化(既定 0)

==============================================================================
6. 既知の限界                                            *saside-limitations*

- パーサは正規表現ベースであり、完全な構文解析ではありません。
  マクロが生成するコード(%include・コード生成マクロ)の中身は
  追跡できません。
- ログの自動追尾は実行完了時のみ更新されます(実行中のtailはしません)。
- 意味解析を要するリファクタリング(変数の安全な一括リネーム等)は
  対象外です。:SasGrep + cdo での一括置換を推奨します。

vim:tw=78:ts=8:ft=help:norl:
SASIDE_INSTALLER_EOF
write_file 'filetype.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/filetype.vim
" $VIMRUNTIME/filetype.vim が壊れている/存在しない環境向けの補完ブートストラップ。
"
" 重要: 自分以外の runtimepath 上に filetype.vim が存在するなら、
" そちらに処理を委譲して何もしない($VIMRUNTIME という環境変数の有無に
" 依存させず、実際の 'runtimepath' を globpath() で直接調べる)。
" これにより、システム側が生きている環境ではシステム版の大量の言語判定を
" 壊さず、SASのftdetectだけが素直に additive に効く。

if exists("did_load_filetypes")
  finish
endif

let s:self = fnamemodify(expand('<sfile>:p'), ':p')
let s:other_exists = 0
for s:f in globpath(&runtimepath, 'filetype.vim', 0, 1)
  if fnamemodify(s:f, ':p') !=# s:self
    let s:other_exists = 1
    break
  endif
endfor
unlet! s:self s:f

if s:other_exists
  unlet s:other_exists
  finish
endif
unlet s:other_exists

let did_load_filetypes = 1

augroup filetypedetect
  au!
  " 全 runtimepath 上の ftdetect/*.vim を読み込む
  runtime! ftdetect/*.vim
augroup END
SASIDE_INSTALLER_EOF
write_file 'ftdetect/sas.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        ftdetect/sas.vim
" Module:      SASIDE ファイルタイプ判定
" Description: *.sas は無条件、*.txt は内容ベースのスコア判定で filetype=sas
"              を設定する(会社運用が `sas program.txt` のため)。
"              *.log / *.lst は内容から saslog / saslst を割り当てる。
" Options:     g:saside_txt_detect    (既定1) .txt判定の有効/無効
"              g:saside_txt_threshold (既定3) SAS特徴行の必要数
" =============================================================================

au BufNewFile,BufRead *.sas setfiletype sas

" --- .txt: 内容スコアリングによる判定 ---
" 先頭200行に DATA/PROC/%MACRO/LIBNAME/OPTIONS/%LET の行が閾値以上あればSAS
function! s:DetectSasTxt() abort
  if !get(g:, 'saside_txt_detect', 1)
    return
  endif
  let score = 0
  let threshold = get(g:, 'saside_txt_threshold', 3)
  for line in getline(1, 200)
    if line =~? '^\s*\%(data\|proc\|libname\|options\|%macro\|%let\|run\s*;\|quit\s*;\)'
      let score += 1
      if score >= threshold
        setfiletype sas
        return
      endif
    endif
  endfor
endfunction
au BufNewFile,BufRead *.txt call s:DetectSasTxt()

" --- .log / .lst: SAS出力の判定 ---
function! s:DetectSasLog() abort
  if getline(1) =~# 'The SAS System'
        \ || search('^\d\+\s*NOTE:', 'nw')
        \ || search('^\d\+\s*ERROR:', 'nw')
    setfiletype saslog
  endif
endfunction
au BufNewFile,BufRead *.log call s:DetectSasLog()

function! s:DetectSasLst() abort
  if getline(1) =~# 'The SAS System'
    setfiletype saslst
  endif
endfunction
au BufNewFile,BufRead *.lst call s:DetectSasLst()
SASIDE_INSTALLER_EOF
write_file 'ftplugin.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/ftplugin.vim
" $VIMRUNTIME/ftplugin.vim の代替ブートストラップ。
" FileType イベント発生時に ftplugin/<filetype>.vim を読み込む「配線」を行う。
" 自分以外の runtimepath 上に ftplugin.vim があればそちらに委譲する。

if exists("did_load_ftplugin")
  finish
endif

let s:self = fnamemodify(expand('<sfile>:p'), ':p')
let s:other_exists = 0
for s:f in globpath(&runtimepath, 'ftplugin.vim', 0, 1)
  if fnamemodify(s:f, ':p') !=# s:self
    let s:other_exists = 1
    break
  endif
endfor
unlet! s:self s:f

if s:other_exists
  unlet s:other_exists
  finish
endif
unlet s:other_exists

let did_load_ftplugin = 1

augroup filetypeplugin
  au!
  au FileType * call s:LoadFTPlugin()
augroup END

function! s:LoadFTPlugin() abort
  if exists("b:undo_ftplugin")
    silent! exe b:undo_ftplugin
    unlet! b:undo_ftplugin b:did_ftplugin
  endif
  let s = expand("<amatch>")
  if s !=# ""
    let name = substitute(s, '\.', '_', 'g')
    exe 'runtime! ftplugin/' . name . '.vim ftplugin/' . name . '_*.vim ftplugin/' . name . '/*.vim'
  endif
endfunction
SASIDE_INSTALLER_EOF
write_file 'ftplugin/sas.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/ftplugin/sas.vim
if exists("b:did_ftplugin")
  finish
endif
let b:did_ftplugin = 1

let s:save_cpo = &cpo
set cpo&vim

setlocal commentstring=/*\ %s\ */
setlocal comments=s1:/*,mb:*,ex:*/,:%*
setlocal expandtab
setlocal shiftwidth=4
setlocal tabstop=4
setlocal softtabstop=4
setlocal autoindent
setlocal iskeyword+=%,&
setlocal formatoptions-=t

" IntelliJ風補完(plugin/ide.vim のスマートTab / Ctrl+Space が利用する)
setlocal omnifunc=sasomni#Complete

" --- 折り畳み: DATA/PROC ~ RUN;/QUIT; を1ブロックとして畳む ---
setlocal foldmethod=expr
setlocal foldexpr=SasFoldExpr(v:lnum)
setlocal foldtext=SasFoldText()

function! SasFoldExpr(lnum) abort
  let line = getline(a:lnum)
  if line =~? '^\s*\(data\|proc\)\>'
    return '>1'
  elseif line =~? '^\s*\(run\|quit\)\s*;'
    return '<1'
  endif
  return '='
endfunction

function! SasFoldText() abort
  return getline(v:foldstart) . ' ... (' . (v:foldend - v:foldstart + 1) . ' lines)'
endfunction

" --- 頻出スニペットの略語 ---
iabbrev <buffer> pdsql proc sql;<CR>quit;<Esc>O
iabbrev <buffer> ppmeans proc means data=;<CR>run;<Esc>O
iabbrev <buffer> ddstep data ;<CR>set ;<CR>run;<Esc>kkA

" --- 実行/ログ確認/整形マッピング(autoload/sas.vim に処理を委譲) ---
nnoremap <buffer> <F5> :call sas#run(expand('%:p'))<CR>
nnoremap <buffer> <F6> :call sas#openlog(expand('%:p:r') . '.log')<CR>
nnoremap <buffer> <F7> :call sas#openlst(expand('%:p:r') . '.lst')<CR>
nnoremap <buffer> <F8> :call sas#quickfixlog(expand('%:p:r') . '.log')<CR>
nnoremap <buffer> <F9> :call sas#format()<CR>

command! -buffer SasFormat call sas#format()

" --- ブロック間ジャンプ: ]] で次の DATA/PROC/%MACRO へ、[[ で前へ ---
nnoremap <buffer> <silent> ]] :call search('\c^\s*\%(data\<Bar>proc\<Bar>%macro\)\>', 'W')<CR>
nnoremap <buffer> <silent> [[ :call search('\c^\s*\%(data\<Bar>proc\<Bar>%macro\)\>', 'bW')<CR>

" --- スマート % : DO/END, %DO/%END, %MACRO/%MEND, DATA・PROC/RUN の対応へ
"     ジャンプ(対象外の位置では通常の % として振る舞う) ---
nnoremap <buffer> <silent> % :call sas#matchjump()<CR>

let b:undo_ftplugin = "setlocal cms< com< et< sw< ts< sts< ai< iskeyword< fo< fdm< fde< fdt< omnifunc<"
      \ . " | silent! iunmap <buffer> pdsql | silent! iunmap <buffer> ppmeans | silent! iunmap <buffer> ddstep"
      \ . " | silent! nunmap <buffer> <F5> | silent! nunmap <buffer> <F6>"
      \ . " | silent! nunmap <buffer> <F7> | silent! nunmap <buffer> <F8>"
      \ . " | silent! nunmap <buffer> <F9> | silent! delcommand SasFormat"
      \ . " | silent! nunmap <buffer> ]] | silent! nunmap <buffer> [["
      \ . " | silent! nunmap <buffer> %"

let &cpo = s:save_cpo
unlet s:save_cpo
SASIDE_INSTALLER_EOF
write_file 'ftplugin/saslog.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        ftplugin/saslog.vim
" Module:      SASログバッファのローカル設定
" Keymaps:     ]e/[e 次/前のERROR    ]w/[w 次/前のWARNING    q 閉じる
" =============================================================================
if exists('b:did_ftplugin')
  finish
endif
let b:did_ftplugin = 1

setlocal autoread nowrap nomodifiable readonly
setlocal nonumber

nnoremap <buffer> <silent> ]e :call search('^\d*\s*ERROR', 'W')<CR>
nnoremap <buffer> <silent> [e :call search('^\d*\s*ERROR', 'bW')<CR>
nnoremap <buffer> <silent> ]w :call search('^\d*\s*WARNING', 'W')<CR>
nnoremap <buffer> <silent> [w :call search('^\d*\s*WARNING', 'bW')<CR>
nnoremap <buffer> <silent> q  :close<CR>

let b:undo_ftplugin = 'setlocal ar< wrap< ma< ro< nu<'
      \ . ' | silent! nunmap <buffer> ]e | silent! nunmap <buffer> [e'
      \ . ' | silent! nunmap <buffer> ]w | silent! nunmap <buffer> [w'
      \ . ' | silent! nunmap <buffer> q'
SASIDE_INSTALLER_EOF
write_file 'indent.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/indent.vim
" $VIMRUNTIME/indent.vim の代替ブートストラップ。
" 自分以外の runtimepath 上に indent.vim があればそちらに委譲する。

if exists("did_indent_on")
  finish
endif

let s:self = fnamemodify(expand('<sfile>:p'), ':p')
let s:other_exists = 0
for s:f in globpath(&runtimepath, 'indent.vim', 0, 1)
  if fnamemodify(s:f, ':p') !=# s:self
    let s:other_exists = 1
    break
  endif
endfor
unlet! s:self s:f

if s:other_exists
  unlet s:other_exists
  finish
endif
unlet s:other_exists

let did_indent_on = 1

augroup filetypeindent
  au!
  au FileType * call s:LoadIndent()
augroup END

function! s:LoadIndent() abort
  if exists("b:undo_indent")
    silent! exe b:undo_indent
    unlet! b:undo_indent b:did_indent
  endif
  let s = expand("<amatch>")
  if s !=# ""
    let name = substitute(s, '\.', '_', 'g')
    exe 'runtime! indent/' . name . '.vim'
  endif
endfunction
SASIDE_INSTALLER_EOF
write_file 'indent/sas.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/indent/sas.vim
" SAS用スマートインデント(DATA/PROC-RUN、DO/SELECT-END、%DO-%END、
" %MACRO-%MEND のネストを searchpair() で正しく追跡し、複数行にまたがる
" 文(継続行)も検出して字下げする)

if exists("b:did_indent")
  finish
endif
let b:did_indent = 1

setlocal indentexpr=SasIndent()
setlocal indentkeys=0{,0},0),!^F,o,O,=~end,=~run,=~quit,=~%mend,=~%end,=~else

let s:cpo_save = &cpo
set cpo&vim

" ============================================================
" ヘルパー
" ============================================================

" 指定位置がコメント/文字列の中かどうか
function! s:InComment(lnum, col) abort
  let name = synIDattr(synID(a:lnum, a:col, 1), "name")
  return name =~? 'sasComment\|sasString'
endfunction

" searchpair() 用: マッチ位置がコメント/文字列内なら真(スキップ対象)
function! s:SkipCommentExpr() abort
  return s:InComment(line('.'), col('.'))
endfunction

" コメント部分を取り除いた行テキストを返す
" 同一行内で閉じている /* ... */ と行頭の * ... コメントに加え、
" この行の途中で開いたまま閉じていない /* コメント(次行以降へ継続)も
" 末尾から取り除く
function! s:StripTrailingComment(line) abort
  let l = a:line
  let l = substitute(l, '/\*.\{-}\*/', '', 'g')
  let l = substitute(l, '^\(\s*\)\*.*', '\1', '')
  let l = substitute(l, '/\*.*$', '', '')
  return l
endfunction

" 行の最初の非空白文字が、複数行コメント/文字列の「継続行」の中に
" あるかどうか(=この行にコードが一切無く、前の行から続くコメントの
" 内部にいる状態)を syntax 情報から判定する
function! s:IsPureCommentContinuation(lnum) abort
  let line = getline(a:lnum)
  let firstcol = match(line, '\S') + 1
  if firstcol <= 0
    return 0
  endif
  " この行自体がコメントを開始している(/* または行頭の *)なら、
  " それは「開始行」であって継続行ではない。通常のインデント計算に委ねる
  if line[firstcol - 1:] =~ '^/\*\|^\*'
    return 0
  endif
  return s:InComment(a:lnum, firstcol)
endfunction

" 空行・コメント継続行・「中身がコメントだけの行」を読み飛ばして
" 直前の実コードが残っている行を返す
" (例: "/* これは" のように開き括弧だけの行は、この行自体には
"   実コードが無いので prev-code とはみなさず、さらに前へ遡る)
function! s:PrevCodeLine(lnum) abort
  let l = prevnonblank(a:lnum - 1)
  while l > 0
    if s:IsPureCommentContinuation(l) || s:StripTrailingComment(getline(l)) =~ '^\s*$'
      let l = prevnonblank(l - 1)
      continue
    endif
    return l
  endwhile
  return 0
endfunction

" 行が「;」で終わっている(文が完結している)か
function! s:IsTerminated(line) abort
  return s:StripTrailingComment(a:line) =~ ';\s*$'
endfunction

" 現在の(継続中かもしれない)文が実際に始まった行番号を探す
function! s:StatementStartLine(lnum) abort
  let l = a:lnum
  while l > 1
    let p = s:PrevCodeLine(l)
    if p == 0
      return l
    endif
    if s:IsTerminated(getline(p))
      return l
    endif
    let l = p
  endwhile
  return l
endfunction

" ============================================================
" ブロック開始/終了パターン
" ============================================================
let s:pat_data_proc_open = '\c^\s*\%(data\|proc\)\>'
let s:pat_run_quit_close = '\c^\s*\%(run\|quit\)\s*;'

let s:pat_do_select_open = '\c\<\%(do\|select\)\>.\{-};\s*$'
let s:pat_end_close      = '\c^\s*end\s*;'

let s:pat_pctdo_open  = '\c%do\>.\{-};\s*$'
let s:pat_pctend_close = '\c^\s*%end\s*;'

let s:pat_macro_open = '\c^\s*%macro\>'
let s:pat_mend_close  = '\c^\s*%mend\>'

" ============================================================
" 本体
" ============================================================
function! SasIndent() abort
  let lnum = v:lnum

  " この行が複数行コメント(/* ... */)の継続行であれば、
  " ユーザーが手で整えた体裁を尊重してインデントを変更しない
  if s:IsPureCommentContinuation(lnum)
    return -1
  endif

  let pnum = s:PrevCodeLine(lnum)
  if pnum == 0
    return 0
  endif

  let cline_raw = getline(lnum)
  let cline = s:StripTrailingComment(cline_raw)
  let pline = s:StripTrailingComment(getline(pnum))

  " ---------------------------------------------------
  " 0) 括弧内ルール(データセットオプションの複数行記述など)
  "    現在行が閉じていない '(' の内側にある場合:
  "      ・行頭が ')'                → 開き括弧のある行に揃える
  "      ・「名前 =」形式(keep=等) → 開き括弧の行 +1段
  "      ・それ以外(値の列挙)      → 開き括弧の行 +2段
  "    例:
  "      set test&&yymm&i(          ← 開き括弧行 (8)
  "          keep =                 ← +1段 (12)
  "              aaa                ← +2段 (16)
  "          rename = (             ← +1段 (12) ここが新たな開き括弧行
  "              aaa = a            ← rename行の+1段 (16)
  "          )                      ← rename行に揃う (12)
  "      );                         ← set行に揃う (8)
  " ---------------------------------------------------
  call cursor(lnum, 1)
  let open = searchpairpos('(', '', ')', 'bnW',
        \ 's:SkipCommentExpr()', max([1, lnum - 500]))
  if open[0] > 0
    if cline =~ '^\s*)'
      return indent(open[0])
    elseif cline =~ '^\s*[A-Za-z_&%][A-Za-z0-9_&.%]*\s*='
      return indent(open[0]) + shiftwidth()
    else
      return indent(open[0]) + 2 * shiftwidth()
    endif
  endif

  " ---------------------------------------------------
  " 1) 現在行がブロックの「閉じ」であれば、対応する開始行を
  "    searchpair() で探してその行のインデントに合わせる
  " ---------------------------------------------------
  if cline =~ s:pat_run_quit_close
    call cursor(lnum, 1)
    let m = searchpair(s:pat_data_proc_open, '', s:pat_run_quit_close,
          \ 'bnW', 's:SkipCommentExpr()')
    return m > 0 ? indent(m) : 0
  endif

  if cline =~ s:pat_pctend_close
    call cursor(lnum, 1)
    let m = searchpair(s:pat_pctdo_open, '', s:pat_pctend_close,
          \ 'bnW', 's:SkipCommentExpr()')
    return m > 0 ? indent(m) : indent(pnum)
  endif

  if cline =~ s:pat_mend_close
    call cursor(lnum, 1)
    let m = searchpair(s:pat_macro_open, '', s:pat_mend_close,
          \ 'bnW', 's:SkipCommentExpr()')
    return m > 0 ? indent(m) : 0
  endif

  if cline =~ s:pat_end_close
    call cursor(lnum, 1)
    let m = searchpair(s:pat_do_select_open, '', s:pat_end_close,
          \ 'bnW', 's:SkipCommentExpr()')
    return m > 0 ? indent(m) : indent(pnum)
  endif

  " ---------------------------------------------------
  " 2) ELSE は対応する IF/THEN と同じ深さに揃える
  "    (直前がEND;等で既に正しい深さに戻っているのでそのまま踏襲)
  " ---------------------------------------------------
  if cline =~ '\c^\s*else\>'
    return indent(pnum)
  endif

  " ---------------------------------------------------
  " 3) 直前行が文を終端していない(継続行)場合は、文の開始行を
  "    見つけてそこから1段深くする
  " ---------------------------------------------------
  if !s:IsTerminated(pline)
    let stmt_start = s:StatementStartLine(pnum)
    return indent(stmt_start) + shiftwidth()
  endif

  " ---------------------------------------------------
  " 4) 直前行が新しいブロックを開いているなら1段深くする
  " ---------------------------------------------------
  let ind = indent(pnum)

  if pline =~ s:pat_data_proc_open
    let ind += shiftwidth()
  elseif pline =~ s:pat_macro_open
    let ind += shiftwidth()
  elseif pline =~ s:pat_pctdo_open
    let ind += shiftwidth()
  elseif pline =~ s:pat_do_select_open
    let ind += shiftwidth()
  endif

  return ind
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save

let b:undo_indent = "setlocal indentexpr< indentkeys<"
SASIDE_INSTALLER_EOF
write_file 'plugin/autopairs.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/plugin/autopairs.vim
" IntelliJ / VSCode 風の括弧・クオート自動補完(全filetype共通)
"
"   ( [ { " ' を入力  -> 対応する閉じ側を自動挿入しカーソルを間に置く
"   閉じ文字がすでに直後にある -> 重複挿入せず、その上を素通りする
"   空のペア (()  []  {}  ""  '') の内側で <BS> -> 両方を同時に削除
"   空のペア (()  {}  []) の内側で <CR>  -> インデント付きで展開する
"
" 無効化したい場合は vimrc 等で下記のいずれかを設定する
"   let g:autopairs_enabled = 0                 全体を無効化
"   let b:autopairs_enabled = 0                 特定バッファのみ無効化
" コマンド :AutoPairsToggle でも切り替えられる

if exists("g:loaded_autopairs")
  finish
endif
let g:loaded_autopairs = 1

if !exists("g:autopairs_enabled")
  let g:autopairs_enabled = 1
endif

let s:pairs = {'(': ')', '[': ']', '{': '}'}

function! s:Enabled() abort
  if exists('b:autopairs_enabled') && !b:autopairs_enabled
    return 0
  endif
  return g:autopairs_enabled
endfunction

function! s:CharBefore() abort
  let l = getline('.')
  let c = col('.')
  return c > 1 ? l[c - 2] : ''
endfunction

function! s:CharAfter() abort
  let l = getline('.')
  return l[col('.') - 1]
endfunction

" ---- 開き括弧 ----
function! s:PairOpen(open) abort
  if !s:Enabled()
    return a:open
  endif
  return a:open . s:pairs[a:open] . "\<Left>"
endfunction

" ---- 閉じ括弧(直後に同じ閉じ文字があれば素通り) ----
function! s:PairClose(close) abort
  if s:Enabled() && s:CharAfter() ==# a:close
    return "\<Right>"
  endif
  return a:close
endfunction

" ---- クオート ----
function! s:PairQuote(q) abort
  if !s:Enabled()
    return a:q
  endif
  if s:CharAfter() ==# a:q
    return "\<Right>"
  endif
  " 英数字・アンダースコア・バックスラッシュの直後は補完しない
  " (既存の単語の途中でクオートを打つケースを誤爆させないため)
  if s:CharBefore() =~# '[[:alnum:]_\\]'
    return a:q
  endif
  return a:q . a:q . "\<Left>"
endfunction

" ---- Backspace: 空のペアの内側なら両方消す ----
function! s:PairBS() abort
  if s:Enabled()
    let before = s:CharBefore()
    let after  = s:CharAfter()
    let closers = {'(': ')', '[': ']', '{': '}', '"': '"', "'": "'"}
    if has_key(closers, before) && after ==# closers[before]
      return "\<BS>\<Del>"
    endif
  endif
  return "\<BS>"
endfunction

" ---- Enter: 空の ()/[]/{} の内側ならインデント付きで展開 ----
function! s:PairCR() abort
  if pumvisible()
    " 補完ポップアップ表示中は候補を確定する(IntelliJのEnter確定と同じ)
    return "\<C-y>"
  endif
  if s:Enabled()
    let before = s:CharBefore()
    let after  = s:CharAfter()
    if has_key(s:pairs, before) && after ==# s:pairs[before]
      return "\<CR>\<Esc>O"
    endif
  endif
  return "\<CR>"
endfunction

inoremap <expr> ( <SID>PairOpen('(')
inoremap <expr> [ <SID>PairOpen('[')
inoremap <expr> { <SID>PairOpen('{')
inoremap <expr> ) <SID>PairClose(')')
inoremap <expr> ] <SID>PairClose(']')
inoremap <expr> } <SID>PairClose('}')
inoremap <expr> " <SID>PairQuote('"')
inoremap <expr> ' <SID>PairQuote("'")
inoremap <expr> <BS> <SID>PairBS()
inoremap <expr> <CR> <SID>PairCR()

command! AutoPairsToggle let g:autopairs_enabled = !g:autopairs_enabled
      \ | echo "autopairs: " . (g:autopairs_enabled ? "ON" : "OFF")
SASIDE_INSTALLER_EOF
write_file 'plugin/ide.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/plugin/ide.vim
" IntelliJ風の操作感を提供するグローバル設定・キーマップ
"
"   Tab        : 単語の途中なら補完ポップアップを開く / ポップアップ中は次候補
"   Shift+Tab  : ポップアップ中は前候補
"   Ctrl+Space : 補完を明示的に起動(端末では <C-@> として届く)
"   Enter      : ポップアップ中は候補を確定(autopairs.vim側で処理)
"   Ctrl+/     : 行コメントのトグル(端末では <C-_> として届く)ノーマル/ビジュアル
"   Alt+j/k    : 行を下/上に移動(IntelliJのShift+Alt+Up/Down相当)
"   Tab/S-Tab  : (ビジュアルモード)インデント/逆インデント。選択を維持
"
" 無効化: let g:ide_keymaps_enabled = 0 を vimrc に書く

if exists("g:loaded_ide_keymaps")
  finish
endif
let g:loaded_ide_keymaps = 1

if exists("g:ide_keymaps_enabled") && !g:ide_keymaps_enabled
  finish
endif

" ============================================================
" 補完まわりの体感調整
" ============================================================
" menuone のみ(longest や noselect は付けない):
" ポップアップを開いた時点で先頭候補が選択済みになり、
" Enter で即確定できる(IntelliJの挙動に最も近い)
set completeopt=menuone
set shortmess+=c
set ttimeout
set ttimeoutlen=50

" ---- スマートTab: 単語の途中なら補完、行頭・空白後なら通常のTab ----
function! s:SmartTab() abort
  if pumvisible()
    return "\<C-n>"
  endif
  let col = col('.') - 1
  if col <= 0
    return "\<Tab>"
  endif
  let ch = getline('.')[col - 1]
  if ch =~ '[[:alnum:]_&%]'
    return &omnifunc !=# '' ? "\<C-x>\<C-o>" : "\<C-n>"
  endif
  return "\<Tab>"
endfunction

inoremap <expr> <Tab> <SID>SmartTab()
inoremap <expr> <S-Tab> pumvisible() ? "\<C-p>" : "\<C-d>"

" ---- Ctrl+Space(端末では <C-@>)で補完起動 ----
inoremap <expr> <C-@> pumvisible() ? "\<C-n>" :
      \ (&omnifunc !=# '' ? "\<C-x>\<C-o>" : "\<C-n>")
" GUI/gvim 用
inoremap <expr> <C-Space> pumvisible() ? "\<C-n>" :
      \ (&omnifunc !=# '' ? "\<C-x>\<C-o>" : "\<C-n>")

" ============================================================
" コメントトグル(Ctrl+/ = 端末では <C-_>)
" ============================================================
" 'commentstring' (例: /* %s */) を使うので filetype を問わず動く
function! s:ToggleComment() range abort
  let cms = &commentstring !=# '' ? &commentstring : '/* %s */'
  let [lhs, rhs] = map(split(cms, '%s', 1), 'escape(trim(v:val), "*/\\")')
  let lhs_txt = trim(split(&commentstring !=# '' ? &commentstring : '/* %s */', '%s', 1)[0])
  let rhs_txt = trim(get(split(&commentstring !=# '' ? &commentstring : '/* %s */', '%s', 1), 1, ''))

  " 選択範囲の非空行がすべてコメント済みかを判定
  let all_commented = 1
  for lnum in range(a:firstline, a:lastline)
    let line = getline(lnum)
    if line =~ '^\s*$'
      continue
    endif
    if line !~ '^\s*' . lhs
      let all_commented = 0
      break
    endif
  endfor

  for lnum in range(a:firstline, a:lastline)
    let line = getline(lnum)
    if line =~ '^\s*$'
      continue
    endif
    if all_commented
      " アンコメント
      let line = substitute(line, '^\(\s*\)' . lhs . '\s\?', '\1', '')
      if rhs !=# ''
        let line = substitute(line, '\s\?' . rhs . '\s*$', '', '')
      endif
    else
      " コメント化
      let indent_part = matchstr(line, '^\s*')
      let code_part = strpart(line, strlen(indent_part))
      let line = indent_part . lhs_txt . ' ' . code_part
      if rhs_txt !=# ''
        let line .= ' ' . rhs_txt
      endif
    endif
    call setline(lnum, line)
  endfor
endfunction

nnoremap <silent> <C-_> :call <SID>ToggleComment()<CR>
vnoremap <silent> <C-_> :call <SID>ToggleComment()<CR>gv<Esc>

" ============================================================
" 行の移動(Alt+j / Alt+k)
" ============================================================
" 端末ではAltキーは ESC プレフィックスとして届くため、
" <M-j> が認識されるようにキーコードを登録する
if !has('gui_running')
  execute "set <M-j>=\ej"
  execute "set <M-k>=\ek"
endif

nnoremap <silent> <M-j> :m .+1<CR>==
nnoremap <silent> <M-k> :m .-2<CR>==
inoremap <silent> <M-j> <Esc>:m .+1<CR>==gi
inoremap <silent> <M-k> <Esc>:m .-2<CR>==gi
vnoremap <silent> <M-j> :m '>+1<CR>gv=gv
vnoremap <silent> <M-k> :m '<-2<CR>gv=gv

" ============================================================
" 行の複製(IntelliJのCtrl+D相当。Vimの<C-d>は温存し <leader>d に割当)
" ============================================================
nnoremap <silent> <leader>d :t.<CR>
vnoremap <silent> <leader>d :t'><CR>gv

" ============================================================
" ビジュアルモードのTabインデント(選択を維持)
" ============================================================
vnoremap <Tab> >gv
vnoremap <S-Tab> <gv

" ============================================================
" ステータスライン(モード・ファイル・種別・位置)
" ============================================================
set laststatus=2
set statusline=%f\ %m%r\ %y\ %{&fileencoding}\ %=%l:%c\ \ %p%%
SASIDE_INSTALLER_EOF
write_file 'plugin/sas_tools.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/plugin/sas_tools.vim
" グローバルコマンド。起動時に一度だけ読み込まれる(filetype判定とは無関係)。
if exists("g:loaded_sas_tools")
  finish
endif
let g:loaded_sas_tools = 1

" 会社のSAS実行パスに合わせて ~/.vimrc 側で g:sas_command を上書きすること
if !exists("g:sas_command")
  let g:sas_command = "sas"
endif

command! -nargs=0 SasRun  call sas#run(expand('%:p'))
command! -nargs=0 SasLog  call sas#openlog(expand('%:p:r') . '.log')
command! -nargs=0 SasLst  call sas#openlst(expand('%:p:r') . '.lst')
command! -nargs=0 SasQuickfixLog call sas#quickfixlog(expand('%:p:r') . '.log')
SASIDE_INSTALLER_EOF
write_file 'plugin/saside.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        plugin/saside.vim
" Module:      SASIDE 起動エントリ
" Description: オプション既定値・コマンド・キーマップ・autocmd の登録のみを
"              行う。ロジックは一切持たず、すべて autoload/saside/ に委譲する。
"              このファイル以外は初回使用時までロードされない。
" =============================================================================
if exists('g:loaded_saside')
  finish
endif
let g:loaded_saside = 1

let s:cpo_save = &cpo
set cpo&vim

" -----------------------------------------------------------------------------
" オプション既定値(vimrc での設定を尊重)
" -----------------------------------------------------------------------------
let g:saside_sas_cmd         = get(g:, 'saside_sas_cmd', 'sas')
let g:saside_sas_args        = get(g:, 'saside_sas_args', [])
let g:saside_txt_detect      = get(g:, 'saside_txt_detect', 1)
let g:saside_txt_threshold   = get(g:, 'saside_txt_threshold', 3)
let g:saside_index_maxlines  = get(g:, 'saside_index_maxlines', 20000)
let g:saside_debounce_ms     = get(g:, 'saside_debounce_ms', 300)
let g:saside_explorer_width  = get(g:, 'saside_explorer_width', 28)
let g:saside_navigator_width = get(g:, 'saside_navigator_width', 34)
let g:saside_log_height      = get(g:, 'saside_log_height', 10)
let g:saside_nav_stay        = get(g:, 'saside_nav_stay', 0)
let g:saside_format_upcase   = get(g:, 'saside_format_upcase', 0)
let g:saside_session_auto    = get(g:, 'saside_session_auto', 0)
let g:saside_no_default_maps = get(g:, 'saside_no_default_maps', 0)

" -----------------------------------------------------------------------------
" コマンド(すべて autoload への薄い委譲)
" -----------------------------------------------------------------------------
" 実行・ログ
command! -nargs=? -complete=file SasRun   call saside#run#Execute(<q-args>)
command! -nargs=? -complete=file SasRunTerm call saside#run#ExecuteTerm(<q-args>)
command! -range SasRunSelection call saside#run#ExecuteRange(<line1>, <line2>)
command! -nargs=0 SasRunBlock  call saside#run#ExecuteBlock()
command! -nargs=0 SasCheck   call saside#run#Check()
command! -nargs=0 SasAbort   call saside#run#Abort()
command! -nargs=? -complete=file SasLog   call saside#log#Open(<q-args>)
command! -nargs=? -complete=file SasLst   call saside#log#Lst(<q-args>)
command! -nargs=0 SasLogErrors   call saside#log#Quickfix('error')
command! -nargs=0 SasLogWarnings call saside#log#Quickfix('warning')
command! -nargs=0 SasLogNotes    call saside#log#Quickfix('note')
command! -nargs=0 SasQuickfix    call saside#log#Quickfix('all')
command! -nargs=? -complete=file SasLogRisk call saside#log#Risk(<q-args>)
" ビュー
command! -nargs=0 SasExplorer  call saside#explorer#Toggle()
command! -nargs=0 SasNav       call saside#navigator#Toggle('nav')
command! -nargs=0 SasOutline   call saside#outline#Toggle()
command! -nargs=0 SasDatasets  call saside#dataset#Toggle()
command! -nargs=0 SasMacros    call saside#macro#Toggle()
command! -nargs=0 SasIde       call saside#layout#Ide()
" 解析
command! -nargs=0 SasIndex     echo saside#parser#Index()
command! -nargs=0 SasBlockInfo echo saside#parser#BlockAt(line('.'))
" 整形・テンプレート
command! -nargs=0 SasFormat    call saside#format#Format()
command! -nargs=1 -complete=custom,saside#template#Kinds
      \ SasNew call saside#template#New(<q-args>)
" 検索
command! -nargs=1 SasGrep       call saside#search#Grep(<q-args>)
command! -nargs=? SasFindDs     call saside#search#Dataset(<q-args>)
command! -nargs=? SasFindMacro  call saside#search#Macro(<q-args>)
command! -nargs=0 SasTodo       call saside#todo#List()
" ブックマーク・セッション
command! -nargs=? SasBookmark      call saside#bookmark#Add(<q-args>)
command! -nargs=0 SasBookmarks     call saside#bookmark#List()
command! -nargs=0 SasBookmarkClear call saside#bookmark#Clear()
command! -nargs=? SasSessionSave call saside#session#Save(<q-args>)
command! -nargs=? SasSessionLoad call saside#session#Load(<q-args>)
" ヘルプ
command! -nargs=? SasHelp call saside#help#Lookup(<q-args>)

" -----------------------------------------------------------------------------
" <Plug> マッピングと既定キー
" -----------------------------------------------------------------------------
nnoremap <silent> <Plug>(saside-explorer)  :SasExplorer<CR>
nnoremap <silent> <Plug>(saside-nav)       :SasNav<CR>
nnoremap <silent> <Plug>(saside-outline)   :SasOutline<CR>
nnoremap <silent> <Plug>(saside-run)       :SasRun<CR>
xnoremap <silent> <Plug>(saside-run-selection) :SasRunSelection<CR>
nnoremap <silent> <Plug>(saside-run-block) :SasRunBlock<CR>
nnoremap <silent> <Plug>(saside-log)       :call saside#log#Toggle()<CR>
nnoremap <silent> <Plug>(saside-lst)       :SasLst<CR>
nnoremap <silent> <Plug>(saside-quickfix)  :SasQuickfix<CR>
nnoremap <silent> <Plug>(saside-check)     :SasCheck<CR>
nnoremap <silent> <Plug>(saside-log-risk)  :SasLogRisk<CR>
nnoremap <silent> <Plug>(saside-format)    :SasFormat<CR>
nnoremap <silent> <Plug>(saside-todo)      :SasTodo<CR>
nnoremap <silent> <Plug>(saside-ide)       :SasIde<CR>
nnoremap <silent> <Plug>(saside-grep)      :call saside#search#Grep(input('SasGrep: '))<CR>
nnoremap <silent> <Plug>(saside-find-ds)   :SasFindDs<CR>
nnoremap <silent> <Plug>(saside-find-mac)  :SasFindMacro<CR>
nnoremap <silent> <Plug>(saside-bm-add)    :SasBookmark<CR>
nnoremap <silent> <Plug>(saside-bm-list)   :SasBookmarks<CR>
" <C-r>= 方式(snipMateと同じ): <C-o>だと行末より後のカーソル位置が
" 保持されず、行末で発動した際にキーの最後の1文字を取りこぼすため
inoremap <silent> <Plug>(saside-snippet)   <C-r>=saside#snippet#ExpandOrJump()<CR>

if !g:saside_no_default_maps
  nmap <F2>  <Plug>(saside-explorer)
  nmap <F3>  <Plug>(saside-nav)
  nmap <F4>  <Plug>(saside-outline)
  nmap <F5>  <Plug>(saside-run)
  " ビジュアル選択中の F5 は「選択範囲だけ実行」
  xmap <F5>  <Plug>(saside-run-selection)
  " カーソル位置の DATA/PROC ステップだけ実行
  nmap <leader>rb <Plug>(saside-run-block)
  nmap <F6>  <Plug>(saside-log)
  nmap <F7>  <Plug>(saside-lst)
  nmap <F8>  <Plug>(saside-quickfix)
  " Shift+F5 相当が使えない端末が多いので Leader 系に割当
  nmap <leader>rc <Plug>(saside-check)
  nmap <leader>lr <Plug>(saside-log-risk)
  nmap <F9>  <Plug>(saside-format)
  nmap <F10> <Plug>(saside-todo)
  nmap <F12> <Plug>(saside-ide)
  nmap <leader>sg <Plug>(saside-grep)
  nmap <leader>sd <Plug>(saside-find-ds)
  nmap <leader>sm <Plug>(saside-find-mac)
  nmap <leader>bb <Plug>(saside-bm-add)
  nmap <leader>bl <Plug>(saside-bm-list)
  imap <C-j> <Plug>(saside-snippet)
endif

" -----------------------------------------------------------------------------
" autocmd: イベント配線・インデックスのデバウンス更新
" -----------------------------------------------------------------------------
function! s:schedule_index() abort
  if line('$') > g:saside_index_maxlines
    return
  endif
  if has('timers')
    if exists('s:index_timer')
      call timer_stop(s:index_timer)
    endif
    let bufnr = bufnr('%')
    let s:index_timer = timer_start(g:saside_debounce_ms,
          \ {-> bufexists(bufnr) ? saside#parser#Index(bufnr) : 0})
  else
    call saside#parser#Index()
  endif
endfunction

" ナビゲータが「ロード済みの場合のみ」再描画する(未ロードなら何もしない=
" ペインを開いたことが無いのだから更新も不要)
function! s:nav_refresh() abort
  if exists('*saside#navigator#OnIndexUpdated')
    call saside#navigator#OnIndexUpdated(g:saside_event)
  endif
endfunction

augroup saside_core
  autocmd!
  " インデックス更新のデバウンス
  autocmd FileType sas autocmd! saside_buf TextChanged,InsertLeave <buffer>
        \ call s:schedule_index()
  " イベント配線
  autocmd User saside:RunFinished call saside#log#OnRunFinished(g:saside_event)
  autocmd User saside:RunStarted  call saside#statusline#OnRun(g:saside_event)
  autocmd User saside:RunFinished call saside#statusline#OnRun(g:saside_event)
  autocmd User saside:IndexUpdated call s:nav_refresh()
  " ステータスライン・omnifunc
  autocmd FileType sas call saside#statusline#Enable()
        \ | setlocal omnifunc=saside#complete#Omni
  " 終了時の後始末
  autocmd VimLeavePre * if g:saside_session_auto
        \ |   silent! call saside#session#Save('auto')
        \ | endif
augroup END

augroup saside_buf
  autocmd!
augroup END

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_INSTALLER_EOF
write_file 'syntax/sas.vim' <<'SASIDE_INSTALLER_EOF'
" Vim syntax file
" Language:    SAS 9.x (data step / macro / PROC SQL)
" Maintainer:  ~/.vim only, no system runtime dependency
" Filenames:   *.sas
"
" 設計方針:
"  - SASはキーワードの大文字小文字を区別しないため syntax case ignore を使用
"  - コメント/文字列/日付定数はすべて region ベースにして複数行にまたがっても
"    正しく認識できるようにしている(match のみだと1行しか拾えない)
"  - マクロ関連は "%" が 'iskeyword' に含まれないため syntax keyword ではなく
"    syntax match で実装し、より具体的なグループを後から定義することで
"    優先度を上書きしている(Vimは同じ位置で重なるmatch/regionは
"    「後で定義した方が勝つ」ため)

if exists("b:current_syntax")
  finish
endif

syntax case ignore
syntax sync minlines=100

" ============================================================
" コメント
" ============================================================
syntax region sasComment start="/\*" end="\*/" contains=@Spell fold
syntax region sasComment start="^\s*\*" end=";" contains=@Spell
syntax region sasComment start=";\s*\*"lc=1 end=";" contains=@Spell

" ============================================================
" 文字列
" ============================================================
syntax region sasString start=+"+ skip=+""+ end=+"+ contains=sasMacroVar
syntax region sasString start=+'+ skip=+''+ end=+'+

" 日付・時刻・日時リテラル ('01JAN2024'd  "01JAN2024:00:00:00"dt  '08:30:00't)
syntax match sasDateConst "'[^']\{-}'\c[dt]\{1,2}\>"
syntax match sasDateConst "\"[^\"]\{-}\"\c[dt]\{1,2}\>"
" 16進定数 'FF'x / 2進定数 '1010'b
syntax match sasHexConst "'[0-9A-Fa-f]\+'\cx\>"
syntax match sasBitConst "'[01]\+'\cb\>"

" ============================================================
" 数値(整数・小数・指数表記)
" ============================================================
syntax match sasNumber "\<[0-9]\+\(\.[0-9]*\)\?\([eE][+-]\?[0-9]\+\)\?\>"
syntax match sasNumber "\<\.[0-9]\+\([eE][+-]\?[0-9]\+\)\?\>"

" フォーマット/インフォーマット表記 (comma12.2 / yymmdd10. / $char20. / date9.)
syntax match sasFormat "\<\$\?[A-Za-z_][A-Za-z0-9_]*[0-9]\+\.\d*\>"
syntax match sasFormat "\<\$\?[A-Za-z_][A-Za-z0-9_]*\.\>"

" ============================================================
" マクロ変数  &var  &var.  &&var  &&var&i.(間接参照)
" ============================================================
syntax match sasMacroVar "&\{1,2}[A-Za-z_][A-Za-z0-9_]*\.\?"

" ============================================================
" マクロ呼び出し(総称)→ 後段でより具体的なグループが上書きする
" ============================================================
syntax match sasMacroCall "%[A-Za-z_][A-Za-z0-9_]*"

" マクロ制御構文
syntax match sasMacroCond "%\c\(if\|then\|else\|do\|end\|until\|while\|to\|goto\)\>"
" マクロ宣言・定義
syntax match sasMacroDecl "%\c\(macro\|mend\|let\|global\|local\|return\|abort\|put\|putlog\|window\|display\|input\|include\|inc\|syscall\)\>"
" マクロ関数
syntax match sasMacroFunc "%\c\(scan\|qscan\|substr\|qsubstr\|index\|length\|upcase\|qupcase\|lowcase\|left\|trim\|qtrim\|cmpres\|sysfunc\|qsysfunc\|sysevalf\|eval\|str\|nrstr\|bquote\|nrbquote\|quote\|unquote\|superq\|sysget\|symexist\|symglobl\|symlocal\|sysprod\|sysfunc\)\>"

" ============================================================
" データステップ / 一般ステートメント
" ============================================================
syntax keyword sasStatement
      \ data proc run quit options libname filename footnote footnote1
      \ footnote2 title title1 title2 goto link ods proc

syntax keyword sasStatement
      \ set merge update modify by output input cards cards4 datalines
      \ datalines4 infile file put putlog list

syntax keyword sasStatement
      \ array retain drop keep rename length format informat label attrib
      \ where

" 制御構文(Conditional / Repeat として区別)
syntax keyword sasConditional if then else select when otherwise
syntax keyword sasRepeat do end until while

" 演算子(ニーモニック)
syntax keyword sasOperator eq ne lt gt le ge in not and or

" 自動変数
syntax keyword sasAutoVar _n_ _error_ _all_ _character_ _numeric_ _null_

" CALL ルーチン(call symput / call execute 等の呼び出し)
syntax keyword sasCallStmt call

" ============================================================
" PROC 名
" ============================================================
syntax keyword sasProcName
      \ sql means summary freq print report sort transpose datasets
      \ contents append compare corr univariate rank standardize
      \ sgplot sgpanel gplot gchart tabulate export import copy
      \ reg glm logistic genmod mixed phreg lifetest factor cluster
      \ fastclus timeseries arima hpforecast optmodel iml fcmp
      \ hplogistic hpsplit hpforest stdize score template http json
      \ groovy lua ds2 catalog sgrender boxplot

" ============================================================
" PROC SQL 内キーワード
" ============================================================
" when/then/else/end はデータステップ側(sasConditional/sasRepeat)の
" キーワードと重複するため、ここには含めない(重複させると定義順で
" 上書きされ、同じ単語が文脈によって色が変わってしまい一貫性が失われるため)
syntax keyword sasSqlKw
      \ select from where group having order create table view as
      \ insert into update delete drop alter distinct union intersect
      \ except case between like is null exists
      \ inner left right full outer cross join on count sum avg min max
      \ asc desc all any some using calculated reset feedback noprint

" ============================================================
" 関数(データステップ / マクロ双方でよく使うもの)
" ============================================================
syntax keyword sasFunction
      \ substr scan compress upcase lowcase propcase trim ltrim strip
      \ left right reverse repeat translate tranwrd index indexc find
      \ findc anyalpha anydigit anypunct anyspace verify input put
      \ vvalue vname sum mean std var n nmiss round ceil floor int mod
      \ abs sqrt exp log log10 log2 intck intnx datepart timepart today
      \ date time datdif yrdif year month day qtr week weekday hour
      \ minute second mdy hms dhms symget symput symputx open close
      \ attrn attrc varnum varname varlabel varfmt varlen fetch fetchobs
      \ exist libref pathname dequote quote resolve

" ============================================================
" ハイライトリンク
" ============================================================
highlight default link sasComment    Comment
highlight default link sasString     String
highlight default link sasDateConst  Constant
highlight default link sasHexConst   Constant
highlight default link sasBitConst   Constant
highlight default link sasNumber     Number
highlight default link sasFormat     Special

highlight default link sasMacroVar   Identifier
highlight default link sasMacroCall  PreProc
" PreCondit は多くの環境で色未設定のことがあるため、確実に見える
" PreProc へフォールバックさせておく(syncolor.vim側でPreCondit専用の色を
" 定義していればそちらが優先される。default linkは既に色がある場合は上書きしない)
highlight default link PreCondit    PreProc
highlight default link sasMacroCond PreCondit
highlight default link sasMacroDecl  PreProc
highlight default link sasMacroFunc  Function

highlight default link sasStatement  Statement
highlight default link sasConditional Conditional
highlight default link sasRepeat     Repeat
highlight default link sasOperator   Operator
highlight default link sasAutoVar    Special
highlight default link sasCallStmt   Statement

highlight default link sasProcName   Type
highlight default link sasSqlKw      Statement
highlight default link sasFunction   Function

let b:current_syntax = "sas"
SASIDE_INSTALLER_EOF
write_file 'syntax/saslog.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/syntax/saslog.vim
" SASログ(.log)のERROR/WARNING/NOTEを色分けする
if exists("b:current_syntax")
  finish
endif

syntax match sasLogNote    '^\d*\s*NOTE:.*'
syntax match sasLogWarning '^\d*\s*WARNING:.*'
syntax match sasLogError   '^\d*\s*ERROR\(\s\d\+-\d\+\)\?:.*'
syntax match sasLogInfo    '^\d*\s*INFO:.*'
syntax match sasLogNumber  '^\d\+' nextgroup=sasLogNote,sasLogWarning,sasLogError

highlight default link sasLogNote    Comment
highlight default link sasLogWarning WarningMsg
highlight default link sasLogError   ErrorMsg
highlight default link sasLogInfo    Identifier
highlight default link sasLogNumber  LineNr

let b:current_syntax = "saslog"
SASIDE_INSTALLER_EOF
write_file 'syntax/saslst.vim' <<'SASIDE_INSTALLER_EOF'
" =============================================================================
" File:        syntax/saslst.vim
" Module:      SASリスト出力(.lst)の色分け
" =============================================================================
if exists('b:current_syntax')
  finish
endif

" タイトル行(The SAS System / TITLEステートメントの出力)
syntax match saslstTitle   /^\s*The SAS System.*/
syntax match saslstTitle   /^\s{10,}\S.*\%<4l/
" 改ページ (^L)
syntax match saslstPage    /\%x0c/
" 罫線・区切り
syntax match saslstRule    /^[-=_+|]\{4,}$/
" 観測番号列
syntax match saslstObsnum  /^\s*\d\+\s/

highlight default link saslstTitle  Title
highlight default link saslstPage   NonText
highlight default link saslstRule   Comment
highlight default link saslstObsnum LineNr

let b:current_syntax = 'saslst'
SASIDE_INSTALLER_EOF
write_file 'syntax/syncolor.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/syntax/syncolor.vim
" 既定のハイライトグループ色をリンクする最小版。
" `default link` を使っているため、システム版 syncolor.vim や
" colorscheme が先に色を設定していればそちらを尊重し、上書きしない。

if &background ==# 'dark'
  highlight default Comment    ctermfg=244 guifg=#888888
  highlight default Constant   ctermfg=150 guifg=#afd787
  highlight default String     ctermfg=150 guifg=#afd787
  highlight default Identifier ctermfg=111 guifg=#87afff
  highlight default Statement  ctermfg=214 guifg=#ffaf00 cterm=bold gui=bold
  highlight default PreProc    ctermfg=170 guifg=#d787d7
  highlight default Type       ctermfg=81  guifg=#5fd7ff
  highlight default Special    ctermfg=203 guifg=#ff5f5f
else
  highlight default Comment    ctermfg=DarkGray guifg=#666666
  highlight default Constant   ctermfg=DarkGreen guifg=#006400
  highlight default String     ctermfg=DarkGreen guifg=#006400
  highlight default Identifier ctermfg=DarkBlue guifg=#00008b
  highlight default Statement  ctermfg=DarkYellow guifg=#8b6914 cterm=bold gui=bold
  highlight default PreProc    ctermfg=DarkMagenta guifg=#8b008b
  highlight default Type       ctermfg=DarkCyan guifg=#008b8b
  highlight default Special    ctermfg=DarkRed guifg=#8b0000
endif

highlight default link Boolean  Constant
highlight default link Number   Constant
highlight default link Function Identifier
highlight default link Keyword  Statement
highlight default link Repeat   Statement
highlight default link Conditional Statement
highlight default link Label    Statement
highlight default link Operator Statement
SASIDE_INSTALLER_EOF
write_file 'syntax/syntax.vim' <<'SASIDE_INSTALLER_EOF'
" ~/.vim/syntax/syntax.vim
" これがエラーの本体だった E484 を解消するファイル。
" $VIMRUNTIME/syntax/syntax.vim が存在しない環境で :syntax on を
" 成立させるための最小ブートストラップ。
" 自分以外の runtimepath 上に syntax/syntax.vim があればそちらに委譲する。

if exists("did_load_syntax")
  finish
endif

let s:self = fnamemodify(expand('<sfile>:p'), ':p')
let s:other_exists = 0
for s:f in globpath(&runtimepath, 'syntax/syntax.vim', 0, 1)
  if fnamemodify(s:f, ':p') !=# s:self
    let s:other_exists = 1
    break
  endif
endfor
unlet! s:self s:f

if s:other_exists
  unlet s:other_exists
  finish
endif
unlet s:other_exists

let did_load_syntax = 1

let s:cpo_save = &cpo
set cpo&vim

" FileType が確定したら syntax/<ft>.vim を読みに行く配線
augroup syntaxset
  au!
  au FileType * call s:SynSet()
augroup END

function! s:SynSet() abort
  if exists('b:current_syntax')
    unlet b:current_syntax
  endif
  let s = expand("<amatch>")
  if s !=# ""
    let name = substitute(s, '\.', '_', 'g')
    exe 'runtime! syntax/' . name . '.vim syntax/' . name . '/*.vim'
  endif
endfunction

" 既定ハイライトグループ(本来 syncolor.vim が担う部分)
runtime! syntax/syncolor.vim

let &cpo = s:cpo_save
unlet s:cpo_save

let g:syntax_on = 1
SASIDE_INSTALLER_EOF
write_file 'vimrc.example' <<'SASIDE_INSTALLER_EOF'
" ============================================================
" SAS Vim環境 — ~/.vimrc 追記内容
" 既に同じ設定がある場合は重複行に注意して反映してください
" ============================================================

set nocompatible

" ~/.vim を runtimepath の先頭付近に、~/.vim/after を末尾に確実に置く
set runtimepath^=~/.vim
set runtimepath+=~/.vim/after

filetype plugin indent on
syntax on

" --- 表示・操作の基本 ---
set number
set ruler
set showcmd
set laststatus=2
set hidden
set incsearch
set hlsearch
set ignorecase
set smartcase
set wildmenu
set backspace=indent,eol,start
set encoding=utf-8
set fileencodings=utf-8,cp932,euc-jp,sjis

set tabstop=4
set shiftwidth=4
set expandtab
set autoindent

" 折り畳みは定義するが、ファイルを開いた時点ではすべて展開しておく
set foldlevelstart=99

silent! colorscheme sasdark

set path+=**
set wildignore+=*.sas7bdat,*.sas7bcat

" ============================================================
" 運用向け設定(推奨)
" ============================================================

" --- ペースト対策: F11 で貼り付けモードを切替 ---
" 他所からコピペする際に F11 を押してから貼ると、括弧補完等が暴発せず
" 一瞬で貼り付けられる(貼り終えたら再度 F11 で戻す)
set pastetoggle=<F11>

" --- 他人のファイルの modeline で設定を書き換えられないようにする ---
set nomodeline

" --- 外部で更新されたファイルを自動再読込(SASログの追随に有効)---
set autoread
augroup sasvim_autoread
  autocmd!
  autocmd CursorHold,CursorHoldI,FocusGained,BufEnter * silent! checktime
augroup END

" --- 永続undo・swap・backup を ~/.vim/.state 配下に集約 ---
" 作業ディレクトリに .swp が散らばるのを防ぎ、Vimを閉じてもundoが残る
let s:state = expand('~/.vim/.state')
for s:sub in ['undo', 'swap', 'backup']
  if !isdirectory(s:state . '/' . s:sub)
    call mkdir(s:state . '/' . s:sub, 'p', 0700)
  endif
endfor
if has('persistent_undo')
  set undofile
  let &undodir = s:state . '/undo'
endif
let &directory = s:state . '/swap//'
let &backupdir = s:state . '/backup//'
set backup

" --- 巨大ログ対策: 一定サイズ超のファイルは重い機能を自動オフ ---
" SASバッチのログは数百MBになり得るため、開くだけで固まるのを防ぐ
let g:sasvim_bigfile_mb = 10
augroup sasvim_bigfile
  autocmd!
  autocmd BufReadPre * if getfsize(expand('<afile>')) > g:sasvim_bigfile_mb * 1024 * 1024
        \ | setlocal noswapfile noundofile bufhidden=unload
        \ | setlocal syntax=off
        \ | endif
augroup END

" ============================================================
" SAS実行コマンド(PATHが通っていれば設定不要)
" ============================================================
" 会社の sas 実体がPATHに無い / 別名の場合はフルパスを指定する
" let g:saside_sas_cmd = '/opt/sasfoundation/SASFoundation/9.3/sas'
" 追加引数が必要なら(例: 作業領域の指定)
" let g:saside_sas_args = ['-work', '/your/work/dir']
SASIDE_INSTALLER_EOF

# ===== 整合性マニフェスト(自動生成。編集しないこと)=====
ng=0
check() {
  p="$TARGET/$1"; expL=$2; expB=$3
  actL=$(wc -l < "$p" | tr -d " "); actB=$(wc -c < "$p" | tr -d " ")
  if [ "$actL" != "$expL" ] || [ "$actB" != "$expB" ]; then
    echo "  NG: $1 (expected ${expL}L/${expB}B -> got ${actL}L/${actB}B)"
    ng=$((ng+1))
  fi
}
check 'autoload/sas.vim' 97 3450
check 'autoload/saside/bookmark.vim' 75 2234
check 'autoload/saside/complete.vim' 157 4731
check 'autoload/saside/dataset.vim' 7 380
check 'autoload/saside/event.vim' 72 2705
check 'autoload/saside/explorer.vim' 249 8182
check 'autoload/saside/format.vim' 64 2201
check 'autoload/saside/help.vim' 28 940
check 'autoload/saside/layout.vim' 94 3162
check 'autoload/saside/log.vim' 208 7883
check 'autoload/saside/macro.vim' 7 370
check 'autoload/saside/navigator.vim' 171 6003
check 'autoload/saside/outline.vim' 7 382
check 'autoload/saside/parser.vim' 347 13247
check 'autoload/saside/run.vim' 191 7483
check 'autoload/saside/search.vim' 59 2068
check 'autoload/saside/session.vim' 81 2629
check 'autoload/saside/snippet.vim' 111 3509
check 'autoload/saside/statusline.vim' 70 2528
check 'autoload/saside/template.vim' 76 2394
check 'autoload/saside/todo.vim' 13 425
check 'autoload/saside/util.vim' 104 3793
check 'autoload/sasomni.vim' 201 6783
check 'colors/sasdark.vim' 77 3265
check 'dict/sas_formats.dict' 23 564
check 'dict/sas_functions.dict' 107 3263
check 'dict/sas_macros.dict' 46 1515
check 'dict/sas_ods.dict' 20 732
check 'dict/sas_options.dict' 34 1331
check 'dict/sas_procs.dict' 44 1464
check 'dict/snippets.txt' 44 1000
check 'doc/saside.txt' 142 7877
check 'filetype.vim' 36 1098
check 'ftdetect/sas.vim' 48 1759
check 'ftplugin.vim' 43 1105
check 'ftplugin/sas.vim' 73 2751
check 'ftplugin/saslog.vim' 23 1023
check 'indent.vim' 42 919
check 'indent/sas.vim' 232 8291
check 'plugin/autopairs.vim' 116 3509
check 'plugin/ide.vim' 143 5338
check 'plugin/sas_tools.vim' 16 650
check 'plugin/saside.vim' 181 8740
check 'syntax/sas.vim' 168 8272
check 'syntax/saslog.vim' 19 674
check 'syntax/saslst.vim' 24 849
check 'syntax/syncolor.vim' 33 1649
check 'syntax/syntax.vim' 55 1341
check 'vimrc.example' 95 3198
if [ "$ng" -gt 0 ]; then
  echo ""
  echo "!!! $ng file(s) are CORRUPTED (lines lost in copy/transfer)."
  echo "!!! 記事のインストーラを再コピーしてやり直してください。"
  echo "!!! 退避済みの元ファイルは $BACKUP にあります。"
  exit 1
else
  echo "整合性チェック: 全 $count ファイル OK"
fi

echo ""
echo "完了: $count ファイルを展開しました"
[ -d "$BACKUP" ] && echo "既存ファイルの退避先: $BACKUP"
printf '\n次の手順:\n'
printf '  1. ~/.vim/vimrc.example の内容を ~/.vimrc に反映(新規なら cp)\n'
printf '  2. vim -c "helptags ~/.vim/doc" -c q\n'
printf '  3. .sasファイルを開いて F12 と構文チェックを試す\n'

追加パッチ

#!/bin/sh
# saside_patch2.sh — SAS Vim環境 インデント修正パッチ(第2弾)
#
# 前提: saside_install.sh → saside_patch.sh の順で適用済みであること
# 使い方: sh saside_patch2.sh
# 注意: sudo不要($HOME/.vim にしか書き込みません。既存ファイルは自動退避)
#
# --------------------------------------------------------------------------
# 修正する不具合
# --------------------------------------------------------------------------
# [根本原因] 従来の indent/sas.vim は「コメントかどうか」の判定を
#            syntax(構文ハイライト)の synID() に依存していた。
#            syntax が効いていない環境(colorscheme未適用・巨大ファイルで
#            syntax=off・:syntax on 失敗)ではコメント内が「コード」と
#            誤認識され、以降の行が芋づる式にインデントされる。
#
#   1. /*====== ... ======*/ 形式のバナーコメントの中身が
#      「;で終わらない継続行」と誤判定され、直後のコードが引きずられる
#   2. 文字列リテラル内の /* や ( がコード扱いされ、括弧ルールが暴走する
#   3. PROC SQL の select 文が DATAステップの SELECT ブロックと誤認され、
#      以降が延々と深くなる(select ... ; で終わる行が該当)
#   4. data= オプション(proc means data=x)が DATAステップ開始と誤認される
#   5. datalines/cards の生データ行が勝手に整形される
#   6. else が直前行に揃ってしまい、対応する if とずれる
#   7. 1行完結の if x then do; ... end; で +1段ずれる
#   8. %else / when / otherwise の再インデント契機が無い
#   9. :SasFormat の予約語大文字化が、syntax未適用時に
#      文字列やコメントの中身まで大文字化してしまう
# --------------------------------------------------------------------------

set -e
TARGET="$HOME/.vim"
BACKUP="$HOME/.vim_patch2backup_$(date +%Y%m%d_%H%M%S)"
count=0

if [ ! -d "$TARGET/autoload/saside" ]; then
  echo "エラー: $TARGET/autoload/saside がありません。"
  echo "先に saside_install.sh と saside_patch.sh を実行してください。"
  exit 1
fi

write_file() {
  path="$TARGET/$1"; dir=$(dirname "$path"); mkdir -p "$dir"
  if [ -f "$path" ]; then mkdir -p "$BACKUP/$(dirname "$1")"; cp -p "$path" "$BACKUP/$1"; fi
  cat > "$path"; count=$((count + 1))
}

echo "インデント修正パッチを適用します: $TARGET"

# =============================================================================
write_file 'autoload/sas/code.vim' <<'SASIDE_PATCH_EOF'
" =============================================================================
" File:        autoload/sas/code.vim
" Module:      code — SASコードの字句状態判定(syntax非依存)
"
" なぜ必要か:
"   従来 indent/sas.vim は synID() で「コメント/文字列の中か」を判定していた。
"   しかし syntax は
"     - colorscheme や :syntax on が失敗している
"     - 巨大ファイル対策で syntax=off にしている
"     - syntax sync の同期ずれ
"   といった理由で当てにならないことがある。その場合コメントの中身が
"   コードとして扱われ、インデントが芋づる式に壊れる。
"
"   本モジュールはバッファのテキストだけを見て
"     - ブロックコメント /* */ の内側か
"     - 星コメント * ... ; の内側か
"     - 文字列リテラルの内側か
"   を判定する。indent と format の双方がこれを使う。
"
" 公開API:
"   sas#code#StateAtLineStart(lnum)  行頭時点でブロックコメント内か (0/1)
"   sas#code#Text(lnum)              コメント・文字列を除去した「コード」
"   sas#code#InComment(lnum)         行全体がコメントの内側か
"   sas#code#InCommentOrString(l, c) 指定位置がコメント/文字列の中か
"   sas#code#InDataLines(lnum)       datalines/cards の生データ部か
"   sas#code#InProcSql(lnum)         PROC SQL ~ QUIT; の内側か
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

" 後方探索の上限行数(性能と正確さのトレードオフ)
function! s:lookback() abort
  return get(g:, 'sas_code_lookback', 400)
endfunction

" -----------------------------------------------------------------------------
" 1行分の字句走査
" -----------------------------------------------------------------------------
" a:line を a:st(0=地の文 / 1=ブロックコメント内)から走査し、
" {'code': コード部分, 'st': 行末の状態, 'star': 星コメント継続中か} を返す。
"
" code の作り方:
"   - コメントは空白1個に潰す
"   - 文字列リテラルは '' / "" に潰す(中身の ( ; /* を無害化しつつ
"     「そこに何かある」という構造は残す)
function! s:scan(line, st) abort
  let out  = ''
  let n    = strlen(a:line)
  let i    = 0
  let st   = a:st
  let q    = ''
  let star = 0

  while i < n
    let ch = a:line[i]
    let nx = i + 1 < n ? a:line[i + 1] : ''

    " --- ブロックコメントの中 ---
    if st == 1
      if ch ==# '*' && nx ==# '/'
        let st = 0
        let out .= ' '
        let i += 2
        continue
      endif
      let i += 1
      continue
    endif

    " --- 星コメント(* ... ;)の中 ---
    if star
      if ch ==# ';'
        let star = 0
        let out .= ';'
      endif
      let i += 1
      continue
    endif

    " --- 文字列の中 ---
    if q !=# ''
      if ch ==# q
        let q = ''
      endif
      let i += 1
      continue
    endif

    " --- ブロックコメント開始 ---
    if ch ==# '/' && nx ==# '*'
      let st = 1
      let out .= ' '
      let i += 2
      continue
    endif

    " --- 文字列開始('' / "" に潰して残す)---
    if ch ==# "'" || ch ==# '"'
      let q = ch
      let out .= ch . ch
      let i += 1
      continue
    endif

    " --- 星コメント開始(文の先頭位置の * のみ)---
    " 掛け算やべき乗の * と区別するため、
    " 「行頭から空白のみ」または「直前が ;」の場合だけコメントとみなす
    if ch ==# '*' && (out =~# '^\s*$' || out =~# ';\s*$')
      let star = 1
      let i += 1
      continue
    endif

    let out .= ch
    let i += 1
  endwhile

  return {'code': out, 'st': st, 'star': star}
endfunction

" -----------------------------------------------------------------------------
" 指定位置のマーカーが文字列の中かどうか(1行内の簡易判定)
" -----------------------------------------------------------------------------
function! s:marker_in_string(lnum, col) abort
  let line = getline(a:lnum)
  if line !~# "['\"]"
    return 0
  endif
  let q = ''
  let i = 0
  while i < a:col - 1
    let ch = line[i]
    if q ==# ''
      if ch ==# "'" || ch ==# '"'
        let q = ch
      endif
    elseif ch ==# q
      let q = ''
    endif
    let i += 1
  endwhile
  return q !=# ''
endfunction

" -----------------------------------------------------------------------------
" 行頭時点でブロックコメントの内側か
" -----------------------------------------------------------------------------
" 実装: 直前の /* と */ のうち「後に現れた方」で判定する。
"       search() はC実装なので数百行の後方探索でも十分速い。
function! sas#code#StateAtLineStart(lnum) abort
  if a:lnum <= 1
    return 0
  endif
  let stop = max([1, a:lnum - s:lookback()])
  let save = getcurpos()
  call cursor(a:lnum, 1)
  let st = 0
  let guard = 0
  while guard < 50
    let [l, c] = searchpos('/\*\|\*/', 'bW', stop)
    if l == 0
      break
    endif
    if !s:marker_in_string(l, c)
      let st = strpart(getline(l), c - 1, 2) ==# '/*' ? 1 : 0
      break
    endif
    let guard += 1
  endwhile
  call setpos('.', save)
  return st
endfunction

" -----------------------------------------------------------------------------
" 星コメント(* ... ;)が前の行から継続しているか
" -----------------------------------------------------------------------------
function! s:in_star_comment(lnum) abort
  let l = a:lnum - 1
  let limit = max([1, a:lnum - 20])
  while l >= limit
    let raw = getline(l)
    if raw =~# '^\s*$'
      let l -= 1
      continue
    endif
    " ブロックコメント内の行なら星コメントとは無関係
    if sas#code#StateAtLineStart(l) == 1
      return 0
    endif
    " ; が出ていれば星コメントはそこで閉じている
    if raw =~# ';'
      return 0
    endif
    " ; を挟まずに * で始まる行に到達したら継続中
    if raw =~# '^\s*\*'
      return 1
    endif
    let l -= 1
  endwhile
  return 0
endfunction

" -----------------------------------------------------------------------------
" コード部分の取得(行キャッシュ付き)
" -----------------------------------------------------------------------------
function! sas#code#Text(lnum) abort
  let tick = b:changedtick
  if !exists('b:sas_code_cache') || get(b:, 'sas_code_tick', -1) != tick
    let b:sas_code_cache = {}
    let b:sas_code_tick = tick
  endif
  let key = string(a:lnum)
  if has_key(b:sas_code_cache, key)
    return b:sas_code_cache[key]
  endif

  let raw = getline(a:lnum)
  if raw =~# '^\s*$'
    let b:sas_code_cache[key] = ''
    return ''
  endif
  if s:in_star_comment(a:lnum)
    let b:sas_code_cache[key] = ''
    return ''
  endif
  let r = s:scan(raw, sas#code#StateAtLineStart(a:lnum))
  let b:sas_code_cache[key] = r.code
  return r.code
endfunction

" 行全体がコメントの内側か(バナーコメントの中身・閉じ行を含む)
function! sas#code#InComment(lnum) abort
  if sas#code#StateAtLineStart(a:lnum) == 1
    return 1
  endif
  return s:in_star_comment(a:lnum)
endfunction

" 指定位置がコメントまたは文字列の中か(searchpair の skip 用)
function! sas#code#InCommentOrString(lnum, col) abort
  let st = sas#code#StateAtLineStart(a:lnum)
  if st == 1
    " 行頭がコメント内。*/ より手前なら確実にコメント内
    let close = match(getline(a:lnum), '\*/')
    if close == -1 || a:col - 1 <= close + 1
      return 1
    endif
  endif
  if s:in_star_comment(a:lnum)
    return 1
  endif
  " 行頭から指定桁までを走査して、その時点の状態を見る
  let head = strpart(getline(a:lnum), 0, a:col - 1)
  let r = s:scan(head, st)
  if r.st == 1 || r.star
    return 1
  endif
  " 文字列の中かどうかは scan では戻さないので簡易判定
  return s:marker_in_string(a:lnum, a:col)
endfunction

" -----------------------------------------------------------------------------
" datalines / cards の生データ部か
" -----------------------------------------------------------------------------
function! sas#code#InDataLines(lnum) abort
  let save = getcurpos()
  call cursor(a:lnum, 1)
  let s = search('\c^\s*\%(cards4\?\|datalines4\?\|parmcards\)\s*;', 'bnW',
        \ max([1, a:lnum - 500]))
  call setpos('.', save)
  if s == 0
    return 0
  endif
  " 開始行と現在行の間に終端(; または ;;;;)があれば既に閉じている
  for l in range(s + 1, a:lnum - 1)
    if getline(l) =~# '^\s*;\{1,4}\s*$'
      return 0
    endif
  endfor
  return 1
endfunction

" -----------------------------------------------------------------------------
" PROC SQL の内側か(SQLのSELECTをDATAステップのSELECTと誤認しないため)
" -----------------------------------------------------------------------------
function! sas#code#InProcSql(lnum) abort
  let save = getcurpos()
  call cursor(a:lnum, 1)
  let stop = max([1, a:lnum - 500])
  let p = search('\c^\s*proc\s\+sql\>', 'bnW', stop)
  let q = search('\c^\s*\%(quit\|run\)\s*;', 'bnW', stop)
  call setpos('.', save)
  return p > 0 && p > q
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_PATCH_EOF

# =============================================================================
write_file 'indent/sas.vim' <<'SASIDE_PATCH_EOF'
" =============================================================================
" File:        indent/sas.vim
" Description: SAS用スマートインデント(syntax非依存版)
"
" 従来版との違い:
"   コメント・文字列の判定を synID() ではなく autoload/sas/code.vim の
"   字句走査に委ねる。これにより syntax が効いていない環境でも
"   バナーコメントの中身がコードと誤認されず、後続行が引きずられない。
"
" 対応するブロック:
"   DATA/PROC ~ RUN;/QUIT;   DO/SELECT ~ END;   %DO ~ %END;   %MACRO ~ %MEND;
"   括弧の複数行記述(データセットオプション等)
"   継続行(; で終わっていない文の続き)
" 触らない領域:
"   ブロックコメントの内側・星コメントの内側・datalines/cards の生データ
" =============================================================================
if exists("b:did_indent")
  finish
endif
let b:did_indent = 1

setlocal indentexpr=SasIndent()
setlocal indentkeys=!^F,o,O,0),=~end,=~run,=~quit,=~%end,=~%mend,=~else,=~%else,=~when,=~otherwise

let s:cpo_save = &cpo
set cpo&vim

" =============================================================================
" パターン定義
"
" 注意: ftplugin が 'iskeyword' に % と & を足しているため、\< \> の意味が
"       環境によって変わる。ここでは iskeyword に依存しないよう、
"       単語境界を明示的な文字クラスで表現している。
" =============================================================================
" DATA/PROC の開始。data= オプションや datalines は除外される
let s:P_DATAPROC = '\c^\s*\%(data\|proc\)\%([^A-Za-z0-9_=]\|$\)'
let s:P_RUNQUIT  = '\c^\s*\%(run\|quit\)\s*;'
" %do を誤って拾わないよう直前の % を除外
let s:P_DO       = '\c\%(^\|[^%A-Za-z0-9_]\)\zsdo\%([^A-Za-z0-9_]\|$\)'
let s:P_END      = '\c^\s*end\s*;'
let s:P_PDO      = '\c%do\%([^A-Za-z0-9_]\|$\)'
let s:P_PEND     = '\c^\s*%end\s*;'
let s:P_MACRO    = '\c^\s*%macro\%([^A-Za-z0-9_]\|$\)'
let s:P_MEND     = '\c^\s*%mend\%([^A-Za-z0-9_]\|$\)'
" DATAステップの SELECT。SQL の select は select( / select; の形を取らない
let s:P_SELECT   = '\c^\s*select\s*[(;]'

" =============================================================================
" ヘルパー
" =============================================================================

" searchpair の skip 式(コメント・文字列内のマッチを無視する)
function! s:Skip() abort
  return sas#code#InCommentOrString(line('.'), col('.'))
endfunction

" 直前の「実コードがある行」を返す(コメント行・空行を飛ばす)
function! s:PrevCode(lnum) abort
  let l = prevnonblank(a:lnum - 1)
  while l > 0
    if !sas#code#InComment(l) && sas#code#Text(l) !~# '^\s*$'
      return l
    endif
    let l = prevnonblank(l - 1)
  endwhile
  return 0
endfunction

" 文が完結しているか(; で終わっているか)
function! s:Terminated(code) abort
  return a:code =~# ';\s*$'
endfunction

" 継続中の文が始まった行を探す
function! s:StatementStart(lnum) abort
  let l = a:lnum
  let guard = 0
  while l > 1 && guard < 200
    let p = s:PrevCode(l)
    if p == 0 || s:Terminated(sas#code#Text(p))
      return l
    endif
    let l = p
    let guard += 1
  endwhile
  return l
endfunction

" パターンの出現回数を数える
function! s:Count(str, pat) abort
  let n = 0
  let pos = 0
  let guard = 0
  while guard < 50
    let m = match(a:str, a:pat, pos)
    if m == -1
      break
    endif
    let n += 1
    let e = matchend(a:str, a:pat, pos)
    let pos = e > m ? e : m + 1
    let guard += 1
  endwhile
  return n
endfunction

" その行が正味で何段ぶんブロックを開いたか
" (1行完結の if x then do; ... end; を +0 と正しく扱うため、
"   開きと閉じを同一行内で相殺する)
function! s:OpenDelta(code, lnum) abort
  let n = 0
  if a:code =~# s:P_DATAPROC
    let n += 1
  endif
  if a:code =~# s:P_MACRO
    let n += 1
  endif
  let n += s:Count(a:code, s:P_DO)
  let n += s:Count(a:code, s:P_PDO)
  " SELECT は DATAステップのときだけブロック扱い(PROC SQL では数えない)
  if a:code =~# s:P_SELECT && !sas#code#InProcSql(a:lnum)
    let n += 1
  endif
  " 同一行の閉じで相殺
  let n -= s:Count(a:code, '\c\%(^\|[^%A-Za-z0-9_]\)end\s*;')
  let n -= s:Count(a:code, s:P_PEND)
  let n -= s:Count(a:code, s:P_MEND)
  if a:code =~# s:P_RUNQUIT
    let n -= 1
  endif
  return n
endfunction

" else に対応する if の行を探す
function! s:FindIf(from) abort
  let l = a:from
  let base = indent(a:from)
  let guard = 0
  while l > 0 && guard < 200
    let guard += 1
    let code = sas#code#Text(l)
    " end; に当たったら対応する do まで飛ぶ
    if code =~# s:P_END
      call cursor(l, 1)
      let m = searchpair(s:P_DO, '', s:P_END, 'bnW', 's:Skip()')
      if m > 0 && m < l
        let l = m
        continue
      endif
    endif
    if code =~# '\c^\s*if\%([^A-Za-z0-9_]\|$\)' && indent(l) <= base
      return l
    endif
    " ブロック境界を越えたら諦める
    if code =~# s:P_DATAPROC || code =~# s:P_MACRO
      return 0
    endif
    let l = s:PrevCode(l)
  endwhile
  return 0
endfunction

" %else に対応する %if の行を探す
function! s:FindPctIf(from) abort
  let l = a:from
  let guard = 0
  while l > 0 && guard < 200
    let guard += 1
    let code = sas#code#Text(l)
    if code =~# s:P_PEND
      call cursor(l, 1)
      let m = searchpair(s:P_PDO, '', s:P_PEND, 'bnW', 's:Skip()')
      if m > 0 && m < l
        let l = m
        continue
      endif
    endif
    if code =~# '\c^\s*%if\%([^A-Za-z0-9_]\|$\)'
      return l
    endif
    if code =~# s:P_MACRO
      return 0
    endif
    let l = s:PrevCode(l)
  endwhile
  return 0
endfunction

" =============================================================================
" 本体
" =============================================================================
function! SasIndent() abort
  let lnum = v:lnum

  " ---------------------------------------------------------------
  " 0) 触ってはいけない領域
  " ---------------------------------------------------------------
  " datalines/cards の生データは桁位置に意味があるので絶対に変えない
  if sas#code#InDataLines(lnum)
    return -1
  endif
  " ブロックコメント・星コメントの内側は書き手の体裁を尊重する
  " (/*===== ... =====*/ のバナーが崩れるのを防ぐ)
  if sas#code#InComment(lnum)
    return -1
  endif

  let pnum = s:PrevCode(lnum)
  if pnum == 0
    return 0
  endif

  let cline = sas#code#Text(lnum)
  let pline = sas#code#Text(pnum)

  " ---------------------------------------------------------------
  " 1) 括弧の内側(データセットオプションの複数行記述など)
  "      行頭が )        → 開き括弧の行に揃える
  "      名前= の形      → 開き括弧の行 +1段
  "      それ以外(値)  → 開き括弧の行 +2段
  " ---------------------------------------------------------------
  call cursor(lnum, 1)
  let open = searchpairpos('(', '', ')', 'bnW', 's:Skip()', max([1, lnum - 500]))
  if open[0] > 0
    if cline =~# '^\s*)'
      return indent(open[0])
    elseif cline =~# '^\s*[A-Za-z_&%][A-Za-z0-9_&.%]*\s*='
      return indent(open[0]) + shiftwidth()
    else
      return indent(open[0]) + 2 * shiftwidth()
    endif
  endif

  " ---------------------------------------------------------------
  " 2) 現在行がブロックの閉じ → 対応する開始行に揃える
  " ---------------------------------------------------------------
  if cline =~# s:P_RUNQUIT
    call cursor(lnum, 1)
    let m = searchpair(s:P_DATAPROC, '', s:P_RUNQUIT, 'bnW', 's:Skip()')
    return m > 0 ? indent(m) : 0
  endif

  if cline =~# s:P_PEND
    call cursor(lnum, 1)
    let m = searchpair(s:P_PDO, '', s:P_PEND, 'bnW', 's:Skip()')
    return m > 0 ? indent(m) : indent(pnum)
  endif

  if cline =~# s:P_MEND
    call cursor(lnum, 1)
    let m = searchpair(s:P_MACRO, '', s:P_MEND, 'bnW', 's:Skip()')
    return m > 0 ? indent(m) : 0
  endif

  if cline =~# s:P_END
    " PROC SQL の CASE 式の end は閉じではない
    if !sas#code#InProcSql(lnum)
      call cursor(lnum, 1)
      let m = searchpair(s:P_DO, '', s:P_END, 'bnW', 's:Skip()')
      return m > 0 ? indent(m) : indent(pnum)
    endif
  endif

  " ---------------------------------------------------------------
  " 3) else / %else は対応する if / %if に揃える
  " ---------------------------------------------------------------
  if cline =~# '\c^\s*else\%([^A-Za-z0-9_]\|$\)'
    let m = s:FindIf(pnum)
    return m > 0 ? indent(m) : indent(pnum)
  endif

  if cline =~# '\c^\s*%else\%([^A-Za-z0-9_]\|$\)'
    let m = s:FindPctIf(pnum)
    return m > 0 ? indent(m) : indent(pnum)
  endif

  " ---------------------------------------------------------------
  " 4) 直前行が文を終えていない(継続行)→ 文の開始行から1段深く
  " ---------------------------------------------------------------
  if !s:Terminated(pline)
    return indent(s:StatementStart(pnum)) + shiftwidth()
  endif

  " ---------------------------------------------------------------
  " 5) 直前行が開いたブロックの段数だけ深くする
  " ---------------------------------------------------------------
  let delta = s:OpenDelta(pline, pnum)
  let ind = indent(pnum)
  if delta > 0
    let ind += delta * shiftwidth()
  endif
  return ind
endfunction

" =============================================================================
" デバッグ用: カーソル行の判定結果を表示する
" インデントがおかしいと感じたら :SasIndentInfo で内訳を確認できる
" =============================================================================
function! s:IndentInfo() abort
  let l = line('.')
  let p = s:PrevCode(l)
  echo printf("行           : %d", l)
  echo printf("コメント内   : %s", sas#code#InComment(l) ? 'YES(インデント変更しない)' : 'no')
  echo printf("datalines内  : %s", sas#code#InDataLines(l) ? 'YES(インデント変更しない)' : 'no')
  echo printf("PROC SQL内   : %s", sas#code#InProcSql(l) ? 'yes' : 'no')
  echo printf("コード部分   : [%s]", sas#code#Text(l))
  echo printf("直前のコード行: %d [%s]", p, p > 0 ? sas#code#Text(p) : '')
  if p > 0
    echo printf("直前行の終端 : %s", s:Terminated(sas#code#Text(p)) ? '; で完結' : '継続中')
    echo printf("直前行の開き : %+d 段", s:OpenDelta(sas#code#Text(p), p))
  endif
  echo printf("算出インデント: %d (現在 %d)", SasIndent(), indent(l))
endfunction

command! -buffer SasIndentInfo call <SID>IndentInfo()

let &cpo = s:cpo_save
unlet s:cpo_save

let b:undo_indent = "setlocal indentexpr< indentkeys< | silent! delcommand SasIndentInfo"
SASIDE_PATCH_EOF

# =============================================================================
write_file 'autoload/saside/format.vim' <<'SASIDE_PATCH_EOF'
" =============================================================================
" File:        autoload/saside/format.vim
" Module:      format — コード整形(syntax非依存版)
" Description: 1) indent/sas.vim による全体再インデント
"              2) 行末空白の除去
"              3) 3行以上の連続空行を1行へ圧縮(コメント内は保護)
"              4) g:saside_format_upcase=1 のとき予約語を大文字化
"                 (文字列/コメント内は sas#code の字句走査で保護する)
"              全処理は1回のundoで戻せる。
"
" 従来版との違い:
"   大文字化の保護判定を synID() ではなく sas#code#InCommentOrString() に
"   変更した。syntax が効いていない環境では従来版は文字列やコメントの中身
"   まで大文字化してしまい、データ値やラベルが壊れる恐れがあった。
" =============================================================================
let s:cpo_save = &cpo
set cpo&vim

function! saside#format#Format() abort
  let view = winsaveview()
  try
    " 1) 再インデント(コメント内・datalines内は indent 側が -1 を返すので保持)
    normal! gg=G
    " 2) 行末空白の除去
    keepjumps keeppatterns silent! %s/\s\+$//e
    " 3) 連続空行の圧縮(コメントの中は触らない)
    call s:squeeze_blank_lines()
    " 4) 予約語の大文字化(任意)
    if get(g:, 'saside_format_upcase', 0)
      call s:upcase_buffer()
    endif
  finally
    call winrestview(view)
  endtry
  call saside#util#Info('整形しました')
endfunction

" 3行以上の連続空行を1行にする。ただしコメントブロックの内側は保持する
" (バナーコメント内の意図的な余白を潰さないため)
function! s:squeeze_blank_lines() abort
  let lnum = line('$')
  while lnum > 1
    if getline(lnum) =~# '^\s*$'
      let start = lnum
      while start > 1 && getline(start - 1) =~# '^\s*$'
        let start -= 1
      endwhile
      let n = lnum - start + 1
      if n >= 3 && !sas#code#InComment(start)
        execute (start + 1) . ',' . lnum . 'delete _'
      endif
      let lnum = start - 1
    else
      let lnum -= 1
    endif
  endwhile
endfunction

" 予約語(ステートメント語)を大文字化する。
" 文字列・コメントの中はテキスト走査で判定して保護する。
function! s:upcase_buffer() abort
  let words = saside#parser#Keywords().statement
  let pat = '\c\%(^\|[^A-Za-z0-9_&%]\)\zs\%(' . join(words, '\|')
        \ . '\)\%([^A-Za-z0-9_]\|$\)\@='
  for lnum in range(1, line('$'))
    " コメント行・datalines行はまるごとスキップ
    if sas#code#InComment(lnum) || sas#code#InDataLines(lnum)
      continue
    endif
    let line = getline(lnum)
    let out = ''
    let pos = 0
    let guard = 0
    while guard < 200
      let guard += 1
      let [str, mstart, mend] = matchstrpos(line, pat, pos)
      if mstart == -1
        let out .= strpart(line, pos)
        break
      endif
      let out .= strpart(line, pos, mstart - pos)
      if sas#code#InCommentOrString(lnum, mstart + 1)
        let out .= str
      else
        let out .= toupper(str)
      endif
      let pos = mend
    endwhile
    if out !=# line
      call setline(lnum, out)
    endif
  endfor
endfunction

let &cpo = s:cpo_save
unlet s:cpo_save
SASIDE_PATCH_EOF

# =============================================================================
write_file 'indent.test.sas' <<'SASIDE_PATCH_EOF'
/********************************************

  インデント検証用サンプル
  このファイルを開いて gg=G (または F9)を実行し、
  以下の体裁が崩れないことを確認する

********************************************/

/*==========================================
  Purpose : バナーコメントの中身が
            後続コードを引きずらないこと
  Author  : test
==========================================*/

libname raw '/data/raw';

/* 1) DATAステップ基本 */
data work.a;
    set raw.b;
    where amount > 0;
    if flag = 1 then do;
        x = 1;
        y = 2;
    end;
    else do;
        x = 0;
    end;
run;

/* 2) 1行完結の do/end は段がずれないこと */
data work.a2;
    set raw.b;
    if x then do; y = 1; end;
    z = 3;
run;

/* 3) PROC SQL の select がブロック扱いされないこと */
proc sql;
    create table work.c as
    select id,
           sum(amount) as amt
    from work.a
    group by id;

    select count(*) from work.a;

    create table work.d as
    select a.*,
           case when a.x > 0 then 1
                else 0
           end as flg
    from work.a as a;
quit;

/* 4) 括弧の複数行記述 */
data work.e;
    set raw.b(
        keep =
            id
            amount
        rename = (
            amount = amt
        )
    );
run;

/* 5) 文字列の中の /* や ( に引きずられないこと */
data work.f;
    set work.e;
    memo = 'コメント記号 /* と 括弧 ( を含む文字列';
    note = "閉じ括弧 ) も含む";
run;

/* 6) data= オプションが DATAステップ開始と誤認されないこと */
proc means data=work.a n mean;
    var amt;
run;

/* 7) マクロ */
%macro run_month(from=, to=);
    %do i = &from. %to &to.;
        %if &i. = 1 %then %do;
            %put NOTE: first;
        %end;
        %else %do;
            %put NOTE: &i.;
        %end;
    %end;
%mend run_month;

/* 8) SELECT-WHEN(DATAステップ) */
data work.g;
    set work.a;
    select (x);
        when (1) y = 'a';
        when (2) y = 'b';
        otherwise y = 'z';
    end;
run;

/* 9) datalines の生データが整形されないこと */
data work.h;
    input id name $ amount;
    datalines;
1  aaa   100
2  bbb   200
3  ccc   300
;
run;

* 10) 星コメントも
      複数行にわたって保護されること;

data work.i;
    set work.h;
run;
SASIDE_PATCH_EOF

echo ""
echo "完了: $count ファイルを更新しました"
[ -d "$BACKUP" ] && echo "変更前ファイルの退避先: $BACKUP"
echo ""
echo "次の手順:"
echo "  1. vim を再起動"
echo "  2. vim ~/.vim/indent.test.sas を開く"
echo "  3. gg=G (または F9)を実行し、体裁が崩れないことを確認"
echo "  4. 崩れる箇所があれば、その行で :SasIndentInfo を実行して"
echo "     判定内訳(コメント内か・直前コード行・開き段数)を確認する"
echo ""
echo "備考:"
echo "  インデント判定は syntax に依存しなくなりました。"
echo "  色が出ていない環境でもインデントは正しく動作します。"
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?