kcode-k1-web-code-document 0.3.0

Represent versionless authored K1 Web source trees
Documentation
#![forbid(unsafe_code)]

use kcode_k1_web_package::{
    PackageError, SourceFile, SourcePackage, WebFamily, WebId, project_manifest_version,
    validate_source_path,
};
use semver::Version;
use std::fmt::{Display, Formatter};

const HTML_ENTRY: &str = "const frame=document.createElement(\"iframe\");\nframe.hidden=true;\nframe.src=new URL(\"./index.html\",import.meta.url).href;\ndocument.body.append(frame);\nawait new Promise((resolve,reject)=>{frame.addEventListener(\"load\",resolve,{once:true});frame.addEventListener(\"error\",()=>reject(new Error(\"index.html failed to load\")),{once:true});});\nexport const codeWindow=frame.contentWindow;\n";
const HTML_TESTS: &str = "import {codeWindow} from \"./k1-entry.js\";\nexport async function runTests(){const test=codeWindow.globalThis.runTests;if(typeof test!==\"function\")throw new Error(\"index.html must define globalThis.runTests\");return await test.call(codeWindow);}\n";
const CSS_ENTRY: &str = "const link=document.createElement(\"link\");\nlink.rel=\"stylesheet\";\nlink.href=new URL(\"./index.css\",import.meta.url).href;\ndocument.head.append(link);\nawait new Promise((resolve,reject)=>{link.addEventListener(\"load\",resolve,{once:true});link.addEventListener(\"error\",()=>reject(new Error(\"index.css failed to load\")),{once:true});});\nexport const sheet=link.sheet;\nvoid sheet.cssRules;\n";
const CSS_TESTS: &str = "import {sheet} from \"./k1-entry.js\";\nexport async function runTests(){void sheet.cssRules;}\n";

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Language {
    JavaScript,
    Html,
    Css,
}

impl Language {
    pub const fn template_path(self) -> &'static str {
        match self {
            Self::JavaScript => "index.js",
            Self::Html => "index.html",
            Self::Css => "index.css",
        }
    }

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::JavaScript => "javascript",
            Self::Html => "html",
            Self::Css => "css",
        }
    }
}

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

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

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

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

impl From<PackageError> for DocumentError {
    fn from(value: PackageError) -> Self {
        Self(value.to_string())
    }
}

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

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodeDocument {
    family: WebFamily,
    files: Vec<SourceFile>,
}

impl CodeDocument {
    pub fn new(family: WebFamily, mut files: Vec<SourceFile>) -> Result<Self, DocumentError> {
        files
            .iter()
            .try_for_each(|file| validate_source_path(file.path()))?;
        files.sort();
        if files
            .windows(2)
            .any(|pair| pair[0].path() == pair[1].path())
        {
            return fail("duplicate source path");
        }
        if files
            .iter()
            .any(|file| has_file_ancestor(&files, file.path()))
        {
            return fail("source path collides with a file ancestor");
        }
        Ok(Self { family, files })
    }

    pub fn template(family: WebFamily, language: Language) -> Result<Self, DocumentError> {
        let documentation = format!("# {}\n", family.logical_name()).into_bytes();
        let source = match language {
            Language::JavaScript => b"export async function runTests() {}\n".to_vec(),
            Language::Html => b"<!doctype html>\n<meta charset=\"utf-8\">\n<script>globalThis.runTests = async () => {};</script>\n".to_vec(),
            Language::Css => b":root {}\n".to_vec(),
        };
        Self::new(
            family,
            vec![
                SourceFile::new("Documentation.md", documentation),
                SourceFile::new(language.template_path(), source),
            ],
        )
    }

    pub fn family(&self) -> &WebFamily {
        &self.family
    }

    pub fn files(&self) -> &[SourceFile] {
        &self.files
    }

    pub fn file(&self, path: &str) -> Option<&SourceFile> {
        self.files
            .binary_search_by(|file| file.path().cmp(path))
            .ok()
            .map(|index| &self.files[index])
    }

    pub fn replace_text(&self, path: &str, contents: String) -> Result<Self, DocumentError> {
        let index = self
            .files
            .binary_search_by(|file| file.path().cmp(path))
            .map_err(|_| DocumentError("source file does not exist".into()))?;
        if std::str::from_utf8(self.files[index].bytes()).is_err() {
            return fail("this file is a non-text object and cannot be modified");
        }
        let mut files = self.files.clone();
        files[index] = SourceFile::new(path, contents.into_bytes());
        Self::new(self.family.clone(), files)
    }

