12#![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};181920#[derive(Debug, thiserror::Error)]21pub enum Error {22 23 #[error("{0}")]24 Other(String),25 26 27 #[error("parse error in {path}")]28 Parse {29 30 path: SourcePath,31 32 errors: Vec<LocatedSyntaxError>,33 },34 35 #[error("unsupported dynamic import in {path}")]36 UnsupportedImportString {37 38 path: SourcePath,39 },40 41 #[error("{path}: source is not file-backed, can't bundle")]42 NotFileBacked {43 44 path: String,45 },46 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}565758#[derive(Debug, Clone, Serialize, Deserialize)]59pub struct PlaygroundBundleFile {60 61 pub name: String,62 63 pub content: String,64}65666768#[derive(Debug, Clone, Serialize, Deserialize)]69pub struct PlaygroundBundle {70 71 pub version: u32,72 73 pub entry: String,74 75 #[serde(default, skip_serializing_if = "Option::is_none")]76 pub format: Option<String>,77 78 pub files: Vec<PlaygroundBundleFile>,79}808182838485pub 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}93949596pub 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}260261262263264265pub 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}283284285pub 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("e_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}