#![warn(missing_docs)]
use std::ops::Range;
use std::path::Path;
mod lexers;
use lexers::{cs_comments, go_comments, java_comments, js_comments, py_comments, rust_comments};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
Rust,
TypeScript,
JavaScript,
Python,
Go,
Java,
CSharp,
}
impl Language {
pub fn from_path(path: &Path) -> Option<Self> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
match ext.as_str() {
"rs" => Some(Self::Rust),
"ts" | "tsx" | "mts" | "cts" => Some(Self::TypeScript),
"js" | "jsx" | "mjs" | "cjs" => Some(Self::JavaScript),
"py" | "pyi" => Some(Self::Python),
"go" => Some(Self::Go),
"java" => Some(Self::Java),
"cs" => Some(Self::CSharp),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Rust => "rust",
Self::TypeScript => "typescript",
Self::JavaScript => "javascript",
Self::Python => "python",
Self::Go => "go",
Self::Java => "java",
Self::CSharp => "csharp",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CommentKind {
Line,
Block,
Doc,
Docstring,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comment {
pub raw: String,
pub kind: CommentKind,
pub byte_range: Range<usize>,
pub line: usize,
}
impl Comment {
pub fn body(&self) -> &str {
strip_delimiters(&self.raw, self.kind)
}
}
fn strip_delimiters(raw: &str, kind: CommentKind) -> &str {
match kind {
CommentKind::Line => {
if let Some(rest) = raw.strip_prefix("///") {
rest
} else if let Some(rest) = raw.strip_prefix("//!") {
rest
} else if let Some(rest) = raw.strip_prefix("//") {
rest
} else if let Some(rest) = raw.strip_prefix('#') {
rest
} else {
raw
}
}
CommentKind::Doc => {
if let Some(rest) = raw.strip_prefix("///") {
rest
} else if let Some(rest) = raw.strip_prefix("//!") {
rest
} else if let Some(rest) = raw.strip_prefix("/**").and_then(|r| r.strip_suffix("*/")) {
rest
} else if let Some(rest) = raw.strip_prefix("/*!").and_then(|r| r.strip_suffix("*/")) {
rest
} else {
raw
}
}
CommentKind::Block => raw
.strip_prefix("/*")
.and_then(|r| r.strip_suffix("*/"))
.unwrap_or(raw),
CommentKind::Docstring => raw
.strip_prefix("\"\"\"")
.and_then(|r| r.strip_suffix("\"\"\""))
.or_else(|| raw.strip_prefix("'''").and_then(|r| r.strip_suffix("'''")))
.unwrap_or(raw),
}
}
pub fn extract(source: &str, language: Language) -> Vec<Comment> {
let raw_comments = match language {
Language::Rust => rust_comments(source),
Language::TypeScript | Language::JavaScript => js_comments(source),
Language::Python => py_comments(source),
Language::Go => go_comments(source),
Language::Java => java_comments(source),
Language::CSharp => cs_comments(source),
};
attach_lines(source, raw_comments)
}
pub(crate) struct RawComment {
pub raw: String,
pub kind: CommentKind,
pub byte_range: Range<usize>,
}
fn attach_lines(source: &str, raws: Vec<RawComment>) -> Vec<Comment> {
if raws.is_empty() {
return Vec::new();
}
let bytes = source.as_bytes();
let mut newline_offsets: Vec<usize> = bytes
.iter()
.enumerate()
.filter_map(|(i, b)| if *b == b'\n' { Some(i) } else { None })
.collect();
newline_offsets.push(bytes.len());
raws.into_iter()
.map(|r| {
let line = 1 + newline_offsets
.binary_search(&r.byte_range.start)
.unwrap_or_else(|idx| idx);
Comment {
raw: r.raw,
kind: r.kind,
byte_range: r.byte_range,
line,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn language_from_path_covers_common_extensions() {
for (path, lang) in [
("src/lib.rs", Language::Rust),
("src/App.tsx", Language::TypeScript),
("index.ts", Language::TypeScript),
("index.js", Language::JavaScript),
("bundle.mjs", Language::JavaScript),
("main.py", Language::Python),
("types.pyi", Language::Python),
("cmd/main.go", Language::Go),
("App.java", Language::Java),
("Program.cs", Language::CSharp),
] {
assert_eq!(
Language::from_path(&PathBuf::from(path)),
Some(lang),
"path={path}"
);
}
}
#[test]
fn language_from_path_rejects_unknown() {
assert!(Language::from_path(&PathBuf::from("README.md")).is_none());
assert!(Language::from_path(&PathBuf::from("Cargo.toml")).is_none());
assert!(Language::from_path(&PathBuf::from("noext")).is_none());
}
#[test]
fn language_as_str_stable() {
assert_eq!(Language::Rust.as_str(), "rust");
assert_eq!(Language::TypeScript.as_str(), "typescript");
assert_eq!(Language::JavaScript.as_str(), "javascript");
assert_eq!(Language::Python.as_str(), "python");
assert_eq!(Language::Go.as_str(), "go");
assert_eq!(Language::Java.as_str(), "java");
assert_eq!(Language::CSharp.as_str(), "csharp");
}
#[test]
fn body_strips_line_markers() {
let c = Comment {
raw: "/// docstring".into(),
kind: CommentKind::Doc,
byte_range: 0..13,
line: 1,
};
assert_eq!(c.body(), " docstring");
}
#[test]
fn body_strips_block_markers() {
let c = Comment {
raw: "/* hi */".into(),
kind: CommentKind::Block,
byte_range: 0..8,
line: 1,
};
assert_eq!(c.body(), " hi ");
}
#[test]
fn body_strips_python_docstring() {
let c = Comment {
raw: "\"\"\"module\"\"\"".into(),
kind: CommentKind::Docstring,
byte_range: 0..12,
line: 1,
};
assert_eq!(c.body(), "module");
}
#[test]
fn empty_source_returns_empty_vec() {
assert!(extract("", Language::Rust).is_empty());
assert!(extract("", Language::Python).is_empty());
assert!(extract("", Language::TypeScript).is_empty());
}
}