diff --git a/crates/clew-core/src/apidoc.rs b/crates/clew-core/src/apidoc.rs index 9eedd5d..781e98e 100644 --- a/crates/clew-core/src/apidoc.rs +++ b/crates/clew-core/src/apidoc.rs @@ -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. /// @@ -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 { let Some(lang) = Lang::for_source(lang_key, source) else { return Vec::new(); @@ -71,7 +141,14 @@ pub fn build_file(source: &str, lang_key: &str) -> Vec { 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 @@ -1126,6 +1203,7 @@ mod depth_tests { files: vec![clew_protocol::DocFile { rel: "deep.rs".to_string(), items, + doc: String::new(), }], }, }; @@ -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"), + "" + ); + } } diff --git a/crates/clew-core/src/git.rs b/crates/clew-core/src/git.rs index 99a1329..1058ebc 100644 --- a/crates/clew-core/src/git.rs +++ b/crates/clew-core/src/git.rs @@ -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 { + 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 { + 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, 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 { + 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 { + 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, GitError> { let git = Git::open(root)?; if !git.has_head()? { @@ -1569,7 +1674,13 @@ pub fn churn(root: &Path, commits: usize) -> Result, 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%x00