text editor inspired vim and yi
-- SPDX-FileCopyrightText: 2026 Yuki Otsuka
--
-- SPDX-License-Identifier: BSD-3

import ViE.State.Config
import ViE.State.Layout
import ViE.Workspace
import ViE.Command.Impl
import ViE.App
import ViE.Buffer.Content
import ViE.Basic
import ViETest.Utils

set_option maxRecDepth 2048

namespace ViETest.Workspace

open ViETest.Utils
open ViE

def addWorkspace (state : EditorState) (ws : WorkspaceState) : EditorState :=
  state.updateCurrentWorkgroup fun wg =>
    { wg with workspaces := wg.workspaces.push ws }

def switchWorkspace (state : EditorState) (idx : Nat) : EditorState :=
  state.updateCurrentWorkgroup fun wg =>
    if idx < wg.workspaces.size then
      { wg with currentWorkspace := idx }
    else
      wg

def test : IO Unit := do
  IO.println "Starting Workspace ViETest..."

  let s0 := { ViE.initialState with windowHeight := 40, windowWidth := 120 }
  let wg0 := s0.getCurrentWorkgroup
  assertEqual "Initial workgroups size" 10 s0.workgroups.size
  assertEqual "Initial workgroup count" 1 wg0.workspaces.size
  assertEqual "Initial workspace index" 0 wg0.currentWorkspace
  assertEqual "Initial workspace name" "ViE" s0.getCurrentWorkspace.name

  let ws1 := makeWorkspaceState "Project A" (some "/tmp/project-a") 10
  let s1 := addWorkspace s0 ws1
  let wg1 := s1.getCurrentWorkgroup
  assertEqual "Workgroup has 2 workspaces" 2 wg1.workspaces.size

  let s2 := switchWorkspace s1 1
  assertEqual "Switched to workspace 1" "Project A" s2.getCurrentWorkspace.name
  assertEqual "Active buffer comes from workspace 1" 10 (s2.getActiveBuffer.id)

  let resolved := s2.getCurrentWorkspace.resolvePath "file.txt"
  assertEqual "ResolvePath uses workspace root" "/tmp/project-a/file.txt" resolved

  let resolvedAbs := s2.getCurrentWorkspace.resolvePath "/abs/file.txt"
  assertEqual "ResolvePath keeps absolute path" "/abs/file.txt" resolvedAbs

  IO.println "Starting Workspace Command ViETest..."
  let s3 ← ViE.Command.cmdWs ["new", "Project B", "/tmp/project-b"] s2
  assertEqual "Workspace created and switched" "Project B" s3.getCurrentWorkspace.name
  assertEqual "Workspace rootPath set" (some "/tmp/project-b") s3.getCurrentWorkspace.rootPath
  assertEqual "Active buffer id is new workspace buffer" 11 s3.getActiveBuffer.id

  let s4 ← ViE.Command.cmdWs ["rename", "Project B2"] s3
  assertEqual "Workspace rename applied" "Project B2" s4.getCurrentWorkspace.name

  let s5 ← ViE.Command.cmdWs ["new", "Project C"] s4
  assertEqual "Workspace C created" "Project C" s5.getCurrentWorkspace.name

  let s6 ← ViE.Command.cmdWs ["prev"] s5
  assertEqual "Workspace prev switches back" "Project B2" s6.getCurrentWorkspace.name

  let s7 ← ViE.Command.cmdWs ["0"] s6
  assertEqual "Workspace switch by index" "ViE" s7.getCurrentWorkspace.name

  let s8 ← ViE.Command.cmdWs ["list"] s7
  assertEqual "Workspace list opens explorer buffer" (some "explorer://workgroup") s8.getActiveBuffer.filename
  let ws8 := s8.getCurrentWorkspace
  assertEqual "Workspace explorer opens in regular window" false (ws8.isFloatingWindow ws8.activeWindowId)
  let wsListText := s8.getActiveBuffer.table.toString
  assertEqual "Workspace list contains New Workspace entry" true (wsListText.contains "[New Workspace]")
  assertEqual "Workspace list contains Rename Workspace entry" true (wsListText.contains "[Rename Workspace]")
  assertEqual "Workspace list contains Project B2" true (wsListText.contains "Project B2")
  assertEqual "Workspace list marks current workspace" true (wsListText.contains "*")

  let s8a := s8.updateActiveView fun v => { v with cursor := {row := ⟨2⟩, col := 0} }
  let s8b ← ViE.Feature.handleExplorerEnter s8a
  assertEqual "New workspace opens floating input" true s8b.floatingOverlay.isSome
  assertEqual "New workspace floating command prefix" (some "ws new ") s8b.floatingInputCommand

  let s8c := s8.updateActiveView fun v => { v with cursor := {row := ⟨3⟩, col := 0} }
  let s8d ← ViE.Feature.handleExplorerEnter s8c
  assertEqual "Rename workspace opens floating input" true s8d.floatingOverlay.isSome
  assertEqual "Rename workspace floating command prefix" (some "ws rename ") s8d.floatingInputCommand

  let s8e ← ViE.Command.cmdWorkspace ["rename", ""] s8
  assertEqual "Rename workspace rejects empty" "Workspace name cannot be empty" s8e.message

  let s8f ← ViE.Command.cmdWorkspace ["rename", "Project B2"] s8
  assertEqual "Rename workspace rejects duplicate" true (s8f.message.contains "already exists")

  let s9 ← ViE.Command.cmdWs ["open", "/tmp/project-d"] s8
  assertEqual "Workspace open uses path name" "project-d" s9.getCurrentWorkspace.name
  assertEqual "Workspace open rootPath set" (some "/tmp/project-d") s9.getCurrentWorkspace.rootPath

  let s10 ← ViE.Command.cmdWs ["open", "--name", "Project E", "/tmp/project-e"] s9
  assertEqual "Workspace open --name prefix" "Project E" s10.getCurrentWorkspace.name
  assertEqual "Workspace open --name prefix rootPath" (some "/tmp/project-e") s10.getCurrentWorkspace.rootPath

  let s11 ← ViE.Command.cmdWs ["open", "/tmp/project-f", "--name", "Project F"] s10
  assertEqual "Workspace open --name suffix" "Project F" s11.getCurrentWorkspace.name
  assertEqual "Workspace open --name suffix rootPath" (some "/tmp/project-f") s11.getCurrentWorkspace.rootPath

  IO.println "Starting Workspace Relative Path Resolution ViETest..."
  let stamp ← IO.monoMsNow
  let relBase := s!"/tmp/vie-ws-rel-{stamp}"
  IO.FS.createDirAll (System.FilePath.mk relBase)
  let sRel0 := s11.updateCurrentWorkspace fun ws =>
    { ws with rootPath := some relBase, name := "RelBase" }

  let sRel1 ← ViE.Command.cmdCd ["child"] sRel0
  assertEqual "cd relative resolves from workspace root" (some s!"{relBase}/child") sRel1.getCurrentWorkspace.rootPath

  let sRel2 ← ViE.Command.cmdWs ["open", "nested"] sRel0
  assertEqual "ws open relative resolves from workspace root" (some s!"{relBase}/nested") sRel2.getCurrentWorkspace.rootPath
  assertEqual "ws open relative uses basename from absolute path" "nested" sRel2.getCurrentWorkspace.name

  let sRel3 ← ViE.Command.cmdWs ["new", "RelChild", "child2"] sRel0
  assertEqual "ws new relative resolves from workspace root" (some s!"{relBase}/child2") sRel3.getCurrentWorkspace.rootPath

  let s12 ← ViE.Command.cmdWs ["close"] s11
  assertEqual "Workspace close switches to previous" "Project E" s12.getCurrentWorkspace.name

  IO.println "Starting Workspace Explorer Preview ViETest..."
  let bufP1 : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 1 (some "/tmp/a.txt") #[stringToLine "A"]
  let bufP2 : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 2 (some "/tmp/b.txt") #[stringToLine "B"]
  let wsP : WorkspaceState := {
    name := "WS-P"
    rootPath := none
    buffers := [bufP1, bufP2]
    nextBufferId := 3
    layout := .window 0 { initialView with bufferId := 1 }
    activeWindowId := 0
    nextWindowId := 1
  }
  let s12a := addWorkspace s12 wsP
  let wgP := s12a.getCurrentWorkgroup
  let idxP := if wgP.workspaces.size == 0 then 0 else wgP.workspaces.size - 1
  let s12b := switchWorkspace s12a idxP
  let s12c ← ViE.Feature.openWorkspaceExplorer s12b
  let explorerOpt := s12c.explorers.find? (fun (id, _) => id == s12c.getActiveBuffer.id)
  match explorerOpt with
  | none => throw (IO.userError "Workspace explorer not registered")
  | some (_, explorer) =>
    assertEqual "Workspace explorer preview window exists" true explorer.previewWindowId.isSome
    let previewWinId := explorer.previewWindowId.get!
    let wsPrev := s12c.getCurrentWorkspace
    assertEqual "Workspace explorer preview window is regular" false (wsPrev.isFloatingWindow previewWinId)
    let pairSideBySide :=
      match (ViE.Window.getAllWindowBounds wsPrev.layout (if s12c.windowHeight > 0 then s12c.windowHeight - 1 else 0) s12c.windowWidth).find? (fun (id, _, _, _, _) => id == wsPrev.activeWindowId),
            (ViE.Window.getAllWindowBounds wsPrev.layout (if s12c.windowHeight > 0 then s12c.windowHeight - 1 else 0) s12c.windowWidth).find? (fun (id, _, _, _, _) => id == previewWinId) with
      | some (_, et, el, eh, ew), some (_, pt, pl, ph, pw) =>
          et == pt && eh == ph && ((el + ew <= pl) || (pl + pw <= el))
      | _, _ => false
    assertEqual "Workspace explorer/preview pair is side-by-side" true pairSideBySide
    let previewView := wsPrev.layout.findView previewWinId |>.getD initialView
    let previewBuf := wsPrev.buffers.find? (fun b => b.id == previewView.bufferId) |>.getD initialBuffer
    let previewText := previewBuf.table.toString
    assertEqual "Workspace preview includes a.txt" true (previewText.contains "/tmp/a.txt")
    assertEqual "Workspace preview includes b.txt" true (previewText.contains "/tmp/b.txt")

  IO.println "Starting Workspace Restore ViETest..."
  let bufA : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 0 none #[stringToLine "WS-A"]
  let bufB : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 10 none #[stringToLine "WS-B"]
  let wsA : WorkspaceState := {
    name := "WS-A"
    rootPath := none
    buffers := [bufA]
    nextBufferId := 1
    layout := .window 0 { initialView with bufferId := 0 }
    activeWindowId := 0
    nextWindowId := 1
  }
  let wsB : WorkspaceState := {
    name := "WS-B"
    rootPath := none
    buffers := [bufB]
    nextBufferId := 11
    layout := .window 0 { initialView with bufferId := 10 }
    activeWindowId := 0
    nextWindowId := 1
  }
  let s12a := s12.updateCurrentWorkgroup fun wg =>
    { wg with workspaces := #[wsA, wsB], currentWorkspace := 0 }
  let s12b ← ViE.Feature.openWorkspaceExplorer s12a
  let s12c := s12b.updateActiveView fun v => { v with cursor := {row := ⟨5⟩, col := 0} }
  let s12d ← ViE.Feature.handleExplorerEnter s12c
  assertEqual "Workspace selection switches current workspace" "WS-B" s12d.getCurrentWorkspace.name
  assertEqual "Workspace selection restores active buffer" "WS-B" s12d.getActiveBuffer.table.toString

  IO.println "Starting Workspace Restore Layout ViETest..."
  let bufC : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 20 none #[stringToLine "WS-C1"]
  let bufD : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 21 none #[stringToLine "WS-C2"]
  let layoutC : Layout :=
    .hsplit
      (.window 0 { initialView with bufferId := 20 })
      (.window 1 { initialView with bufferId := 21, cursor := ⟨⟨0⟩, ⟨2⟩⟩ })
      0.3
  let wsC : WorkspaceState := {
    name := "WS-C"
    rootPath := none
    buffers := [bufC, bufD]
    nextBufferId := 22
    layout := layoutC
    activeWindowId := 1
    nextWindowId := 2
  }
  let s12e := s12.updateCurrentWorkgroup fun wg =>
    { wg with workspaces := #[wsA, wsC], currentWorkspace := 0 }
  let s12f ← ViE.Feature.openWorkspaceExplorer s12e
  let s12g := s12f.updateActiveView fun v => { v with cursor := {row := ⟨5⟩, col := 0} }
  let s12h ← ViE.Feature.handleExplorerEnter s12g
  assertEqual "Workspace layout restore (active buffer)" "WS-C2" s12h.getActiveBuffer.table.toString
  let ratio := match s12h.getCurrentWorkspace.layout with
    | .hsplit _ _ r => r
    | _ => 0.0
  assertEqual "Workspace layout restore (ratio)" 0.3 ratio
  let cursor := s12h.getCursor
  assertEqual "Workspace layout restore (cursor row)" 0 cursor.row.val
  assertEqual "Workspace layout restore (cursor col)" 2 cursor.col.val

  IO.println "Starting Workspace Restore VSplit ViETest..."
  let bufE : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 30 none #[stringToLine "WS-D1"]
  let bufF : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 31 none #[stringToLine "WS-D2"]
  let layoutD : Layout :=
    .vsplit
      (.window 0 { initialView with bufferId := 30, cursor := ⟨⟨1⟩, ⟨0⟩⟩, scrollRow := ⟨1⟩, scrollCol := ⟨2⟩ })
      (.window 1 { initialView with bufferId := 31, cursor := ⟨⟨2⟩, ⟨3⟩⟩, scrollRow := ⟨0⟩, scrollCol := ⟨1⟩ })
      0.7
  let wsD : WorkspaceState := {
    name := "WS-D"
    rootPath := none
    buffers := [bufE, bufF]
    nextBufferId := 32
    layout := layoutD
    activeWindowId := 1
    nextWindowId := 2
  }
  let s12i := s12.updateCurrentWorkgroup fun wg =>
    { wg with workspaces := #[wsA, wsC, wsD], currentWorkspace := 0 }
  let s12j ← ViE.Feature.openWorkspaceExplorer s12i
  let s12k := s12j.updateActiveView fun v => { v with cursor := {row := ⟨6⟩, col := 0} }
  let s12l ← ViE.Feature.handleExplorerEnter s12k
  assertEqual "Workspace vsplit restore (active buffer)" "WS-D2" s12l.getActiveBuffer.table.toString
  let ratio2 := match s12l.getCurrentWorkspace.layout with
    | .vsplit _ _ r => r
    | _ => 0.0
  assertEqual "Workspace vsplit restore (ratio)" 0.7 ratio2
  let cursor2 := s12l.getCursor
  assertEqual "Workspace vsplit restore (cursor row)" 2 cursor2.row.val
  assertEqual "Workspace vsplit restore (cursor col)" 3 cursor2.col.val
  let (sRow, sCol) := s12l.getScroll
  assertEqual "Workspace vsplit restore (scroll row)" 0 sRow.val
  assertEqual "Workspace vsplit restore (scroll col)" 1 sCol.val

  IO.println "Starting Workspace Multi-View Restore ViETest..."
  let bufG : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 40 none #[stringToLine "WS-E1"]
  let bufH : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 41 none #[stringToLine "WS-E2"]
  let bufI : FileBuffer := ViE.Buffer.fileBufferFromTextBuffer 42 none #[stringToLine "WS-E3"]
  let layoutE : Layout :=
    .hsplit
      (.vsplit
        (.window 0 { initialView with bufferId := 40, cursor := ⟨⟨0⟩, ⟨1⟩⟩, scrollRow := ⟨0⟩, scrollCol := ⟨0⟩ })
        (.window 1 { initialView with bufferId := 41, cursor := ⟨⟨2⟩, ⟨2⟩⟩, scrollRow := ⟨1⟩, scrollCol := ⟨1⟩ })
        0.4)
      (.window 2 { initialView with bufferId := 42, cursor := ⟨⟨3⟩, ⟨0⟩⟩, scrollRow := ⟨2⟩, scrollCol := ⟨2⟩ })
      0.6
  let wsE : WorkspaceState := {
    name := "WS-E"
    rootPath := none
    buffers := [bufG, bufH, bufI]
    nextBufferId := 43
    layout := layoutE
    activeWindowId := 1
    nextWindowId := 3
  }
  let s12m := s12.updateCurrentWorkgroup fun wg =>
    { wg with workspaces := #[wsA, wsC, wsD, wsE], currentWorkspace := 0 }
  let s12n ← ViE.Feature.openWorkspaceExplorer s12m
  let s12o := s12n.updateActiveView fun v => { v with cursor := {row := ⟨7⟩, col := 0} }
  let s12p ← ViE.Feature.handleExplorerEnter s12o
  assertEqual "Multi-view restore (active buffer)" "WS-E2" s12p.getActiveBuffer.table.toString
  let (sr2, sc2) := s12p.getScroll
  assertEqual "Multi-view restore (scroll row)" 1 sr2.val
  assertEqual "Multi-view restore (scroll col)" 1 sc2.val
  let cursor3 := s12p.getCursor
  assertEqual "Multi-view restore (cursor row)" 2 cursor3.row.val
  assertEqual "Multi-view restore (cursor col)" 2 cursor3.col.val
  assertEqual "Multi-view restore (layout hsplit ratio)" 0.6 (match s12p.getCurrentWorkspace.layout with | .hsplit _ _ r => r | _ => 0.0)
  let subRatio := match s12p.getCurrentWorkspace.layout with
    | .hsplit left _ _ =>
        match left with
        | .vsplit _ _ r => r
        | _ => 0.0
    | _ => 0.0
  assertEqual "Multi-view restore (layout vsplit ratio)" 0.4 subRatio

  IO.println "Starting Workspace Startup Target ViETest..."
  let base := "ViETest/test_paths"
  let absBase ← ViE.resolveAbsolutePath none base

  let dirOnly := s!"{base}/dir0"
  let absDirOnly ← ViE.resolveAbsolutePath none dirOnly
  let (wsDirOnly, fileDirOnly) ← ViE.resolveStartupTarget (some dirOnly)
  assertEqual "Directory arg sets workspace" (some absDirOnly) wsDirOnly
  assertEqual "Directory arg has no file" none fileDirOnly

  let fileNested := s!"{base}/dir0/dir1/dir2/file0.txt"
  let absFileNested ← ViE.resolveAbsolutePath none fileNested
  let nestedParent := match (System.FilePath.mk absFileNested).parent with
    | some p => p.toString
    | none => "/"
  let (wsFileNested, fileFileNested) ← ViE.resolveStartupTarget (some fileNested)
  assertEqual "File arg sets workspace to parent dir" (some nestedParent) wsFileNested
  assertEqual "File arg keeps filename" (some absFileNested) fileFileNested

  let fileTop := s!"{base}/file0.txt"
  let absFileTop ← ViE.resolveAbsolutePath none fileTop
  let (wsFileTop, fileFileTop) ← ViE.resolveStartupTarget (some fileTop)
  assertEqual "Top-level file sets workspace to base" (some absBase) wsFileTop
  assertEqual "Top-level file keeps filename" (some absFileTop) fileFileTop

  let newNested := s!"{base}/newdir/newfile.txt"
  let absNewNested ← ViE.resolveAbsolutePath none newNested
  let newNestedParent := match (System.FilePath.mk absNewNested).parent with
    | some p => p.toString
    | none => "/"
  let (wsNewNested, fileNewNested) ← ViE.resolveStartupTarget (some newNested)
  assertEqual "Nonexistent file sets workspace to parent dir" (some newNestedParent) wsNewNested
  assertEqual "Nonexistent file keeps absolute filename" (some absNewNested) fileNewNested

  IO.println "Starting Explorer Path Resolution ViETest..."
  let absTest ← ViE.resolveAbsolutePath none "ViETest"
  let s13 := s12.updateCurrentWorkspace fun ws =>
    { ws with rootPath := some absTest, name := "Test" }
  let s14 ← ViE.Feature.openExplorer s13 "."
  assertEqual "Explorer opens absolute workspace root from dot" (some s!"explorer://{absTest}") s14.getActiveBuffer.filename
  let s15 ← ViE.Feature.openExplorer s13 "test_paths"
  assertEqual "Explorer resolves relative path from absolute workspace root" (some s!"explorer://{absTest}/test_paths") s15.getActiveBuffer.filename

  IO.println "WorkspaceTest passed!"

end ViETest.Workspace