Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion crates/clew-core/src/apidoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ use clew_protocol::DocItem;
use crate::highlight::Lang;
use crate::outline::Container;

/// The kind an associated type (a Rust `type` inside an `impl` or a trait)
/// is listed under, apart from the module's own type aliases.
pub const ASSOCIATED_TYPE: &str = "associated type";

/// How deeply the emitted [`DocItem`] tree may nest before further items are
/// folded up as siblings at the cap.
///
Expand Down Expand Up @@ -42,6 +46,72 @@ pub const MAX_DOC_DEPTH: usize = 32;
/// Build the documented API of one file: top-level items, with members nested
/// under their enclosing type/module by source-range containment. Returns an
/// empty list when the language has no outline.
/// A file's own doc comment, its comment markers stripped: Rust's leading
/// `//!` lines, a Python module's docstring, Go's package comment (the
/// comment block right above `package`). Empty for the other languages,
/// whose file-top comments are licence headers as often as docs, and for a
/// file without one.
pub fn module_doc(source: &str, lang_key: &str) -> String {
let lines = source.lines().map(|l| l.trim_end_matches('\r'));
match lang_key {
"rust" => {
let mut doc: Vec<&str> = Vec::new();
for line in lines {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("//!") {
doc.push(rest.strip_prefix(' ').unwrap_or(rest));
} else if t.is_empty() && doc.is_empty() || t.starts_with("#!") && doc.is_empty() {
continue;
} else {
break;
}
}
doc.join("\n").trim().to_string()
}
"python" => {
// The first statement, when it is a string literal.
let body: Vec<&str> = lines
.skip_while(|l| {
let t = l.trim();
t.is_empty() || t.starts_with('#')
})
.collect();
let text = body.join("\n");
let text = text.trim_start_matches(['r', 'R', 'u', 'U']);
for quote in ["\"\"\"", "'''", "\"", "'"] {
if let Some(rest) = text.strip_prefix(quote)
&& let Some(end) = rest.find(quote)
{
return rest[..end].trim().to_string();
}
}
String::new()
}
"go" => {
let all: Vec<&str> = lines.collect();
let Some(pkg) = all
.iter()
.position(|l| l.trim_start().starts_with("package "))
else {
return String::new();
};
let mut doc: Vec<&str> = all[..pkg]
.iter()
.rev()
.map(|l| l.trim_start())
.take_while(|l| l.starts_with("//"))
.map(|l| {
let rest = &l[2..];
rest.strip_prefix(' ').unwrap_or(rest)
})
.collect();
doc.reverse();
doc.join("\n").trim().to_string()
}
_ => String::new(),
}
}

