Skip to main content

formal_ai/summarization/
file.rs

1//! Repository-file formalization and summarization.
2//!
3//! This layer adapts the existing statement summarizer to whole files. It keeps
4//! file metadata, optional meta-language parse evidence, and Markdown fenced
5//! code blocks as separate formalized records before rendering a short prose
6//! summary.
7
8use 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/// meta-language parse evidence for a repository file or embedded grammar.
22#[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    /// `true` when the upstream grammar produced syntax links and round-tripped
33    /// the source text without parse errors.
34    #[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/// A fenced code block or other embedded grammar inside a Markdown file.
41#[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/// Link-native representation of one repository file prepared for summary.
50#[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    /// Render a short prose summary of this formalized file using the supplied
63    /// [`SummarizationConfig`]. Reuses the shared `summarize` / `deformalize`
64    /// stages for the retained content statements, so the file boundary stays a
65    /// thin adapter over the meta-algorithm pipeline rather than a parallel
66    /// formatter. Exposed so the recursive resource summarizer in
67    /// `super::resource` can compose file summaries into folder summaries.
68    #[must_use]
69    pub fn summary(&self, config: &SummarizationConfig) -> String {
70        render_repository_file_summary(self, config)
71    }
72
73    /// Render the formalized file as compact indented Links Notation.
74    #[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/// Formalize an arbitrary repository file into metadata, statements, and
115/// optional embedded grammar records.
116#[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/// Summarize any repository file with the existing summarization configuration.
150#[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}