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}