use std::collections::BTreeSet;
use crate::diagnostic::{SchemaDiagnostic, Severity};
use crate::discover::join;
use crate::prepare::{Prepared, PreparedDefinition};
use crate::read::{DefinitionRead, Reading};
pub fn package(prepared: &mut Prepared) {
let skills = prepared
.definitions
.iter()
.map(|definition| (definition, Some(output_root(definition))))
.collect::<Vec<_>>();
let roots = skills
.iter()
.filter_map(|(_, dest)| *dest)
.collect::<Vec<_>>();
let found = check(&skills, &roots)
.into_iter()
.map(|(definition, diagnostic)| (definition.source_path.clone(), diagnostic))
.collect::<Vec<_>>();
for (source_path, diagnostic) in found {
prepared
.diagnostics
.entry(source_path)
.or_default()
.push(diagnostic);
}
}
fn output_root(definition: &PreparedDefinition) -> &str {
definition
.output_path
.strip_suffix("SKILL.md")
.map_or(&definition.output_path, |root| root.trim_end_matches('/'))
}
pub type Installed<'a> = (&'a PreparedDefinition, Option<&'a str>);
pub fn check<'a>(
skills: &[Installed<'a>],
installed: &[&str],
) -> Vec<(&'a PreparedDefinition, SchemaDiagnostic)> {
let mut found = Vec::new();
for &(definition, dest) in skills {
if let (
Reading::Skill(DefinitionRead {
body: Some(body), ..
}),
Some(dest),
Some(root),
) = (&definition.reading, dest, definition.skill_root.as_deref())
{
found.extend(
reference_diagnostics(root, dest, body, skills, installed)
.into_iter()
.map(|diagnostic| (definition, diagnostic)),
);
}
}
found
}
fn at_or_under(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|rest| rest.starts_with('/'))
}
fn reference_diagnostics(
root: &str,
dest: &str,
body: &str,
all: &[Installed<'_>],
installed: &[&str],
) -> Vec<SchemaDiagnostic> {
let mut seen = BTreeSet::new();
let mut diagnostics = Vec::new();
for target in link_targets(body) {
if !seen.insert(target.clone()) {
continue;
}
if resolve(dest, &target)
.is_some_and(|landing| installed.iter().any(|root| at_or_under(&landing, root)))
{
continue;
}
let owner = resolve(root, &target).and_then(|resolved| {
all.iter()
.filter_map(|&(other, other_dest)| {
Some((other, other_dest, other.skill_root.as_deref()?))
})
.filter(|(_, _, other_root)| {
other_root.is_empty() || at_or_under(&resolved, other_root)
})
.max_by_key(|(_, _, other_root)| other_root.len())
.map(|(other, other_dest, other_root)| (other, other_dest, other_root, resolved))
});
let Some((other, other_dest, other_root, resolved)) = owner else {
diagnostics.push(outside(&target));
continue;
};
let inside = resolved
.strip_prefix(other_root)
.unwrap_or("")
.trim_start_matches('/');
let what = if inside.is_empty() {
"the folder"
} else {
"a file"
};
let advice = match other_dest {
Some(other_dest) => format!(
"which does not resolve after install; link `{}` instead",
relative(dest, &join(other_dest, inside))
),
None => "which is not installed; import it or drop the link".to_owned(),
};
diagnostics.push(SchemaDiagnostic::new(
"skill.reference-to-skill",
Severity::Warning,
Vec::new(),
format!(
"links to `{target}`, {what} of skill `{}`, {advice}",
other.slug
),
));
}
diagnostics
}
fn outside(target: &str) -> SchemaDiagnostic {
SchemaDiagnostic::new(
"skill.outside-reference",
Severity::Warning,
Vec::new(),
format!("links to `{target}`, which is outside the skill and is not imported"),
)
}
fn resolve(root: &str, target: &str) -> Option<String> {
let mut parts = root
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
for part in target.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
part => parts.push(part),
}
}
Some(parts.join("/"))
}
fn relative(from: &str, to: &str) -> String {
let from = from
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
let to = to.split('/').collect::<Vec<_>>();
let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
let ups = std::iter::repeat_n("..", from.len() - common);
ups.chain(to[common..].iter().copied())
.collect::<Vec<_>>()
.join("/")
}
fn link_targets(body: &str) -> Vec<String> {
let mut targets = Vec::new();
let mut fence: Option<(char, usize)> = None;
let mut previous_blank = true;
let mut previous_code = false;
let mut in_list = false;
for line in body.lines() {
let trimmed = line.trim_start();
let blank = trimmed.is_empty();
let marker = trimmed.chars().next().filter(|c| matches!(c, '`' | '~'));
let run = marker.map_or(0, |m| trimmed.chars().take_while(|&c| c == m).count());
if let Some((open, length)) = fence {
if marker == Some(open) && run >= length && trimmed[run..].trim().is_empty() {
fence = None;
}
continue;
}
if let Some(marker) = marker
&& run >= 3
{
fence = Some((marker, run));
previous_blank = false;
previous_code = false;
continue;
}
let indented = line.starts_with(" ") || line.starts_with('\t');
let code = indented && !in_list && (previous_blank || previous_code);
previous_blank = blank;
previous_code = code;
if code {
continue;
}
if !blank && !indented {
in_list = is_list_item(trimmed);
}
let line = without_code_spans(line);
let mut rest = line.as_str();
while let Some(index) = rest.find("](") {
rest = &rest[index + 2..];
if let Some(end) = rest.find(')') {
targets.push(rest[..end].to_owned());
rest = &rest[end..];
}
}
let trimmed = line.trim_start();
if trimmed.starts_with('[')
&& let Some((_, target)) = trimmed.split_once("]:")
{
targets.push(target.trim().to_owned());
}
}
targets
.into_iter()
.filter_map(|target| clean(&target))
.collect()
}
fn is_list_item(trimmed: &str) -> bool {
let after_digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
let after_marker = if after_digits.len() < trimmed.len() {
after_digits.strip_prefix(['.', ')'])
} else {
trimmed.strip_prefix(['-', '*', '+'])
};
after_marker.is_some_and(|rest| rest.starts_with(' ') || rest.is_empty())
}
fn without_code_spans(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(start) = rest.find('`') {
let length = rest[start..].bytes().take_while(|&b| b == b'`').count();
let after = &rest[start + length..];
out.push_str(&rest[..start]);
match closing_run(after, length) {
Some(end) => {
out.push(' ');
rest = &after[end + length..];
}
None => {
out.push_str(&rest[start..start + length]);
rest = after;
}
}
}
out.push_str(rest);
out
}
fn closing_run(text: &str, length: usize) -> Option<usize> {
let mut from = 0;
while let Some(offset) = text[from..].find('`') {
let start = from + offset;
let run = text[start..].bytes().take_while(|&b| b == b'`').count();
if run == length {
return Some(start);
}
from = start + run;
}
None
}
fn clean(raw: &str) -> Option<String> {
let target = raw.trim().trim_start_matches('<');
let target = target.split([' ', '\t', '>']).next()?;
let target = target.split(['#', '?']).next()?;
let external = target.is_empty()
|| target.starts_with(['/', '$', '~'])
|| target.contains("://")
|| target.starts_with("mailto:");
(!external).then(|| target.to_owned())
}