pub fn build_file(source: &str, lang_key: &str) -> Vec<DocItem> {
let Some(lang) = Lang::for_source(lang_key, source) else {
return Vec::new();
Expand Down Expand Up @@ -71,7 +141,14 @@ pub fn build_file(source: &str, lang_key: &str) -> Vec<DocItem> {
let s = &l.symbol;
Raw {
name: s.name.clone(),
kind: s.kind.clone(),
// `type Item = …;` in an `impl` is a member of the impl, not
// a type of the module: saying so keeps it out of the type
// map and the glossary, which list the project's own types.
kind: if lang == Lang::Rust && s.kind == "type" && l.container.is_some() {
ASSOCIATED_TYPE.to_string()
} else {
s.kind.clone()
},
line: s.line,
end_line: s.end_line,
// From where the declaration starts, and at least through the
Expand Down Expand Up @@ -1126,6 +1203,7 @@ mod depth_tests {
files: vec![clew_protocol::DocFile {
rel: "deep.rs".to_string(),
items,
doc: String::new(),
}],
},
};
Expand Down Expand Up @@ -1649,4 +1727,43 @@ module.exports = {
assert!(!find(&k.children, "_private").public);
assert!(!find(&k.children, "__mangled").public);
}

#[test]
fn a_files_own_doc_is_read_per_language() {
assert_eq!(
module_doc(
"//! Debug Adapter Protocol (DAP) support.\n//!\n//! More.\nuse x;\n",
"rust"
),
"Debug Adapter Protocol (DAP) support.\n\nMore."
);
assert_eq!(module_doc("#![allow(x)]\nuse y;\n", "rust"), "");
assert_eq!(
module_doc("/// An item's doc, not the file's.\nfn f() {}\n", "rust"),
""
);
assert_eq!(
module_doc(
"#!/usr/bin/env python\n# coding: utf-8\n\n\"\"\"The catalog.\n\nMore.\"\"\"\nimport x\n",
"python"
),
"The catalog.\n\nMore."
);
assert_eq!(module_doc("'''Orders.'''\n", "python"), "Orders.");
assert_eq!(
module_doc("import x\n\"\"\"Not first.\"\"\"\n", "python"),
""
);
assert_eq!(
module_doc(
"// Copyright.\n\n// Package shop prices orders.\n// Carts too.\npackage shop\n",
"go"
),
"Package shop prices orders.\nCarts too."
);
assert_eq!(
module_doc("/** Licence. */\nexport const a = 1;\n", "typescript"),
""
);
}
}
116 changes: 113 additions & 3 deletions crates/clew-core/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1544,8 +1544,113 @@ pub fn range_patch_of(
/// HEAD, merges left out: every file those commits touched (root-relative;
/// files outside the project root are not counted), with its commit count
/// and the time of its latest commit, most changed first, then by path.
/// Capped at [`MAX_CHURN_FILES`] files. A repository without commits, or a
/// Only files that exist under `root` now are listed: one deleted or renamed
/// away since is history, not a file to open. Capped at [`MAX_CHURN_FILES`]
/// files. A repository without commits, or a
/// project directory none of them touch, has none.
/// Whether the project's tracked files differ from `HEAD`, staged or not:
/// work not committed yet. `false` in a repository without a commit.
pub fn has_uncommitted(root: &Path) -> Result<bool, GitError> {
let git = Git::open(root)?;
if !git.has_head()? {
return Ok(false);
}
// `--quiet` exits 1 when there ARE changes; `-- .` keeps the question to
// the project directory.
let unchanged = git.check([
"diff",
"--quiet",
"--no-ext-diff",
END_OF_OPTIONS,
"HEAD",
"--",
".",
])?;
Ok(!unchanged)
}

/// The commit the current work started from, for a review `base` (a base
/// branch, or `HEAD` / `HEAD~1` on HEAD's own line): where HEAD left it.
fn work_start(git: &Git<'_>, base: &str) -> Result<String, GitError> {
let out = git.text(["merge-base", END_OF_OPTIONS, base, "HEAD"], GIT_TIMEOUT)?;
let start = out.trim();
if start.is_empty() {
return Err(GitError::Refused(format!(
"{base} shares no commit with HEAD"
)));
}
Ok(start.to_string())
}

/// [`changed_files`], up to the working tree rather than `HEAD`: what the
/// branch's commits and the uncommitted edits change together, from where
/// the work left `base`.
pub fn work_changed_files(root: &Path, base: &str) -> Result<Vec<(String, char)>, GitError> {
check_base(base)?;
let git = Git::open(root)?;
let start = work_start(&git, base)?;
let out = git.bytes(
[
"diff",
"--no-color",
"--no-ext-diff",
"--relative",
"--name-status",
"-z",
END_OF_OPTIONS,
&start,
],
GIT_TIMEOUT,
)?;
Ok(parse_name_status_z(&out))
}

/// [`range_patch`], up to the working tree (see [`work_changed_files`]).
pub fn work_patch(root: &Path, base: &str, max_bytes: usize) -> Result<String, GitError> {
check_base(base)?;
let git = Git::open(root)?;
let start = work_start(&git, base)?;
git.text_truncated(
[
"diff",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--relative",
END_OF_OPTIONS,
&start,
],
max_bytes,
)
}

/// [`range_patch_of`], up to the working tree (see [`work_changed_files`]).
pub fn work_patch_of(
root: &Path,
base: &str,
rel: &str,
max_bytes: usize,
) -> Result<String, GitError> {
check_base(base)?;
check_rel(rel)?;
let git = Git::open(root)?;
let start = work_start(&git, base)?;
git.text_truncated(
[
"diff",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--relative",
END_OF_OPTIONS,
&start,
"--",
rel,
],
max_bytes,
)
}

pub fn churn(root: &Path, commits: usize) -> Result<Vec<FileChurn>, GitError> {
let git = Git::open(root)?;
if !git.has_head()? {
Expand All @@ -1569,7 +1674,13 @@ pub fn churn(root: &Path, commits: usize) -> Result<Vec<FileChurn>, GitError> {
],
GIT_HISTORY_TIMEOUT,
)?;
Ok(parse_churn(&out))
// History names files deleted or renamed since: they are no file the
// reader can open (a click on one only reported a missing path), so the
// most-changed list keeps the files that are here now.
let mut files = parse_churn(&out);
files.retain(|f| root.join(&f.rel).exists());
files.truncate(MAX_CHURN_FILES);
Ok(files)
}

/// `%x00<sha>%x00<time>`: a commit's header line, which no path can start
Expand Down Expand Up @@ -1607,7 +1718,6 @@ fn parse_churn(out: &str) -> Vec<FileChurn> {
.map(|(rel, (commits, last))| FileChurn { rel, commits, last })
.collect();
files.sort_by(|a, b| b.commits.cmp(&a.commits).then_with(|| a.rel.cmp(&b.rel)));
files.truncate(MAX_CHURN_FILES);
files
}

Expand Down
60 changes: 60 additions & 0 deletions crates/clew-core/src/git/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,15 @@ fn churn_counts_each_files_commits_over_the_recent_history() {
["a.rs"],
"{recent:?}"
);

// A file deleted (or renamed away) since is history, not a file the
// reader can open: it leaves the list, however often it changed.
sh_git(&dir, &["rm", "-q", "b.rs"]);
sh_git(&dir, &["mv", "ünï.rs", "moved.rs"]);
commit_all(&dir, "drop b, rename ünï");
let files = churn(&dir, 300).unwrap();
let names: Vec<&str> = files.iter().map(|f| f.rel.as_str()).collect();
assert_eq!(names, ["a.rs", "moved.rs"], "{files:?}");
}

/// A blobless clone fetches old blobs on demand from its promisor remote, and
Expand Down Expand Up @@ -1734,3 +1743,54 @@ fn review_queries_are_scoped_to_a_subdirectory_root() {
let small = range_patch(&sub, &base, 16).unwrap();
assert!(small.ends_with("… (truncated)\n"), "{small:?}");
}

/// The current work includes what is not committed yet: from where the
/// branch left its base (or from HEAD on the base itself) to the working
/// tree — the branch's commits and the edits on top, together.
#[test]
fn uncommitted_work_is_part_of_the_current_work() {
let dir = repo_dir("git-work");
assert!(!has_uncommitted(&dir).unwrap(), "no commit yet");
std::fs::write(dir.join("a.rs"), "1\n").unwrap();
std::fs::write(dir.join("b.rs"), "1\n").unwrap();
commit_all(&dir, "start");
assert!(!has_uncommitted(&dir).unwrap());
std::fs::write(dir.join("a.rs"), "1\nedited\n").unwrap();
assert!(has_uncommitted(&dir).unwrap());
// On the base itself, the work is the edit alone.
assert_eq!(
work_changed_files(&dir, "HEAD").unwrap(),
[("a.rs".to_string(), 'M')]
);
let patch = work_patch(&dir, "HEAD", 64 * 1024).unwrap();
assert!(patch.contains("+edited"), "{patch}");
// Staged counts too.
sh_git(&dir, &["add", "a.rs"]);
assert!(has_uncommitted(&dir).unwrap());
sh_git(&dir, &["commit", "-qm", "edit a"]);
assert!(!has_uncommitted(&dir).unwrap());

// On a branch: its commit, and an uncommitted edit of another file.
let base = sh_git_out(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
sh_git(&dir, &["checkout", "-q", "-b", "feature"]);
std::fs::write(dir.join("c.rs"), "new\n").unwrap();
commit_all(&dir, "add c");
std::fs::write(dir.join("b.rs"), "1\nwip\n").unwrap();
let files = work_changed_files(&dir, &base).unwrap();
assert_eq!(
files,
[("b.rs".to_string(), 'M'), ("c.rs".to_string(), 'A')],
"the branch's commit and the edit on top"
);
let patch = work_patch_of(&dir, &base, "b.rs", 64 * 1024).unwrap();
assert!(patch.contains("+wip") && !patch.contains("c.rs"), "{patch}");
// The commits-only range still leaves the edit out.
assert_eq!(
changed_files(&dir, &base).unwrap(),
[("c.rs".to_string(), 'A')]
);
assert!(
work_changed_files(&dir, "-x").is_err(),
"an option is no base"
);
}
Loading
Loading