#![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]
);
}
}