Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions

These instructions are for AI assistants working in this project.

Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding

Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines

Keep this managed block so 'openspec update' can refresh the instructions.

<!-- OPENSPEC:END -->

# Repository Guidelines

## 프로젝트 구조와 모듈 구성
이 저장소는 Neovim용 Lua 플러그인이다. 진입점은 `lua/gx/init.lua`이며 `lua/gx/handle_n.lua`와 `lua/gx/handle_v.lua`가 모드별 디스패치를 맡는다. `lua/gx/n.lua`와 `lua/gx/v.lua`는 핸들러 목록을 구성하고, 실제 핸들러는 `lua/gx/handlers_n/`와 `lua/gx/handlers_v/`에 있다. 새 핸들러를 추가할 때는 대응하는 `handlers_*/init.lua`에 등록한다. 재사용 유틸은 `lua/gx/lib/`에 두며 중첩 디렉터리는 만들지 않는다. 설치 예시는 `README.md`를 참고한다.

## 빌드, 테스트, 개발 명령
별도 빌드 스크립트는 없다. 로컬 확인은 플러그인을 로드한 Neovim에서 `:Gx` 또는 `gx` 키로 실행해 동작을 검증한다. 시각 선택 모드 동작은 텍스트를 선택한 뒤 `gx`로 확인한다. 문제 추적은 `:messages`와 `vim.notify` 출력으로 확인한다.

## 코딩 스타일과 네이밍
Lua는 2칸 들여쓰기, `local` 정의, 모듈 `return` 패턴을 유지한다. 문자열은 기존 파일과 같은 큰따옴표 스타일을 따른다. 파일명은 `github_commit_file.lua`처럼 snake_case를 사용한다. 핸들러 모듈은 `{ name, match, handler }` 형태의 테이블을 반환하고, `match`는 입력 문자열을 받아 매칭 정보를 돌려준다. JS/TS 파일을 추가한다면 세미콜론 없이 홑따옴표 문자열을 사용하고 모든 `if`에 중괄호를 포함한다.

## 테스트 가이드라인
현재 자동화 테스트가 없으므로 변경 시 핵심 동작을 Neovim에서 재현해 확인한다. 테스트를 추가할 경우 실행 방법과 의존성을 `README.md`에 명시하고, 외부 명령 호출은 모킹 가능한 형태로 분리한다.

## 커밋 & 풀 리퀘스트
커밋 메시지는 `feat:`, `fix:` 같은 Conventional Commits 형식을 따른다. PR에는 변경 요약, 관련 이슈 링크, 테스트 결과를 포함한다. UI 동작이 바뀌면 스크린샷이나 짧은 GIF를 첨부한다.

## 보안 및 설정
비밀 정보는 절대 커밋하지 않는다. 로컬 설정이 필요하면 루트 `.env`를 사용하고 문서에 필요한 키만 안내한다. 셸 명령을 조합할 때는 인자 이스케이프와 입력 검증을 우선한다.
156 changes: 147 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,160 @@
# gx

;wip
Neovim에서 `gx`로 현재 위치나 선택 텍스트에 맞는 작업을 실행하는 플러그인이다.

## Installation
### [lazy.nvim](https://github.com/folke/lazy.nvim)
## 설치
### lazy.nvim
```lua
{
"deptno/gx.nvim",
'deptno/gx.nvim',
opts = {},
keys = {
{
"gx",
"<cmd>Gx<cr>",
'gx',
'<cmd>Gx<cr>',
mode = {
"n",
"v",
'n',
'v',
},
desc = 'gx.nvim',
},
},
}
```

## 기본 사용
- 노말 모드에서 `gx`를 누르면 커서 기준으로 매칭되는 핸들러가 실행된다
- 비주얼 모드에서 텍스트를 선택한 뒤 `gx`를 누르면 선택 영역 기준으로 동작한다
- 여러 핸들러가 매칭되면 목록에서 선택한다

## 핸들러 설정
`opts.handlers`에 n/v 모드용 핸들러를 추가한다. 기본 핸들러는 유지된다.

```lua
opts = {
handlers = {
n = {
{
name = 'example',
ui = 'hover',
handler = function(ctx)
return ctx.matched
end,
},
},
v = {},
},
}
```

### 핸들러 스펙
- `name`: 선택 UI에 표시되는 이름
- `handler(ctx)`: 실행 함수
- `match(line|lines)` (선택): 매칭 함수
- `ui`: `'hover'` 사용 시 결과를 hover로 표시

`match`를 생략하면
- n: 커서 단어를 매칭 값으로 사용
- v: 선택된 텍스트 라인을 매칭 값으로 사용

`ctx`에 포함되는 값
- `mode`: `'n' | 'v'`
- `line`: 현재 라인 또는 선택 첫 줄
- `lines`: 선택된 라인 배열
- `range`: 선택 범위
- `matched`: `match` 결과
- `ui.show(text)`: 비동기 결과 표시 함수

`handler`가 문자열/라인 배열을 반환하면 즉시 표시되고, 비동기라면 `ctx.ui.show`를 호출한다.

## hover UI
- plain text로 표시한다
- 너비는 `vim.o.columns - 4`로 고정한다
- 커서 위치 기준으로 표시한다

## 환경변수와 시크릿
시크릿은 설정 파일에 두지 말고 환경변수에서 읽는다.

```bash
export GX_API_KEY=YOUR_API_KEY
export GX_API_URL=https://xxx.com/v1/chat/completions
```

## 예제: AI 번역 핸들러 (n/v 공용)
`plenary.job`을 사용하므로 `nvim-lua/plenary.nvim`가 필요하다.

