1use super::MetricValues;
4
5#[derive(Debug)]
14pub struct CodeAccumulator {
15 syntax: Syntax,
16 state: State,
17 line: Vec<u8>,
18 previous_cr: bool,
19 metrics: MetricValues,
20}
21
22impl CodeAccumulator {
23 pub fn for_type(file_type: &str) -> Option<Self> {
26 let syntax = Syntax::for_type(file_type)?;
27 Some(Self {
28 syntax,
29 state: State::Normal,
30 line: Vec::new(),
31 previous_cr: false,
32 metrics: MetricValues::default(),
33 })
34 }
35
36 pub fn push(&mut self, chunk: &[u8]) {
38 for &byte in chunk {
39 if self.previous_cr {
40 self.previous_cr = false;
41 if byte == b'\n' {
42 continue;
43 }
44 }
45 match byte {
46 b'\r' => {
47 self.finish_line();
48 self.previous_cr = true;
49 }
50 b'\n' => self.finish_line(),
51 _ => self.line.push(byte),
52 }
53 }
54 }
55
56 pub fn finish(mut self) -> MetricValues {
58 if !self.line.is_empty() && self.line.as_slice() != [0xef, 0xbb, 0xbf] {
59 self.finish_line();
60 }
61 self.metrics
62 }
63
64 fn finish_line(&mut self) {
65 let class = classify_line(self.syntax, &mut self.state, &self.line);
66 self.metrics.physical_lines = self.metrics.physical_lines.saturating_add(1);
67 match class {
68 LineClass::Code => {
69 self.metrics.code_lines = self.metrics.code_lines.saturating_add(1);
70 }
71 LineClass::Comment => {
72 self.metrics.comment_lines = self.metrics.comment_lines.saturating_add(1);
73 }
74 LineClass::Blank => {
75 self.metrics.code_blank_lines = self.metrics.code_blank_lines.saturating_add(1);
76 }
77 }
78 self.line.clear();
79 }
80}
81
82#[derive(Clone, Copy, Debug)]
83#[allow(clippy::struct_excessive_bools)]
84struct Syntax {
85 line_comments: &'static [&'static [u8]],
86 block: Option<BlockSyntax>,
87 nested_blocks: bool,
88 triple_quotes: bool,
89 backtick_strings: bool,
90 rust_raw_strings: bool,
91 shell_hash_boundary: bool,
92 ruby_blocks: bool,
93}
94
95impl Syntax {
96 fn for_type(file_type: &str) -> Option<Self> {
97 let c_like = Self {
98 line_comments: &[b"//"],
99 block: Some(BlockSyntax { open: b"/*", close: b"*/" }),
100 nested_blocks: false,
101 triple_quotes: false,
102 backtick_strings: false,
103 rust_raw_strings: false,
104 shell_hash_boundary: false,
105 ruby_blocks: false,
106 };
107 match file_type {
108 "rust" => Some(Self { nested_blocks: true, rust_raw_strings: true, ..c_like }),
109 "javascript" | "typescript" | "go" => Some(Self { backtick_strings: true, ..c_like }),
110 "c" | "cpp" | "csharp" | "java" => Some(c_like),
111 "kotlin" | "swift" => Some(Self { nested_blocks: true, triple_quotes: true, ..c_like }),
112 "php" => Some(Self { line_comments: &[b"//", b"#"], ..c_like }),
113 "python" | "ruby" => Some(Self {
114 line_comments: &[b"#"],
115 block: None,
116 nested_blocks: false,
117 triple_quotes: true,
118 backtick_strings: false,
119 rust_raw_strings: false,
120 shell_hash_boundary: false,
121 ruby_blocks: file_type == "ruby",
122 }),
123 "shell" => Some(Self {
124 line_comments: &[b"#"],
125 block: None,
126 nested_blocks: false,
127 triple_quotes: false,
128 backtick_strings: true,
129 rust_raw_strings: false,
130 shell_hash_boundary: true,
131 ruby_blocks: false,
132 }),
133 "sql" => Some(Self {
134 line_comments: &[b"--"],
135 block: Some(BlockSyntax { open: b"/*", close: b"*/" }),
136 nested_blocks: false,
137 triple_quotes: false,
138 backtick_strings: true,
139 rust_raw_strings: false,
140 shell_hash_boundary: false,
141 ruby_blocks: false,
142 }),
143 _ => None,
144 }
145 }
146}
147
148#[derive(Clone, Copy, Debug)]
149struct BlockSyntax {
150 open: &'static [u8],
151 close: &'static [u8],
152}
153
154#[derive(Clone, Copy, Debug)]
155enum State {
156 Normal,
157 BlockComment { depth: u16 },
158 Quoted { quote: u8, escaped: bool, multiline: bool },
159 TripleQuoted { quote: u8 },
160 RustRaw { hashes: u8 },
161 RubyBlock,
162}
163
164#[derive(Clone, Copy, Debug)]
165enum LineClass {
166 Code,
167 Comment,
168 Blank,
169}
170
171fn classify_line(syntax: Syntax, state: &mut State, line: &[u8]) -> LineClass {
172 let mut index = usize::from(line.starts_with(&[0xef, 0xbb, 0xbf]));
173 index = index.saturating_mul(3);
174 let mut whitespace_boundary = matches!(state, State::Normal);
175 let mut code =
176 matches!(state, State::Quoted { .. } | State::TripleQuoted { .. } | State::RustRaw { .. });
177 let mut comment = matches!(state, State::BlockComment { .. });
178
179 if matches!(state, State::RubyBlock) {
180 if line.starts_with(b"=end") {
181 *state = State::Normal;
182 }
183 return LineClass::Comment;
184 }
185 if syntax.ruby_blocks && matches!(state, State::Normal) && line.starts_with(b"=begin") {
186 *state = State::RubyBlock;
187 return LineClass::Comment;
188 }
189
190 while index < line.len() {
191 match *state {
192 State::BlockComment { mut depth } => {
193 comment = true;
194 let block = syntax.block.expect("block state requires block syntax");
195 if syntax.nested_blocks && line[index..].starts_with(block.open) {
196 depth = depth.saturating_add(1);
197 *state = State::BlockComment { depth };
198 index += block.open.len();
199 } else if line[index..].starts_with(block.close) {
200 depth = depth.saturating_sub(1);
201 *state = if depth == 0 { State::Normal } else { State::BlockComment { depth } };
202 index += block.close.len();
203 } else {
204 index += 1;
205 }
206 }
207 State::Quoted { quote, mut escaped, multiline } => {
208 code = true;
209 let byte = line[index];
210 if escaped {
211 escaped = false;
212 } else if byte == b'\\' {
213 escaped = true;
214 } else if byte == quote {
215 *state = State::Normal;
216 index += 1;
217 continue;
218 }
219 *state = State::Quoted { quote, escaped, multiline };
220 index += 1;
221 }
222 State::TripleQuoted { quote } => {
223 code = true;
224 if line[index..].starts_with(&[quote, quote, quote]) {
225 *state = State::Normal;
226 index += 3;
227 } else {
228 index += 1;
229 }
230 }
231 State::RustRaw { hashes } => {
232 code = true;
233 if rust_raw_close(&line[index..], hashes) {
234 *state = State::Normal;
235 index += usize::from(hashes) + 1;
236 } else {
237 index += 1;
238 }
239 }
240 State::RubyBlock => unreachable!("Ruby blocks return before byte scanning"),
241 State::Normal => {
242 let byte = line[index];
243 if byte.is_ascii_whitespace() {
244 whitespace_boundary = true;
245 index += 1;
246 continue;
247 }
248 if let Some((character, width)) = leading_utf8_character(&line[index..]) {
249 if super::content_basic_metrics::is_content_whitespace(character) {
250 whitespace_boundary = true;
251 index += width;
252 continue;
253 }
254 }
255 if syntax.line_comments.iter().any(|marker| {
256 line[index..].starts_with(marker)
257 && (!syntax.shell_hash_boundary || whitespace_boundary)
258 }) {
259 comment = true;
260 break;
261 }
262 if let Some(block) = syntax.block {
263 if line[index..].starts_with(block.open) {
264 comment = true;
265 whitespace_boundary = false;
266 *state = State::BlockComment { depth: 1 };
267 index += block.open.len();
268 continue;
269 }
270 }
271 if syntax.rust_raw_strings {
272 if let Some((hashes, consumed)) = rust_raw_open(&line[index..]) {
273 code = true;
274 whitespace_boundary = false;
275 *state = State::RustRaw { hashes };
276 index += consumed;
277 continue;
278 }
279 }
280 if syntax.triple_quotes
281 && matches!(byte, b'\'' | b'"')
282 && line[index..].starts_with(&[byte, byte, byte])
283 {
284 code = true;
285 whitespace_boundary = false;
286 *state = State::TripleQuoted { quote: byte };
287 index += 3;
288 continue;
289 }
290 if matches!(byte, b'\'' | b'"') || (syntax.backtick_strings && byte == b'`') {
291 code = true;
292 whitespace_boundary = false;
293 *state = State::Quoted { quote: byte, escaped: false, multiline: byte == b'`' };
294 index += 1;
295 continue;
296 }
297 code = true;
298 whitespace_boundary = false;
299 index += 1;
300 }
301 }
302 }
303
304 if let State::Quoted { multiline: false, .. } = state {
305 *state = State::Normal;
306 }
307 if code {
308 LineClass::Code
309 } else if comment {
310 LineClass::Comment
311 } else {
312 LineClass::Blank
313 }
314}
315
316fn leading_utf8_character(input: &[u8]) -> Option<(char, usize)> {
317 let width = match *input.first()? {
318 0x00..=0x7f => 1,
319 0xc2..=0xdf => 2,
320 0xe0..=0xef => 3,
321 0xf0..=0xf4 => 4,
322 _ => return None,
323 };
324 let character = std::str::from_utf8(input.get(..width)?).ok()?.chars().next()?;
325 Some((character, width))
326}
327
328fn rust_raw_open(input: &[u8]) -> Option<(u8, usize)> {
329 if input.first() != Some(&b'r') {
330 return None;
331 }
332 let hashes = input[1..].iter().take_while(|byte| **byte == b'#').count();
333 if hashes > usize::from(u8::MAX) || input.get(hashes + 1) != Some(&b'"') {
334 return None;
335 }
336 Some((u8::try_from(hashes).expect("bounded above"), hashes + 2))
337}
338
339fn rust_raw_close(input: &[u8], hashes: u8) -> bool {
340 input.first() == Some(&b'"')
341 && (hashes == 0
342 || input
343 .get(1..=usize::from(hashes))
344 .is_some_and(|tail| tail.iter().all(|byte| *byte == b'#')))
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn count(language: &str, chunks: &[&[u8]]) -> MetricValues {
352 let mut counter = CodeAccumulator::for_type(language).expect("supported language");
353 for chunk in chunks {
354 counter.push(chunk);
355 }
356 counter.finish()
357 }
358
359 #[test]
360 fn partitions_c_like_source_and_counts_mixed_lines_as_code() {
361 let metrics = count(
362 "javascript",
363 &[b"// first\r\nlet url = \"https://example.test\"; // tail\r/* block\n\nend */\n`// text\nmore`;"],
364 );
365 assert_eq!(metrics.physical_lines, 7);
366 assert_eq!(metrics.code_lines, 3);
367 assert_eq!(metrics.comment_lines, 4);
368 assert_eq!(metrics.code_blank_lines, 0);
369 }
370
371 #[test]
372 fn rust_nested_comments_and_raw_strings_ignore_comment_markers() {
373 let source =
374 b"/* outer\n/* inner */\n*/\nlet raw = r##\"/* text */\n// still text\"##;\n\n";
375 let expected = count("rust", &[source]);
376 assert_eq!(expected.physical_lines, 6);
377 assert_eq!(expected.code_lines, 2);
378 assert_eq!(expected.comment_lines, 3);
379 assert_eq!(expected.code_blank_lines, 1);
380 for split in 0..=source.len() {
381 assert_eq!(count("rust", &[&source[..split], &source[split..]]), expected);
382 }
383 }
384
385 #[test]
386 fn triple_quoted_docstrings_are_code_in_v1() {
387 let metrics = count("python", &[b"\"\"\"docs\n# text\n\"\"\"\n# comment\npass\n"]);
388 assert_eq!(metrics.physical_lines, 5);
389 assert_eq!(metrics.code_lines, 4);
390 assert_eq!(metrics.comment_lines, 1);
391 assert_eq!(metrics.code_blank_lines, 0);
392 }
393
394 #[test]
395 fn every_line_ending_convention_has_the_same_partition() {
396 for source in [
397 "// comment\nlet value = 1;\n\n",
398 "// comment\r\nlet value = 1;\r\n\r\n",
399 "// comment\rlet value = 1;\r\r",
400 "// comment\r\nlet value = 1;\r\n",
401 ] {
402 let metrics = count("rust", &[source.as_bytes()]);
403 let expected_lines = if source.ends_with("value = 1;\r\n") { 2 } else { 3 };
404 assert_eq!(metrics.physical_lines, expected_lines, "{source:?}");
405 assert_eq!(metrics.code_lines, 1, "{source:?}");
406 assert_eq!(metrics.comment_lines, 1, "{source:?}");
407 assert_eq!(metrics.code_blank_lines, expected_lines - 2, "{source:?}");
408 }
409 }
410
411 #[test]
412 fn a_leading_utf8_bom_is_not_an_invented_line() {
413 let empty = count("rust", &[b"\xef\xbb\xbf"]);
414 assert_eq!(empty.physical_lines, 0);
415
416 let blank = count("rust", &[b"\xef\xbb\xbf\n"]);
417 assert_eq!(blank.physical_lines, 1);
418 assert_eq!(blank.code_blank_lines, 1);
419 }
420
421 #[test]
422 fn unicode_whitespace_uses_the_basic_analyzers_pinned_table() {
423 let metrics = count("rust", &["\u{3000}\n\u{2003}// comment\n".as_bytes()]);
424 assert_eq!(metrics.physical_lines, 2);
425 assert_eq!(metrics.code_blank_lines, 1);
426 assert_eq!(metrics.comment_lines, 1);
427 assert_eq!(metrics.code_lines, 0);
428
429 let shell = count("shell", &["\u{3000}# comment\n".as_bytes()]);
430 assert_eq!(shell.comment_lines, 1);
431 assert_eq!(shell.code_lines, 0);
432 }
433
434 #[test]
435 fn unsupported_languages_are_explicit() {
436 assert!(CodeAccumulator::for_type("haskell").is_none());
437 }
438}