git.delta.rocks / jrsonnet / refs/heads / master

difftreelog

source

crates/jrsonnet-pack/src/lib.rs14.9 KiBsourcehistory
1//! Pack a jsonnet entry point and its transitive imports into a self-contained bundle2#![deny(missing_docs)]34use std::{5	collections::{HashMap, hash_map::Entry},6	path::{Component, Path, PathBuf},7};89use base64::{Engine, engine::general_purpose::STANDARD};10use jrsonnet_evaluator::ImportResolver;11use jrsonnet_ir::SourcePath;12use jrsonnet_lexer::unescape;13use jrsonnet_rowan_parser::{14	AstNode, AstToken, LocatedSyntaxError,15	nodes::{ExprImport, ImportKindKind, Text, TextKind},16};17use serde::{Deserialize, Serialize};1819/// Error that might occur during the bundle preparation20#[derive(Debug, thiserror::Error)]21pub enum Error {22	/// Other error23	#[error("{0}")]24	Other(String),25	/// Source parse error26	// TODO: Include syntax error?27	#[error("parse error in {path}")]28	Parse {29		/// Path with parse error30		path: SourcePath,31		/// Parse errors32		errors: Vec<LocatedSyntaxError>,33	},34	/// Dynamic imports are not supported.35	#[error("unsupported dynamic import in {path}")]36	UnsupportedImportString {37		/// Stringified [`SourcePath`]38		path: SourcePath,39	},40	/// Only file-backed sources can be packed for now.41	#[error("{path}: source is not file-backed, can't bundle")]42	NotFileBacked {43		/// Stringified [`SourcePath`]44		path: String,45	},46	/// File is expected to be utf-8 encoded, but it is not.47	#[error("{0} was imported with {1:?}, but it is not utf-8 encoded")]48	NotUtf8(SourcePath, ImportKindKind),49}5051impl From<String> for Error {52	fn from(s: String) -> Self {53		Error::Other(s)54	}55}5657/// One file inside a [`PlaygroundBundle`].58#[derive(Debug, Clone, Serialize, Deserialize)]59pub struct PlaygroundBundleFile {60	/// File name.61	pub name: String,62	/// File text content.63	pub content: String,64}6566/// Bundle as accepted by jrsonnet playground67/// <https://delta.rocks/jrsonnet/playground>68#[derive(Debug, Clone, Serialize, Deserialize)]69pub struct PlaygroundBundle {70	/// Bundle format version; currently always `1`.71	pub version: u32,72	/// Name of the bundle's entry file (should be contained in `files`).73	pub entry: String,74	/// Optional output format hint for the playground.75	#[serde(default, skip_serializing_if = "Option::is_none")]76	pub format: Option<String>,77	/// Files inside of the bundle.78	pub files: Vec<PlaygroundBundleFile>,79}8081/// Bundle `entry` and all of its transitive imports into a [`PlaygroundBundle`].82///83/// `format` becomes the bundle's optional `format` field — the playground uses84/// it to pre-select the manifest format. Pass `None` to omit it.85pub fn bundle_playground(86	resolver: &dyn ImportResolver,87	entry: &SourcePath,88	format: Option<String>,89) -> Result<PlaygroundBundle, Error> {90	let walked = walk(resolver, entry)?;91	build_playground(&walked, format)92}9394/// Bundle `entry` and all of its transitive imports into a single95/// self-contained jsonnet expression.96pub fn bundle_onefile(resolver: &dyn ImportResolver, entry: &SourcePath) -> Result<String, Error> {97	let walked = walk(resolver, entry)?;98	build_onefile(&walked)99}100101#[derive(Debug)]102struct RawImport {103	start: usize,104	end: usize,105	decoded: String,106	kind: ImportKindKind,107}108109#[derive(Debug)]110struct PendingRewrite {111	start: usize,112	end: usize,113	resolved: SourcePath,114	kind: ImportKindKind,115}116117#[derive(Debug, Default)]118struct AccessFlags {119	normal: bool,120	str: bool,121	bin: bool,122}123124#[derive(Debug)]125struct FileData {126	source: SourcePath,127	raw_text: Option<String>,128	raw_bytes: Vec<u8>,129130	rewrites: Vec<PendingRewrite>,131	access: AccessFlags,132}133134#[derive(Debug)]135struct Walked {136	files: HashMap<SourcePath, FileData>,137	entry: SourcePath,138}139140fn walk(resolver: &dyn ImportResolver, entry: &SourcePath) -> Result<Walked, Error> {141	let mut files: HashMap<SourcePath, FileData> = HashMap::new();142	process(resolver, entry, ImportKindKind::ImportKw, &mut files)?;143	Ok(Walked {144		entry: entry.clone(),145		files,146	})147}148149fn process(150	resolver: &dyn ImportResolver,151	source: &SourcePath,152	import_kind: ImportKindKind,153	files: &mut HashMap<SourcePath, FileData>,154) -> Result<(), Error> {155	let data = files.entry(source.clone());156	let data = match data {157		Entry::Vacant(e) => {158			let bytes = resolver159				.load_file_contents(source)160				.map_err(|e| Error::Other(format!("{e}")))?;161162			e.insert(FileData {163				source: source.clone(),164				raw_text: None,165				raw_bytes: bytes,166				rewrites: Vec::new(),167				access: AccessFlags::default(),168			})169		}170		Entry::Occupied(v) => v.into_mut(),171	};172173	if data.raw_text.is_none() && import_kind != ImportKindKind::ImportbinKw {174		let raw_text = String::from_utf8(data.raw_bytes.clone())175			.map_err(|_| Error::NotUtf8(source.clone(), import_kind))?;176		data.raw_text = Some(raw_text);177	}178179	let imports = if import_kind == ImportKindKind::ImportKw && !data.access.normal {180		data.access.normal = true;181		let code = data.raw_text.as_ref().expect("set in the branch above");182		collect_imports(code).map_err(|e| match e {183			ImportScanError::Parse(errors) => Error::Parse {184				path: source.clone(),185				errors,186			},187			ImportScanError::Unsupported => Error::UnsupportedImportString {188				path: source.clone(),189			},190		})?191	} else {192		vec![]193	};194195	if import_kind == ImportKindKind::ImportbinKw {196		data.access.bin = true;197	}198	if import_kind == ImportKindKind::ImportstrKw {199		data.access.str = true;200	}201202	let mut rewrites = Vec::with_capacity(imports.len());203	let mut children: Vec<(SourcePath, ImportKindKind)> = Vec::with_capacity(imports.len());204	for imp in imports {205		let resolved = resolver206			.resolve_from(source, &imp.decoded.as_str())207			.map_err(|e| Error::Other(format!("{e}")))?;208		rewrites.push(PendingRewrite {209			start: imp.start,210			end: imp.end,211			resolved: resolved.clone(),212			kind: imp.kind,213		});214		children.push((resolved, imp.kind));215	}216	rewrites.sort_by_key(|r| r.start);217	data.rewrites = rewrites;218219	for (child, kind) in children {220		process(resolver, &child, kind, files)?;221	}222223	Ok(())224}225226enum ImportScanError {227	Parse(Vec<LocatedSyntaxError>),228	Unsupported,229}230231fn collect_imports(source: &str) -> Result<Vec<RawImport>, ImportScanError> {232	let (file, errs) = jrsonnet_rowan_parser::parse(source);233	if !errs.is_empty() {234		return Err(ImportScanError::Parse(errs));235	}236	let mut out = Vec::new();237	for node in file.syntax().descendants() {238		let Some(imp) = ExprImport::cast(node) else {239			continue;240		};241		let kind = imp242			.import_kind()243			.map_or(ImportKindKind::ImportKw, |k| k.kind());244		let Some(text) = imp.text() else {245			return Err(ImportScanError::Unsupported);246		};247		let Some(decoded) = decode_text(&text) else {248			return Err(ImportScanError::Unsupported);249		};250		let range = text.syntax().text_range();251		out.push(RawImport {252			start: range.start().into(),253			end: range.end().into(),254			decoded,255			kind,256		});257	}258	Ok(out)259}260261/// Unescape/decode rowan [`Text`] node to the original text.262/// TODO: It does not work with string blocks as for now.263// TODO: Move to lexer or idk?264// TODO: String blocks (It is only used for imports for now, and it feels insane to use block imports)265pub fn decode_text(t: &Text) -> Option<String> {266	let raw = t.syntax().text();267	match t.kind() {268		TextKind::StringDouble | TextKind::StringSingle => {269			let inner = raw.get(1..raw.len() - 1)?;270			unescape(inner)271		}272		TextKind::StringDoubleVerbatim => {273			let inner = raw.get(2..raw.len() - 1)?;274			Some(inner.replace("\"\"", "\""))275		}276		TextKind::StringSingleVerbatim => {277			let inner = raw.get(2..raw.len() - 1)?;278			Some(inner.replace("''", "'"))279		}280		_ => None,281	}282}283284/// Encode jsonnet string to the source form285pub fn quote_jsonnet(s: &str) -> String {286	let mut out = String::with_capacity(s.len() + 2);287	out.push('\'');288	for c in s.chars() {289		match c {290			'\\' => out.push_str("\\\\"),291			'\'' => out.push_str("\\'"),292			'\n' => out.push_str("\\n"),293			'\r' => out.push_str("\\r"),294			'\t' => out.push_str("\\t"),295			c => out.push(c),296		}297	}298	out.push('\'');299	out300}301302fn path_of(s: &SourcePath) -> Result<&Path, Error> {303	s.path().ok_or_else(|| Error::NotFileBacked {304		path: format!("{s}"),305	})306}307308fn common_ancestor(parents: &[&Path]) -> PathBuf {309	let Some((first, rest)) = parents.split_first() else {310		return PathBuf::new();311	};312	let mut common: Vec<Component> = first.components().collect();313	for p in rest {314		let comps: Vec<Component> = p.components().collect();315		let n = common316			.iter()317			.zip(comps.iter())318			.take_while(|(a, b)| a == b)319			.count();320		common.truncate(n);321	}322	let mut out = PathBuf::new();323	for c in common {324		out.push(c.as_os_str());325	}326	out327}328329fn bundle_name_from(root: &Path, path: &Path) -> String {330	let rel = path.strip_prefix(root).unwrap_or(path);331	rel.to_string_lossy().replace('\\', "/")332}333334fn relative_bundle_path(from: &str, to: &str) -> String {335	let from_parts: Vec<&str> = from.split('/').collect();336	let from_dir = &from_parts[..from_parts.len().saturating_sub(1)];337	let to_parts: Vec<&str> = to.split('/').collect();338	let to_dir = &to_parts[..to_parts.len().saturating_sub(1)];339340	let mut i = 0;341	while i < from_dir.len() && i < to_dir.len() && from_dir[i] == to_dir[i] {342		i += 1;343	}344345	let mut out = String::new();346	for _ in i..from_dir.len() {347		out.push_str("../");348	}349	for seg in &to_parts[i..] {350		out.push_str(seg);351		out.push('/');352	}353	if out.ends_with('/') {354		out.pop();355	}356	if out.is_empty() {357		out.push_str(to_parts.last().copied().unwrap_or(""));358	}359	out360}361362fn build_playground(walked: &Walked, format: Option<String>) -> Result<PlaygroundBundle, Error> {363	let canonical_paths: Vec<&Path> = walked364		.files365		.values()366		.map(|fd| path_of(&fd.source))367		.collect::<Result<_, _>>()?;368	let parents: Vec<&Path> = canonical_paths369		.iter()370		.map(|p| p.parent().unwrap_or_else(|| Path::new("")))371		.collect();372	let root = common_ancestor(&parents);373374	let mut names: HashMap<SourcePath, String> = HashMap::new();375	for (key, fd) in &walked.files {376		names.insert(key.clone(), bundle_name_from(&root, path_of(&fd.source)?));377	}378379	let entry_name = names380		.get(&walked.entry)381		.cloned()382		.ok_or_else(|| Error::Other("entry missing from bundle".into()))?;383384	let mut files = Vec::with_capacity(walked.files.len());385	for (key, fd) in &walked.files {386		let self_name = names.get(key).expect("name");387		let Some(raw_text) = &fd.raw_text else {388			return Err(Error::Other(389				"playground does not support importbin".to_owned(),390			));391		};392		let content = rewrite_imports_for_playground(raw_text, &fd.rewrites, self_name, &names)?;393		files.push(PlaygroundBundleFile {394			name: self_name.clone(),395			content,396		});397	}398	files.sort_by(|a, b| a.name.cmp(&b.name));399400	Ok(PlaygroundBundle {401		version: 1,402		entry: entry_name,403		format,404		files,405	})406}407408fn rewrite_imports_for_playground(409	code: &str,410	rewrites: &[PendingRewrite],411	self_name: &str,412	names: &HashMap<SourcePath, String>,413) -> Result<String, Error> {414	let mut out = String::with_capacity(code.len());415	let mut cursor = 0usize;416	for r in rewrites {417		out.push_str(&code[cursor..r.start]);418		let target_name = names419			.get(&r.resolved)420			.ok_or_else(|| Error::Other(format!("missing bundle name for {}", r.resolved)))?;421		let rel = relative_bundle_path(self_name, target_name);422		out.push_str(&quote_jsonnet(&rel));423		cursor = r.end;424	}425	out.push_str(&code[cursor..]);426	Ok(out)427}428429fn local_name(prefix: &str, idx: usize) -> String {430	format!("__bundle_{prefix}{idx}")431}432433fn build_onefile(walked: &Walked) -> Result<String, Error> {434	let mut normal_idx: HashMap<SourcePath, usize> = HashMap::new();435	let mut str_idx: HashMap<SourcePath, usize> = HashMap::new();436	let mut bin_idx: HashMap<SourcePath, usize> = HashMap::new();437	for (key, fd) in &walked.files {438		if fd.access.normal {439			let n = normal_idx.len();440			normal_idx.insert(key.clone(), n);441		}442		if fd.access.str {443			let n = str_idx.len();444			str_idx.insert(key.clone(), n);445		}446		if fd.access.bin {447			let n = bin_idx.len();448			bin_idx.insert(key.clone(), n);449		}450	}451452	let entry_idx = *normal_idx.get(&walked.entry).ok_or_else(|| {453		Error::Other("entry was not bound as a normal local; nothing to emit".into())454	})?;455456	let mut bindings: Vec<String> =457		Vec::with_capacity(normal_idx.len() + str_idx.len() + bin_idx.len());458459	for (key, &idx) in &normal_idx {460		let fd = &walked.files[key];461		let Some(raw_text) = fd.raw_text.as_ref() else {462			continue;463		};464		let body =465			rewrite_imports_for_onefile(raw_text, &fd.rewrites, &normal_idx, &str_idx, &bin_idx)?;466		let name = local_name("f", idx);467		bindings.push(format!("{name} = (\n{}\n  )", indent(&body, "    ")));468	}469	for (key, &idx) in &str_idx {470		let fd = &walked.files[key];471		let Some(raw_text) = fd.raw_text.as_ref() else {472			continue;473		};474		bindings.push(format!(475			"{} = {}",476			local_name("s", idx),477			quote_jsonnet(raw_text)478		));479	}480	for (key, &idx) in &bin_idx {481		let fd = &walked.files[key];482		bindings.push(format!(483			"{} = std.base64DecodeBytes({})",484			local_name("b", idx),485			quote_jsonnet(&STANDARD.encode(&fd.raw_bytes))486		));487	}488489	let entry_local = local_name("f", entry_idx);490	let mut out = String::new();491	out.push_str("local\n");492	for (i, b) in bindings.iter().enumerate() {493		out.push_str("  ");494		out.push_str(b);495		if i + 1 == bindings.len() {496			out.push(';');497		} else {498			out.push(',');499		}500		out.push('\n');501	}502	out.push_str(&entry_local);503	out.push('\n');504	Ok(out)505}506507fn indent(s: &str, prefix: &str) -> String {508	let mut out = String::with_capacity(s.len() + prefix.len() * 4);509	for (i, line) in s.lines().enumerate() {510		if i > 0 {511			out.push('\n');512		}513		if !line.is_empty() {514			out.push_str(prefix);515			out.push_str(line);516		}517	}518	out519}520521fn rewrite_imports_for_onefile(522	code: &str,523	rewrites: &[PendingRewrite],524	normal_idx: &HashMap<SourcePath, usize>,525	str_idx: &HashMap<SourcePath, usize>,526	bin_idx: &HashMap<SourcePath, usize>,527) -> Result<String, Error> {528	let mut out = String::with_capacity(code.len());529	let mut cursor = 0usize;530	for r in rewrites {531		let (kw_start, _kw_end) = keyword_span_before(code, r.start)532			.ok_or_else(|| Error::Other("could not locate import keyword".into()))?;533		out.push_str(&code[cursor..kw_start]);534		match r.kind {535			ImportKindKind::ImportKw => {536				let idx = *normal_idx.get(&r.resolved).ok_or_else(|| {537					Error::Other(format!("missing normal local for {}", r.resolved))538				})?;539				out.push_str(&local_name("f", idx));540			}541			ImportKindKind::ImportstrKw => {542				let idx = *str_idx543					.get(&r.resolved)544					.ok_or_else(|| Error::Other(format!("missing str local for {}", r.resolved)))?;545				out.push_str(&local_name("s", idx));546			}547			ImportKindKind::ImportbinKw => {548				let idx = *bin_idx549					.get(&r.resolved)550					.ok_or_else(|| Error::Other(format!("missing bin local for {}", r.resolved)))?;551				out.push_str(&local_name("b", idx));552			}553		}554		cursor = r.end;555	}556	out.push_str(&code[cursor..]);557	Ok(out)558}559560fn keyword_span_before(code: &str, pos: usize) -> Option<(usize, usize)> {561	let prefix = &code[..pos];562	let end = prefix.trim_end().len();563	if end == 0 {564		return None;565	}566	for kw in ["importbin", "importstr", "import"] {567		if end >= kw.len() && prefix[..end].ends_with(kw) {568			let start = end - kw.len();569			if start == 0 || !is_ident_continue(prefix.as_bytes()[start - 1]) {570				return Some((start, start + kw.len()));571			}572		}573	}574	None575}576577fn is_ident_continue(b: u8) -> bool {578	b.is_ascii_alphanumeric() || b == b'_'579}