Skip to content

Commit c6e9fa1

Browse files
committed
refactor: Phase 2 single parse authority and session lifecycle
Add poste run --describe as the sole HTTP block metadata source, wire Lua through describe.lua, and clear request-scoped state via HTTP/SQL sessions on each run_* entry so prior request data cannot leak.
1 parent 2316e06 commit c6e9fa1

18 files changed

Lines changed: 962 additions & 113 deletions

File tree

.opencode/skills/http/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ crosses protocols.
6262
| `context_detector.lua` | Detect context at cursor (within `###` block, inside `{%`, etc.) |
6363
| `data.lua` | Dynamic data definitions for the HTTP script API |
6464
| `highlights.lua` | Syntax highlighting for HTTP result buffers |
65-
| `cache.lua` | Response caching (ETag, Last-Modified) |
65+
| `cache.lua` | UI buffer index (line types, block bounds); semantic via describe |
66+
| `describe.lua` | Single parse authority — `poste run --describe` |
67+
| `session.lua` | Per-request session lifecycle (clears request-scoped state) |
6668
| `boundary_indicator.lua` | `###` block boundary indicator line |
6769
| `history.lua` | Request history: floating UI, persistence, navigation |
6870

LEARNINGS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
Agent self-evolution log. When you fix a non-obvious bug or encounter a
44
pitfall, log it here. Check this file before starting any task.
55

6-
- 2026-07-08: infra — Added `tools/relation-check.sh` for pre-flight code relation scanning. Run before modifying HTTP Lua code. Covers: `nvim_buf_set_lines` + `sanitize_lines` coverage, state field lifecycle (SET/READ/CLEAR), format function callers, pre-render consistency. Replaces the need for a manually-synced relation map. See `tools/relation-check.sh`.
6+
- 2026-07-19: Phase2 F1 — HTTP method/path/headers must not be re-parsed in Lua (`run.lua` / `indicators.extract_request_block` for semantics). Use `poste run --describe` via `lua/poste/http/describe.lua`. `cache.lua` keeps only UI line_type/bounds; semantic blocks via `cache.get_semantic_blocks()`. See `crates/poste-core/src/parser/mod.rs` `describe_blocks`, `crates/poste-cli/src/run.rs --describe`.
7+
- 2026-07-19: Phase2 F5 — Request-scoped state (`last_response`, `last_responses`, `response_index`, `_json` filter fields, `pending_request`, assertions/logs) must be cleared at every `run_*` entry via `http/session.begin()` / `sql/session.begin()`. Persistent: `global_vars`, `script_variables`, `http_history`, `current_env`, SQL connection/database. See `lua/poste/http/session.lua`.
8+
- 2026-07-08: infra — Added `tools/relation-check.sh` for pre-flight code relation scanning. Run before modifying HTTP Lua code. Covers: `nvim_buf_set_lines` + `sanitize_lines` coverage, state field lifecycle (SET/READ/CLEAR), format function callers, pre-render consistency, session lifecycle. See `tools/relation-check.sh`.
79

810
- 2026-07-05: HTTP Verbose tab shows `{{base_url}}/users/42` instead of resolved URL. Fix: `build_pending_request` in `run.lua:212` only resolved `@var` definitions but not `{{var}}` from env.json. Added env.json var resolution with iterative chaining. See `lua/poste/http/run.lua:212`.
911
- 2026-07-05: Picker forced to snacks.nvim. Removed auto-detection (telescope/fzf/mini/snacks chain). `select.lua` now requires snacks as hard dependency, with built-in float + vim.ui.select fallbacks. Normalizes items to `{key, name, description}` format. See `lua/poste/select.lua`.

