use std::{
fs,
io::{Read, stdin},
path::Path,
};
use crate::parser::{Grammar, parse};
use tree_sitter::{Node, Tree};
use crate::language::Language;
pub fn debug_cst_command<W, P>(
stdout: &mut W,
filenames: &[P],
language: Option<Language>,
) -> anyhow::Result<()>
where
W: std::io::Write,
P: AsRef<Path>,
{
let mut stdin = stdin();
debug_cst_command_with_reader(stdout, filenames, language, &mut stdin)
}
fn debug_cst_command_with_reader<W, P, R>(
stdout: &mut W,
filenames: &[P],
language: Option<Language>,
stdin: &mut R,
) -> anyhow::Result<()>
where
W: std::io::Write,
P: AsRef<Path>,
R: Read,
{
if filenames.is_empty() {
let mut content = String::with_capacity(1024);
stdin.read_to_string(&mut content)?;
let grammar = language.unwrap_or(Language::Markdown).grammar();
write_tree(stdout, grammar, &content)?;
} else {
for filename in filenames {
let filename = filename.as_ref();
let language =
Language::explicit_or_inferred_path(language, filename).ok_or_else(|| {
anyhow::anyhow!(
"could not infer the language for {}; specify it with --language",
filename.display()
)
})?;
let grammar: Grammar = language.grammar();
let content = fs::read_to_string(filename)?;
write_tree(stdout, grammar, &content)?;
}
}
Ok(())
}
fn write_tree<W: std::io::Write>(
stdout: &mut W,
grammar: Grammar,
content: &str,
) -> anyhow::Result<()> {
let tree = parse(grammar, content)?;
writeln!(stdout, "{}", render_tree(&tree))?;
Ok(())
}
fn render_tree(tree: &Tree) -> String {
let mut output = String::new();
render_node(&mut output, tree.root_node(), None, 0);
output
}
fn render_node(output: &mut String, node: Node<'_>, field_name: Option<&str>, indent: usize) {
output.push_str(&" ".repeat(indent));
if let Some(field_name) = field_name {
output.push_str(field_name);
output.push_str(": ");
}
output.push('(');
output.push_str(node.kind());
output.push(' ');
output.push_str(&format_position(node.start_position()));
output.push_str(" - ");
output.push_str(&format_position(node.end_position()));
let named_child_count = node.named_child_count();
if named_child_count == 0 {
output.push(')');
return;
}
for i in 0..named_child_count {
output.push('\n');
let child = node
.named_child(i as u32)
.expect("named child should exist");
let field_name = node.field_name_for_named_child(i as u32);
render_node(output, child, field_name, indent + 2);
}
output.push(')');
}
fn format_position(point: tree_sitter::Point) -> String {
format!("[{}, {}]", point.row, point.column)
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use super::*;
fn make_temp_path(extension: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let filename = if extension.is_empty() {
format!("cjkfmt-debug-cst-{unique}")
} else {
format!("cjkfmt-debug-cst-{unique}.{extension}")
};
std::env::temp_dir().join(filename)
}
#[test]
fn debug_cst_command_reads_from_stdin_as_markdown() {
let mut stdout = Vec::new();
let mut stdin = "# Test\n".as_bytes();
debug_cst_command_with_reader(&mut stdout, &[] as &[PathBuf], None, &mut stdin).unwrap();
let actual = String::from_utf8(stdout).unwrap();
assert_eq!(
actual,
concat!(
"(document [0, 0] - [1, 0]\n",
" (section [0, 0] - [1, 0]\n",
" (atx_heading [0, 0] - [1, 0]\n",
" (atx_h1_marker [0, 0] - [0, 1])\n",
" heading_content: (inline [0, 2] - [0, 6]))))\n",
)
);
}
#[test]
fn debug_cst_command_uses_json_grammar_for_uppercase_json_files() {
let path = make_temp_path("JSON");
fs::write(&path, "{\"name\":1}\n").unwrap();
let mut stdout = Vec::new();
let mut stdin = "".as_bytes();
debug_cst_command_with_reader(&mut stdout, &[&path], None, &mut stdin).unwrap();
let actual = String::from_utf8(stdout).unwrap();
assert!(actual.contains("(object [0, 0] - [0, 10]"));
assert!(!actual.contains("(section "));
fs::remove_file(path).unwrap();
}
#[test]
fn debug_cst_command_rejects_unrecognized_named_files_without_an_override() {
for extension in ["txt", ""] {
let path = make_temp_path(extension);
fs::write(&path, "# Test\n").unwrap();
let error =
debug_cst_command_with_reader(&mut Vec::new(), &[&path], None, &mut "".as_bytes())
.expect_err("unsupported named files should require an explicit language");
let message = error.to_string();
assert!(message.contains(path.to_string_lossy().as_ref()));
assert!(message.contains("specify it with --language"));
fs::remove_file(path).unwrap();
}
}
#[test]
fn debug_cst_command_language_override_uses_json_for_stdin() {
let mut stdout = Vec::new();
let mut stdin = "{\"name\":1}\n".as_bytes();
debug_cst_command_with_reader(
&mut stdout,
&[] as &[PathBuf],
Some(Language::Json),
&mut stdin,
)
.unwrap();
let actual = String::from_utf8(stdout).unwrap();
assert!(actual.contains("(object [0, 0] - [0, 10]"));
}
#[test]
fn debug_cst_command_language_override_applies_to_every_named_file() {
let first = make_temp_path("json");
let second = make_temp_path("txt");
fs::write(&first, "# First\n").unwrap();
fs::write(&second, "# Second\n").unwrap();
let mut stdout = Vec::new();
let mut stdin = "".as_bytes();
debug_cst_command_with_reader(
&mut stdout,
&[&first, &second],
Some(Language::Markdown),
&mut stdin,
)
.unwrap();
let actual = String::from_utf8(stdout).unwrap();
assert_eq!(actual.matches("(section [0, 0]").count(), 2);
fs::remove_file(first).unwrap();
fs::remove_file(second).unwrap();
}
#[test]
fn debug_cst_command_uses_json_grammar_for_lowercase_json_files() {
let path = make_temp_path("json");
fs::write(&path, "{\"name\":1}\n").unwrap();
let mut stdout = Vec::new();
let mut stdin = "".as_bytes();
debug_cst_command_with_reader(&mut stdout, &[&path], None, &mut stdin).unwrap();
let actual = String::from_utf8(stdout).unwrap();
assert_eq!(
actual,
concat!(
"(document [0, 0] - [1, 0]\n",
" (object [0, 0] - [0, 10]\n",
" (pair [0, 1] - [0, 9]\n",
" key: (string [0, 1] - [0, 7]\n",
" (string_content [0, 2] - [0, 6]))\n",
" value: (number [0, 8] - [0, 9]))))\n",
)
);
fs::remove_file(path).unwrap();
}
}