    pub fn create_text(&self, path: &str, contents: String) -> Result<Self, DocumentError> {
        if self.file(path).is_some() {
            return fail("source file already exists");
        }
        let mut files = self.files.clone();
        files.push(SourceFile::new(path, contents.into_bytes()));
        Self::new(self.family.clone(), files)
    }

    pub fn rename(&self, path: &str, destination: &str) -> Result<Self, DocumentError> {
        if path == destination {
            return fail("source and destination paths must differ");
        }
        validate_source_path(destination)?;
        if self.file(destination).is_some() {
            return fail("destination source file already exists");
        }
        let index = self
            .files
            .binary_search_by(|file| file.path().cmp(path))
            .map_err(|_| DocumentError("source file does not exist".into()))?;
        let mut files = self.files.clone();
        files[index] = SourceFile::new(destination, files[index].bytes().to_vec());
        Self::new(self.family.clone(), files)
    }

    pub fn delete(&self, path: &str) -> Result<Self, DocumentError> {
        let index = self
            .files
            .binary_search_by(|file| file.path().cmp(path))
            .map_err(|_| DocumentError("source file does not exist".into()))?;
        let mut files = self.files.clone();
        files.remove(index);
        Self::new(self.family.clone(), files)
    }

    pub fn projected_files(&self, version: Version) -> Result<Vec<SourceFile>, DocumentError> {
        let package = self.to_source_package(version)?;
        Ok(package.files().to_vec())
    }

    pub fn to_source_package(&self, version: Version) -> Result<SourcePackage, DocumentError> {
        let id = WebId::new(self.family.clone(), version)?;
        let files = if self.file("k1-web.json").is_some() {
            project_manifest_version(&self.files, id.version())?
        } else {
            self.generated_projection(id.version())?
        };
        SourcePackage::new(id, files).map_err(Into::into)
    }

    pub fn from_source_package(source: &SourcePackage) -> Result<Self, DocumentError> {
        Self::new(source.id().family().clone(), source.files().to_vec())
    }

    fn generated_projection(&self, version: &Version) -> Result<Vec<SourceFile>, DocumentError> {
        let languages = [Language::JavaScript, Language::Html, Language::Css]
            .into_iter()
            .filter(|language| self.file(language.template_path()).is_some())
            .collect::<Vec<_>>();
        let [language] = languages.as_slice() else {
            return fail(
                "source without k1-web.json must contain exactly one supported root index file",
            );
        };
        if self.file("Documentation.md").is_none() {
            return fail("source projection is missing Documentation.md");
        }
        let (entry, tests) = match language {
            Language::JavaScript => ("index.js", "index.js"),
            Language::Html | Language::Css => ("k1-entry.js", "k1-tests.js"),
        };
        let name = serde_json::to_string(self.family.logical_name())
            .map_err(|error| DocumentError(error.to_string()))?;
        let version = serde_json::to_string(&version.to_string())
            .map_err(|error| DocumentError(error.to_string()))?;
        let manifest = format!(
            "{{\"name\":{name},\"version\":{version},\"entry\":\"{entry}\",\"tests\":\"{tests}\",\"dependencies\":[]}}"
        );
        let mut files = self.files.clone();
        files.push(SourceFile::new("k1-web.json", manifest.into_bytes()));
        match language {
            Language::JavaScript => {}
            Language::Html => {
                ensure_generated_absent(self, "k1-entry.js")?;
                ensure_generated_absent(self, "k1-tests.js")?;
                files.push(SourceFile::new(
                    "k1-entry.js",
                    HTML_ENTRY.as_bytes().to_vec(),
                ));
                files.push(SourceFile::new(
                    "k1-tests.js",
                    HTML_TESTS.as_bytes().to_vec(),
                ));
            }
            Language::Css => {
                ensure_generated_absent(self, "k1-entry.js")?;
                ensure_generated_absent(self, "k1-tests.js")?;
                files.push(SourceFile::new(
                    "k1-entry.js",
                    CSS_ENTRY.as_bytes().to_vec(),
                ));
                files.push(SourceFile::new(
                    "k1-tests.js",
                    CSS_TESTS.as_bytes().to_vec(),
                ));
            }
        }
        Ok(files)
    }
}