crates/poste-cli/src/run.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@ pub struct RunArgs {
99
/// File path (used for env.json discovery and extension detection;
1010
/// with --stdin the file does not need to exist on disk)
1111
pub file: String,
12-
/// Line number
13-
#[arg(short, long)]
12+
/// Line number (required for execution; ignored with --describe unless filtering)
13+
#[arg(short, long, default_value = "1")]
1414
pub line: usize,
1515
/// Environment name
1616
#[arg(short, long, default_value = "dev")]
1717
pub env: String,
1818
/// Output as JSON (for Neovim plugin consumption)
1919
#[arg(long)]
2020
pub json: bool,
21+
/// Describe all request blocks as JSON metadata (no execution).
22+
/// Single source of truth for block name/line/method/path/headers.
23+
#[arg(long)]
24+
pub describe: bool,
2125
/// Read request content from stdin instead of from the file
2226
#[arg(long)]
2327
pub stdin: bool,
@@ -97,6 +101,15 @@ pub async fn execute(args: RunArgs) -> Result<()> {
97101
std::fs::read_to_string(&canonical)?
98102
};
99103

104+
// --describe: emit block metadata JSON and exit (no network I/O).
105+
// Always returns a JSON array of BlockMeta — single parse authority for Lua.
106+
if args.describe {
107+
let parser = poste_core::Parser::new(env_vars);
108+
let blocks = parser.describe_blocks(&content, &file_ext)?;
109+
println!("{}", serde_json::to_string(&blocks)?);
110+
return Ok(());
111+
}
112+
100113
// Parse the request
101114
let parser = poste_core::Parser::new(env_vars.clone());
102115
let mut request = parser.parse_at_line(&content, args.line, &file_ext)?;

crates/poste-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ pub mod sql_parser;
1111

1212
pub use env::{substitute_vars, Environment};
1313
pub use formatter::{Formatter, Region, ScriptStyle, ScriptType, Tokenizer, VarStyle};
14-
pub use parser::{Parser, VarResolver};
14+
pub use parser::{BlockMeta, Parser, VarResolver};
1515
pub use request::{replace_database_in_url, Protocol, Request};

crates/poste-core/src/parser/mod.rs

Lines changed: 259 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
11
use crate::request::{Protocol, Request};
22
use anyhow::Result;
33
use regex::Regex;
4+
use serde::{Deserialize, Serialize};
45
use std::collections::HashMap;
56
use std::sync::OnceLock;
67

78
pub mod vars;
89
pub use vars::VarResolver;
910

11+
/// Structured metadata for a single `###` request block.
12+
/// Emitted by `poste run --describe` so Lua does not re-parse HTTP semantics.
13+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14+
pub struct BlockMeta {
15+
/// Request name from the `### Name` line (empty string if unnamed).
16+
pub name: String,
17+
/// 1-indexed start line of the block (`###` line, or 1 if no separator).
18+
pub line: usize,
19+
/// 1-indexed inclusive end line of the block content.
20+
pub end_line: usize,
21+
/// HTTP method / Redis command / SCRIPT / first token of request line.
22+
pub method: String,
23+
/// URL path (HTTP) or remainder of the request line (other protocols).
24+
pub path: String,
25+
/// Request headers as `[name, value]` pairs (HTTP only; empty otherwise).
26+
pub headers: Vec<(String, String)>,
27+
/// Request body after headers (may be empty).
28+
pub body: String,
29+
/// Full first request line (e.g. `GET https://example.com/users`).
30+
pub request_line: String,
31+
}
32+
33+
/// A raw `###` block with absolute line numbers (1-indexed).
34+
struct RawBlock {
35+
start_line: usize,
36+
end_line: usize,
37+
content: String,
38+
}
39+
1040
pub struct Parser {
1141
env: HashMap<String, String>,
1242
}
@@ -27,43 +57,187 @@ impl Parser {
2757
}
2858
}
2959

