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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ dist/
build/
out/

# Python
__pycache__/
*.py[cod]

# IDE
.vscode/
.idea/
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A Neovim plugin that captures and displays console outputs as virtual text inlin

- Real-time Console Capture - See console outputs instantly as virtual text next to your code
- Browser Support - Automatic console capture for Next.js, React, Vue, and Vite projects
- Single-File Runner - Run standalone `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, and `.cts` files with console capture via Node.js Inspector
- Single-File Runner - Run standalone `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts`, and `.py` files with console capture via Node.js Inspector (JS/TS) or a zero-dependency Python bootstrap (Python 3.8+)
- Smart Object Display - Inline previews for small objects, floating inspector for large ones
- Zero Config - Works out of the box with intelligent project detection
- Accurate Line Mapping - Outputs appear exactly where they're logged using source maps
Expand Down Expand Up @@ -52,18 +52,29 @@ ConsoleLog automatically detects your project type and enables console capture:
### Project-Specific Behavior

**Single-File Execution** (`:ConsoleLogRun` or `<leader>lr`):
- Supports: `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts` files
- TypeScript requires Node >= 22.6 (native from 23.6; `--experimental-strip-types` added automatically for 22.6–23.5). Node type stripping supports only erasable TypeScript syntax and does not apply `tsconfig` transforms.
- Runs via Node.js Inspector with console capture
- **JavaScript/TypeScript**: `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts` files
- TypeScript requires Node >= 22.6 (native from 23.6; `--experimental-strip-types` added automatically for 22.6–23.5). Node type stripping supports only erasable TypeScript syntax and does not apply `tsconfig` transforms.
- Runs via Node.js Inspector with console capture
- **Python**: `.py` files — zero-dependency stdlib bootstrap, Python 3.8+
- Captures `print()`, `logging` records, raw `sys.stderr` writes, and uncaught exceptions
- Interpreter resolution: `runner.python_executable` config → `$VIRTUAL_ENV` → `.venv`/`venv` walking up from file → `python3`
- Auto re-runs on save for buffers previously run with `:ConsoleLogRun` (configurable via `runner.rerun_on_save`)
- Perfect for quick scripts and standalone JavaScript/TypeScript files
- Perfect for quick scripts and standalone JavaScript/TypeScript/Python files

**Browser Framework Projects** (automatic):
- Supports: `.js`, `.jsx`, `.ts`, `.tsx`
- Works with: Next.js, React, Vue, Vite, and any framework with source maps
- Automatically injects WebSocket console capture
- Just run `npm run dev` and start coding

**Python** (single-file execution):
- `print()` calls are captured with source location (file + line number)
- `logging` records at WARNING+ are captured by default; lower levels are captured if the script configures its own logging level or calls `logging.basicConfig(level=...)`
- Raw `sys.stderr` writes are buffered per-line and emitted as error events
- Uncaught exceptions (including `SyntaxError` and non-zero `SystemExit`) report the deepest relevant traceback frame
- Interpreter resolution order: `runner.python_executable` config key → `$VIRTUAL_ENV/bin/python` → `.venv/bin/python` or `venv/bin/python` walking up from the script's directory → system `python3`
- Zero external dependencies — the bootstrap is a single stdlib-only Python 3.8+ script (`py/consolelog_runner.py`)

## Commands & Keybindings

### Core Commands
Expand Down Expand Up @@ -135,6 +146,7 @@ The plugin works out of the box with sensible defaults. Here's the full configur
},
runner = {
rerun_on_save = true, -- Re-run single-file buffers on save after :ConsoleLogRun
python_executable = nil, -- Override Python interpreter (nil = auto-detect)
},
keymaps = {
enabled = true, -- Enable default keymaps
Expand Down Expand Up @@ -175,7 +187,7 @@ The plugin works out of the box with sensible defaults. Here's the full configur
"ConsoleLogDebug",
"ConsoleLogDebugClear",
},
ft = { "javascript", "typescript", "javascriptreact", "typescriptreact" },
ft = { "javascript", "typescript", "javascriptreact", "typescriptreact", "python" },
}
```

Expand Down
18 changes: 10 additions & 8 deletions js/nextjs-auto-injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
const fs = require('fs');
const path = require('path');

