1686 lines
54 KiB
Rust
1686 lines
54 KiB
Rust
//! The `ctxpack` command packs mixed inputs into compact context blocks.
|
|
|
|
use codeindex::{CodeIndexer, detect_language};
|
|
use common::{
|
|
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice,
|
|
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
|
|
should_read_stdin,
|
|
};
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
const KIND_DEFINITION: &str = "definition";
|
|
const KIND_CODESHAPE: &str = "codeshape";
|
|
const KIND_FILE: &str = "file";
|
|
const KIND_SNIPPET: &str = "snippet";
|
|
const HEADER_BLOCK: &str = "block";
|
|
const HEADER_FILE: &str = "file";
|
|
const HEADER_SNIPPET: &str = "snippet";
|
|
const PATH_STDIN: &str = "<stdin>";
|
|
use std::collections::BTreeSet;
|
|
use std::env;
|
|
use std::ffi::OsString;
|
|
use std::fmt::Write as _;
|
|
use std::fs;
|
|
use std::io::{self, BufRead, Read};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::SystemTime;
|
|
|
|
const HELP: &str = "\
|
|
Pack files, hits, snippets, and definition records into compact context blocks.
|
|
|
|
Usage:
|
|
ctxpack [OPTIONS] [INPUT...]
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--quiet Suppress non-essential status output
|
|
--max-blocks <COUNT> Maximum number of blocks to emit
|
|
--max-lines <COUNT> Maximum number of lines per block
|
|
--dedupe Collapse duplicate blocks
|
|
--sort <MODE> Block order: input, path, recent
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Examples:
|
|
ctxpack .\\fixtures\\polyglot\\repo\\src\\lib.rs:26
|
|
refs helper .\\fixtures\\polyglot\\repo --json | ctxpack --input-format auto
|
|
rg -nH \"helper\" .\\fixtures\\polyglot\\repo\\src\\lib.rs | hitsnip --def --json | ctxpack --input-format auto
|
|
diagpick .\\fixtures\\diag\\rust-errors.txt --json | ctxpack --input-format auto --json | ConvertFrom-Json
|
|
'.\\fixtures\\polyglot\\repo\\src\\lib.rs' | ctxpack --json | ConvertFrom-Json
|
|
|
|
Notes:
|
|
JSON input may be a direct block record or a wrapper object with arrays such as:
|
|
items, records, diagnostics, hits, blocks, files, source, snippet, or enclosing_definition
|
|
Wrapper arrays expand in-place; scalar source/snippet/definition records become one block each
|
|
Definition records dedupe by source location automatically; --dedupe also collapses identical non-definition blocks
|
|
When JSON records contain both snippet and definition payloads, ctxpack prefers the definition block
|
|
";
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Cli {
|
|
common: CommonArgs,
|
|
inputs: Vec<String>,
|
|
max_blocks: usize,
|
|
max_lines: usize,
|
|
dedupe: bool,
|
|
sort_mode: SortMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ParseOutcome {
|
|
Help,
|
|
Version,
|
|
Run,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SortMode {
|
|
Input,
|
|
Path,
|
|
Recent,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct ContextBlock {
|
|
path: String,
|
|
kind: String,
|
|
header: String,
|
|
start_line: Option<usize>,
|
|
end_line: Option<usize>,
|
|
truncated: bool,
|
|
text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum InputItem {
|
|
Text(String),
|
|
Json(Value),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct LineHit {
|
|
path: String,
|
|
line: usize,
|
|
column: Option<usize>,
|
|
}
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
#[must_use]
|
|
pub fn main_entry() -> i32 {
|
|
match parse_cli_from(std::env::args_os()) {
|
|
Ok((ParseOutcome::Help, _)) => {
|
|
print!("{HELP}");
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
println!("ctxpack {}", env!("CARGO_PKG_VERSION"));
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
Ok(code) => code.as_i32(),
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
},
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString>,
|
|
{
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
let mut cli = Cli {
|
|
common: CommonArgs::default(),
|
|
inputs: Vec::new(),
|
|
max_blocks: 32,
|
|
max_lines: 40,
|
|
dedupe: false,
|
|
sort_mode: SortMode::Input,
|
|
};
|
|
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
|
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
|
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
cli.common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("input-format") => {
|
|
cli.common.input_format =
|
|
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
|
|
}
|
|
Long("color") => {
|
|
cli.common.color =
|
|
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
|
}
|
|
Long("quiet") => cli.common.quiet = true,
|
|
Long("max-blocks") => {
|
|
cli.max_blocks = parse_positive_usize_flag(
|
|
"--max-blocks",
|
|
&parser_value_string(&mut parser, "--max-blocks")?,
|
|
)?;
|
|
}
|
|
Long("max-lines") => {
|
|
cli.max_lines = parse_positive_usize_flag(
|
|
"--max-lines",
|
|
&parser_value_string(&mut parser, "--max-lines")?,
|
|
)?;
|
|
}
|
|
Long("dedupe") => cli.dedupe = true,
|
|
Long("sort") => {
|
|
cli.sort_mode = parse_sort_mode(&parser_value_string(&mut parser, "--sort")?)?;
|
|
}
|
|
ArgValue(value) => cli.inputs.push(os_value_string(value, "input")?),
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((ParseOutcome::Run, cli))
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
let items = load_inputs(cli)?;
|
|
if items.is_empty() {
|
|
return Err(CliError::usage(
|
|
"provide at least one input or pipe inputs into stdin",
|
|
));
|
|
}
|
|
|
|
let mut indexer = CodeIndexer::new();
|
|
let mut blocks = Vec::new();
|
|
for item in items {
|
|
blocks.extend(materialize_blocks(cli, &item, &mut indexer)?);
|
|
}
|
|
|
|
blocks = dedupe_definition_blocks(blocks);
|
|
if cli.dedupe {
|
|
blocks = dedupe_blocks(blocks);
|
|
}
|
|
sort_blocks(&mut blocks, cli.sort_mode);
|
|
if blocks.len() > cli.max_blocks {
|
|
blocks.truncate(cli.max_blocks);
|
|
}
|
|
|
|
match cli.common.render_mode() {
|
|
RenderMode::Json => print_json(&blocks)?,
|
|
RenderMode::Toon => print_structured(&blocks, RenderMode::Toon)?,
|
|
RenderMode::Text => {
|
|
if blocks.is_empty() {
|
|
if !cli.common.quiet {
|
|
println!("0 context blocks");
|
|
}
|
|
} else {
|
|
print!("{}", render_blocks(&blocks));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(if blocks.is_empty() {
|
|
ExitCode::NoResults
|
|
} else {
|
|
ExitCode::Success
|
|
})
|
|
}
|
|
|
|
fn load_inputs(cli: &Cli) -> Result<Vec<InputItem>, CliError> {
|
|
if should_read_stdin(!cli.inputs.is_empty(), cli.common.stdin_is_terminal()) {
|
|
let mut buffer = String::new();
|
|
io::stdin()
|
|
.read_to_string(&mut buffer)
|
|
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
|
let items = parse_input_items(&buffer, cli.common.input_format)?;
|
|
if !items.is_empty() {
|
|
return Ok(items);
|
|
}
|
|
}
|
|
|
|
Ok(cli
|
|
.inputs
|
|
.iter()
|
|
.cloned()
|
|
.map(InputItem::Text)
|
|
.collect::<Vec<_>>())
|
|
}
|
|
|
|
fn parse_input_items(buffer: &str, input_format: InputFormat) -> Result<Vec<InputItem>, CliError> {
|
|
let trimmed = buffer.trim();
|
|
if trimmed.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
if input_format != InputFormat::Lines
|
|
&& matches!(trimmed.as_bytes().first(), Some(b'[' | b'{'))
|
|
&& let Ok(value) = serde_json::from_str::<Value>(trimmed)
|
|
{
|
|
return expand_json_value(value);
|
|
}
|
|
|
|
let mut items = Vec::new();
|
|
for (index, line) in io::Cursor::new(buffer).lines().enumerate() {
|
|
let raw =
|
|
line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?;
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
match input_format {
|
|
InputFormat::Lines => items.push(InputItem::Text(trimmed.to_string())),
|
|
InputFormat::Jsonl => {
|
|
let value = serde_json::from_str::<Value>(trimmed).map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"invalid JSONL input at line {}: {error}",
|
|
index + 1
|
|
))
|
|
})?;
|
|
items.extend(expand_json_value(value)?);
|
|
}
|
|
InputFormat::Auto => match serde_json::from_str::<Value>(trimmed) {
|
|
Ok(value) => items.extend(expand_json_value(value)?),
|
|
Err(_) => items.push(InputItem::Text(trimmed.to_string())),
|
|
},
|
|
}
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
fn expand_json_value(value: Value) -> Result<Vec<InputItem>, CliError> {
|
|
match value {
|
|
Value::Array(items) => Ok(items.into_iter().map(InputItem::Json).collect()),
|
|
Value::Object(object) => array_field_items(&object)?
|
|
.map_or_else(|| Ok(vec![InputItem::Json(Value::Object(object))]), Ok),
|
|
_ => Err(CliError::runtime(
|
|
"JSON input must be an object or array of objects",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn array_field_items(
|
|
object: &serde_json::Map<String, Value>,
|
|
) -> Result<Option<Vec<InputItem>>, CliError> {
|
|
for key in ["items", "records", "diagnostics", "blocks", "hits", "files"] {
|
|
let Some(candidate) = object.get(key) else {
|
|
continue;
|
|
};
|
|
if candidate.is_null() {
|
|
continue;
|
|
}
|
|
let Value::Array(items) = candidate else {
|
|
return Err(CliError::runtime(format!(
|
|
"context JSON field '{key}' must be an array when present"
|
|
)));
|
|
};
|
|
return Ok(Some(items.iter().cloned().map(InputItem::Json).collect()));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn materialize_blocks(
|
|
cli: &Cli,
|
|
item: &InputItem,
|
|
indexer: &mut CodeIndexer,
|
|
) -> Result<Vec<ContextBlock>, CliError> {
|
|
match item {
|
|
InputItem::Text(value) => materialize_text_input(value, cli.max_lines, indexer),
|
|
InputItem::Json(value) => materialize_json_value(value, cli.max_lines, indexer),
|
|
}
|
|
}
|
|
|
|
fn materialize_text_input(
|
|
value: &str,
|
|
max_lines: usize,
|
|
indexer: &mut CodeIndexer,
|
|
) -> Result<Vec<ContextBlock>, CliError> {
|
|
let path = PathBuf::from(value);
|
|
if path.exists() && path.is_file() {
|
|
return Ok(vec![block_from_file(&path, max_lines)?]);
|
|
}
|
|
|
|
if let Ok(hit) = parse_text_hit(value) {
|
|
return Ok(vec![block_from_hit(&hit, max_lines, indexer)?]);
|
|
}
|
|
|
|
Err(CliError::runtime(format!(
|
|
"unsupported context input: {value}"
|
|
)))
|
|
}
|
|
|
|
fn materialize_json_value(
|
|
value: &Value,
|
|
max_lines: usize,
|
|
indexer: &mut CodeIndexer,
|
|
) -> Result<Vec<ContextBlock>, CliError> {
|
|
let Value::Object(object) = value else {
|
|
return Err(CliError::runtime(
|
|
"context JSON input must be an object or array of objects",
|
|
));
|
|
};
|
|
Ok(decorate_block_headers(
|
|
materialize_json_object(object, value, max_lines, indexer)?,
|
|
diagnostic_label(object),
|
|
))
|
|
}
|
|
|
|
fn materialize_json_object(
|
|
object: &serde_json::Map<String, Value>,
|
|
value: &Value,
|
|
max_lines: usize,
|
|
indexer: &mut CodeIndexer,
|
|
) -> Result<Vec<ContextBlock>, CliError> {
|
|
if let Some(definition) = object
|
|
.get("definition")
|
|
.filter(|value| !value.is_null())
|
|
.or_else(|| {
|
|
object
|
|
.get("enclosing_definition")
|
|
.filter(|value| !value.is_null())
|
|
})
|
|
{
|
|
return materialize_json_value(definition, max_lines, indexer);
|
|
}
|
|
if let Some(snippet) = object
|
|
.get("snippet")
|
|
.or_else(|| object.get("source"))
|
|
.filter(|value| !value.is_null())
|
|
{
|
|
return Ok(vec![block_from_source_object(
|
|
snippet,
|
|
object.get("path").and_then(Value::as_str),
|
|
max_lines,
|
|
)?]);
|
|
}
|
|
if object.get("text").is_some() && object.get("path").is_some() {
|
|
return Ok(vec![block_from_text_object(object, max_lines)?]);
|
|
}
|
|
if object.get("items").is_some() && object.get("path").is_some() {
|
|
return Ok(vec![block_from_codeshape_file_object(object, max_lines)?]);
|
|
}
|
|
if object.get("lines").is_some() && object.get("path").is_some() {
|
|
return Ok(vec![block_from_source_object(value, None, max_lines)?]);
|
|
}
|
|
if let (Some(path), Some(line)) = (
|
|
object.get("path").and_then(Value::as_str),
|
|
object.get("line").and_then(Value::as_u64),
|
|
) {
|
|
return Ok(vec![block_from_hit(
|
|
&LineHit {
|
|
path: path.to_string(),
|
|
line: usize::try_from(line).map_err(|error| {
|
|
CliError::runtime(format!("invalid JSON line number for context: {error}"))
|
|
})?,
|
|
column: object
|
|
.get("column")
|
|
.and_then(Value::as_u64)
|
|
.map(usize::try_from)
|
|
.transpose()
|
|
.map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"invalid JSON column number for context: {error}"
|
|
))
|
|
})?,
|
|
},
|
|
max_lines,
|
|
indexer,
|
|
)?]);
|
|
}
|
|
if let (Some(path), Some(start_line), Some(end_line)) = (
|
|
object.get("path").and_then(Value::as_str),
|
|
object.get("start_line").and_then(Value::as_u64),
|
|
object.get("end_line").and_then(Value::as_u64),
|
|
) {
|
|
return Ok(vec![block_from_ranged_file_object(
|
|
path,
|
|
usize::try_from(start_line).map_err(|error| {
|
|
CliError::runtime(format!("invalid JSON start_line for context: {error}"))
|
|
})?,
|
|
usize::try_from(end_line).map_err(|error| {
|
|
CliError::runtime(format!("invalid JSON end_line for context: {error}"))
|
|
})?,
|
|
object,
|
|
max_lines,
|
|
)?]);
|
|
}
|
|
if let Some(path) = object.get("path").and_then(Value::as_str) {
|
|
let file = PathBuf::from(path);
|
|
if file.exists() && file.is_file() {
|
|
return Ok(vec![block_from_file(&file, max_lines)?]);
|
|
}
|
|
}
|
|
|
|
Err(CliError::runtime(
|
|
"unsupported JSON shape for context input",
|
|
))
|
|
}
|
|
|
|
fn block_from_codeshape_file_object(
|
|
object: &serde_json::Map<String, Value>,
|
|
max_lines: usize,
|
|
) -> Result<ContextBlock, CliError> {
|
|
let path = object
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CliError::runtime("codeshape file object is missing a path"))?;
|
|
let items = object
|
|
.get("items")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| CliError::runtime("codeshape file object is missing an items array"))?;
|
|
let language = object
|
|
.get("language")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown");
|
|
|
|
let rendered_lines = items
|
|
.iter()
|
|
.map(render_codeshape_item_line)
|
|
.collect::<Vec<_>>();
|
|
let start_line = items
|
|
.first()
|
|
.and_then(|item| item.get("start_line"))
|
|
.map(|value| optional_usize_value(value, "codeshape start_line"))
|
|
.transpose()?
|
|
.flatten();
|
|
let end_line = items
|
|
.last()
|
|
.and_then(|item| item.get("end_line"))
|
|
.map(|value| optional_usize_value(value, "codeshape end_line"))
|
|
.transpose()?
|
|
.flatten();
|
|
|
|
Ok(build_block(
|
|
path.to_string(),
|
|
KIND_CODESHAPE.to_owned(),
|
|
format!("codeshape {language}"),
|
|
start_line,
|
|
end_line,
|
|
&rendered_lines.join("\n"),
|
|
max_lines,
|
|
))
|
|
}
|
|
|
|
fn render_codeshape_item_line(item: &Value) -> String {
|
|
let depth = item
|
|
.get("depth")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|value| usize::try_from(value).ok())
|
|
.unwrap_or(0);
|
|
let indent = " ".repeat(depth.saturating_sub(1));
|
|
let kind = item.get("kind").and_then(Value::as_str).unwrap_or("item");
|
|
let name = item
|
|
.get("qualified_name")
|
|
.and_then(Value::as_str)
|
|
.or_else(|| item.get("name").and_then(Value::as_str))
|
|
.unwrap_or("?");
|
|
let signature = item.get("signature").and_then(Value::as_str).unwrap_or("");
|
|
let start_line = item.get("start_line").and_then(Value::as_u64);
|
|
|
|
let mut line = format!("{indent}{kind} {name}");
|
|
if !signature.is_empty() && signature != name {
|
|
let _ = write!(line, " signature={signature}");
|
|
}
|
|
if let Some(start_line) = start_line {
|
|
let _ = write!(line, " line={start_line}");
|
|
}
|
|
line
|
|
}
|
|
|
|
fn optional_usize_value(value: &Value, label: &str) -> Result<Option<usize>, CliError> {
|
|
value
|
|
.as_u64()
|
|
.map(usize::try_from)
|
|
.transpose()
|
|
.map_err(|error| CliError::runtime(format!("invalid {label}: {error}")))
|
|
}
|
|
|
|
fn block_from_file(path: &Path, max_lines: usize) -> Result<ContextBlock, CliError> {
|
|
let source = fs::read_to_string(path).map_err(|error| {
|
|
CliError::runtime(format!("failed to read {}: {error}", path.display()))
|
|
})?;
|
|
Ok(build_block(
|
|
path.display().to_string(),
|
|
KIND_FILE.to_owned(),
|
|
path.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or(HEADER_FILE)
|
|
.to_owned(),
|
|
Some(1),
|
|
source.lines().count().checked_sub(0),
|
|
&source,
|
|
max_lines,
|
|
))
|
|
}
|
|
|
|
fn block_from_hit(
|
|
hit: &LineHit,
|
|
max_lines: usize,
|
|
indexer: &mut CodeIndexer,
|
|
) -> Result<ContextBlock, CliError> {
|
|
let path = PathBuf::from(&hit.path);
|
|
let source = fs::read_to_string(&path).map_err(|error| {
|
|
CliError::runtime(format!("failed to read {}: {error}", path.display()))
|
|
})?;
|
|
if detect_language(&path).is_some()
|
|
&& let Some(symbol) = indexer.find_enclosing_symbol(&path, &source, hit.line, hit.column)?
|
|
{
|
|
return Ok(build_block(
|
|
symbol.path,
|
|
KIND_DEFINITION.to_owned(),
|
|
symbol.qualified_name,
|
|
Some(symbol.start_line),
|
|
Some(symbol.end_line),
|
|
&symbol.text,
|
|
max_lines,
|
|
));
|
|
}
|
|
|
|
Ok(build_snippet_block(&hit.path, &source, hit.line, max_lines))
|
|
}
|
|
|
|
fn block_from_source_object(
|
|
value: &Value,
|
|
path_override: Option<&str>,
|
|
max_lines: usize,
|
|
) -> Result<ContextBlock, CliError> {
|
|
let Value::Object(object) = value else {
|
|
return Err(CliError::runtime("invalid source snippet object"));
|
|
};
|
|
let path = path_override
|
|
.or_else(|| object.get("path").and_then(Value::as_str))
|
|
.unwrap_or(PATH_STDIN)
|
|
.to_string();
|
|
let start_line = object
|
|
.get("start_line")
|
|
.map(|value| optional_usize_value(value, "start_line for context"))
|
|
.transpose()?
|
|
.flatten();
|
|
let end_line = object
|
|
.get("end_line")
|
|
.map(|value| optional_usize_value(value, "end_line for context"))
|
|
.transpose()?
|
|
.flatten();
|
|
let lines = object
|
|
.get("lines")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| CliError::runtime("source snippet is missing a lines array"))?;
|
|
let mut text = String::new();
|
|
for line in lines {
|
|
let line_text = line
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CliError::runtime("source snippet line is missing text"))?;
|
|
if !text.is_empty() {
|
|
text.push('\n');
|
|
}
|
|
text.push_str(line_text);
|
|
}
|
|
Ok(build_block(
|
|
path,
|
|
KIND_SNIPPET.to_owned(),
|
|
HEADER_SNIPPET.to_owned(),
|
|
start_line,
|
|
end_line,
|
|
&text,
|
|
max_lines,
|
|
))
|
|
}
|
|
|
|
fn block_from_text_object(
|
|
object: &serde_json::Map<String, Value>,
|
|
max_lines: usize,
|
|
) -> Result<ContextBlock, CliError> {
|
|
let path = object
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CliError::runtime("context text object is missing path"))?;
|
|
let text = object
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CliError::runtime("context text object is missing text"))?;
|
|
let header = object
|
|
.get("qualified_name")
|
|
.or_else(|| object.get("signature"))
|
|
.or_else(|| object.get("name"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(HEADER_BLOCK);
|
|
let start_line = object
|
|
.get("start_line")
|
|
.map(|value| optional_usize_value(value, "start_line for context"))
|
|
.transpose()?
|
|
.flatten();
|
|
let end_line = object
|
|
.get("end_line")
|
|
.map(|value| optional_usize_value(value, "end_line for context"))
|
|
.transpose()?
|
|
.flatten();
|
|
Ok(build_block(
|
|
path.to_string(),
|
|
KIND_DEFINITION.to_owned(),
|
|
header.to_string(),
|
|
start_line,
|
|
end_line,
|
|
text,
|
|
max_lines,
|
|
))
|
|
}
|
|
|
|
fn block_from_ranged_file_object(
|
|
path: &str,
|
|
start_line: usize,
|
|
end_line: usize,
|
|
object: &serde_json::Map<String, Value>,
|
|
max_lines: usize,
|
|
) -> Result<ContextBlock, CliError> {
|
|
let file = PathBuf::from(path);
|
|
let source = fs::read_to_string(&file).map_err(|error| {
|
|
CliError::runtime(format!("failed to read {}: {error}", file.display()))
|
|
})?;
|
|
let lines = source.lines().collect::<Vec<_>>();
|
|
let start_index = start_line.saturating_sub(1).min(lines.len());
|
|
let end_index = end_line.min(lines.len());
|
|
let text = if start_index < end_index {
|
|
lines[start_index..end_index].join("\n")
|
|
} else {
|
|
String::new()
|
|
};
|
|
let header = object
|
|
.get("qualified_name")
|
|
.or_else(|| object.get("signature"))
|
|
.or_else(|| object.get("name"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(KIND_DEFINITION);
|
|
Ok(build_block(
|
|
path.to_string(),
|
|
KIND_DEFINITION.to_owned(),
|
|
header.to_string(),
|
|
Some(start_line),
|
|
Some(end_line),
|
|
&text,
|
|
max_lines,
|
|
))
|
|
}
|
|
|
|
fn build_snippet_block(path: &str, source: &str, line: usize, max_lines: usize) -> ContextBlock {
|
|
let lines = source.lines().collect::<Vec<_>>();
|
|
if lines.is_empty() {
|
|
return build_block(
|
|
path.to_string(),
|
|
KIND_SNIPPET.to_owned(),
|
|
format!("line {line} (past EOF, empty file)"),
|
|
Some(0),
|
|
Some(0),
|
|
"",
|
|
max_lines,
|
|
);
|
|
}
|
|
let effective_line = line.clamp(1, lines.len());
|
|
let (start_line, end_line) = if line > lines.len() {
|
|
(effective_line, effective_line)
|
|
} else {
|
|
let radius = max_lines.saturating_sub(1) / 2;
|
|
let start_line = effective_line.saturating_sub(radius).max(1);
|
|
let end_line = (start_line + max_lines.saturating_sub(1)).min(lines.len());
|
|
(start_line, end_line)
|
|
};
|
|
let text = lines
|
|
.get(start_line.saturating_sub(1)..end_line)
|
|
.unwrap_or_default()
|
|
.join("\n");
|
|
let header = if effective_line == line {
|
|
format!("line {line}")
|
|
} else {
|
|
format!("line {line} (past EOF, clamped to {effective_line})")
|
|
};
|
|
build_block(
|
|
path.to_string(),
|
|
KIND_SNIPPET.to_owned(),
|
|
header,
|
|
Some(start_line),
|
|
Some(end_line),
|
|
&text,
|
|
max_lines,
|
|
)
|
|
}
|
|
|
|
fn build_block(
|
|
path: String,
|
|
kind: String,
|
|
header: String,
|
|
start_line: Option<usize>,
|
|
end_line: Option<usize>,
|
|
text: &str,
|
|
max_lines: usize,
|
|
) -> ContextBlock {
|
|
let line_budget = if kind == KIND_DEFINITION {
|
|
max_lines.saturating_mul(3).max(24)
|
|
} else {
|
|
max_lines
|
|
};
|
|
let (rendered_lines, rendered_line_count, truncated) = render_limited_lines(text, line_budget);
|
|
let actual_start = start_line;
|
|
let actual_end = actual_start
|
|
.map(|start| start + rendered_line_count.saturating_sub(1))
|
|
.or(end_line);
|
|
ContextBlock {
|
|
path,
|
|
kind,
|
|
header,
|
|
start_line: actual_start,
|
|
end_line: actual_end,
|
|
truncated,
|
|
text: rendered_lines,
|
|
}
|
|
}
|
|
|
|
fn render_limited_lines(text: &str, line_budget: usize) -> (String, usize, bool) {
|
|
let mut rendered = String::new();
|
|
let mut rendered_count = 0usize;
|
|
|
|
for (index, line) in text.lines().enumerate() {
|
|
if index >= line_budget {
|
|
return (rendered, rendered_count, true);
|
|
}
|
|
if index > 0 {
|
|
rendered.push('\n');
|
|
}
|
|
rendered.push_str(line);
|
|
rendered_count += 1;
|
|
}
|
|
|
|
(rendered, rendered_count, false)
|
|
}
|
|
|
|
fn dedupe_blocks(blocks: Vec<ContextBlock>) -> Vec<ContextBlock> {
|
|
let mut seen = BTreeSet::<(String, String, Option<usize>, Option<usize>, String)>::new();
|
|
let mut deduped = Vec::new();
|
|
for block in blocks {
|
|
let key = (
|
|
block.path.clone(),
|
|
block.kind.clone(),
|
|
block.start_line,
|
|
block.end_line,
|
|
block.text.clone(),
|
|
);
|
|
if seen.insert(key) {
|
|
deduped.push(block);
|
|
}
|
|
}
|
|
deduped
|
|
}
|
|
|
|
fn dedupe_definition_blocks(blocks: Vec<ContextBlock>) -> Vec<ContextBlock> {
|
|
let mut seen = BTreeSet::<(String, Option<usize>, Option<usize>, String)>::new();
|
|
let mut deduped = Vec::with_capacity(blocks.len());
|
|
for block in blocks {
|
|
if block.kind == "definition" {
|
|
let key = (
|
|
block.path.clone(),
|
|
block.start_line,
|
|
block.end_line,
|
|
block.text.clone(),
|
|
);
|
|
if seen.insert(key) {
|
|
deduped.push(block);
|
|
}
|
|
continue;
|
|
}
|
|
deduped.push(block);
|
|
}
|
|
deduped
|
|
}
|
|
|
|
fn sort_blocks(blocks: &mut [ContextBlock], sort_mode: SortMode) {
|
|
match sort_mode {
|
|
SortMode::Input => {}
|
|
SortMode::Path => {
|
|
blocks.sort_by(|left, right| {
|
|
(
|
|
left.path.as_str(),
|
|
left.start_line.unwrap_or(usize::MAX),
|
|
left.end_line.unwrap_or(usize::MAX),
|
|
left.header.as_str(),
|
|
)
|
|
.cmp(&(
|
|
right.path.as_str(),
|
|
right.start_line.unwrap_or(usize::MAX),
|
|
right.end_line.unwrap_or(usize::MAX),
|
|
right.header.as_str(),
|
|
))
|
|
});
|
|
}
|
|
SortMode::Recent => {
|
|
blocks.sort_by(|left, right| recent_sort_key(right).cmp(&recent_sort_key(left)));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn recent_sort_key(block: &ContextBlock) -> (Option<SystemTime>, &str, Option<usize>) {
|
|
let modified = fs::metadata(&block.path)
|
|
.and_then(|metadata| metadata.modified())
|
|
.ok();
|
|
(modified, block.path.as_str(), block.start_line)
|
|
}
|
|
|
|
fn render_blocks(blocks: &[ContextBlock]) -> String {
|
|
let mut output = String::new();
|
|
let current_dir = env::current_dir().ok();
|
|
for (index, block) in blocks.iter().enumerate() {
|
|
let display_path = compact_display_path(&block.path, current_dir.as_deref());
|
|
if index > 0 {
|
|
output.push('\n');
|
|
}
|
|
if let (Some(start_line), Some(end_line)) = (block.start_line, block.end_line) {
|
|
writeln!(
|
|
output,
|
|
"{}:{}-{} kind={} header={}",
|
|
display_path, start_line, end_line, block.kind, block.header
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
for (offset, line) in block.text.lines().enumerate() {
|
|
writeln!(output, "{}: {}", start_line + offset, line)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
} else {
|
|
writeln!(
|
|
output,
|
|
"{} kind={} header={}",
|
|
display_path, block.kind, block.header
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
writeln!(output, "{}", block.text).expect("writing to a String cannot fail");
|
|
}
|
|
if block.truncated {
|
|
writeln!(output, "[truncated]").expect("writing to a String cannot fail");
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
fn compact_display_path(path: &str, current_dir: Option<&Path>) -> String {
|
|
let candidate = PathBuf::from(path);
|
|
if !candidate.is_absolute() {
|
|
return path.to_string();
|
|
}
|
|
let Some(current_dir) = current_dir else {
|
|
return path.to_string();
|
|
};
|
|
candidate.strip_prefix(current_dir).map_or_else(
|
|
|_| path.to_string(),
|
|
|relative| relative.display().to_string(),
|
|
)
|
|
}
|
|
|
|
fn decorate_block_headers(blocks: Vec<ContextBlock>, prefix: Option<String>) -> Vec<ContextBlock> {
|
|
let Some(prefix) = prefix else {
|
|
return blocks;
|
|
};
|
|
|
|
blocks
|
|
.into_iter()
|
|
.map(|mut block| {
|
|
block.header = format!("{prefix} :: {}", block.header);
|
|
block
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn diagnostic_label(object: &serde_json::Map<String, Value>) -> Option<String> {
|
|
let severity = object.get("severity").and_then(Value::as_str)?;
|
|
let code = object.get("code").and_then(Value::as_str);
|
|
let message = object
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.map(compact_header_text)
|
|
.filter(|message| !message.is_empty());
|
|
|
|
let mut label = String::from("diag ");
|
|
label.push_str(severity);
|
|
if let Some(code) = code.filter(|code| !code.is_empty()) {
|
|
let _ = write!(label, "[{code}]");
|
|
}
|
|
if let Some(message) = message {
|
|
let _ = write!(label, " {message}");
|
|
}
|
|
Some(label)
|
|
}
|
|
|
|
fn compact_header_text(text: &str) -> String {
|
|
const MAX_CHARS: usize = 72;
|
|
|
|
let mut single_line = String::new();
|
|
for word in text.split_whitespace() {
|
|
if !single_line.is_empty() {
|
|
single_line.push(' ');
|
|
}
|
|
single_line.push_str(word);
|
|
}
|
|
if single_line.chars().count() <= MAX_CHARS {
|
|
return single_line;
|
|
}
|
|
|
|
let end_index = single_line
|
|
.char_indices()
|
|
.nth(MAX_CHARS)
|
|
.map_or(single_line.len(), |(index, _)| index);
|
|
format!("{}...", &single_line[..end_index])
|
|
}
|
|
|
|
fn parse_text_hit(raw: &str) -> Result<LineHit, CliError> {
|
|
let segments = raw.split(':').collect::<Vec<_>>();
|
|
if segments.len() < 2 {
|
|
return Err(CliError::usage(format!("invalid hit format: {raw}")));
|
|
}
|
|
|
|
let numeric_index = segments
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.find_map(|(index, segment)| segment.parse::<usize>().ok().map(|value| (index, value)))
|
|
.ok_or_else(|| CliError::usage(format!("invalid hit format: {raw}")))?;
|
|
|
|
let (path, line, column) = if numeric_index.0 > 0
|
|
&& segments[numeric_index.0 - 1].parse::<usize>().is_ok()
|
|
&& !segments[..numeric_index.0 - 1].join(":").is_empty()
|
|
{
|
|
let path = segments[..numeric_index.0 - 1].join(":");
|
|
let line = segments[numeric_index.0 - 1]
|
|
.parse::<usize>()
|
|
.map_err(|error| CliError::usage(format!("invalid hit line in '{raw}': {error}")))?;
|
|
(path, line, Some(numeric_index.1))
|
|
} else {
|
|
let path = segments[..numeric_index.0].join(":");
|
|
(path, numeric_index.1, None)
|
|
};
|
|
|
|
if line == 0 || column == Some(0) || path.is_empty() {
|
|
return Err(CliError::usage(format!("invalid hit format: {raw}")));
|
|
}
|
|
|
|
Ok(LineHit { path, line, column })
|
|
}
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
let value = parser
|
|
.value()
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
os_value_string(value, flag)
|
|
}
|
|
|
|
fn os_value_string(value: OsString, flag: &str) -> Result<String, CliError> {
|
|
value.into_string().map_err(|invalid| {
|
|
CliError::usage(format!(
|
|
"{flag} expects UTF-8 text, got '{}'",
|
|
invalid.to_string_lossy()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
value
|
|
.parse::<usize>()
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
}
|
|
|
|
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
let parsed = parse_usize_flag(flag, value)?;
|
|
if parsed == 0 {
|
|
return Err(CliError::usage(format!("{flag} must be greater than 0")));
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn parse_sort_mode(value: &str) -> Result<SortMode, CliError> {
|
|
match value {
|
|
"input" => Ok(SortMode::Input),
|
|
"path" => Ok(SortMode::Path),
|
|
"recent" => Ok(SortMode::Recent),
|
|
other => Err(CliError::usage(format!(
|
|
"invalid --sort value '{other}'; expected input, path, or recent"
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::{thread, time::Duration};
|
|
|
|
fn fixture_path(relative: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("..")
|
|
.join("..")
|
|
.join("fixtures")
|
|
.join("polyglot")
|
|
.join("repo")
|
|
.join(relative)
|
|
}
|
|
|
|
#[test]
|
|
fn parse_text_hit_supports_rg_style_lines() {
|
|
let hit = parse_text_hit(r"C:\repo\demo.rs:18:9:pub fn run() {}").expect("hit");
|
|
assert_eq!(hit.path, r"C:\repo\demo.rs");
|
|
assert_eq!(hit.line, 18);
|
|
assert_eq!(hit.column, Some(9));
|
|
}
|
|
|
|
#[test]
|
|
fn build_block_truncates_to_max_lines() {
|
|
let block = build_block(
|
|
"demo.rs".to_string(),
|
|
"file".to_string(),
|
|
"demo".to_string(),
|
|
Some(1),
|
|
Some(4),
|
|
"a\nb\nc\nd",
|
|
2,
|
|
);
|
|
assert_eq!(block.end_line, Some(2));
|
|
assert!(block.truncated);
|
|
assert_eq!(block.text, "a\nb");
|
|
}
|
|
|
|
#[test]
|
|
fn build_block_keeps_larger_definition_windows() {
|
|
let long_text = (1..=30)
|
|
.map(|value| format!("line {value}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let block = build_block(
|
|
"demo.rs".to_string(),
|
|
"definition".to_string(),
|
|
"fn helper()".to_string(),
|
|
Some(1),
|
|
Some(30),
|
|
&long_text,
|
|
2,
|
|
);
|
|
assert!(block.text.contains("line 24"));
|
|
assert_eq!(block.end_line, Some(24));
|
|
assert!(block.truncated);
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_text_hit_prefers_enclosing_definition() {
|
|
let mut indexer = CodeIndexer::new();
|
|
let blocks = materialize_text_input(
|
|
&format!("{}:26", fixture_path("src/lib.rs").display()),
|
|
20,
|
|
&mut indexer,
|
|
)
|
|
.expect("blocks");
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
assert_eq!(blocks[0].kind, "definition");
|
|
assert_eq!(blocks[0].header, "call_helper");
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_snippet_uses_lines_array() {
|
|
let placeholder = ['{', 'v', 'a', 'l', 'u', 'e', '}']
|
|
.into_iter()
|
|
.collect::<String>();
|
|
let line_text = format!("println!(\"{placeholder}\");");
|
|
let value = serde_json::json!({
|
|
"path": "demo.rs",
|
|
"start_line": 4,
|
|
"end_line": 5,
|
|
"lines": [
|
|
{ "number": 4, "text": "let value = 1;" },
|
|
{ "number": 5, "text": line_text }
|
|
]
|
|
});
|
|
let mut indexer = CodeIndexer::new();
|
|
|
|
let blocks = materialize_json_value(&value, 20, &mut indexer).expect("blocks");
|
|
|
|
assert_eq!(blocks[0].kind, "snippet");
|
|
assert_eq!(blocks[0].start_line, Some(4));
|
|
assert!(blocks[0].text.contains("println!"));
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_value_prefers_nested_definition_records() {
|
|
let mut indexer = CodeIndexer::new();
|
|
let blocks = materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": "demo.rs",
|
|
"line": 7,
|
|
"definition": {
|
|
"path": "demo.rs",
|
|
"qualified_name": "demo::helper",
|
|
"signature": "fn helper()",
|
|
"start_line": 3,
|
|
"end_line": 5,
|
|
"text": "fn helper() {\n 1\n}"
|
|
},
|
|
"snippet": {
|
|
"start_line": 7,
|
|
"end_line": 7,
|
|
"lines": [
|
|
{ "number": 7, "text": "helper();" }
|
|
]
|
|
}
|
|
}),
|
|
4,
|
|
&mut indexer,
|
|
)
|
|
.expect("blocks");
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
assert_eq!(blocks[0].kind, "definition");
|
|
assert_eq!(blocks[0].header, "demo::helper");
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_value_preserves_diagnostic_identity_in_headers() {
|
|
let mut indexer = CodeIndexer::new();
|
|
let blocks = materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": "demo.rs",
|
|
"line": 7,
|
|
"column": 2,
|
|
"severity": "error",
|
|
"code": "E0425",
|
|
"message": "cannot find value `missing` in this scope",
|
|
"source": {
|
|
"start_line": 7,
|
|
"end_line": 7,
|
|
"lines": [
|
|
{ "text": "missing();" }
|
|
]
|
|
}
|
|
}),
|
|
4,
|
|
&mut indexer,
|
|
)
|
|
.expect("blocks");
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
assert!(blocks[0].header.contains("diag error[E0425]"));
|
|
assert!(blocks[0].header.contains("cannot find value"));
|
|
assert!(blocks[0].header.contains("snippet"));
|
|
}
|
|
|
|
#[test]
|
|
fn expand_json_value_supports_wrapper_records_array() {
|
|
let items = expand_json_value(serde_json::json!({
|
|
"records": [
|
|
{ "path": "demo.rs", "line": 7 }
|
|
]
|
|
}))
|
|
.expect("items");
|
|
|
|
assert_eq!(items.len(), 1);
|
|
assert!(matches!(items[0], InputItem::Json(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn expand_json_value_supports_codeshape_files_array() {
|
|
let items = expand_json_value(serde_json::json!({
|
|
"files": [
|
|
{
|
|
"path": "demo.rs",
|
|
"language": "rust",
|
|
"items": [
|
|
{
|
|
"kind": "function",
|
|
"name": "helper",
|
|
"qualified_name": "helper",
|
|
"signature": "fn helper()",
|
|
"depth": 1,
|
|
"start_line": 3,
|
|
"end_line": 5
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}))
|
|
.expect("items");
|
|
|
|
assert_eq!(items.len(), 1);
|
|
assert!(matches!(items[0], InputItem::Json(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_value_supports_codeshape_file_objects() {
|
|
let mut indexer = CodeIndexer::new();
|
|
let blocks = materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": "demo.rs",
|
|
"language": "rust",
|
|
"items": [
|
|
{
|
|
"kind": "module",
|
|
"name": "demo",
|
|
"qualified_name": "demo",
|
|
"signature": "mod demo",
|
|
"depth": 1,
|
|
"start_line": 1,
|
|
"end_line": 10
|
|
},
|
|
{
|
|
"kind": "function",
|
|
"name": "helper",
|
|
"qualified_name": "demo::helper",
|
|
"signature": "fn helper()",
|
|
"depth": 2,
|
|
"start_line": 3,
|
|
"end_line": 5
|
|
}
|
|
]
|
|
}),
|
|
20,
|
|
&mut indexer,
|
|
)
|
|
.expect("blocks");
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
assert_eq!(blocks[0].kind, "codeshape");
|
|
assert_eq!(blocks[0].header, "codeshape rust");
|
|
assert!(blocks[0].text.contains("module demo"));
|
|
assert!(blocks[0].text.contains("function demo::helper"));
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_value_ignores_null_optional_fields() {
|
|
let mut indexer = CodeIndexer::new();
|
|
let blocks = materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": fixture_path("src/lib.rs").display().to_string(),
|
|
"line": 26,
|
|
"column": 11,
|
|
"snippet": null,
|
|
"source": null,
|
|
"enclosing_definition": null
|
|
}),
|
|
12,
|
|
&mut indexer,
|
|
)
|
|
.expect("blocks");
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
assert_eq!(blocks[0].kind, "definition");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_input_items_auto_mixes_wrappers_and_text_lines() {
|
|
let items = parse_input_items(
|
|
"{\"hits\":[{\"path\":\"demo.rs\",\"line\":7}]}\nplain.txt\n{\"path\":\"demo.rs\",\"name\":\"helper\",\"text\":\"fn helper() {}\"}\n",
|
|
InputFormat::Auto,
|
|
)
|
|
.expect("items");
|
|
|
|
assert_eq!(items.len(), 3);
|
|
assert!(matches!(items[0], InputItem::Json(_)));
|
|
assert_eq!(items[1], InputItem::Text("plain.txt".to_string()));
|
|
assert!(matches!(items[2], InputItem::Json(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_value_supports_source_and_text_objects() {
|
|
let mut indexer = CodeIndexer::new();
|
|
let source_blocks = materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": "outer.rs",
|
|
"source": {
|
|
"start_line": 3,
|
|
"end_line": 4,
|
|
"lines": [
|
|
{ "text": "let alpha = 1;" },
|
|
{ "text": "let beta = 2;" }
|
|
]
|
|
}
|
|
}),
|
|
20,
|
|
&mut indexer,
|
|
)
|
|
.expect("source blocks");
|
|
assert_eq!(source_blocks[0].path, "outer.rs");
|
|
assert_eq!(source_blocks[0].kind, "snippet");
|
|
assert_eq!(source_blocks[0].start_line, Some(3));
|
|
assert!(source_blocks[0].text.contains("beta"));
|
|
|
|
let text_blocks = materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": "demo.rs",
|
|
"signature": "fn helper()",
|
|
"text": "fn helper() {}"
|
|
}),
|
|
20,
|
|
&mut indexer,
|
|
)
|
|
.expect("text blocks");
|
|
assert_eq!(text_blocks[0].kind, "definition");
|
|
assert_eq!(text_blocks[0].header, "fn helper()");
|
|
assert_eq!(text_blocks[0].start_line, None);
|
|
}
|
|
|
|
#[test]
|
|
fn dedupe_and_recent_sort_keep_unique_newest_blocks_first() {
|
|
let temp = std::env::temp_dir().join(format!(
|
|
"ctxpack-tests-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("clock")
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(&temp).expect("temp dir");
|
|
let older = temp.join("older.rs");
|
|
let newer = temp.join("newer.rs");
|
|
fs::write(&older, "fn older() {}\n").expect("older");
|
|
thread::sleep(Duration::from_millis(20));
|
|
fs::write(&newer, "fn newer() {}\n").expect("newer");
|
|
|
|
let mut blocks = dedupe_blocks(vec![
|
|
ContextBlock {
|
|
path: older.display().to_string(),
|
|
kind: "file".to_string(),
|
|
header: "older".to_string(),
|
|
start_line: Some(1),
|
|
end_line: Some(1),
|
|
truncated: false,
|
|
text: "fn older() {}".to_string(),
|
|
},
|
|
ContextBlock {
|
|
path: older.display().to_string(),
|
|
kind: "file".to_string(),
|
|
header: "older".to_string(),
|
|
start_line: Some(1),
|
|
end_line: Some(1),
|
|
truncated: false,
|
|
text: "fn older() {}".to_string(),
|
|
},
|
|
ContextBlock {
|
|
path: newer.display().to_string(),
|
|
kind: "file".to_string(),
|
|
header: "newer".to_string(),
|
|
start_line: Some(1),
|
|
end_line: Some(1),
|
|
truncated: false,
|
|
text: "fn newer() {}".to_string(),
|
|
},
|
|
]);
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
sort_blocks(&mut blocks, SortMode::Recent);
|
|
assert_eq!(blocks[0].path, newer.display().to_string());
|
|
assert_eq!(blocks[1].path, older.display().to_string());
|
|
fs::remove_dir_all(temp).expect("cleanup");
|
|
}
|
|
|
|
#[test]
|
|
fn dedupe_definition_blocks_only_collapses_definition_duplicates() {
|
|
let blocks = dedupe_definition_blocks(vec![
|
|
ContextBlock {
|
|
path: "src/lib.rs".to_string(),
|
|
kind: "definition".to_string(),
|
|
header: "fn helper()".to_string(),
|
|
start_line: Some(10),
|
|
end_line: Some(12),
|
|
truncated: false,
|
|
text: "fn helper() {\n run();\n}".to_string(),
|
|
},
|
|
ContextBlock {
|
|
path: "src/lib.rs".to_string(),
|
|
kind: "definition".to_string(),
|
|
header: "fn helper()".to_string(),
|
|
start_line: Some(10),
|
|
end_line: Some(12),
|
|
truncated: false,
|
|
text: "fn helper() {\n run();\n}".to_string(),
|
|
},
|
|
ContextBlock {
|
|
path: "src/lib.rs".to_string(),
|
|
kind: "snippet".to_string(),
|
|
header: "line 11".to_string(),
|
|
start_line: Some(11),
|
|
end_line: Some(11),
|
|
truncated: false,
|
|
text: "run();".to_string(),
|
|
},
|
|
ContextBlock {
|
|
path: "src/lib.rs".to_string(),
|
|
kind: "snippet".to_string(),
|
|
header: "line 11".to_string(),
|
|
start_line: Some(11),
|
|
end_line: Some(11),
|
|
truncated: false,
|
|
text: "run();".to_string(),
|
|
},
|
|
]);
|
|
|
|
assert_eq!(blocks.len(), 3);
|
|
assert_eq!(blocks[0].kind, "definition");
|
|
assert_eq!(blocks[1].kind, "snippet");
|
|
assert_eq!(blocks[2].kind, "snippet");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_input_items_and_json_helpers_reject_invalid_shapes() {
|
|
assert!(matches!(
|
|
expand_json_value(serde_json::json!(42)),
|
|
Err(CliError::Runtime(message))
|
|
if message.contains("JSON input must be an object or array of objects")
|
|
));
|
|
assert!(matches!(
|
|
expand_json_value(serde_json::json!({ "hits": { "path": "demo.rs", "line": 7 } })),
|
|
Err(CliError::Runtime(message))
|
|
if message.contains("context JSON field 'hits' must be an array")
|
|
));
|
|
assert!(matches!(
|
|
parse_input_items("42\n", InputFormat::Auto),
|
|
Err(CliError::Runtime(message))
|
|
if message.contains("JSON input must be an object or array of objects")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostic_header_helpers_compact_message_text() {
|
|
let label = diagnostic_label(
|
|
serde_json::json!({
|
|
"severity": "warning",
|
|
"code": "W1",
|
|
"message": "first line\nsecond line with extra spacing"
|
|
})
|
|
.as_object()
|
|
.expect("object"),
|
|
)
|
|
.expect("label");
|
|
assert_eq!(
|
|
label,
|
|
"diag warning[W1] first line second line with extra spacing"
|
|
);
|
|
assert!(compact_header_text(&"x".repeat(100)).ends_with("..."));
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_text_and_json_file_inputs_cover_file_and_snippet_paths() {
|
|
let temp = std::env::temp_dir().join(format!(
|
|
"ctxpack-inputs-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("clock")
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(&temp).expect("temp dir");
|
|
let notes = temp.join("notes.txt");
|
|
fs::write(¬es, "alpha\nbeta\ngamma\ndelta\n").expect("notes");
|
|
|
|
let mut indexer = CodeIndexer::new();
|
|
let file_blocks =
|
|
materialize_text_input(notes.to_str().expect("path"), 3, &mut indexer).expect("file");
|
|
assert_eq!(file_blocks[0].kind, "file");
|
|
assert_eq!(file_blocks[0].header, "notes.txt");
|
|
assert_eq!(file_blocks[0].start_line, Some(1));
|
|
assert_eq!(file_blocks[0].end_line, Some(3));
|
|
assert!(file_blocks[0].truncated);
|
|
|
|
let hit_blocks = materialize_text_input(&format!("{}:3", notes.display()), 3, &mut indexer)
|
|
.expect("hit");
|
|
assert_eq!(hit_blocks[0].kind, "snippet");
|
|
assert_eq!(hit_blocks[0].header, "line 3");
|
|
assert_eq!(hit_blocks[0].start_line, Some(2));
|
|
assert_eq!(hit_blocks[0].end_line, Some(4));
|
|
assert!(hit_blocks[0].text.contains("gamma"));
|
|
|
|
let json_blocks = materialize_json_value(
|
|
&serde_json::json!({ "path": notes.display().to_string() }),
|
|
2,
|
|
&mut indexer,
|
|
)
|
|
.expect("json file");
|
|
assert_eq!(json_blocks[0].kind, "file");
|
|
assert_eq!(json_blocks[0].header, "notes.txt");
|
|
assert_eq!(json_blocks[0].start_line, Some(1));
|
|
assert_eq!(json_blocks[0].end_line, Some(2));
|
|
assert!(json_blocks[0].truncated);
|
|
|
|
fs::remove_dir_all(temp).expect("cleanup");
|
|
}
|
|
|
|
#[test]
|
|
fn build_snippet_block_clamps_past_eof_requests() {
|
|
let block = build_snippet_block("notes.txt", "alpha\nbeta\n", 10, 3);
|
|
|
|
assert_eq!(block.header, "line 10 (past EOF, clamped to 2)");
|
|
assert_eq!(block.start_line, Some(2));
|
|
assert_eq!(block.end_line, Some(2));
|
|
assert_eq!(block.text, "beta");
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_json_value_and_render_blocks_cover_error_and_formatting_paths() {
|
|
let mut indexer = CodeIndexer::new();
|
|
assert!(matches!(
|
|
materialize_json_value(
|
|
&serde_json::json!({
|
|
"path": "demo.rs",
|
|
"lines": { "text": "fn helper() {}" }
|
|
}),
|
|
5,
|
|
&mut indexer,
|
|
),
|
|
Err(CliError::Runtime(message))
|
|
if message.contains("source snippet is missing a lines array")
|
|
));
|
|
assert!(matches!(
|
|
materialize_json_value(
|
|
&serde_json::json!({ "text": "fn helper() {}" }),
|
|
5,
|
|
&mut indexer,
|
|
),
|
|
Err(CliError::Runtime(message))
|
|
if message.contains("unsupported JSON shape for context input")
|
|
));
|
|
|
|
let rendered = render_blocks(&[
|
|
ContextBlock {
|
|
path: "demo.rs".to_string(),
|
|
kind: "definition".to_string(),
|
|
header: "helper".to_string(),
|
|
start_line: Some(4),
|
|
end_line: Some(5),
|
|
truncated: false,
|
|
text: "fn helper() {\n}".to_string(),
|
|
},
|
|
ContextBlock {
|
|
path: "<stdin>".to_string(),
|
|
kind: "snippet".to_string(),
|
|
header: "snippet".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
truncated: true,
|
|
text: "line one\nline two".to_string(),
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"demo.rs:4-5 kind=definition header=helper\n4: fn helper() {\n5: }\n\n<stdin> kind=snippet header=snippet\nline one\nline two\n[truncated]\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_blocks_supports_input_and_path_modes() {
|
|
let first = ContextBlock {
|
|
path: "b.rs".to_string(),
|
|
kind: "definition".to_string(),
|
|
header: "beta".to_string(),
|
|
start_line: Some(4),
|
|
end_line: Some(4),
|
|
truncated: false,
|
|
text: "beta".to_string(),
|
|
};
|
|
let second = ContextBlock {
|
|
path: "a.rs".to_string(),
|
|
kind: "definition".to_string(),
|
|
header: "alpha".to_string(),
|
|
start_line: Some(2),
|
|
end_line: Some(2),
|
|
truncated: false,
|
|
text: "alpha".to_string(),
|
|
};
|
|
let mut input_sorted = vec![first.clone(), second.clone()];
|
|
sort_blocks(&mut input_sorted, SortMode::Input);
|
|
assert_eq!(
|
|
input_sorted
|
|
.iter()
|
|
.map(|block| block.path.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["b.rs", "a.rs"]
|
|
);
|
|
|
|
let mut path_sorted = vec![first, second];
|
|
sort_blocks(&mut path_sorted, SortMode::Path);
|
|
assert_eq!(
|
|
path_sorted
|
|
.iter()
|
|
.map(|block| block.path.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["a.rs", "b.rs"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_text_hit_and_cli_validation_reject_invalid_zero_values() {
|
|
assert!(matches!(
|
|
parse_text_hit("demo.rs:0"),
|
|
Err(CliError::Usage(message)) if message.contains("invalid hit format")
|
|
));
|
|
assert!(matches!(
|
|
parse_text_hit("demo.rs:4:0"),
|
|
Err(CliError::Usage(message)) if message.contains("invalid hit format")
|
|
));
|
|
assert!(matches!(
|
|
parse_cli_from(["ctxpack", "--max-blocks", "0", "demo.rs"]),
|
|
Err(CliError::Usage(message)) if message.contains("--max-blocks must be greater than 0")
|
|
));
|
|
assert!(matches!(
|
|
parse_cli_from(["ctxpack", "--max-lines", "0", "demo.rs"]),
|
|
Err(CliError::Usage(message)) if message.contains("--max-lines must be greater than 0")
|
|
));
|
|
}
|
|
}
|