chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "codeshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Emit recursive AST-backed codebase maps with compact signatures."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+700
View File
@@ -0,0 +1,700 @@
//! The `codeshape` command emits recursive AST-backed project maps.
use codeindex::{
CodeIndexer, CodeLanguage, ENGINE_NAME, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind,
detect_language, parse_language_label,
};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files,
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 std::collections::BTreeSet;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
const HELP: &str = "\
Emit recursive AST-backed codebase maps with compact signatures via the shared codeindex engine.
Usage:
codeshape [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
--lang <LIST> Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript
--max-files <COUNT> Maximum number of files to include
--max-depth <COUNT> Maximum symbol depth to include
--limit-per-file <COUNT> Maximum symbols to include per file after depth filtering
-h, --help Show this help text
-V, --version Show the command version
Examples:
codeshape .\\fixtures\\polyglot\\repo
codeshape --max-depth 1 --limit-per-file 8 . --json | ConvertFrom-Json
'.\\fixtures\\polyglot\\repo' | codeshape --json | ConvertFrom-Json
JSON fields:
engine, roots, files[].path, files[].language, files[].items[], totals
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
max_files: usize,
max_depth: Option<usize>,
limit_per_file: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CodeShapeItem {
kind: SymbolKind,
name: String,
qualified_name: String,
signature: String,
depth: usize,
start_line: usize,
end_line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct FileReport {
path: String,
language: CodeLanguage,
items: Vec<CodeShapeItem>,
omitted_items: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct Totals {
files_seen: usize,
files_indexed: usize,
files_omitted_by_limit: usize,
symbols_emitted: usize,
symbols_omitted: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CodeShapeReport {
engine: &'static str,
roots: Vec<String>,
files: Vec<FileReport>,
totals: Totals,
}
/// 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!("codeshape {}", 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()
}
}
}
#[allow(
clippy::too_many_lines,
reason = "single-pass CLI parsing keeps global and shared output flags auditable"
)]
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 common = CommonArgs::default();
let mut paths = Vec::new();
let mut languages = None::<BTreeSet<CodeLanguage>>;
let mut max_files = 200_usize;
let mut max_depth = None::<usize>;
let mut limit_per_file = 32_usize;
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 {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
));
}
Long("version") | Short('V') => {
return Ok((
ParseOutcome::Version,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
));
}
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => common.quiet = true,
Long("lang") => {
languages = Some(parse_language_list(&parser_value_string(
&mut parser,
"--lang",
)?)?);
}
Long("max-files") => {
max_files = parse_usize_flag(
"--max-files",
&parser_value_string(&mut parser, "--max-files")?,
)?;
}
Long("max-depth") => {
max_depth = Some(parse_usize_flag(
"--max-depth",
&parser_value_string(&mut parser, "--max-depth")?,
)?);
}
Long("limit-per-file") => {
limit_per_file = parse_usize_flag(
"--limit-per-file",
&parser_value_string(&mut parser, "--limit-per-file")?,
)?;
}
ArgValue(value) => paths.push(PathBuf::from(value)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
if max_files == 0 {
return Err(CliError::usage("--max-files must be greater than 0"));
}
if limit_per_file == 0 {
return Err(CliError::usage("--limit-per-file must be greater than 0"));
}
Ok((
ParseOutcome::Run,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
))
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let report = build_report(cli)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print!("{}", render_text(&report)),
}
Ok(if report.files.is_empty() {
ExitCode::NoResults
} else {
ExitCode::Success
})
}
fn build_report(cli: &Cli) -> Result<CodeShapeReport, CliError> {
let roots = collect_roots(cli)?;
let discovered = discover_supported_files(&roots)?;
let mut indexer = CodeIndexer::new();
let mut files = Vec::new();
let mut totals = Totals {
files_seen: discovered.len(),
files_indexed: 0,
files_omitted_by_limit: 0,
symbols_emitted: 0,
symbols_omitted: 0,
};
for path in discovered {
if files.len() >= cli.max_files {
totals.files_omitted_by_limit += 1;
continue;
}
let Some(language) = detect_language(&path) else {
continue;
};
if cli
.languages
.as_ref()
.is_some_and(|languages| !languages.contains(&language))
{
continue;
}
if is_source_too_large(&path, MAX_SOURCE_BYTES)? {
totals.files_omitted_by_limit += 1;
continue;
}
let source = fs::read_to_string(&path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let mut items = indexer
.index_source_summary(&path, &source)?
.into_iter()
.filter(|item| cli.max_depth.is_none_or(|depth| item.depth <= depth))
.map(into_codeshape_item)
.collect::<Vec<_>>();
items.sort_unstable_by(|left, right| {
(left.depth, left.start_line, left.name.as_str()).cmp(&(
right.depth,
right.start_line,
right.name.as_str(),
))
});
if items.is_empty() {
continue;
}
let omitted_items = items.len().saturating_sub(cli.limit_per_file);
items.truncate(cli.limit_per_file);
totals.files_indexed += 1;
totals.symbols_emitted += items.len();
totals.symbols_omitted += omitted_items;
files.push(FileReport {
path: path.display().to_string(),
language,
items,
omitted_items,
});
}
Ok(CodeShapeReport {
engine: ENGINE_NAME,
roots: roots
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
files,
totals,
})
}
fn is_source_too_large(path: &Path, max_bytes: u64) -> Result<bool, CliError> {
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!(
"failed to read metadata for {}: {error}",
path.display()
))
})?;
Ok(metadata.len() > max_bytes)
}
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 parse_language_list(value: &str) -> Result<BTreeSet<CodeLanguage>, CliError> {
let mut languages = BTreeSet::new();
for raw in value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
{
let language = parse_language_label(raw).ok_or_else(|| {
CliError::usage(format!(
"invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}"
))
})?;
let _ = languages.insert(language);
}
Ok(languages)
}
fn collect_roots(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(!cli.paths.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 roots = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !roots.is_empty() {
return Ok(roots);
}
}
if cli.paths.is_empty() {
Ok(vec![PathBuf::from(".")])
} else {
common::expand_input_patterns(&cli.paths, "codeshape")
}
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "codeshape")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn discover_supported_files(roots: &[PathBuf]) -> Result<Vec<PathBuf>, CliError> {
collect_matching_files(roots, &|path| detect_language(path).is_some())
}
fn into_codeshape_item(symbol: IndexedSymbol) -> CodeShapeItem {
CodeShapeItem {
kind: symbol.kind,
name: symbol.name,
qualified_name: symbol.qualified_name,
signature: symbol.signature,
depth: symbol.depth,
start_line: symbol.start_line,
end_line: symbol.end_line,
}
}
fn render_text(report: &CodeShapeReport) -> String {
let mut output = String::new();
for file in &report.files {
let _ = writeln!(output, "{} [{}]", file.path, language_label(file.language));
for item in &file.items {
let indent = " ".repeat(item.depth + 1);
let _ = writeln!(
output,
"{}{} {} :: {}",
indent,
kind_label(item.kind),
item.qualified_name,
item.signature,
);
}
if file.omitted_items > 0 {
let _ = writeln!(output, " ... {} more item(s)", file.omitted_items);
}
}
let _ = writeln!(
output,
"totals: files_indexed={} symbols_emitted={} symbols_omitted={} files_omitted_by_limit={}",
report.totals.files_indexed,
report.totals.symbols_emitted,
report.totals.symbols_omitted,
report.totals.files_omitted_by_limit
);
output
}
const fn language_label(language: CodeLanguage) -> &'static str {
match language {
CodeLanguage::Rust
| CodeLanguage::Csharp
| CodeLanguage::Powershell
| CodeLanguage::Python
| CodeLanguage::Go
| CodeLanguage::Java
| CodeLanguage::Javascript
| CodeLanguage::Typescript => language.label(),
}
}
const fn kind_label(kind: SymbolKind) -> &'static str {
match kind {
SymbolKind::Module => "module",
SymbolKind::Namespace => "namespace",
SymbolKind::Class => "class",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Interface => "interface",
SymbolKind::Record => "record",
SymbolKind::Trait => "trait",
SymbolKind::Impl => "impl",
SymbolKind::TypeAlias => "type_alias",
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Constructor => "constructor",
SymbolKind::Const => "const",
SymbolKind::Static => "static",
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::ColorChoice;
fn fixture_repo() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
}
fn cli_for(root: PathBuf) -> Cli {
Cli {
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
paths: vec![root],
languages: None,
max_files: 200,
max_depth: None,
limit_per_file: 32,
}
}
fn sample_symbol() -> IndexedSymbol {
IndexedSymbol {
engine: ENGINE_NAME,
path: "fixture.ts".to_string(),
language: CodeLanguage::Typescript,
kind: SymbolKind::Function,
name: "helper".to_string(),
qualified_name: "web::helper".to_string(),
signature: "export const helper = (name: string) => name.trim();".to_string(),
parents: Vec::new(),
depth: 0,
start_line: 1,
end_line: 1,
text: "export const helper = (name: string) => name.trim();".to_string(),
}
}
#[test]
fn parse_cli_and_lists_cover_help_version_and_validation() {
assert_eq!(
parse_cli_from(["codeshape", "--help"]).expect("help").0,
ParseOutcome::Help
);
assert_eq!(
parse_cli_from(["codeshape", "--version"])
.expect("version")
.0,
ParseOutcome::Version
);
let (_, cli) = parse_cli_from([
"codeshape",
"--json",
"--input-format",
"jsonl",
"--color",
"never",
"--lang",
"rust,java,typescript",
"--max-files",
"4",
"--max-depth",
"1",
"--limit-per-file",
"6",
"fixtures/polyglot/repo",
])
.expect("parsed cli");
assert!(cli.common.json);
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
assert_eq!(cli.common.color, ColorChoice::Never);
assert_eq!(cli.max_files, 4);
assert_eq!(cli.max_depth, Some(1));
assert_eq!(cli.limit_per_file, 6);
assert!(
cli.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Typescript)
&& cli
.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Java)
);
assert!(parse_cli_from(["codeshape", "--max-files", "0"]).is_err());
assert!(parse_cli_from(["codeshape", "--limit-per-file", "0"]).is_err());
assert!(parse_language_list("lua").is_err());
assert!(parse_usize_flag("--max-files", "nope").is_err());
}
#[test]
fn path_parsing_and_discovery_cover_supported_inputs_and_failures() {
let source = PathBuf::from("src/lib.rs");
let manifest = PathBuf::from("Cargo.toml");
let line_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Lines)
.expect("line paths");
assert_eq!(line_paths, vec![manifest.clone(), source.clone()]);
let json_paths = parse_paths_from_string("{\"path\":\"src/lib.rs\"}\n", InputFormat::Jsonl)
.expect("json paths");
assert_eq!(json_paths, vec![source.clone()]);
let auto_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Auto)
.expect("auto paths");
assert_eq!(auto_paths, vec![manifest, source]);
assert!(
discover_supported_files(&[fixture_repo()])
.expect("discover")
.len()
>= 8
);
assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err());
}
#[test]
fn build_report_skips_supported_sources_above_size_limit() {
let root = std::env::temp_dir().join(format!(
"codeshape-large-source-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(&root).expect("temp root");
let large_len = usize::try_from(MAX_SOURCE_BYTES + 1).expect("test size fits usize");
fs::write(root.join("large.rs"), vec![b' '; large_len]).expect("large source");
let report = build_report(&cli_for(root.clone())).expect("report");
assert!(report.files.is_empty());
assert_eq!(report.totals.files_omitted_by_limit, 1);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn build_report_render_and_run_cover_truncation_and_empty_results() {
let mut cli = cli_for(fixture_repo());
let report = build_report(&cli).expect("report");
assert_eq!(report.engine, ENGINE_NAME);
assert!(report.files.len() >= 6);
assert!(report.totals.files_seen >= report.totals.files_indexed);
assert!(
report
.files
.iter()
.any(|file| file.path.ends_with("web\\app.ts"))
);
cli.languages = Some(BTreeSet::from([CodeLanguage::Rust]));
cli.max_files = 1;
cli.max_depth = Some(1);
cli.limit_per_file = 2;
let limited = build_report(&cli).expect("limited report");
assert_eq!(limited.files.len(), 1);
assert!(limited.files[0].omitted_items <= limited.totals.symbols_omitted);
let item = into_codeshape_item(sample_symbol());
assert_eq!(item.name, "helper");
let text = render_text(&CodeShapeReport {
engine: ENGINE_NAME,
roots: vec!["repo".to_string()],
files: vec![FileReport {
path: "repo/web/app.ts".to_string(),
language: CodeLanguage::Typescript,
items: vec![item],
omitted_items: 2,
}],
totals: Totals {
files_seen: 1,
files_indexed: 1,
files_omitted_by_limit: 0,
symbols_emitted: 1,
symbols_omitted: 2,
},
});
assert!(text.contains("repo/web/app.ts [typescript]"));
assert!(text.contains("function web::helper"));
assert!(text.contains("... 2 more item(s)"));
assert!(text.contains("totals: files_indexed=1"));
assert_eq!(language_label(CodeLanguage::Javascript), "javascript");
assert_eq!(language_label(CodeLanguage::Java), "java");
assert_eq!(kind_label(SymbolKind::Static), "static");
let success_cli = cli_for(fixture_repo());
assert_eq!(run(&success_cli).expect("run success"), ExitCode::Success);
let empty_cli = cli_for(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("README.md"),
);
assert_eq!(run(&empty_cli).expect("run empty"), ExitCode::NoResults);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `codeshape`.
fn main() {
std::process::exit(codeshape::main_entry());
}
+92
View File
@@ -0,0 +1,92 @@
//! Integration tests for the `codeshape` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("codeshape").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn summarizes_polyglot_repo_as_json() {
let mut command = cargo_command();
command
.arg("--json")
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("\"roots\""))
.stdout(predicate::str::contains("\"language\":\"rust\""))
.stdout(predicate::str::contains("\"language\":\"go\""))
.stdout(predicate::str::contains("\"language\":\"java\""))
.stdout(predicate::str::contains(
"\"qualified_name\":\"Service::execute\"",
))
.stdout(predicate::str::contains(
"\"signature\":\"pub fn helper(name: &str) -> String\"",
))
.stdout(predicate::str::contains(
"\"signature\":\"function Invoke-Helper\"",
));
}
#[test]
fn renders_compact_tree_text() {
let mut command = cargo_command();
command
.args(["--limit-per-file", "4"])
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("src\\lib.rs"))
.stdout(predicate::str::contains("function helper"))
.stdout(predicate::str::contains("namespace Mercury.Game"));
}
#[test]
fn supports_powershell_pipeline_roots() {
let binary = assert_cmd::cargo::cargo_bin("codeshape");
let root = fixture_path("");
let script = format!(
"'{}' | & '{}' --json | ConvertFrom-Json | Select-Object -ExpandProperty totals | Select-Object -ExpandProperty files_indexed",
root.display(),
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("10"));
}
#[test]
fn help_includes_repo_map_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--max-files"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("codeshape"));
}