const MARKER = '/* ConsoleLog.nvim auto-injection */';
const START_MARKER = '// ConsoleLog.nvim auto-injection start';
const END_MARKER = '// ConsoleLog.nvim auto-injection end';
const WS_PORT = __WS_PORT__;
const PROJECT_ID = '__PROJECT_ID__';

Expand All @@ -16,7 +17,8 @@
try {
if (!fs.existsSync(filepath)) return false;
const content = fs.readFileSync(filepath, 'utf8');
return !content.includes('window.__CONSOLELOG_WS_PORT');
return !content.includes(START_MARKER) ||
!content.includes(`window.__CONSOLELOG_WS_PORT = ${WS_PORT};`);
} catch (err) {
return false;
}
Expand All @@ -25,14 +27,13 @@
function patchFile(filepath) {
try {
const backup = filepath + '.bk';
if (!fs.existsSync(backup)) {
let content = fs.readFileSync(filepath, 'utf8');
if (!fs.existsSync(backup) || !content.includes('window.__CONSOLELOG_WS_PORT')) {
fs.copyFileSync(filepath, backup);
}

let content = fs.readFileSync(filepath, 'utf8');

if (content.includes(MARKER)) {
return true;
if (content.includes('window.__CONSOLELOG_WS_PORT') && fs.existsSync(backup)) {
content = fs.readFileSync(backup, 'utf8');
}

const clientCodePath = path.join(__dirname, '../../../.consolelog-client-inject.js');
Expand All @@ -43,14 +44,15 @@
return false;
}

const injection = `${MARKER}
const injection = `${START_MARKER}
if (typeof window !== "undefined") {
window.__CONSOLELOG_WS_PORT = ${WS_PORT};
window.__CONSOLELOG_PROJECT_ID = "${PROJECT_ID}";
window.__CONSOLELOG_FRAMEWORK = "Next.js";
window.__CONSOLELOG_DEBUG = true;
}
${clientCode}
${END_MARKER}
`;

if (content.match(/^['"]use client['"]/m)) {
Expand Down
250 changes: 250 additions & 0 deletions lua/consolelog/communication/python_runner.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
local M = {}
local constants = require("consolelog.core.constants")

M.sessions = {}
M._intentionally_stopped_jobs = {}

local _run_counter = 0

function M._generate_run_id()
_run_counter = _run_counter + 1
return string.format("cl_%d_%d", os.time(), _run_counter)
end

function M.get_plugin_root()
local current_file = debug.getinfo(1, "S").source:sub(2)
return vim.fn.fnamemodify(current_file, ":p:h:h:h:h") .. "/"
end

function M.get_runner_script()
local script = M.get_plugin_root() .. "py/consolelog_runner.py"
if vim.fn.filereadable(script) ~= 1 then
return nil
end
return script
end

function M.resolve_python_executable(filepath)
-- (1) Config override
local ok, cfg = pcall(function() return require("consolelog").config.runner.python_executable end)
if ok and cfg and vim.fn.executable(cfg) == 1 then
return cfg
end

-- (2) VIRTUAL_ENV
local venv = os.getenv("VIRTUAL_ENV")
if venv then
local venv_exe = venv .. "/bin/python"
if vim.fn.executable(venv_exe) == 1 then
return venv_exe
end
end

-- (3) Walk up from file directory looking for .venv/bin/python or venv/bin/python
local dir = vim.fn.fnamemodify(filepath, ":h")
for _ = 1, constants.FILES.MAX_PATH_DEPTH do
for _, venv_dir in ipairs({ ".venv", "venv" }) do
local candidate = dir .. "/" .. venv_dir .. "/bin/python"
if vim.fn.executable(candidate) == 1 then
return candidate
end
end
local parent = vim.fn.fnamemodify(dir, ":h")
if parent == dir then break end
dir = parent
end

-- (4) Fallback to python3
if vim.fn.executable("python3") == 1 then
return "python3"
end

return nil, "No Python interpreter found (need python3 or a virtualenv)"
end

function M.build_run_command(filepath)
local exe, err = M.resolve_python_executable(filepath)
if not exe then
return nil, err
end

local script = M.get_runner_script()
if not script then
return nil, "Python runner script not found at " .. M.get_plugin_root() .. "py/consolelog_runner.py"
end

return { exe, script, filepath }
end

function M.parse_event(line)
local sentinel = "__CONSOLELOG_EVENT__"
local search_from = 1

while search_from <= #line do
local sentinel_pos = line:find(sentinel, search_from, true)
if not sentinel_pos then
return nil
end

local payload = line:sub(sentinel_pos + #sentinel)
local ok, result = pcall(vim.json.decode, payload)
if ok and type(result) == "table" and result.event ~= nil then
return result
end

-- Invalid event at this sentinel; try the next occurrence
search_from = sentinel_pos + #sentinel
end

return nil
end

function M.handle_event(session, event)
if session.cancelled then
return
end

if event.event == "console" then
if vim.fn.fnamemodify(event.file, ":p") ~= vim.fn.fnamemodify(session.filepath, ":p") then
return
end

local args = {}
for _, arg in ipairs(event.args or {}) do
if arg == vim.NIL then
table.insert(args, "None")
else
table.insert(args, arg)
end
end

local message_processor = require("consolelog.processing.message_processor_impl")
local display = require("consolelog.display.display")

vim.schedule(function()
if session.cancelled then
return
end
local output, raw_value = message_processor.format_args(args, event.method, true)
display.update_output(session.bufnr, event.line, output, event.method, raw_value)
end)
elseif event.event == "exception" then
if vim.fn.fnamemodify(event.file, ":p") ~= vim.fn.fnamemodify(session.filepath, ":p") then
return
end

local text = (event.text or ""):match("[^\n]+") or event.text or ""
local display = require("consolelog.display.display")

vim.schedule(function()
if session.cancelled then
return
end
display.update_output(session.bufnr, event.line, text, "error", event.text)
end)
end
end

function M.start_debug_session(filepath, bufnr)
local cmd, err = M.build_run_command(filepath)
if not cmd then
vim.notify("ConsoleLog: " .. err, vim.log.levels.ERROR)
return nil
end

local run_id = M._generate_run_id()

local session = {
filepath = filepath,
bufnr = bufnr,
job_id = nil,
run_id = run_id,
_stdout_buffer = "",
}

local session_id = nil

session.job_id = vim.fn.jobstart(cmd, {
stdout_buffered = false,
stderr_buffered = false,
env = { CONSOLELOG_RUN_ID = run_id },
on_stdout = function(_, data)
session._stdout_buffer = session._stdout_buffer .. table.concat(data, "\n")
while true do
local newline_pos = session._stdout_buffer:find("\n")
if not newline_pos then break end
local line = session._stdout_buffer:sub(1, newline_pos - 1)
session._stdout_buffer = session._stdout_buffer:sub(newline_pos + 1)
local event = M.parse_event(line)
if event and event.run_id == session.run_id then
M.handle_event(session, event)
end
end
end,
on_exit = function(job_id, exit_code)
local was_intentional = M._intentionally_stopped_jobs[job_id]
M._intentionally_stopped_jobs[job_id] = nil

local sid = tostring(job_id)
M.sessions[sid] = nil

if exit_code ~= 0 and not was_intentional then
vim.notify("Process exited with code " .. exit_code, vim.log.levels.ERROR)
end
end,
})

if session.job_id == nil or session.job_id <= 0 then
return nil
end

session_id = tostring(session.job_id)
M.sessions[session_id] = session
require("consolelog.communication.inspector").single_file_buffers[bufnr] = filepath

return session_id
end

function M.get_session_for_buffer(bufnr)
for _, session in pairs(M.sessions) do
if session.bufnr == bufnr then
return session
end
end
return nil
end

function M.cleanup_session(session)
session.cancelled = true

if session.bufnr then
local display = require("consolelog.display.display")
display.clear_buffer(session.bufnr)
end

if session.job_id then
M._intentionally_stopped_jobs[session.job_id] = true
vim.fn.jobstop(session.job_id)
session.job_id = nil
end

for id, s in pairs(M.sessions) do
if s == session then
M.sessions[id] = nil
break
end
end
end

function M.stop_all_sessions()
local inspector = require("consolelog.communication.inspector")
for _, session in pairs(M.sessions) do
M.cleanup_session(session)
if session.bufnr and inspector.single_file_buffers then
inspector.single_file_buffers[session.bufnr] = nil
end
end
M.sessions = {}
end

return M
Loading