1use std::fmt::Write as _;
9use std::path::Path;
10
11#[cfg(feature = "meta-language")]
12use meta_language::{LinkNetwork, LinkType, NetworkProjection, ParseConfiguration};
13
14use crate::links_format::flatten_lino_value;
15
16use super::{
17 deformalize, formalize, formalize_markdown, summarize, Statement, StatementKind,
18 SummarizationConfig,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct MetaLanguageFormalization {
24 pub label: String,
25 pub syntax_link_count: usize,
26 pub total_link_count: usize,
27 pub has_error: bool,
28 pub text_preserved: bool,
29}
30
31impl MetaLanguageFormalization {
32 #[must_use]
35 pub const fn is_valid(&self) -> bool {
36 !self.has_error && self.syntax_link_count > 0 && self.text_preserved
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct EmbeddedGrammarFormalization {
43 pub language: String,
44 pub line_count: usize,
45 pub statement_count: usize,
46 pub meta_language: Option<MetaLanguageFormalization>,
47}
48
49#[derive(Debug, Clone)]
51pub struct RepositoryFileFormalization {
52 pub path: String,
53 pub format: String,
54 pub line_count: usize,
55 pub byte_count: usize,
56 pub statements: Vec<Statement>,
57 pub embedded_grammars: Vec<EmbeddedGrammarFormalization>,
58 pub meta_language: Option<MetaLanguageFormalization>,
59}
60
61impl RepositoryFileFormalization {
62 #[must_use]
69 pub fn summary(&self, config: &SummarizationConfig) -> String {
70 render_repository_file_summary(self, config)
71 }
72
73 #[must_use]
75 pub fn links_notation(&self) -> String {
76 let mut out = String::from("repository_file\n");
77 push_field(&mut out, 1, "path", &self.path);
78 push_field(&mut out, 1, "format", &self.format);
79 push_field(&mut out, 1, "line_count", &self.line_count.to_string());
80 push_field(&mut out, 1, "byte_count", &self.byte_count.to_string());
81 push_field(
82 &mut out,
83 1,
84 "statement_count",
85 &self.statements.len().to_string(),
86 );
87 if let Some(meta) = &self.meta_language {
88 push_meta_language(&mut out, 1, meta);
89 }
90 for statement in &self.statements {
91 let _ = writeln!(out, " statement");
92 push_field(&mut out, 2, "kind", statement_kind_label(statement.kind));
93 push_field(&mut out, 2, "weight", &statement.weight.to_string());
94 push_field(&mut out, 2, "text", &statement.text);
95 }
96 for embedded in &self.embedded_grammars {
97 let _ = writeln!(out, " embedded_grammar");
98 push_field(&mut out, 2, "language", &embedded.language);
99 push_field(&mut out, 2, "line_count", &embedded.line_count.to_string());
100 push_field(
101 &mut out,
102 2,
103 "statement_count",
104 &embedded.statement_count.to_string(),
105 );
106 if let Some(meta) = &embedded.meta_language {
107 push_meta_language(&mut out, 2, meta);
108 }
109 }
110 out.trim_end().to_owned()
111 }
112}
113
114#[must_use]
117pub fn formalize_repository_file(path: &str, content: &str) -> RepositoryFileFormalization {
118 let format = detect_repository_file_format(path);
119 #[cfg(feature = "meta-language")]
120 let meta_language = meta_language_label_for_format(format)
121 .map(|label| parse_with_meta_language(label, content));
122 #[cfg(not(feature = "meta-language"))]
123 let meta_language = None;
124 let embedded_grammars = if format == "markdown" {
125 formalize_markdown_embedded_grammars(content)
126 } else {
127 Vec::new()
128 };
129 let mut statements = statements_for_file(path, content, format);
130 if statements.is_empty() {
131 statements.push(Statement::new(
132 format!("{path} is an empty {} file", display_file_format(format)),
133 StatementKind::Identity,
134 90,
135 ));
136 }
137
138 RepositoryFileFormalization {
139 path: path.to_owned(),
140 format: format.to_owned(),
141 line_count: line_count(content),
142 byte_count: content.len(),
143 statements,
144 embedded_grammars,
145 meta_language,
146 }
147}
148
149#[must_use]
151pub fn summarize_repository_file(
152 path: &str,
153 content: &str,
154 config: &SummarizationConfig,
155) -> String {
156 formalize_repository_file(path, content).summary(config)
157}
158
159fn render_repository_file_summary(
160 formalized: &RepositoryFileFormalization,
161 config: &SummarizationConfig,
162) -> String {
163 let mut parts = Vec::new();
164 parts.push(format!(
165 "{} is a {} file with {} lines and {} bytes.",
166 formalized.path,
167 display_file_format(&formalized.format),
168 formalized.line_count,
169 formalized.byte_count
170 ));
171 if let Some(meta) = &formalized.meta_language {
172 if meta.is_valid() {
173 parts.push(format!(
174 "meta-language parsed it as {} with {} syntax links.",
175 meta.label, meta.syntax_link_count
176 ));
177 }
178 }
179 if !formalized.embedded_grammars.is_empty() {
180 parts.push(format!(
181 "It has embedded grammar blocks: {}.",
182 embedded_language_list(&formalized.embedded_grammars)
183 ));
184 }
185 let summarized = summarize(&formalized.statements, config);
186 let content_summary = deformalize(&summarized);
187 if !content_summary.is_empty() {
188 parts.push(format!("Key content: {content_summary}"));
189 }
190 parts.join(" ")
191}
192
193fn statements_for_file(path: &str, content: &str, format: &str) -> Vec<Statement> {
194 if format == "markdown" {
195 return markdown_file_statements(content);
196 }
197 if is_code_format(format) {
198 return code_statements(path, content, format);
199 }
200 if is_structured_format(format) {
201 return structured_statements(path, content, format);
202 }
203 formalize(content)
204}
205
206fn markdown_file_statements(content: &str) -> Vec<Statement> {
207 let mut statements = formalize_markdown(content);
208 for statement in &mut statements {
209 if looks_like_heading_fragment(&statement.text) {
210 statement.weight = statement.weight.saturating_sub(15);
211 }
212 }
213 statements
214}
215
216fn code_statements(path: &str, content: &str, format: &str) -> Vec<Statement> {
217 let mut statements = vec![Statement::new(
218 format!(
219 "{path} is a {} source file",
220 display_file_format(format).to_lowercase()
221 ),
222 StatementKind::Identity,
223 90,
224 )];
225 for symbol in extract_code_symbols(content, format).into_iter().take(8) {
226 statements.push(Statement::new(
227 format!("Defines {symbol}."),
228 StatementKind::Feature,
229 70,
230 ));
231 }
232 statements
233}
234
235fn structured_statements(path: &str, content: &str, format: &str) -> Vec<Statement> {
236 let mut statements = vec![Statement::new(
237 format!("{path} is a {} data file", display_file_format(format)),
238 StatementKind::Identity,
239 90,
240 )];
241 let keys = extract_structural_keys(content);
242 if !keys.is_empty() {
243 statements.push(Statement::new(
244 format!("Top-level keys: {}.", keys.join(", ")),
245 StatementKind::Feature,
246 70,
247 ));
248 }
249 statements
250}
251
252fn formalize_markdown_embedded_grammars(markdown: &str) -> Vec<EmbeddedGrammarFormalization> {
253 let mut blocks = Vec::new();
254 let mut active: Option<FencedBlock> = None;
255 for line in markdown.lines() {
256 let trimmed = line.trim_start();
257 if let Some(block) = active.take() {
258 if is_closing_fence(trimmed, &block.marker) {
259 blocks.push(formalize_fenced_block(&block));
260 } else {
261 let mut block = block;
262 block.source.push_str(line);
263 block.source.push('\n');
264 active = Some(block);
265 }
266 continue;
267 }
268 if let Some(marker) = opening_fence_marker(trimmed) {
269 active = Some(FencedBlock {
270 marker,
271 language: fence_language(trimmed),
272 source: String::new(),
273 });
274 }
275 }
276 if let Some(block) = active {
277 blocks.push(formalize_fenced_block(&block));
278 }
279 blocks
280}
281
282fn formalize_fenced_block(block: &FencedBlock) -> EmbeddedGrammarFormalization {
283 let language = normalize_language_label(&block.language);
284 let statements = if is_code_format(&language) {
285 extract_code_symbols(&block.source, &language)
286 } else {
287 Vec::new()
288 };
289 #[cfg(feature = "meta-language")]
290 let meta_language = meta_language_label_for_format(&language)
291 .map(|label| parse_with_meta_language(label, &block.source));
292 #[cfg(not(feature = "meta-language"))]
293 let meta_language = None;
294 EmbeddedGrammarFormalization {
295 language,
296 line_count: line_count(&block.source),
297 statement_count: statements.len(),
298 meta_language,
299 }
300}
301
302#[derive(Debug, Clone)]
303struct FencedBlock {
304 marker: FencedMarker,
305 language: String,
306 source: String,
307}
308
309#[derive(Debug, Clone, Copy)]
310struct FencedMarker {
311 ch: char,
312 len: usize,
313}
314
315fn opening_fence_marker(trimmed_line: &str) -> Option<FencedMarker> {
316 let ch = trimmed_line.chars().next()?;
317 if ch != '`' && ch != '~' {
318 return None;
319 }
320 let len = trimmed_line
321 .chars()
322 .take_while(|candidate| *candidate == ch)
323 .count();
324 (len >= 3).then_some(FencedMarker { ch, len })
325}
326
327fn is_closing_fence(trimmed_line: &str, opening: &FencedMarker) -> bool {
328 let Some(closing) = opening_fence_marker(trimmed_line) else {
329 return false;
330 };
331 if closing.ch != opening.ch || closing.len < opening.len {
332 return false;
333 }
334 let rest = &trimmed_line[closing.len..];
335 rest.trim().is_empty()
336}
337
338fn fence_language(trimmed_line: &str) -> String {
339 let Some(marker) = opening_fence_marker(trimmed_line) else {
340 return "text".to_owned();
341 };
342 let without_marker = &trimmed_line[marker.len..];
343 let info_string = without_marker.trim();
344 if marker.ch == '`' && info_string.contains('`') {
345 "text".to_owned()
346 } else {
347 info_string
348 .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '{'))
349 .next()
350 .filter(|language| !language.is_empty())
351 .unwrap_or("text")
352 .to_owned()
353 }
354}
355
356#[cfg(feature = "meta-language")]
357fn parse_with_meta_language(label: &str, source: &str) -> MetaLanguageFormalization {
358 let network = LinkNetwork::parse(source, label, ParseConfiguration::default());
359 let verification = network.verify_full_match(None);
360 let syntax_link_count = network
361 .projected_links(NetworkProjection::ConcreteSyntax)
362 .filter(|link| link.metadata().link_type() == Some(LinkType::Syntax))
363 .count();
364 MetaLanguageFormalization {
365 label: label.to_owned(),
366 syntax_link_count,
367 total_link_count: network.len(),
368 has_error: !verification.is_clean(),
369 text_preserved: network.reconstruct_text() == source,
370 }
371}
372
373fn detect_repository_file_format(path: &str) -> &'static str {
374 let lower = path.to_ascii_lowercase();
375 let file_name = Path::new(&lower)
376 .file_name()
377 .and_then(|name| name.to_str())
378 .unwrap_or("");
379 match file_name {
380 "cargo.toml" | "pyproject.toml" => return "toml",
381 "package.json" | "tsconfig.json" | "package-lock.json" | "bun.lock" => return "json",
382 "dockerfile" => return "dockerfile",
383 _ => {}
384 }
385 let extension = Path::new(&lower)
386 .extension()
387 .and_then(|extension| extension.to_str())
388 .unwrap_or("");
389 match extension {
390 "md" | "markdown" | "mdown" => "markdown",
391 "rs" => "rust",
392 "js" | "mjs" | "cjs" | "jsx" => "javascript",
393 "ts" | "tsx" => "typescript",
394 "py" => "python",
395 "go" => "go",
396 "java" => "java",
397 "c" | "h" => "c",
398 "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
399 "cs" => "csharp",
400 "rb" => "ruby",
401 "json" => "json",
402 "yaml" | "yml" => "yaml",
403 "toml" => "toml",
404 "html" | "htm" => "html",
405 "css" => "css",
406 "xml" | "svg" => "xml",
407 "ini" => "ini",
408 "sh" | "bash" => "shell",
409 "lino" => "links_notation",
410 _ => "text",
411 }
412}
413
414fn normalize_language_label(label: &str) -> String {
415 match label.trim().to_ascii_lowercase().as_str() {
416 "rs" => "rust",
417 "js" | "mjs" | "cjs" | "jsx" => "javascript",
418 "ts" | "tsx" => "typescript",
419 "py" => "python",
420 "c++" => "cpp",
421 "c#" | "cs" => "csharp",
422 "md" => "markdown",
423 "" => "text",
424 other => other,
425 }
426 .to_owned()
427}
428
429fn meta_language_label_for_format(format: &str) -> Option<&'static str> {
430 match format {
431 "rust" => Some("rust"),
432 "javascript" => Some("javascript"),
433 "typescript" => Some("typescript"),
434 "python" => Some("python"),
435 "go" => Some("go"),
436 "java" => Some("java"),
437 "c" => Some("c"),
438 "cpp" => Some("cpp"),
439 "csharp" => Some("csharp"),
440 "ruby" => Some("ruby"),
441 "json" => Some("json"),
442 "yaml" => Some("yaml"),
443 "toml" => Some("toml"),
444 "html" => Some("html"),
445 "css" => Some("css"),
446 "xml" => Some("xml"),
447 "ini" => Some("ini"),
448 _ => None,
449 }
450}
451
452fn is_code_format(format: &str) -> bool {
453 matches!(
454 format,
455 "rust"
456 | "javascript"
457 | "typescript"
458 | "python"
459 | "go"
460 | "java"
461 | "c"
462 | "cpp"
463 | "csharp"
464 | "ruby"
465 )
466}
467
468fn is_structured_format(format: &str) -> bool {
469 matches!(
470 format,
471 "json" | "yaml" | "toml" | "ini" | "links_notation" | "xml" | "html" | "css"
472 )
473}
474
475fn display_file_format(format: &str) -> &'static str {
476 match format {
477 "markdown" => "Markdown",
478 "rust" => "Rust",
479 "javascript" => "JavaScript",
480 "typescript" => "TypeScript",
481 "python" => "Python",
482 "go" => "Go",
483 "java" => "Java",
484 "c" => "C",
485 "cpp" => "C++",
486 "csharp" => "C#",
487 "ruby" => "Ruby",
488 "json" => "JSON",
489 "yaml" => "YAML",
490 "toml" => "TOML",
491 "html" => "HTML",
492 "css" => "CSS",
493 "xml" => "XML",
494 "ini" => "INI",
495 "shell" => "shell",
496 "links_notation" => "Links Notation",
497 "dockerfile" => "Dockerfile",
498 _ => "text",
499 }
500}
501
502fn extract_code_symbols(content: &str, format: &str) -> Vec<String> {
503 let mut symbols = Vec::new();
504 for line in content.lines() {
505 let trimmed = line.trim_start();
506 if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with('*') {
507 continue;
508 }
509 if let Some(symbol) = symbol_from_line(trimmed, format) {
510 push_unique(&mut symbols, symbol);
511 }
512 }
513 symbols
514}
515
516fn symbol_from_line(line: &str, format: &str) -> Option<String> {
517 match format {
518 "rust" => rust_symbol(line),
519 "javascript" | "typescript" => js_symbol(line),
520 "python" => prefixed_symbol(line, "def ")
521 .or_else(|| prefixed_symbol(line, "class "))
522 .map(|name| format!("python symbol {name}")),
523 "go" => prefixed_symbol(line, "func ").map(|name| format!("go function {name}")),
524 "java" | "csharp" => class_like_symbol(line),
525 "c" | "cpp" => c_like_symbol(line),
526 "ruby" => prefixed_symbol(line, "def ")
527 .or_else(|| prefixed_symbol(line, "class "))
528 .map(|name| format!("ruby symbol {name}")),
529 _ => None,
530 }
531}
532
533fn rust_symbol(line: &str) -> Option<String> {
534 let stripped = strip_leading_words(line, &["pub", "async", "unsafe", "const"]);
535 for (keyword, label) in [
536 ("fn ", "function"),
537 ("struct ", "struct"),
538 ("enum ", "enum"),
539 ("trait ", "trait"),
540 ("mod ", "module"),
541 ("type ", "type"),
542 ("const ", "constant"),
543 ("static ", "static"),
544 ] {
545 if let Some(name) = prefixed_symbol(stripped, keyword) {
546 return Some(format!("rust {label} {name}"));
547 }
548 }
549 stripped
550 .strip_prefix("impl ")
551 .and_then(first_identifier)
552 .map(|name| format!("rust impl {name}"))
553}
554
555fn js_symbol(line: &str) -> Option<String> {
556 let stripped = strip_leading_words(line, &["export", "default", "async"]);
557 prefixed_symbol(stripped, "function ")
558 .map(|name| format!("javascript function {name}"))
559 .or_else(|| {
560 prefixed_symbol(stripped, "class ").map(|name| format!("javascript class {name}"))
561 })
562 .or_else(|| {
563 prefixed_symbol(stripped, "const ")
564 .or_else(|| prefixed_symbol(stripped, "let "))
565 .or_else(|| prefixed_symbol(stripped, "var "))
566 .map(|name| format!("javascript binding {name}"))
567 })
568}
569
570fn class_like_symbol(line: &str) -> Option<String> {
571 let stripped = strip_leading_words(
572 line,
573 &[
574 "public",
575 "private",
576 "protected",
577 "internal",
578 "static",
579 "sealed",
580 ],
581 );
582 prefixed_symbol(stripped, "class ")
583 .map(|name| format!("class {name}"))
584 .or_else(|| prefixed_symbol(stripped, "interface ").map(|name| format!("interface {name}")))
585 .or_else(|| prefixed_symbol(stripped, "enum ").map(|name| format!("enum {name}")))
586}
587
588fn c_like_symbol(line: &str) -> Option<String> {
589 if !line.contains('(') || line.ends_with(';') {
590 return None;
591 }
592 let before_paren = line.split_once('(')?.0.trim_end();
593 let name = before_paren.split_whitespace().last()?;
594 is_identifier(name).then(|| format!("function {name}"))
595}
596
597fn prefixed_symbol(line: &str, prefix: &str) -> Option<String> {
598 line.strip_prefix(prefix).and_then(first_identifier)
599}
600
601fn first_identifier(text: &str) -> Option<String> {
602 let candidate: String = text
603 .chars()
604 .skip_while(|ch| !is_identifier_start(*ch))
605 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
606 .collect();
607 (!candidate.is_empty()).then_some(candidate)
608}
609
610fn strip_leading_words<'a>(line: &'a str, words: &[&str]) -> &'a str {
611 let mut rest = line.trim_start();
612 loop {
613 let mut changed = false;
614 for word in words {
615 if let Some(after_word) = rest.strip_prefix(word) {
616 if after_word.starts_with(char::is_whitespace) {
617 rest = after_word.trim_start();
618 changed = true;
619 }
620 }
621 }
622 if !changed {
623 return rest;
624 }
625 }
626}
627
628fn extract_structural_keys(content: &str) -> Vec<String> {
629 let mut keys = Vec::new();
630 for line in content.lines() {
631 let trimmed = line.trim();
632 if let Some(key) = quoted_key(trimmed).or_else(|| bare_key(trimmed)) {
633 push_unique(&mut keys, key);
634 }
635 if keys.len() >= 8 {
636 break;
637 }
638 }
639 keys
640}
641
642fn quoted_key(line: &str) -> Option<String> {
643 let rest = line.strip_prefix('"')?;
644 let (key, after_key) = rest.split_once('"')?;
645 after_key
646 .trim_start()
647 .starts_with(':')
648 .then(|| key.to_owned())
649}
650
651fn bare_key(line: &str) -> Option<String> {
652 let (key, _) = line.split_once([':', '='])?;
653 let trimmed = key.trim();
654 (!trimmed.is_empty()
655 && trimmed
656 .chars()
657 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')))
658 .then(|| trimmed.to_owned())
659}
660
661fn embedded_language_list(blocks: &[EmbeddedGrammarFormalization]) -> String {
662 let mut languages = Vec::new();
663 for block in blocks {
664 push_unique(&mut languages, block.language.clone());
665 }
666 languages.join(", ")
667}
668
669fn push_unique(values: &mut Vec<String>, candidate: String) {
670 if !values.iter().any(|value| value == &candidate) {
671 values.push(candidate);
672 }
673}
674
675fn line_count(content: &str) -> usize {
676 if content.is_empty() {
677 0
678 } else {
679 content.lines().count()
680 }
681}
682
683fn looks_like_heading_fragment(text: &str) -> bool {
684 text.split_whitespace().count() <= 6 && !has_terminal_punctuation(text)
685}
686
687fn has_terminal_punctuation(text: &str) -> bool {
688 text.chars()
689 .last()
690 .is_some_and(|ch| matches!(ch, '.' | '!' | '?' | ':' | '。' | '…'))
691}
692
693fn is_identifier(text: &str) -> bool {
694 let mut chars = text.chars();
695 chars.next().is_some_and(is_identifier_start)
696 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
697}
698
699const fn is_identifier_start(ch: char) -> bool {
700 ch.is_ascii_alphabetic() || ch == '_'
701}
702
703const fn statement_kind_label(kind: StatementKind) -> &'static str {
704 match kind {
705 StatementKind::Identity => "identity",
706 StatementKind::Purpose => "purpose",
707 StatementKind::Language => "language",
708 StatementKind::Stars => "stars",
709 StatementKind::Feature => "feature",
710 StatementKind::UseCase => "use_case",
711 StatementKind::Install => "install",
712 StatementKind::Example => "example",
713 StatementKind::Misc => "misc",
714 }
715}
716
717fn push_meta_language(out: &mut String, indent: usize, meta: &MetaLanguageFormalization) {
718 write_indent(out, indent);
719 let _ = writeln!(out, "meta_language");
720 push_field(out, indent + 1, "label", &meta.label);
721 push_field(
722 out,
723 indent + 1,
724 "syntax_link_count",
725 &meta.syntax_link_count.to_string(),
726 );
727 push_field(
728 out,
729 indent + 1,
730 "total_link_count",
731 &meta.total_link_count.to_string(),
732 );
733 push_field(out, indent + 1, "has_error", &meta.has_error.to_string());
734 push_field(
735 out,
736 indent + 1,
737 "text_preserved",
738 &meta.text_preserved.to_string(),
739 );
740}
741
742fn push_field(out: &mut String, indent: usize, name: &str, value: &str) {
743 write_indent(out, indent);
744 let _ = writeln!(out, "{name} {}", flatten_lino_value(value));
745}
746
747fn write_indent(out: &mut String, indent: usize) {
748 for _ in 0..indent {
749 out.push_str(" ");
750 }
751}