use std::path::{Path, PathBuf};
use skif_core::{FileSystem, Span};
use skif_js_syntax::{
ConfigValue, Import, ImportKind, ImportedKind, ImportedName, extract_imported_calls, is_declaration_file,
};
use crate::discover::{Candidate, relative};
use crate::setup::PackageSetup;
const SIGNATURE: &str = "Generated by unplugin-vue-components";
const PLUGIN_MODULES: &[&str] = &[
"unplugin-vue-components",
"unplugin-vue-components/vite",
"unplugin-vue-components/webpack",
"unplugin-vue-components/rollup",
"unplugin-vue-components/rolldown",
"unplugin-vue-components/esbuild",
"unplugin-vue-components/rspack",
];
const UNTAGGED_EXTENSIONS: &[&str] = &["tsx", "jsx"];
#[derive(Debug, Clone)]
pub(crate) struct Registered {
pub specifier: String,
pub file: PathBuf,
}
pub(crate) fn is_generated(path: &Path, source: &str) -> bool {
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
if !is_declaration_file(name) {
return false;
}
let head = source.char_indices().nth(300).map_or(source, |(at, _)| &source[..at]);
head.contains(SIGNATURE)
}
pub(crate) fn may_be_used_untagged(fs: &dyn FileSystem, package: &PackageSetup, candidates: &[Candidate]) -> bool {
let mut configs: Vec<&Path> = package.config_files.iter().map(PathBuf::as_path).collect();
for candidate in candidates.iter().filter(|candidate| candidate.owner == package.id) {
let Some(in_package) = relative(&candidate.abs, &package.dir) else {
continue;
};
let site = in_package.starts_with(".vitepress/")
|| in_package.contains("/.vitepress/")
|| in_package.starts_with(".vuepress/")
|| in_package.contains("/.vuepress/");
let untagged = candidate
.abs
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| UNTAGGED_EXTENSIONS.contains(&extension));
if site || (untagged && package.project.is_match(&in_package)) {
return true;
}
let vue = candidate
.abs
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("vue"));
if vue
&& fs
.read_to_string(&candidate.abs)
.is_ok_and(|source| has_untagged_block(&source))
{
return true;
}
if is_bundler_config(&in_package) {
configs.push(&candidate.abs);
}
}
configs.sort_unstable();
configs.dedup();
configs.into_iter().any(|path| {
fs.read_to_string(path)
.is_ok_and(|source| includes_more_than_vue(&source, path))
})
}
fn has_untagged_block(source: &str) -> bool {
["<template", "<script"].iter().any(|open| {
source.match_indices(open).any(|(at, _)| {
let rest = &source[at + open.len()..];
if !rest.starts_with(|c: char| c.is_whitespace()) {
return false;
}
let attributes = rest.split('>').next().unwrap_or_default();
match (*open, lang_of(attributes)) {
("<template", Some(lang)) => !lang.eq_ignore_ascii_case("html"),
("<script", Some(lang)) => lang.eq_ignore_ascii_case("tsx") || lang.eq_ignore_ascii_case("jsx"),
_ => false,
}
})
})
}
fn lang_of(attributes: &str) -> Option<&str> {
attributes.match_indices("lang").find_map(|(at, _)| {
let before = attributes[..at].chars().next_back();
if !before.is_some_and(char::is_whitespace) {
return None;
}
let value = attributes[at + "lang".len()..]
.trim_start()
.strip_prefix('=')?
.trim_start();
let quote = value.chars().next().filter(|c| matches!(c, '"' | '\''))?;
value[1..].split(quote).next()
})
}
fn is_bundler_config(in_package: &str) -> bool {
let name = in_package.rsplit('/').next().unwrap_or(in_package);
[
"vite.config.",
"vitest.config.",
"vue.config.",
"webpack.config.",
"rollup.config.",
"rspack.config.",
]
.iter()
.any(|prefix| name.starts_with(prefix))
}
fn includes_more_than_vue(source: &str, path: &Path) -> bool {
extract_imported_calls(source, path, PLUGIN_MODULES, &[])
.iter()
.any(|call| match call.arguments.first() {
None => false,
Some(options @ ConfigValue::Object(entries)) => options.get("include").map_or_else(
|| entries.iter().any(|(key, _)| key == "..."),
|include| !names_only_vue(include),
),
Some(_) => true,
})
}
fn names_only_vue(include: &ConfigValue) -> bool {
match include {
ConfigValue::Array(items) => items.iter().all(names_only_vue),
ConfigValue::Regex { pattern, .. } => pattern.split('|').all(|alternative| alternative.contains(r"\.vue")),
ConfigValue::String(glob) => Path::new(glob).extension().is_some_and(|extension| extension == "vue"),
_ => false,
}
}
pub(crate) fn import(name: &str, specifier: &str, first: bool) -> Import {
let names = if first {
vec![ImportedName {
kind: ImportedKind::Default,
local: Some(name.to_owned()),
is_type: false,
accessed_members: Vec::new(),
nested_members: std::collections::BTreeMap::new(),
escapes: true,
}]
} else {
Vec::new()
};
Import {
specifier: specifier.to_owned(),
kind: if first {
ImportKind::Static
} else {
ImportKind::SideEffect
},
names,
call: None,
span: Span::new(0, 0, 1, 1, 1, 1),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{has_untagged_block, includes_more_than_vue, is_bundler_config, is_generated};
#[test]
fn templates_in_other_languages_and_jsx_scripts_are_untagged_blocks() {
for untagged in [
"<template lang=\"pug\">\n PugCard(:x=\"x\")\n</template>\n",
"<template lang='jade'>\n a\n</template>\n",
"<script setup lang=\"tsx\">\nconst r = () => <InTemplateString />;\n</script>\n",
"<script lang = \"jsx\">\nexport default { render: () => <A /> };\n</script>\n",
] {
assert!(has_untagged_block(untagged), "{untagged}");
}
for tagged in [
"<template>\n <AppButton />\n</template>\n<script setup lang=\"ts\">\nconst a = 1;\n</script>\n",
"<template lang=\"html\">\n <AppButton />\n</template>\n",
"<template>\n <template v-if=\"x\"><slang /></template>\n</template>\n",
"<script setup>\nconst a = 1;\n</script>\n<templates lang=\"pug\"></templates>\n",
] {
assert!(!has_untagged_block(tagged), "{tagged}");
}
}
#[test]
fn generated_declarations_are_recognised_by_their_header() {
let header = "/* eslint-disable */\n// Generated by unplugin-vue-components\nexport {}\n";
assert!(is_generated(Path::new("/ws/components.d.ts"), header));
assert!(!is_generated(Path::new("/ws/components.ts"), header));
assert!(!is_generated(
Path::new("/ws/env.d.ts"),
"/// <reference types=\"vite/client\" />"
));
let late = format!("{}{header}", " ".repeat(300));
assert!(!is_generated(Path::new("/ws/components.d.ts"), &late));
}
#[test]
fn an_include_beyond_vue_files_or_unread_options_credit_every_component() {
let config = |plugin: &str| {
format!(
"import Components from 'unplugin-vue-components/vite';\nimport {{ defineConfig }} from 'vite';\nexport default defineConfig({{ plugins: [{plugin}] }});\n"
)
};
let path = Path::new("/ws/vite.config.ts");
for vue_only in [
"Components()",
"Components({ dts: true, dirs: ['src/components'] })",
"Components({ include: [/\\.vue$/, /\\.vue\\?vue/] })",
"Components({ include: '**/*.vue' })",
] {
assert!(!includes_more_than_vue(&config(vue_only), path), "{vue_only}");
}
for wider in [
"Components({ include: [/\\.vue$/, /\\.md$/] })",
"Components({ include: [/\\.vue$|\\.[tj]sx$/] })",
"Components({ include: ['**/*.vue', '**/*.md'] })",
"Components({ include: patterns })",
"Components(options)",
"Components({ ...options })",
] {
assert!(includes_more_than_vue(&config(wider), path), "{wider}");
}
assert!(!includes_more_than_vue("export default { plugins: [] };\n", path));
}
#[test]
fn bundler_configs_are_recognised_by_name() {
assert!(is_bundler_config("vite.config.ts"));
assert!(is_bundler_config("app/vue.config.js"));
assert!(!is_bundler_config("src/config.ts"));
}
}