mod language;
mod load;
mod names;
mod origin;
mod source;
mod uses;
pub use language::Language;
pub(crate) use names::{Binding, Names};
pub(crate) use origin::Origin;
use origin::RESERVED_SCOPE;
pub(crate) use uses::invocations;
use crate::diagnostic::Diagnostic;
use crate::host::{Files, Packs, Release};
use crate::lines::{LineStarts, Lines};
use crate::parse::{Parsed, Spliced};
use crate::reserved::Keyword;
use crate::settings::Settings;
use crate::tree::{Move, Node, Param, Point, Position, Root, Shift, Signature};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::rc::Rc;
pub(crate) const CORE_TEXT: &str = include_str!("core.comp.md");
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Ambient {
pub packs: Vec<String>,
pub report: bool,
}
#[derive(Clone, Copy)]
pub(crate) struct Store<'h> {
pub(crate) files: &'h dyn Files,
pub(crate) packs: &'h dyn Packs,
}
impl Store<'_> {
pub(crate) fn read(&self, origin: &Origin) -> Option<String> {
match origin {
Origin::File(path) => self.files.file(path),
_ if origin.is_core() => Some(CORE_TEXT.to_string()),
Origin::Pack { name, .. } if name.starts_with(RESERVED_SCOPE) => None,
Origin::Pack {
name,
version,
path,
} => self.packs.pack_file(name, version, path),
}
}
pub(crate) fn releases(&self, pack: &str) -> Vec<Release> {
if pack.starts_with(RESERVED_SCOPE) {
Vec::new()
} else {
self.packs.releases(pack)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct DocumentId(usize);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct ComponentId(usize);
#[derive(Clone, Debug)]
pub(crate) struct Document {
pub(crate) origin: Origin,
pub(crate) text: String,
pub(crate) line_starts: LineStarts,
pub(crate) root: Rc<Root>,
pub(crate) names: Names,
pub(crate) exports: BTreeMap<String, Binding>,
pub(crate) provided: bool,
pub(crate) settings: Settings,
}
impl Document {
pub(crate) fn lines(&self) -> Lines<'_> {
Lines::new(&self.text, &self.line_starts)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Body {
Compmd(Vec<Node>),
Host {
language: String,
runtime: Option<String>,
source: String,
},
File {
origin: Origin,
language: Language,
},
Processor,
}
impl Body {
pub(crate) fn supported(&self) -> bool {
match self {
Self::Host {
language, runtime, ..
} => Language::from_name(language)
.is_some_and(|binding| binding.runs(runtime.as_deref())),
_ => true,
}
}
pub(crate) fn is_host(&self) -> bool {
matches!(self, Self::Host { .. } | Self::File { .. })
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Component {
pub(crate) name: String,
pub(crate) document: DocumentId,
pub(crate) signature: Option<Signature>,
pub(crate) body: Body,
pub(crate) fence: Vec<Param>,
pub(crate) position: Option<Position>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostComponent {
pub name: String,
pub language: Language,
pub runtime: Option<String>,
pub code: String,
}
impl Component {
fn host_code(&self, name: &str, store: Store<'_>) -> Option<HostComponent> {
let (language, runtime, code) = match &self.body {
Body::Host {
language,
runtime,
source,
} => {
let language = Language::from_name(language)?;
language.runs(runtime.as_deref()).then_some(())?;
(language, runtime.clone(), source.clone())
}
Body::File { origin, language } => (*language, None, store.read(origin)?),
Body::Compmd(_) | Body::Processor => return None,
};
Some(HostComponent {
name: name.to_string(),
language,
runtime,
code,
})
}
pub(crate) fn keyword(&self) -> Option<Keyword> {
Keyword::from_name(&self.name)
}
pub(crate) fn takes_breakpoints(&self) -> bool {
match self.body {
Body::Compmd(_) => true,
Body::Processor => self.keyword() == Some(Keyword::Box),
Body::Host { .. } | Body::File { .. } => false,
}
}
pub(crate) fn is_contents(&self) -> bool {
matches!(self.body, Body::Compmd(_))
&& self
.fence
.iter()
.any(|param| param.is_display_contents() && param.breakpoint.is_none())
}
}
#[derive(Clone, Debug)]
pub(crate) struct Resolved {
pub(crate) documents: Vec<Document>,
pub(crate) components: Vec<Component>,
pub(crate) main: DocumentId,
pub(crate) cycles: HashSet<(DocumentId, Point)>,
diagnostics: Found,
}
#[derive(Clone, Debug, Default)]
struct Found {
before: Vec<Diagnostic>,
blocks: Vec<Vec<Diagnostic>>,
after: Vec<Diagnostic>,
}
impl Resolved {
pub(crate) fn document(&self, id: DocumentId) -> &Document {
&self.documents[id.0]
}
pub(crate) fn component(&self, id: ComponentId) -> &Component {
&self.components[id.0]
}
pub(crate) fn ids(&self) -> impl Iterator<Item = DocumentId> + use<> {
(0..self.documents.len()).map(DocumentId)
}
pub(crate) fn file(&self, id: DocumentId) -> Option<String> {
(id != self.main).then(|| self.document(id).origin.to_string())
}
pub(crate) fn closes_cycle(&self, id: DocumentId, position: Option<Position>) -> bool {
position.is_some_and(|position| self.cycles.contains(&(id, position.start)))
}
pub(crate) fn take_text(&mut self) -> (String, LineStarts) {
let main = &mut self.documents[self.main.0];
main.root = Rc::default();
let lines = std::mem::replace(&mut main.line_starts, LineStarts::new(""));
(std::mem::take(&mut main.text), lines)
}
pub(crate) fn host_components(&self, store: Store<'_>) -> Vec<HostComponent> {
let mut found: BTreeMap<&str, HostComponent> = BTreeMap::new();
let main_first = std::iter::once(self.main).chain(self.ids().filter(|id| *id != self.main));
for id in main_first {
for (name, component) in self.document(id).names.components() {
if found.contains_key(name) {
continue;
}
if let Some(host) = self.component(component).host_code(name, store) {
found.insert(name, host);
}
}
}
found.into_values().collect()
}
pub(crate) fn diagnostics(&self) -> impl Iterator<Item = &Diagnostic> {
let found = &self.diagnostics;
found
.before
.iter()
.chain(found.blocks.iter().flatten())
.chain(&found.after)
}
pub(crate) fn splice(
&mut self,
(text, lines): (String, LineStarts),
root: Rc<Root>,
spliced: &Spliced,
report_ambient: bool,
) {
let main = self.main;
let moved = spliced.moved;
let document = &mut self.documents[main.0];
document.text = text;
document.line_starts = lines;
document.root = root;
for component in &mut self.components {
if component.document == main
&& component
.position
.is_some_and(|position| moved.moves(position.start))
{
component.shift(moved.by);
}
}
self.cycles = self
.cycles
.drain()
.map(|(id, mut start)| {
if id == main {
moved.point(&mut start);
}
(id, start)
})
.collect();
let start = spliced.blocks.start;
let blocks = &self.document(main).root.children[start..start + spliced.inserted];
let found: Vec<Vec<Diagnostic>> = blocks
.iter()
.map(|block| uses::block(self, block, report_ambient))
.collect();
let diagnostics = &mut self.diagnostics;
let kept = diagnostics.before.iter_mut().chain(&mut diagnostics.after);
for diagnostic in kept.chain(diagnostics.blocks.iter_mut().flatten()) {
moved.diagnostic(diagnostic);
}
diagnostics.blocks.splice(spliced.blocks.clone(), found);
}
}
impl Shift for Component {
fn shift(&mut self, by: Move) {
self.signature.shift(by);
if let Body::Compmd(body) = &mut self.body {
body.shift(by);
}
self.fence.shift(by);
self.position.shift(by);
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Parses(HashMap<Origin, (String, Parsed)>);
pub(crate) fn resolve(
path: &str,
text: String,
parsed: Parsed,
store: Store,
ambient: &Ambient,
known: &mut Parses,
) -> Resolved {
load::Loader::new(store, Origin::File(path.to_string()), known).resolve(text, parsed, ambient)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::LoadedRelease;
use crate::parse::{ParseOptions, parse};
use serde_json::{Value, json};
use std::collections::HashMap;
fn packs_of(packs: Value) -> HashMap<String, Vec<LoadedRelease>> {
serde_json::from_value(packs).expect("packs")
}
fn resolved(files: &[(&str, &str)], packs: Value, ambient: &[&str]) -> Resolved {
let files: HashMap<String, String> = files
.iter()
.map(|(path, text)| ((*path).to_string(), (*text).to_string()))
.collect();
let packs = packs_of(packs);
let text = &files["input.comp.md"];
let ambient = Ambient {
packs: ambient.iter().map(|pack| (*pack).to_string()).collect(),
report: false,
};
let parsed = parse(text, &ParseOptions::default());
let store = Store {
files: &files,
packs: &packs,
};
resolve(
"input.comp.md",
text.clone(),
parsed,
store,
&ambient,
&mut Parses::default(),
)
}
fn bound<'r>(resolved: &'r Resolved, name: &str) -> &'r Component {
let main = resolved.document(resolved.main);
let Some(Binding::Component(id, _)) = main.names.get(name) else {
panic!("`{name}` is bound");
};
resolved.component(*id)
}
#[test]
fn a_document_is_parsed_again_only_when_its_text_changed() {
let text = "---\nmd:\n import:\n - from: ./kit.comp.md\n names: [card]\n---\n\n:: card[x]\n";
let kit = |body: &str| format!("```compmd component=card\n---\n{body}\n```\n");
let mut known = Parses::default();
let mut resolve_with = |kit: &str| {
let files = HashMap::from([("kit.comp.md".to_string(), kit.to_string())]);
let store = Store {
files: &files,
packs: &(),
};
let parsed = parse(text, &ParseOptions::default());
let resolved = resolve(
"input.comp.md",
text.to_string(),
parsed,
store,
&Ambient::default(),
&mut known,
);
resolved
.ids()
.map(|id| Rc::clone(&resolved.document(id).root))
.collect::<Vec<_>>()
};
let first = resolve_with(&kit("One."));
let same = resolve_with(&kit("One."));
let changed = resolve_with(&kit("Two."));
assert_eq!(first.len(), 3, "core, the processed document and the kit");
let shared = |one: &[Rc<Root>], other: &[Rc<Root>]| {
one.iter()
.zip(other)
.filter(|(one, other)| Rc::ptr_eq(one, other))
.count()
};
assert_eq!(shared(&first, &same), 2, "core and the kit are kept");
assert_eq!(
shared(&first, &changed),
1,
"the changed kit is parsed again"
);
}
#[test]
fn core_is_built_in_even_when_the_host_has_a_pack_of_its_name() {
let impostor = json!({ "@compmd/core": [{ "version": "1.0.0", "files": {
"package.json": "{\"name\": \"@compmd/core\", \"version\": \"1.0.0\"}",
"index.comp.md": "Not core.\n",
} }] });
let packs = packs_of(impostor.clone());
let store = Store {
files: &(),
packs: &packs,
};
let manifest = Origin::Pack {
name: "@compmd/core".into(),
version: semver::Version::new(1, 0, 0),
path: "package.json".into(),
};
assert_eq!(store.read(&manifest), None, "the host isn't asked");
let resolved = resolved(
&[("input.comp.md", ":: box[B]\n")],
impostor,
&["@compmd/core"],
);
assert_eq!(resolved.documents.len(), 2, "the document and core, once");
let core = resolved.document(bound(&resolved, "box").document);
assert!(core.origin.is_core());
assert_eq!(core.text, CORE_TEXT);
}
#[test]
fn a_component_file_is_bound_with_its_signature_fence() {
let input = "---\nmd:\n import:\n - from: ./charts/Chart.jsx\n names: [{name: Chart, as: plot}]\n---\n\n```compmd component=plot signature\nparams:\n rows: array\n```\n";
let resolved = resolved(
&[
("input.comp.md", input),
("charts/Chart.jsx", "export default () => null;"),
],
json!({}),
&[],
);
let plot = bound(&resolved, "plot");
assert!(
matches!(&plot.body, Body::File { origin: Origin::File(path), .. } if path == "charts/Chart.jsx")
);
assert!(plot.signature.is_some() && plot.body.supported() && plot.body.is_host());
}
#[test]
fn a_packs_own_file_is_loaded_once() {
let kit = json!({ "@acme/kit": [{ "version": "2.0.0", "files": {
"package.json": "{\"name\": \"@acme/kit\", \"version\": \"2.0.0\"}",
"index.comp.md": "---\nmd:\n import:\n - from: ./parts.comp.md\n names: [part]\n export:\n names: [part]\n---\n",
"parts.comp.md": "---\nmd:\n export:\n names: \"*\"\n---\n\n```compmd component=part\n---\nPart.\n```\n",
} }] });
let input = "---\nmd:\n import:\n - from: \"@acme/kit@2\"\n names: [part]\n - from: \"@acme/kit\"\n names: \"*\"\n---\n";
let resolved = resolved(&[("input.comp.md", input)], kit, &[]);
let parts = resolved
.documents
.iter()
.filter(|document| document.origin.to_string() == "@acme/kit@2.0.0/parts.comp.md");
assert_eq!(parts.count(), 1);
}
#[test]
fn an_ambient_packs_newest_release_gives_the_processors_components() {
let release = |version: &str, component: &str| {
json!({ "version": version, "files": {
"package.json": format!("{{\"name\": \"@host/c\", \"version\": \"{version}\"}}"),
"index.comp.md": format!("---\nmd:\n export:\n names: \"*\"\n---\n\n```compmd component={component}\n---\nX.\n```\n\n```compmd component=tag signature\nparams: {{}}\n```\n"),
} })
};
let packs = json!({ "@host/c": [release("1.0.0", "old"), release("1.1.0", "new")] });
let resolved = resolved(&[("input.comp.md", ":: tag[T]\n")], packs, &["@host/c"]);
assert_eq!(bound(&resolved, "tag").body, Body::Processor);
assert_eq!(bound(&resolved, "new").name, "new");
assert!(resolved.document(resolved.main).names.get("old").is_none());
}
}