chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "snip"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Extract precise code and text snippets for AI-friendly terminal workflows."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
lexopt.workspace = true
|
||||
regex-lite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,867 @@
|
||||
//! The `snip` command extracts precise snippets from files or stdin.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, 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 regex_lite::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// CLI arguments for the `snip` binary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cli {
|
||||
/// Shared output and stdin policy flags.
|
||||
pub common: CommonArgs,
|
||||
/// Extract an exact inclusive line range such as `12:20`.
|
||||
pub lines: Option<String>,
|
||||
/// Extract snippets around lines matching this regex.
|
||||
pub around: Option<String>,
|
||||
/// Extract the block belonging to this symbol name.
|
||||
pub symbol: Option<String>,
|
||||
/// Extra context lines around `--around` and `--symbol` matches.
|
||||
pub context: usize,
|
||||
/// Maximum number of regex matches to emit for `--around`.
|
||||
pub max_matches: usize,
|
||||
/// Optional files to read when stdin is empty.
|
||||
pub paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
const HELP: &str = "\
|
||||
Extract precise code and text snippets from files or stdin.
|
||||
|
||||
Usage:
|
||||
snip [OPTIONS] [PATH...]
|
||||
|
||||
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
|
||||
--lines <RANGE> Extract an exact inclusive line range such as 12:20
|
||||
--around <REGEX> Extract snippets around lines matching this regex
|
||||
--symbol <NAME> Extract the block belonging to this symbol name
|
||||
--context <COUNT> Extra context lines around --around and --symbol
|
||||
--max-matches <COUNT> Maximum number of matches to emit for --around
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
snip --lines 16:27 .\\fixtures\\reading\\sample.rs
|
||||
snip --symbol run .\\fixtures\\reading\\sample.rs --json | ConvertFrom-Json
|
||||
bat --style=plain --paging=never .\\fixtures\\reading\\sample.rs | snip --around helper --context 0
|
||||
|
||||
Notes:
|
||||
for AST-backed full definitions across files or repos, prefer `defsnip`
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct LineRange {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Selector {
|
||||
Lines(LineRange),
|
||||
Around {
|
||||
pattern: String,
|
||||
regex: Regex,
|
||||
context: usize,
|
||||
max_matches: usize,
|
||||
},
|
||||
Symbol {
|
||||
name: String,
|
||||
context: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Source {
|
||||
label: String,
|
||||
extension: Option<String>,
|
||||
lines: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct SnippetLine {
|
||||
number: usize,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct Snippet {
|
||||
path: String,
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
reason: String,
|
||||
lines: Vec<SnippetLine>,
|
||||
}
|
||||
|
||||
/// 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!("snip {}", 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(),
|
||||
lines: None,
|
||||
around: None,
|
||||
symbol: None,
|
||||
context: 2,
|
||||
max_matches: 1,
|
||||
paths: Vec::new(),
|
||||
};
|
||||
|
||||
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") => {
|
||||
let value = parser_value_string(&mut parser, "--input-format")?;
|
||||
cli.common.input_format = parse_input_format(&value)?;
|
||||
}
|
||||
Long("color") => {
|
||||
let value = parser_value_string(&mut parser, "--color")?;
|
||||
cli.common.color = parse_color_choice(&value)?;
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("lines") => {
|
||||
cli.lines = Some(parser_value_string(&mut parser, "--lines")?);
|
||||
}
|
||||
Long("around") => {
|
||||
cli.around = Some(parser_value_string(&mut parser, "--around")?);
|
||||
}
|
||||
Long("symbol") => {
|
||||
cli.symbol = Some(parser_value_string(&mut parser, "--symbol")?);
|
||||
}
|
||||
Long("context") => {
|
||||
cli.context =
|
||||
parse_usize_flag("--context", &parser_value_string(&mut parser, "--context")?)?;
|
||||
}
|
||||
Long("max-matches") => {
|
||||
cli.max_matches = parse_usize_flag(
|
||||
"--max-matches",
|
||||
&parser_value_string(&mut parser, "--max-matches")?,
|
||||
)?;
|
||||
}
|
||||
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
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()))?;
|
||||
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 run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
let selector = build_selector(cli)?;
|
||||
let sources = load_sources(cli)?;
|
||||
let mut snippets = Vec::new();
|
||||
|
||||
for source in &sources {
|
||||
snippets.extend(select_snippets(source, &selector));
|
||||
}
|
||||
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&snippets)?,
|
||||
RenderMode::Toon => print_structured(&snippets, RenderMode::Toon)?,
|
||||
RenderMode::Text => print!("{}", render_text_snippets(&snippets)),
|
||||
}
|
||||
|
||||
Ok(if snippets.is_empty() {
|
||||
ExitCode::NoResults
|
||||
} else {
|
||||
ExitCode::Success
|
||||
})
|
||||
}
|
||||
|
||||
fn build_selector(cli: &Cli) -> Result<Selector, CliError> {
|
||||
let selector_count = usize::from(cli.lines.is_some())
|
||||
+ usize::from(cli.around.is_some())
|
||||
+ usize::from(cli.symbol.is_some());
|
||||
if selector_count != 1 {
|
||||
return Err(CliError::usage(
|
||||
"select exactly one of --lines, --around, or --symbol",
|
||||
));
|
||||
}
|
||||
if cli.max_matches == 0 {
|
||||
return Err(CliError::usage("--max-matches must be greater than 0"));
|
||||
}
|
||||
|
||||
if let Some(lines) = &cli.lines {
|
||||
return Ok(Selector::Lines(parse_range(lines)?));
|
||||
}
|
||||
if let Some(pattern) = &cli.around {
|
||||
let regex = Regex::new(pattern)
|
||||
.map_err(|error| CliError::usage(format!("invalid --around regex: {error}")))?;
|
||||
return Ok(Selector::Around {
|
||||
pattern: pattern.clone(),
|
||||
regex,
|
||||
context: cli.context,
|
||||
max_matches: cli.max_matches,
|
||||
});
|
||||
}
|
||||
if let Some(name) = &cli.symbol {
|
||||
return Ok(Selector::Symbol {
|
||||
name: name.clone(),
|
||||
context: cli.context,
|
||||
});
|
||||
}
|
||||
|
||||
Err(CliError::usage(
|
||||
"select one of --lines, --around, or --symbol",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_range(raw: &str) -> Result<LineRange, CliError> {
|
||||
let Some((start, end)) = raw.split_once(':') else {
|
||||
let line = parse_positive(raw, "--lines")?;
|
||||
return Ok(LineRange {
|
||||
start: line,
|
||||
end: line,
|
||||
});
|
||||
};
|
||||
|
||||
let start = parse_positive(start, "--lines")?;
|
||||
let end = parse_positive(end, "--lines")?;
|
||||
if start > end {
|
||||
return Err(CliError::usage(
|
||||
"--lines start must be less than or equal to end",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(LineRange { start, end })
|
||||
}
|
||||
|
||||
fn parse_positive(raw: &str, flag: &str) -> Result<usize, CliError> {
|
||||
let value = raw
|
||||
.parse::<usize>()
|
||||
.map_err(|error| CliError::usage(format!("invalid {flag} value '{raw}': {error}")))?;
|
||||
if value == 0 {
|
||||
return Err(CliError::usage(format!(
|
||||
"{flag} values must be greater than 0"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn load_sources(cli: &Cli) -> Result<Vec<Source>, CliError> {
|
||||
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
|
||||
let mut buffer = String::new();
|
||||
io::stdin()
|
||||
.take(MAX_SOURCE_BYTES + 1)
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
||||
if buffer.len() as u64 > MAX_SOURCE_BYTES {
|
||||
return Err(CliError::runtime(format!(
|
||||
"refusing to read stdin larger than {MAX_SOURCE_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
return Ok(vec![Source {
|
||||
label: "<stdin>".to_string(),
|
||||
extension: None,
|
||||
lines: text_lines(&buffer),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
if cli.paths.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"provide at least one path or pipe file content into stdin",
|
||||
));
|
||||
}
|
||||
|
||||
let mut sources = Vec::new();
|
||||
for path in &cli.paths {
|
||||
let content = read_bounded_source(path)?;
|
||||
sources.push(Source {
|
||||
label: path.display().to_string(),
|
||||
extension: extension_label(path),
|
||||
lines: text_lines(&content),
|
||||
});
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
fn read_bounded_source(path: &Path) -> Result<String, CliError> {
|
||||
read_bounded_source_with_limit(path, MAX_SOURCE_BYTES)
|
||||
}
|
||||
|
||||
fn read_bounded_source_with_limit(path: &Path, max_source_bytes: u64) -> Result<String, CliError> {
|
||||
let metadata = fs::metadata(path).map_err(|error| {
|
||||
CliError::runtime(format!("failed to inspect {}: {error}", path.display()))
|
||||
})?;
|
||||
if metadata.len() > max_source_bytes {
|
||||
return Err(CliError::runtime(format!(
|
||||
"refusing to read {} because it is {} bytes; snip source files are capped at {max_source_bytes} bytes",
|
||||
path.display(),
|
||||
metadata.len()
|
||||
)));
|
||||
}
|
||||
fs::read_to_string(path)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display())))
|
||||
}
|
||||
|
||||
fn text_lines(content: &str) -> Vec<String> {
|
||||
content
|
||||
.trim_start_matches('\u{feff}')
|
||||
.lines()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn extension_label(path: &Path) -> Option<String> {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
fn select_snippets(source: &Source, selector: &Selector) -> Vec<Snippet> {
|
||||
match selector {
|
||||
Selector::Lines(range) => {
|
||||
select_range_snippet(source, *range).map_or_else(Vec::new, |snippet| vec![snippet])
|
||||
}
|
||||
Selector::Around {
|
||||
pattern,
|
||||
regex,
|
||||
context,
|
||||
max_matches,
|
||||
} => select_around_snippets(source, pattern, regex, *context, *max_matches),
|
||||
Selector::Symbol { name, context } => select_symbol_snippet(source, name, *context)
|
||||
.map_or_else(Vec::new, |snippet| vec![snippet]),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_range_snippet(source: &Source, range: LineRange) -> Option<Snippet> {
|
||||
if range.start > source.lines.len() {
|
||||
return None;
|
||||
}
|
||||
Some(make_snippet(
|
||||
source,
|
||||
LineRange {
|
||||
start: range.start,
|
||||
end: range.end.min(source.lines.len()),
|
||||
},
|
||||
"lines".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn select_around_snippets(
|
||||
source: &Source,
|
||||
pattern: &str,
|
||||
regex: &Regex,
|
||||
context: usize,
|
||||
max_matches: usize,
|
||||
) -> Vec<Snippet> {
|
||||
source
|
||||
.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| regex.is_match(line))
|
||||
.take(max_matches)
|
||||
.map(|(index, _)| {
|
||||
let line = index + 1;
|
||||
make_snippet(
|
||||
source,
|
||||
LineRange {
|
||||
start: line.saturating_sub(context).max(1),
|
||||
end: (line + context).min(source.lines.len()),
|
||||
},
|
||||
format!("around:{pattern}"),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn select_symbol_snippet(source: &Source, name: &str, context: usize) -> Option<Snippet> {
|
||||
let line = find_symbol_line(source, name)?;
|
||||
let range = expand_symbol_range(&source.lines, line, context);
|
||||
Some(make_snippet(source, range, format!("symbol:{name}")))
|
||||
}
|
||||
|
||||
fn find_symbol_line(source: &Source, name: &str) -> Option<usize> {
|
||||
let patterns = symbol_patterns(source.extension.as_deref(), name);
|
||||
source.lines.iter().enumerate().find_map(|(index, line)| {
|
||||
patterns
|
||||
.iter()
|
||||
.any(|pattern| pattern.is_match(line))
|
||||
.then_some(index + 1)
|
||||
})
|
||||
}
|
||||
|
||||
fn symbol_patterns(extension: Option<&str>, name: &str) -> Vec<Regex> {
|
||||
let escaped = regex_lite::escape(name);
|
||||
let raw_patterns = match extension {
|
||||
Some("rs") => vec![
|
||||
format!(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+{escaped}\b"),
|
||||
format!(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|mod|type)\s+{escaped}\b"),
|
||||
format!(r"^\s*impl\b.*\b{escaped}\b"),
|
||||
],
|
||||
Some("cs") => vec![
|
||||
format!(
|
||||
r"^\s*(?:public|private|protected|internal|static|sealed|abstract|partial|\s)+(?:class|struct|enum|interface|record)\s+{escaped}\b"
|
||||
),
|
||||
format!(
|
||||
r"^\s*(?:public|private|protected|internal|static|virtual|override|async|\s)+[\w<>\[\],?]+\s+{escaped}\s*\("
|
||||
),
|
||||
format!(r"^\s*namespace\s+.*\b{escaped}\b"),
|
||||
],
|
||||
_ => vec![format!(r"\b{escaped}\b")],
|
||||
};
|
||||
|
||||
raw_patterns
|
||||
.into_iter()
|
||||
.map(|pattern| Regex::new(&pattern).expect("internal symbol regex must compile"))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn expand_symbol_range(lines: &[String], line: usize, context: usize) -> LineRange {
|
||||
let start_index = line - 1;
|
||||
find_open_brace_line(lines, start_index).map_or_else(
|
||||
|| LineRange {
|
||||
start: line.saturating_sub(context).max(1),
|
||||
end: (line + context).min(lines.len()),
|
||||
},
|
||||
|open_index| LineRange {
|
||||
start: line.saturating_sub(context).max(1),
|
||||
end: find_block_end(lines, open_index),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn find_open_brace_line(lines: &[String], start_index: usize) -> Option<usize> {
|
||||
let search_end = (start_index + 8).min(lines.len().saturating_sub(1));
|
||||
(start_index..=search_end).find(|index| lines[*index].contains('{'))
|
||||
}
|
||||
|
||||
fn find_block_end(lines: &[String], open_index: usize) -> usize {
|
||||
let mut depth = 0_usize;
|
||||
let mut saw_open = false;
|
||||
|
||||
for (index, line) in lines.iter().enumerate().skip(open_index) {
|
||||
for ch in line.chars() {
|
||||
if ch == '{' {
|
||||
depth += 1;
|
||||
saw_open = true;
|
||||
} else if ch == '}' && saw_open {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open_index + 1
|
||||
}
|
||||
|
||||
fn make_snippet(source: &Source, range: LineRange, reason: String) -> Snippet {
|
||||
Snippet {
|
||||
path: source.label.clone(),
|
||||
start_line: range.start,
|
||||
end_line: range.end,
|
||||
reason,
|
||||
lines: source.lines[range.start - 1..range.end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(offset, text)| SnippetLine {
|
||||
number: range.start + offset,
|
||||
text: text.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_snippets(snippets: &[Snippet]) -> String {
|
||||
let mut rendered = String::new();
|
||||
|
||||
for (index, snippet) in snippets.iter().enumerate() {
|
||||
if index > 0 {
|
||||
rendered.push('\n');
|
||||
}
|
||||
writeln!(
|
||||
rendered,
|
||||
"path={} lines={}:{} reason={}",
|
||||
snippet.path, snippet.start_line, snippet.end_line, snippet.reason
|
||||
)
|
||||
.expect("writing to a String cannot fail");
|
||||
for line in &snippet.lines {
|
||||
writeln!(rendered, "{}: {}", line.number, line.text)
|
||||
.expect("writing to a String cannot fail");
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use common::ColorChoice;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn common_args(json: bool) -> CommonArgs {
|
||||
CommonArgs {
|
||||
json,
|
||||
format: None,
|
||||
input_format: common::InputFormat::Auto,
|
||||
color: ColorChoice::Never,
|
||||
quiet: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn source(lines: &[&str], extension: Option<&str>) -> Source {
|
||||
Source {
|
||||
label: "fixture".to_string(),
|
||||
extension: extension.map(str::to_string),
|
||||
lines: lines
|
||||
.iter()
|
||||
.map(|line| (*line).to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_range_accepts_single_lines_and_ranges() {
|
||||
assert_eq!(
|
||||
parse_range("4").expect("single line"),
|
||||
LineRange { start: 4, end: 4 }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_range("2:7").expect("range"),
|
||||
LineRange { start: 2, end: 7 }
|
||||
);
|
||||
|
||||
let error = parse_range("7:2").expect_err("reversed range should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("start must be less than or equal")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_patterns_and_block_expansion_cover_rust_and_csharp() {
|
||||
let rust = source(
|
||||
&[
|
||||
"pub fn run() {",
|
||||
" println!(\"hi\");",
|
||||
"}",
|
||||
"fn helper() {}",
|
||||
],
|
||||
Some("rs"),
|
||||
);
|
||||
assert_eq!(find_symbol_line(&rust, "run"), Some(1));
|
||||
assert_eq!(
|
||||
expand_symbol_range(&rust.lines, 1, 0),
|
||||
LineRange { start: 1, end: 3 }
|
||||
);
|
||||
|
||||
let csharp = source(
|
||||
&[
|
||||
"public class PlayerController {",
|
||||
" private int ComputeScore(int baseScore) {",
|
||||
" return baseScore;",
|
||||
" }",
|
||||
"}",
|
||||
],
|
||||
Some("cs"),
|
||||
);
|
||||
assert_eq!(find_symbol_line(&csharp, "PlayerController"), Some(1));
|
||||
assert_eq!(find_symbol_line(&csharp, "ComputeScore"), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_and_around_selection_render_compact_text() {
|
||||
let source = source(&["alpha", "beta", "helper", "delta"], Some("txt"));
|
||||
let range =
|
||||
select_range_snippet(&source, LineRange { start: 2, end: 3 }).expect("range snippet");
|
||||
assert_eq!(range.start_line, 2);
|
||||
assert_eq!(range.end_line, 3);
|
||||
|
||||
let regex = Regex::new("hel.+er").expect("regex");
|
||||
let around = select_around_snippets(&source, "helper", ®ex, 1, 1);
|
||||
assert_eq!(around.len(), 1);
|
||||
assert_eq!(around[0].start_line, 2);
|
||||
assert_eq!(around[0].end_line, 4);
|
||||
|
||||
let text = render_text_snippets(&around);
|
||||
assert!(text.contains("reason=around:helper"));
|
||||
assert!(text.contains("3: helper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_builder_and_numeric_parsing_report_usage_errors() {
|
||||
let error = build_selector(&Cli {
|
||||
common: common_args(false),
|
||||
lines: Some("1".to_string()),
|
||||
around: None,
|
||||
symbol: None,
|
||||
context: 0,
|
||||
max_matches: 0,
|
||||
paths: Vec::new(),
|
||||
})
|
||||
.expect_err("zero max matches should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("--max-matches must be greater than 0")
|
||||
));
|
||||
|
||||
let error = build_selector(&Cli {
|
||||
common: common_args(false),
|
||||
lines: None,
|
||||
around: Some("[".to_string()),
|
||||
symbol: None,
|
||||
context: 0,
|
||||
max_matches: 1,
|
||||
paths: Vec::new(),
|
||||
})
|
||||
.expect_err("invalid regex should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("invalid --around regex")
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
build_selector(&Cli {
|
||||
common: common_args(false),
|
||||
lines: None,
|
||||
around: None,
|
||||
symbol: Some("helper".to_string()),
|
||||
context: 1,
|
||||
max_matches: 1,
|
||||
paths: Vec::new(),
|
||||
})
|
||||
.expect("symbol selector"),
|
||||
Selector::Symbol { .. }
|
||||
));
|
||||
|
||||
let error = parse_positive("0", "--lines").expect_err("zero should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("greater than 0")
|
||||
));
|
||||
|
||||
let error = build_selector(&Cli {
|
||||
common: common_args(false),
|
||||
lines: Some("1".to_string()),
|
||||
around: Some("helper".to_string()),
|
||||
symbol: None,
|
||||
context: 1,
|
||||
max_matches: 1,
|
||||
paths: Vec::new(),
|
||||
})
|
||||
.expect_err("multiple selectors should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("select exactly one")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_loading_and_range_selection_cover_file_and_error_paths() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("sample.rs");
|
||||
fs::write(&path, "pub fn run() {}\n").expect("fixture");
|
||||
|
||||
let loaded = load_sources(&Cli {
|
||||
common: common_args(false),
|
||||
lines: Some("1".to_string()),
|
||||
around: None,
|
||||
symbol: None,
|
||||
context: 0,
|
||||
max_matches: 1,
|
||||
paths: vec![path],
|
||||
})
|
||||
.expect("sources");
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].extension.as_deref(), Some("rs"));
|
||||
|
||||
let missing = load_sources(&Cli {
|
||||
common: common_args(false),
|
||||
lines: Some("1".to_string()),
|
||||
around: None,
|
||||
symbol: None,
|
||||
context: 0,
|
||||
max_matches: 1,
|
||||
paths: Vec::new(),
|
||||
})
|
||||
.expect_err("missing input should fail");
|
||||
assert!(matches!(
|
||||
missing,
|
||||
CliError::Usage(message)
|
||||
if message.contains("provide at least one path")
|
||||
));
|
||||
|
||||
let source = source(&["alpha"], Some("txt"));
|
||||
assert!(select_range_snippet(&source, LineRange { start: 9, end: 9 }).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_loading_rejects_large_files() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("large.txt");
|
||||
fs::write(&path, vec![b'a'; 129]).expect("large file");
|
||||
|
||||
let error =
|
||||
read_bounded_source_with_limit(&path, 128).expect_err("large input should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Runtime(message) if message.contains("source files are capped")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_lookup_handles_generic_matches_and_braceless_fallbacks() {
|
||||
let generic = source(&["alpha helper beta", "omega"], None);
|
||||
let snippet = select_symbol_snippet(&generic, "helper", 1).expect("generic symbol");
|
||||
assert_eq!(snippet.start_line, 1);
|
||||
assert_eq!(snippet.end_line, 2);
|
||||
|
||||
let lines = vec!["fn demo()".to_string(), "value".to_string()];
|
||||
assert_eq!(
|
||||
expand_symbol_range(&lines, 1, 1),
|
||||
LineRange { start: 1, end: 2 }
|
||||
);
|
||||
|
||||
let unmatched = vec!["fn demo() {".to_string(), "value".to_string()];
|
||||
assert_eq!(find_block_end(&unmatched, 0), 1);
|
||||
assert!(select_symbol_snippet(&generic, "missing", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_lookup_handles_multiline_rust_signatures_before_open_brace() {
|
||||
let source = source(
|
||||
&[
|
||||
"fn parse_cli_from<I, T>(",
|
||||
" args: I,",
|
||||
") -> Result<(), CliError>",
|
||||
"where",
|
||||
" I: IntoIterator<Item = T>,",
|
||||
" T: Into<OsString>,",
|
||||
"{",
|
||||
" Ok(())",
|
||||
"}",
|
||||
],
|
||||
Some("rs"),
|
||||
);
|
||||
let snippet = select_symbol_snippet(&source, "parse_cli_from", 0).expect("symbol");
|
||||
assert_eq!(snippet.start_line, 1);
|
||||
assert_eq!(snippet.end_line, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_maps_success_and_no_results_for_text_and_json_modes() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("sample.txt");
|
||||
fs::write(&path, "alpha\nbeta\nhelper\n").expect("fixture");
|
||||
|
||||
let success = run(&Cli {
|
||||
common: common_args(true),
|
||||
lines: None,
|
||||
around: Some("helper".to_string()),
|
||||
symbol: None,
|
||||
context: 0,
|
||||
max_matches: 1,
|
||||
paths: vec![path.clone()],
|
||||
})
|
||||
.expect("json run");
|
||||
assert_eq!(success, ExitCode::Success);
|
||||
|
||||
let no_results = run(&Cli {
|
||||
common: common_args(false),
|
||||
lines: None,
|
||||
around: Some("missing".to_string()),
|
||||
symbol: None,
|
||||
context: 0,
|
||||
max_matches: 1,
|
||||
paths: vec![path],
|
||||
})
|
||||
.expect("text run");
|
||||
assert_eq!(no_results, ExitCode::NoResults);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `snip`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(snip::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Integration tests for the `snip` command.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
const SAMPLE_RS: &str = "reading/sample.rs";
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("snip").expect("binary")
|
||||
}
|
||||
|
||||
fn fixture(path: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("fixtures")
|
||||
.join(path)
|
||||
}
|
||||
|
||||
fn temp_file(name: &str, contents: impl AsRef<[u8]>) -> (TempDir, PathBuf) {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let path = dir.path().join(name);
|
||||
fs::write(&path, contents).expect("fixture");
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn pwsh_command(script: impl AsRef<str>) -> Command {
|
||||
let mut command = Command::new("pwsh");
|
||||
command
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg(script.as_ref());
|
||||
command
|
||||
}
|
||||
|
||||
fn ps_quote(value: impl std::fmt::Display) -> String {
|
||||
format!("'{}'", value.to_string().replace('\'', "''"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_requested_line_ranges() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--lines")
|
||||
.arg("18:31")
|
||||
.arg(fixture(SAMPLE_RS))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("reason=lines"))
|
||||
.stdout(predicate::str::contains("18: pub fn run"))
|
||||
.stdout(predicate::str::contains("31: }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_symbol_blocks() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--symbol")
|
||||
.arg("ComputeScore")
|
||||
.arg(fixture("reading/sample.cs"))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("reason=symbol:ComputeScore"))
|
||||
.stdout(predicate::str::contains(
|
||||
"9: private int ComputeScore",
|
||||
))
|
||||
.stdout(predicate::str::contains("15: }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_regex_matches_with_context_as_json() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--around")
|
||||
.arg("Mode::Fast")
|
||||
.arg("--context")
|
||||
.arg("1")
|
||||
.arg("--json")
|
||||
.arg(fixture("reading/sample.rs"))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"reason\":\"around:Mode::Fast\""))
|
||||
.stdout(predicate::str::contains("\"start_line\":21"))
|
||||
.stdout(predicate::str::contains("\"end_line\":23"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_stdin_content_snipping() {
|
||||
let binary = assert_cmd::cargo::cargo_bin("snip");
|
||||
let input = fixture(SAMPLE_RS);
|
||||
let script = format!(
|
||||
"[System.IO.File]::ReadLines({}) | & {} --around 'helper' --context 0",
|
||||
ps_quote(input.display()),
|
||||
ps_quote(binary.display())
|
||||
);
|
||||
|
||||
let mut command = pwsh_command(script);
|
||||
command
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("<stdin>"))
|
||||
.stdout(predicate::str::contains("fn helper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_stdin_does_not_pollute_first_snippet_line() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.args(["--lines", "1:1"])
|
||||
.write_stdin("\u{feff}fn main() {}\n")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("1: fn main() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_file_does_not_pollute_first_snippet_line() {
|
||||
let (_dir, path) = temp_file("bom.rs", "\u{feff}fn main() {}\n");
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--lines")
|
||||
.arg("1:1")
|
||||
.arg(&path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("1: fn main() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_file_reports_read_error() {
|
||||
let (_dir, path) = temp_file("invalid-utf8.rs", [0x66, 0x6E, 0x80, 0x0A]);
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--lines")
|
||||
.arg("1:1")
|
||||
.arg(&path)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("failed to read"))
|
||||
.stderr(predicate::str::contains("invalid-utf8.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_includes_selector_examples() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--lines"))
|
||||
.stdout(predicate::str::contains("--symbol"))
|
||||
.stdout(predicate::str::contains("ConvertFrom-Json"));
|
||||
}
|
||||
Reference in New Issue
Block a user