```lua
local translate = function(ctx)
local token = vim.env.GX_API_KEY
local url = vim.env.GX_API_URL or 'https://xxx.com/v1/chat/completions'

if not token then
return 'GX_API_KEY가 없습니다'
end

local text = ctx.matched
if ctx.mode == 'v' then
text = table.concat(ctx.lines or {}, ' ')
end

if not text or text == '' then
return '번역할 텍스트가 없습니다'
end

local payload = vim.fn.json_encode({
model = 'ollama/demonbyron/HY-MT1.5-7B:latest',
messages = {
{
role = 'user',
content = 'Translate the following segment into Korean, without additional explanation.\n\nText:\n' .. text,
},
},
temperature = 0.3,
})

local Job = require('plenary.job')
Job:new({
command = 'curl',
args = {
url,
'-s',
'-H',
'Content-Type: application/json',
'-H',
'Authorization: Bearer ' .. token,
'-d',
payload,
},
on_exit = function(job)
local output = table.concat(job:result(), '\n')

vim.schedule(function()
local ok, decoded = pcall(vim.fn.json_decode, output)
local content = output

if ok and decoded and decoded.choices and decoded.choices[1] then
local message = decoded.choices[1].message or {}
content = message.content or decoded.choices[1].text or output
end

ctx.ui.show(content)
end)
end,
}):start()
end

return {
{
'deptno/gx.nvim',
opts = {
handlers = {
n = {
{ name = 'ai 번역', ui = 'hover', handler = translate },
},
v = {
{ name = 'ai 번역', ui = 'hover', handler = translate },
},
},
desc = "gx.nvim",
},
},
}
Expand Down
29 changes: 29 additions & 0 deletions lua/gx/config.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
local config = {
handlers = {
n = {},
v = {},
},
}

local set = function(params)
local handlers = params and params.handlers or {}

config.handlers.n = handlers.n or {}
config.handlers.v = handlers.v or {}
end

local get_handlers = function(mode)
if mode == "n" then
return config.handlers.n
end
if mode == "v" then
return config.handlers.v
end

return {}
end

return {
set = set,
get_handlers = get_handlers,
}
50 changes: 44 additions & 6 deletions lua/gx/handle_n.lua
Original file line number Diff line number Diff line change
@@ -1,27 +1,65 @@
local handlers = require("gx/n")
local open_hover = require("gx/lib/open_hover")

local handle_n = function()
local line = vim.api.nvim_get_current_line()
local matched_handlers = {}

for _, h in ipairs(handlers) do
if h.match(line) then
table.insert(matched_handlers, h)
local matched = h.match(line)

if matched then
table.insert(matched_handlers, { handler = h, matched = matched })
end
end

local run_user_handler = function(handler, matched)
local ui = {
show = function(text)
if handler.ui == "hover" then
vim.schedule(function()
open_hover(text)
end)
end
end,
}
local result = handler.handler({
mode = "n",
line = line,
lines = { line },
matched = matched,
ui = ui,
})

if handler.ui == "hover" and (type(result) == "string" or type(result) == "table") then
open_hover(result)
end
end

local run_handler = function(handler, matched)
if handler.kind == "user" then
return run_user_handler(handler, matched)
end

handler.handler(line, matched)
end

if #matched_handlers == 1 then
local matched_handler = matched_handlers[#matched_handlers]

matched_handler.handler(line, matched_handler.match(line))
run_handler(matched_handler.handler, matched_handler.matched)
elseif #matched_handlers > 1 then
vim.ui.select(matched_handlers, {
prompt = "handlers:",
format_item = function(h)
return h.name
format_item = function(item)
return item.handler.name
end,
}, function(selected)
selected.handler(line, selected.match(line))
if not selected then
return
end

run_handler(selected.handler, selected.matched)
end)
end
end
Expand Down
51 changes: 45 additions & 6 deletions lua/gx/handle_v.lua
Original file line number Diff line number Diff line change
@@ -1,30 +1,69 @@
local get_visual_selection = require("gx/lib/get_visual_selection")
local get_visual_selection_text = require("gx/lib/get_visual_selection_text")
local handlers = require("gx/v")
local open_hover = require("gx/lib/open_hover")

local handle_v = function()
local range = get_visual_selection()
local lines = get_visual_selection_text()
local matched_handlers = {}

for _, h in ipairs(handlers) do
if h.match(lines) then
table.insert(matched_handlers, h)
local matched = h.match(lines)

if matched then
table.insert(matched_handlers, { handler = h, matched = matched })
end
end

local run_user_handler = function(handler, matched)
local ui = {
show = function(text)
if handler.ui == "hover" then
vim.schedule(function()
open_hover(text)
end)
end
end,
}
local result = handler.handler({
mode = "v",
range = range,
line = lines and lines[1],
lines = lines,
matched = matched,
ui = ui,
})

if handler.ui == "hover" and (type(result) == "string" or type(result) == "table") then
open_hover(result)
end
end

local run_handler = function(handler, matched)
if handler.kind == "user" then
return run_user_handler(handler, matched)
end

handler.handler({ range = range, lines = lines })
end

if #matched_handlers == 1 then
local matched_handler = matched_handlers[#matched_handlers]

matched_handler.handler({ range = range, lines = lines })
run_handler(matched_handler.handler, matched_handler.matched)
elseif #matched_handlers > 1 then
vim.ui.select(matched_handlers, {
prompt = "handlers:",
format_item = function(h)
return h.name
format_item = function(item)
return item.handler.name
end,
}, function(selected)
selected.handler({ range = range, lines = lines })
if not selected then
return
end

run_handler(selected.handler, selected.matched)
end)
end
end
Expand Down
Loading