kcode-k1-rust-code-document 0.1.1

Represent and chunk one authored K1 Rust code document
Documentation
use kcode_k1_rust_package::{LibraryId, SourceFile, SourcePackage};
use proc_macro2::Span;
use std::fmt::{Display, Formatter};
use std::ops::Range;
use syn::spanned::Spanned;
use syn::{ImplItem, Item, TraitItem};

const SUPPORTED_PATHS: [&str; 3] = ["Cargo.toml", "Documentation.md", "src/lib.rs"];
const MIN_CHUNK_SCALARS: usize = 800;
const DEFAULT_DOCUMENTATION: &str = "# Consumer contract\n\n`run` performs no operation.\n";
const DEFAULT_CODE: &str = "pub fn run() {}\n";

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RustCodeDocument {
    package: SourcePackage,
}

impl RustCodeDocument {
    pub fn new(
        identity: LibraryId,
        documentation: impl Into<String>,
        manifest: impl Into<String>,
        code: impl Into<String>,
    ) -> Result<Self, RustCodeDocumentError> {
        let files = vec![
            SourceFile::new("Documentation.md", documentation.into().into_bytes()),
            SourceFile::new("Cargo.toml", manifest.into().into_bytes()),
            SourceFile::new("src/lib.rs", code.into().into_bytes()),
        ];
        let package = SourcePackage::new(identity, files)
            .map_err(|cause| RustCodeDocumentError(cause.to_string()))?;
        Self::from_source_package(package)
    }

    pub fn from_source_package(package: SourcePackage) -> Result<Self, RustCodeDocumentError> {
        let paths: Vec<&str> = package.files().iter().map(|file| file.path()).collect();
        if paths != SUPPORTED_PATHS {
            return fail("source package must contain exactly the three supported paths");
        }
        for file in package.files() {
            if std::str::from_utf8(file.bytes()).is_err() {
                return fail(format!("{} must be UTF-8", file.path()));
            }
        }
        Ok(Self { package })
    }

    pub fn approved_default(identity: LibraryId) -> Result<Self, RustCodeDocumentError> {
        let family = identity.family();
        let manifest = format!(
            "[package]\nname = \"{}\"\nversion = \"{}\"\nedition = \"2024\"\nrust-version = \"1.97\"\nlicense = \"MIT\"\n",
            family.package_name(),
            identity.version(),
        );
        Self::new(identity, DEFAULT_DOCUMENTATION, manifest, DEFAULT_CODE)
    }

    pub fn identity(&self) -> &LibraryId {
        self.package.id()
    }

    pub fn documentation(&self) -> &str {
        source_text(&self.package, "Documentation.md")
    }

    pub fn manifest(&self) -> &str {
        source_text(&self.package, "Cargo.toml")
    }

    pub fn code(&self) -> &str {
        source_text(&self.package, "src/lib.rs")
    }

    pub fn into_source_package(self) -> SourcePackage {
        self.package
    }

    pub fn code_chunk_ranges(&self) -> Vec<Range<usize>> {
        chunk_ranges(self.code())
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RustCodeDocumentError(String);

impl RustCodeDocumentError {
    pub fn message(&self) -> &str {
        &self.0
    }
}

impl Display for RustCodeDocumentError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for RustCodeDocumentError {}

fn fail<T>(message: impl Into<String>) -> Result<T, RustCodeDocumentError> {
    Err(RustCodeDocumentError(message.into()))
}

fn source_text<'a>(package: &'a SourcePackage, path: &str) -> &'a str {
    let file = package
        .files()
        .iter()
        .find(|file| file.path() == path)
        .expect("validated document path");
    std::str::from_utf8(file.bytes()).expect("validated UTF-8 document")
}

fn whole_range(end: usize) -> Vec<Range<usize>> {
    std::iter::once(0..end).collect()
}

fn chunk_ranges(source: &str) -> Vec<Range<usize>> {
    if source.is_empty() {
        return whole_range(0);
    }
    let Some(points) = safe_points(source) else {
        return whole_range(source.len());
    };
    let mut ranges = Vec::new();
    let mut start = 0;
    for end in points {
        if end <= start || end >= source.len() {
            continue;
        }
        if source[start..end].chars().count() < MIN_CHUNK_SCALARS
            || source[end..].chars().count() < MIN_CHUNK_SCALARS
        {
            continue;
        }
        ranges.push(start..end);
        start = end;
    }
    ranges.push(start..source.len());
    ranges
}

fn safe_points(source: &str) -> Option<Vec<usize>> {
    let file = syn::parse_file(source).ok()?;
    let mut points = Vec::new();
    for item in &file.items {
        let outer = checked_span(item.span(), source)?;
        match item {
            Item::Impl(item) => {
                for member in &item.items {
                    match member {
                        ImplItem::Const(_)
                        | ImplItem::Fn(_)
                        | ImplItem::Type(_)
                        | ImplItem::Macro(_) => {
                            add_member(member.span(), &outer, source, &mut points)?
                        }
                        ImplItem::Verbatim(_) => return None,
                        _ => return None,
                    }
                }
            }
            Item::Trait(item) => {
                for member in &item.items {
                    match member {
                        TraitItem::Const(_)
                        | TraitItem::Fn(_)
                        | TraitItem::Type(_)
                        | TraitItem::Macro(_) => {
                            add_member(member.span(), &outer, source, &mut points)?
                        }
                        TraitItem::Verbatim(_) => return None,
                        _ => return None,
                    }
                }
            }
            Item::Const(_)
            | Item::Enum(_)
            | Item::ExternCrate(_)
            | Item::Fn(_)
            | Item::ForeignMod(_)
            | Item::Macro(_)
            | Item::Mod(_)
            | Item::Static(_)
            | Item::Struct(_)
            | Item::TraitAlias(_)
            | Item::Type(_)
            | Item::Union(_)
            | Item::Use(_) => {}
            Item::Verbatim(_) => return None,
            _ => return None,
        }
        points.push(outer.end);
    }
    points.sort_unstable();
    points.dedup();
    Some(points)
}

fn checked_span(span: Span, source: &str) -> Option<Range<usize>> {
    let range = span.byte_range();
    if range.start >= range.end
        || range.end > source.len()
        || !source.is_char_boundary(range.start)
        || !source.is_char_boundary(range.end)
        || source[range.clone()].trim().is_empty()
    {
        return None;
    }
    Some(range)
}

fn add_member(
    span: Span,
    outer: &Range<usize>,
    source: &str,
    points: &mut Vec<usize>,
) -> Option<()> {
    let member = checked_span(span, source)?;
    if member.start < outer.start || member.end > outer.end {
        return None;
    }
    points.push(member.end);
    Some(())
}