fn ensure_generated_absent(document: &CodeDocument, path: &str) -> Result<(), DocumentError> {
    if document.file(path).is_some() {
        fail(format!("generated projection path already exists: {path}"))
    } else {
        Ok(())
    }
}

fn has_file_ancestor(files: &[SourceFile], path: &str) -> bool {
    path.match_indices('/').any(|(index, _)| {
        files
            .binary_search_by(|file| file.path().cmp(&path[..index]))
            .is_ok()
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use kcode_k1_transaction_id::TxId;
    use kcode_k1_web_package::AuthorityId;

    fn family() -> WebFamily {
        WebFamily::new(AuthorityId::new(TxId::from_bytes([1; 12])), "alpha").unwrap()
    }

    #[test]
    fn templates_are_two_authored_files_and_project_deterministically() {
        for language in [Language::JavaScript, Language::Html, Language::Css] {
            let document = CodeDocument::template(family(), language).unwrap();
            assert_eq!(document.files().len(), 2);
            assert!(document.file("Documentation.md").is_some());
            assert!(document.file(language.template_path()).is_some());
            assert!(
                document
                    .files()
                    .iter()
                    .all(|file| !file.path().starts_with("Code."))
            );
            let package = document.to_source_package(Version::new(1, 2, 3)).unwrap();
            assert_eq!(package.id().version(), &Version::new(1, 2, 3));
        }
        let html = CodeDocument::template(family(), Language::Html).unwrap();
        let package = html.to_source_package(Version::new(1, 0, 0)).unwrap();
        assert!(
            package
                .files()
                .iter()
                .any(|file| file.path() == "index.html")
        );
        assert!(
            std::str::from_utf8(
                package
                    .files()
                    .iter()
                    .find(|file| file.path() == "k1-entry.js")
                    .unwrap()
                    .bytes(),
            )
            .unwrap()
            .contains("index.html")
        );
    }

    #[test]
    fn temporary_invalid_trees_and_binary_mutations_follow_contract() {
        let document = CodeDocument::new(
            family(),
            vec![
                SourceFile::new("nested/readme.odd", b"hello".to_vec()),
                SourceFile::new("assets/icon.png", vec![0, 159, 255]),
            ],
        )
        .unwrap();
        assert!(document.to_source_package(Version::new(1, 0, 0)).is_err());
        assert!(
            document
                .replace_text("assets/icon.png", "x".into())
                .is_err()
        );
        let renamed = document
            .rename("assets/icon.png", "assets/logo.png")
            .unwrap();
        assert_eq!(
            renamed.file("assets/logo.png").unwrap().bytes(),
            [0, 159, 255]
        );
        assert!(
            renamed
                .delete("assets/logo.png")
                .unwrap()
                .file("assets/logo.png")
                .is_none()
        );
        assert_eq!(
            document
                .replace_text("nested/readme.odd", "new".into())
                .unwrap()
                .file("nested/readme.odd")
                .unwrap()
                .bytes(),
            b"new"
        );
    }

    #[test]
    fn explicit_manifest_projection_changes_only_version() {
        let manifest = b"{\n \"name\":\"alpha\",\"version\" : \"0.0.0\",\"entry\":\"index.js\",\"tests\":\"index.js\",\"dependencies\":[]\n}";
        let document = CodeDocument::new(
            family(),
            vec![
                SourceFile::new("Documentation.md", b"docs".to_vec()),
                SourceFile::new("index.js", b"export function runTests(){}".to_vec()),
                SourceFile::new("k1-web.json", manifest.to_vec()),
                SourceFile::new("asset.bin", vec![255]),
            ],
        )
        .unwrap();
        let package = document.to_source_package(Version::new(2, 0, 0)).unwrap();
        let rendered = std::str::from_utf8(
            package
                .files()
                .iter()
                .find(|file| file.path() == "k1-web.json")
                .unwrap()
                .bytes(),
        )
        .unwrap();
        assert_eq!(
            rendered,
            std::str::from_utf8(manifest)
                .unwrap()
                .replace("\"0.0.0\"", "\"2.0.0\"")
        );
        assert_eq!(
            package
                .files()
                .iter()
                .find(|file| file.path() == "asset.bin")
                .unwrap()
                .bytes(),
            [255]
        );
    }
}