60+
/// Split content into raw blocks by `###` markers, tracking 1-indexed lines.
61+
fn split_raw_blocks(content: &str) -> Vec<RawBlock> {
62+
let mut blocks = Vec::new();
63+
let mut current = String::new();
64+
let mut start_line: usize = 1;
65+
let mut line_no: usize = 0;
66+
67+
for line in content.lines() {
68+
line_no += 1;
69+
if line.trim().starts_with("###") && !current.is_empty() {
70+
blocks.push(RawBlock {
71+
start_line,
72+
end_line: line_no - 1,
73+
content: std::mem::take(&mut current),
74+
});
75+
start_line = line_no;
76+
}
77+
current.push_str(line);
78+
current.push('\n');
79+
}
80+
if !current.is_empty() {
81+
blocks.push(RawBlock {
82+
start_line,
83+
end_line: line_no.max(1),
84+
content: current,
85+
});
86+
}
87+
blocks
88+
}
89+
3090
/// Parse a request file and extract the request at the given line.
3191
/// `file_ext` is the file extension (without dot), used for protocol detection.
3292
pub fn parse_at_line(&self, content: &str, line_num: usize, file_ext: &str) -> Result<Request> {
3393
let protocol = Self::detect_protocol(file_ext);
34-
35-
// Extract file-level variables (before first ###)
3694
let file_vars = self.extract_file_variables(content);
95+
let blocks = Self::split_raw_blocks(content);
3796

38-
// Split content into request blocks by ### markers
39-
let mut blocks = Vec::new();
40-
let mut current_block = String::new();
41-
42-
for line in content.lines() {
43-
if line.trim().starts_with("###") && !current_block.is_empty() {
44-
blocks.push(current_block.clone());
45-
current_block.clear();
97+
for block in &blocks {
98+
if line_num >= block.start_line && line_num <= block.end_line {
99+
return self.parse_block(&block.content, protocol, &file_vars);
46100
}
47-
current_block.push_str(line);
48-
current_block.push('\n');
49-
}
50-
if !current_block.is_empty() {
51-
blocks.push(current_block);
101+
// Fallback: cursor on inter-block separator falls into the next block
102+
// only when line_num matches start; otherwise use cumulative range like before.
52103
}
53104

54-
// Find the block containing the cursor line
55-
let mut current_line = 0;
105+
// Preserve prior semantics: first block whose cumulative end >= line_num.
106+
let mut current_line = 0usize;
56107
for block in &blocks {
57-
let block_lines = block.lines().count();
108+
let block_lines = block.content.lines().count();
58109
if current_line + block_lines >= line_num {
59-
return self.parse_block(block, protocol, &file_vars);
110+
return self.parse_block(&block.content, protocol, &file_vars);
60111
}
61112
current_line += block_lines;
62113
}
63114

64115
anyhow::bail!("No request found at line {}", line_num);
65116
}
66117

118+
/// Describe all request blocks in `content` as structured metadata.
119+
///
120+
/// This is the single parse authority for block name/line/method/path/headers.
121+
/// Variable substitution uses the same rules as `parse_at_line`.
122+
pub fn describe_blocks(&self, content: &str, file_ext: &str) -> Result<Vec<BlockMeta>> {
123+
let protocol = Self::detect_protocol(file_ext);
124+
let file_vars = self.extract_file_variables(content);
125+
let raw_blocks = Self::split_raw_blocks(content);
126+
let mut out = Vec::with_capacity(raw_blocks.len());
127+
128+
for raw in raw_blocks {
129+
// Skip pure preamble (file-level vars only, no ### and no request line)
130+
let has_separator = raw.content.lines().any(|l| l.trim().starts_with("###"));
131+
let request = match self.parse_block(&raw.content, protocol.clone(), &file_vars) {
132+
Ok(r) => r,
133+
Err(_) if !has_separator => continue, // file preamble without a request
134+
Err(e) => return Err(e),
135+
};
136+
137+
let body_str = request.body_str();
138+
let (method, path, headers, body, request_line) =
139+
Self::extract_request_parts(body_str, &protocol);
140+
141+
// File-level preamble parses as empty Request — skip unless it has a real request
142+
if request_line.is_empty() && !has_separator {
143+
continue;
144+
}
145+
146+
out.push(BlockMeta {
147+
name: request.name.unwrap_or_default(),
148+
line: raw.start_line,
149+
end_line: raw.end_line,
150+
method,
151+
path,
152+
headers,
153+
body,
154+
request_line,
155+
});
156+
}
157+
158+
Ok(out)
159+
}
160+
161+
/// Split a resolved request body into method / path / headers / body parts.
162+
fn extract_request_parts(
163+
body: &str,
164+
protocol: &Protocol,
165+
) -> (String, String, Vec<(String, String)>, String, String) {
166+
let lines: Vec<&str> = body.lines().collect();
167+
if lines.is_empty() {
168+
return (
169+
String::new(),
170+
String::new(),
171+
Vec::new(),
172+
String::new(),
173+
String::new(),
174+
);
175+
}
176+
177+
// Find first non-empty non-comment line as the request line
178+
let mut idx = 0usize;
179+
while idx < lines.len() {
180+
let t = lines[idx].trim();
181+
if t.is_empty() || t.starts_with('#') {
182+
idx += 1;
183+
continue;
184+
}
185+
break;
186+
}
187+
if idx >= lines.len() {
188+
return (
189+
String::new(),
190+
String::new(),
191+
Vec::new(),
192+
String::new(),
193+
String::new(),
194+
);
195+
}
196+
197+
let request_line = lines[idx].trim().to_string();
198+
let (method, path) = match request_line.split_once(char::is_whitespace) {
199+
Some((m, rest)) => (m.to_string(), rest.trim().to_string()),
200+
None => (request_line.clone(), String::new()),
201+
};
202+
203+
let mut headers = Vec::new();
204+
let mut body_start = idx + 1;
205+
206+
if matches!(protocol, Protocol::Http) {
207+
let mut i = idx + 1;
208+
while i < lines.len() {
209+
let t = lines[i].trim();
210+
if t.is_empty() {
211+
body_start = i + 1;
212+
break;
213+
}
214+
if let Some((k, v)) = lines[i].split_once(':') {
215+
headers.push((k.trim().to_string(), v.trim().to_string()));
216+
body_start = i + 1;
217+
} else {
218+
// Non-header line without blank separator — treat as body start
219+
body_start = i;
220+
break;
221+
}
222+
i += 1;
223+
if i == lines.len() {
224+
body_start = i;
225+
}
226+
}
227+
} else {
228+
// Non-HTTP: everything after request line is body
229+
body_start = idx + 1;
230+
}
231+
232+
let body = if body_start < lines.len() {
233+
lines[body_start..].join("\n")
234+
} else {
235+
String::new()
236+
};
237+
238+
(method, path, headers, body, request_line)
239+
}
240+
67241
fn parse_block(
68242
&self,
69243
block: &str,
@@ -877,4 +1051,70 @@ X-Val: {{a}}
8771051
let body = req.body_str();
8781052
assert!(body.contains("X-Val: {{b}}") || body.contains("X-Val: {{a}}"));
8791053
}
1054+
1055+
// ---- describe_blocks (Phase 2 single parse authority) ----
1056+
1057+
#[test]
1058+
fn test_describe_blocks_basic() {
1059+
let parser = Parser::new(HashMap::new());
1060+
let content = r#"@base = http://example.com
1061+
1062+
### Get Users
1063+
GET {{base}}/api/users
1064+
Accept: application/json
1065+
1066+
### Create User
1067+
POST {{base}}/api/users
1068+
Content-Type: application/json
1069+
1070+
{"name": "Ada"}
1071+
"#;
1072+
let blocks = parser.describe_blocks(content, "http").unwrap();
1073+
assert_eq!(blocks.len(), 2);
1074+
1075+
assert_eq!(blocks[0].name, "Get Users");
1076+
assert_eq!(blocks[0].line, 3);
1077+
assert_eq!(blocks[0].method, "GET");
1078+
assert_eq!(blocks[0].path, "http://example.com/api/users");
1079+
assert_eq!(blocks[0].headers.len(), 1);
1080+
assert_eq!(blocks[0].headers[0].0, "Accept");
1081+
assert_eq!(blocks[0].headers[0].1, "application/json");
1082+
assert_eq!(blocks[0].request_line, "GET http://example.com/api/users");
1083+
1084+
assert_eq!(blocks[1].name, "Create User");
1085+
assert_eq!(blocks[1].method, "POST");
1086+
assert!(blocks[1].body.contains(r#""name": "Ada""#));
1087+
assert_eq!(blocks[1].headers[0].0, "Content-Type");
1088+
}
1089+
1090+
#[test]
1091+
fn test_describe_blocks_line_numbers() {
1092+
let parser = Parser::new(HashMap::new());
1093+
let content = "### A\nGET /a\n\n### B\nGET /b\n";
1094+
let blocks = parser.describe_blocks(content, "http").unwrap();
1095+
assert_eq!(blocks.len(), 2);
1096+
assert_eq!(blocks[0].line, 1);
1097+
assert_eq!(blocks[0].end_line, 3); // includes blank line before next ###
1098+
assert_eq!(blocks[1].line, 4);
1099+
assert_eq!(blocks[1].method, "GET");
1100+
assert_eq!(blocks[1].path, "/b");
1101+
}
1102+
1103+
#[test]
1104+
fn test_describe_blocks_skips_preamble_only() {
1105+
let parser = Parser::new(HashMap::new());
1106+
// Content with only file-level vars and no ### — no blocks
1107+
let content = "@x = 1\n@y = 2\n";
1108+
let blocks = parser.describe_blocks(content, "http").unwrap();
1109+
assert!(blocks.is_empty());
1110+
}
1111+
1112+
#[test]
1113+
fn test_describe_blocks_script() {
1114+
let parser = Parser::new(HashMap::new());
1115+
let content = "### Setup\nSCRIPT\n";
1116+
let blocks = parser.describe_blocks(content, "http").unwrap();
1117+
assert_eq!(blocks.len(), 1);
1118+
assert_eq!(blocks[0].method, "SCRIPT");
1119+
}
8801120
}

0 commit comments

Comments
 (0)