Skip to main content

kcode_k1_web_code_document/
lib.rs

1#![forbid(unsafe_code)]
2
3use kcode_k1_web_package::{
4    PackageError, SourceFile, SourcePackage, WebFamily, WebId, project_manifest_version,
5    validate_source_path,
6};
7use semver::Version;
8use std::fmt::{Display, Formatter};
9
10const 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";
11const 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";
12const 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";
13const CSS_TESTS: &str = "import {sheet} from \"./k1-entry.js\";\nexport async function runTests(){void sheet.cssRules;}\n";
14
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub enum Language {
17    JavaScript,
18    Html,
19    Css,
20}
21
22impl Language {
23    pub const fn template_path(self) -> &'static str {
24        match self {
25            Self::JavaScript => "index.js",
26            Self::Html => "index.html",
27            Self::Css => "index.css",
28        }
29    }
30
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::JavaScript => "javascript",
34            Self::Html => "html",
35            Self::Css => "css",
36        }
37    }
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct DocumentError(String);
42
43impl DocumentError {
44    pub fn message(&self) -> &str {
45        &self.0
46    }
47}
48
49impl Display for DocumentError {
50    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
51        formatter.write_str(&self.0)
52    }
53}
54
55impl std::error::Error for DocumentError {}
56
57impl From<PackageError> for DocumentError {
58    fn from(value: PackageError) -> Self {
59        Self(value.to_string())
60    }
61}
62
63fn fail<T>(message: impl Into<String>) -> Result<T, DocumentError> {
64    Err(DocumentError(message.into()))
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct CodeDocument {
69    family: WebFamily,
70    files: Vec<SourceFile>,
71}
72
73impl CodeDocument {
74    pub fn new(family: WebFamily, mut files: Vec<SourceFile>) -> Result<Self, DocumentError> {
75        files
76            .iter()
77            .try_for_each(|file| validate_source_path(file.path()))?;
78        files.sort();
79        if files
80            .windows(2)
81            .any(|pair| pair[0].path() == pair[1].path())
82        {
83            return fail("duplicate source path");
84        }
85        if files
86            .iter()
87            .any(|file| has_file_ancestor(&files, file.path()))
88        {
89            return fail("source path collides with a file ancestor");
90        }
91        Ok(Self { family, files })
92    }
93
94    pub fn template(family: WebFamily, language: Language) -> Result<Self, DocumentError> {
95        let documentation = format!("# {}\n", family.logical_name()).into_bytes();
96        let source = match language {
97            Language::JavaScript => b"export async function runTests() {}\n".to_vec(),
98            Language::Html => b"<!doctype html>\n<meta charset=\"utf-8\">\n<script>globalThis.runTests = async () => {};</script>\n".to_vec(),
99            Language::Css => b":root {}\n".to_vec(),
100        };
101        Self::new(
102            family,
103            vec![
104                SourceFile::new("Documentation.md", documentation),
105                SourceFile::new(language.template_path(), source),
106            ],
107        )
108    }
109
110    pub fn family(&self) -> &WebFamily {
111        &self.family
112    }
113
114    pub fn files(&self) -> &[SourceFile] {
115        &self.files
116    }
117
118    pub fn file(&self, path: &str) -> Option<&SourceFile> {
119        self.files
120            .binary_search_by(|file| file.path().cmp(path))
121            .ok()
122            .map(|index| &self.files[index])
123    }
124
125    pub fn replace_text(&self, path: &str, contents: String) -> Result<Self, DocumentError> {
126        let index = self
127            .files
128            .binary_search_by(|file| file.path().cmp(path))
129            .map_err(|_| DocumentError("source file does not exist".into()))?;
130        if std::str::from_utf8(self.files[index].bytes()).is_err() {
131            return fail("this file is a non-text object and cannot be modified");
132        }
133        let mut files = self.files.clone();
134        files[index] = SourceFile::new(path, contents.into_bytes());
135        Self::new(self.family.clone(), files)
136    }
137
138    pub fn create_text(&self, path: &str, contents: String) -> Result<Self, DocumentError> {
139        if self.file(path).is_some() {
140            return fail("source file already exists");
141        }
142        let mut files = self.files.clone();
143        files.push(SourceFile::new(path, contents.into_bytes()));
144        Self::new(self.family.clone(), files)
145    }
146
147    pub fn rename(&self, path: &str, destination: &str) -> Result<Self, DocumentError> {
148        if path == destination {
149            return fail("source and destination paths must differ");
150        }
151        validate_source_path(destination)?;
152        if self.file(destination).is_some() {
153            return fail("destination source file already exists");
154        }
155        let index = self
156            .files
157            .binary_search_by(|file| file.path().cmp(path))
158            .map_err(|_| DocumentError("source file does not exist".into()))?;
159        let mut files = self.files.clone();
160        files[index] = SourceFile::new(destination, files[index].bytes().to_vec());
161        Self::new(self.family.clone(), files)
162    }
163
164    pub fn delete(&self, path: &str) -> Result<Self, DocumentError> {
165        let index = self
166            .files
167            .binary_search_by(|file| file.path().cmp(path))
168            .map_err(|_| DocumentError("source file does not exist".into()))?;
169        let mut files = self.files.clone();
170        files.remove(index);
171        Self::new(self.family.clone(), files)
172    }
173
174    pub fn projected_files(&self, version: Version) -> Result<Vec<SourceFile>, DocumentError> {
175        let package = self.to_source_package(version)?;
176        Ok(package.files().to_vec())
177    }
178
179    pub fn to_source_package(&self, version: Version) -> Result<SourcePackage, DocumentError> {
180        let id = WebId::new(self.family.clone(), version)?;
181        let files = if self.file("k1-web.json").is_some() {
182            project_manifest_version(&self.files, id.version())?
183        } else {
184            self.generated_projection(id.version())?
185        };
186        SourcePackage::new(id, files).map_err(Into::into)
187    }
188
189    pub fn from_source_package(source: &SourcePackage) -> Result<Self, DocumentError> {
190        Self::new(source.id().family().clone(), source.files().to_vec())
191    }
192
193    fn generated_projection(&self, version: &Version) -> Result<Vec<SourceFile>, DocumentError> {
194        let languages = [Language::JavaScript, Language::Html, Language::Css]
195            .into_iter()
196            .filter(|language| self.file(language.template_path()).is_some())
197            .collect::<Vec<_>>();
198        let [language] = languages.as_slice() else {
199            return fail(
200                "source without k1-web.json must contain exactly one supported root index file",
201            );
202        };
203        if self.file("Documentation.md").is_none() {
204            return fail("source projection is missing Documentation.md");
205        }
206        let (entry, tests) = match language {
207            Language::JavaScript => ("index.js", "index.js"),
208            Language::Html | Language::Css => ("k1-entry.js", "k1-tests.js"),
209        };
210        let name = serde_json::to_string(self.family.logical_name())
211            .map_err(|error| DocumentError(error.to_string()))?;
212        let version = serde_json::to_string(&version.to_string())
213            .map_err(|error| DocumentError(error.to_string()))?;
214        let manifest = format!(
215            "{{\"name\":{name},\"version\":{version},\"entry\":\"{entry}\",\"tests\":\"{tests}\",\"dependencies\":[]}}"
216        );
217        let mut files = self.files.clone();
218        files.push(SourceFile::new("k1-web.json", manifest.into_bytes()));
219        match language {
220            Language::JavaScript => {}
221            Language::Html => {
222                ensure_generated_absent(self, "k1-entry.js")?;
223                ensure_generated_absent(self, "k1-tests.js")?;
224                files.push(SourceFile::new(
225                    "k1-entry.js",
226                    HTML_ENTRY.as_bytes().to_vec(),
227                ));
228                files.push(SourceFile::new(
229                    "k1-tests.js",
230                    HTML_TESTS.as_bytes().to_vec(),
231                ));
232            }
233            Language::Css => {
234                ensure_generated_absent(self, "k1-entry.js")?;
235                ensure_generated_absent(self, "k1-tests.js")?;
236                files.push(SourceFile::new(
237                    "k1-entry.js",
238                    CSS_ENTRY.as_bytes().to_vec(),
239                ));
240                files.push(SourceFile::new(
241                    "k1-tests.js",
242                    CSS_TESTS.as_bytes().to_vec(),
243                ));
244            }
245        }
246        Ok(files)
247    }
248}
249
250fn ensure_generated_absent(document: &CodeDocument, path: &str) -> Result<(), DocumentError> {
251    if document.file(path).is_some() {
252        fail(format!("generated projection path already exists: {path}"))
253    } else {
254        Ok(())
255    }
256}
257
258fn has_file_ancestor(files: &[SourceFile], path: &str) -> bool {
259    path.match_indices('/').any(|(index, _)| {
260        files
261            .binary_search_by(|file| file.path().cmp(&path[..index]))
262            .is_ok()
263    })
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use kcode_k1_transaction_id::TxId;
270    use kcode_k1_web_package::AuthorityId;
271
272    fn family() -> WebFamily {
273        WebFamily::new(AuthorityId::new(TxId::from_bytes([1; 12])), "alpha").unwrap()
274    }
275
276    #[test]
277    fn templates_are_two_authored_files_and_project_deterministically() {
278        for language in [Language::JavaScript, Language::Html, Language::Css] {
279            let document = CodeDocument::template(family(), language).unwrap();
280            assert_eq!(document.files().len(), 2);
281            assert!(document.file("Documentation.md").is_some());
282            assert!(document.file(language.template_path()).is_some());
283            assert!(
284                document
285                    .files()
286                    .iter()
287                    .all(|file| !file.path().starts_with("Code."))
288            );
289            let package = document.to_source_package(Version::new(1, 2, 3)).unwrap();
290            assert_eq!(package.id().version(), &Version::new(1, 2, 3));
291        }
292        let html = CodeDocument::template(family(), Language::Html).unwrap();
293        let package = html.to_source_package(Version::new(1, 0, 0)).unwrap();
294        assert!(
295            package
296                .files()
297                .iter()
298                .any(|file| file.path() == "index.html")
299        );
300        assert!(
301            std::str::from_utf8(
302                package
303                    .files()
304                    .iter()
305                    .find(|file| file.path() == "k1-entry.js")
306                    .unwrap()
307                    .bytes(),
308            )
309            .unwrap()
310            .contains("index.html")
311        );
312    }
313
314    #[test]
315    fn temporary_invalid_trees_and_binary_mutations_follow_contract() {
316        let document = CodeDocument::new(
317            family(),
318            vec![
319                SourceFile::new("nested/readme.odd", b"hello".to_vec()),
320                SourceFile::new("assets/icon.png", vec![0, 159, 255]),
321            ],
322        )
323        .unwrap();
324        assert!(document.to_source_package(Version::new(1, 0, 0)).is_err());
325        assert!(
326            document
327                .replace_text("assets/icon.png", "x".into())
328                .is_err()
329        );
330        let renamed = document
331            .rename("assets/icon.png", "assets/logo.png")
332            .unwrap();
333        assert_eq!(
334            renamed.file("assets/logo.png").unwrap().bytes(),
335            [0, 159, 255]
336        );
337        assert!(
338            renamed
339                .delete("assets/logo.png")
340                .unwrap()
341                .file("assets/logo.png")
342                .is_none()
343        );
344        assert_eq!(
345            document
346                .replace_text("nested/readme.odd", "new".into())
347                .unwrap()
348                .file("nested/readme.odd")
349                .unwrap()
350                .bytes(),
351            b"new"
352        );
353    }
354
355    #[test]
356    fn explicit_manifest_projection_changes_only_version() {
357        let manifest = b"{\n \"name\":\"alpha\",\"version\" : \"0.0.0\",\"entry\":\"index.js\",\"tests\":\"index.js\",\"dependencies\":[]\n}";
358        let document = CodeDocument::new(
359            family(),
360            vec![
361                SourceFile::new("Documentation.md", b"docs".to_vec()),
362                SourceFile::new("index.js", b"export function runTests(){}".to_vec()),
363                SourceFile::new("k1-web.json", manifest.to_vec()),
364                SourceFile::new("asset.bin", vec![255]),
365            ],
366        )
367        .unwrap();
368        let package = document.to_source_package(Version::new(2, 0, 0)).unwrap();
369        let rendered = std::str::from_utf8(
370            package
371                .files()
372                .iter()
373                .find(|file| file.path() == "k1-web.json")
374                .unwrap()
375                .bytes(),
376        )
377        .unwrap();
378        assert_eq!(
379            rendered,
380            std::str::from_utf8(manifest)
381                .unwrap()
382                .replace("\"0.0.0\"", "\"2.0.0\"")
383        );
384        assert_eq!(
385            package
386                .files()
387                .iter()
388                .find(|file| file.path() == "asset.bin")
389                .unwrap()
390                .bytes(),
391            [255]
392        );
393    }
394}