skif-js 0.4.0

JavaScript and TypeScript module graph and dependency rules for skif, built on oxc.
Documentation
//! Components that `unplugin-vue-components` registers globally (ADR 0020).
//!
//! The generated `components.d.ts` names every component of the project, used or not. knip compiles each `.vue` file
//! against it and adds an import of a registered component only where the template uses its tag, so a component no
//! template uses is an unused file. skif does the same: the declaration file's `import()` types of local components
//! are no edges, and each module whose `template_tags` name a component imports it.
//!
//! skif records tags only for HTML `.vue` templates and `resolveComponent` calls. Where a package may use its
//! components elsewhere (TSX and JSX files, VitePress pages, an `include` of the plugin that names more than `.vue`, a
//! `.vue` template in another language such as Pug, a `.vue` script block in TSX or JSX), the declaration file keeps
//! its edges and credits every component it registers, as before: a use skif cannot read is assumed rather than a
//! file reported unused that a template uses.

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;

/// The header `unplugin-vue-components` writes into the declaration files it generates.
const SIGNATURE: &str = "Generated by unplugin-vue-components";

/// The modules the plugin is imported from in a bundler config.
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",
];

/// The extensions of project files that may use a registered component without a tag skif records.
const UNTAGGED_EXTENSIONS: &[&str] = &["tsx", "jsx"];

/// A file a component is registered from.
#[derive(Debug, Clone)]
pub(crate) struct Registered {
    /// The specifier as the declaration file writes it.
    pub specifier: String,
    pub file: PathBuf,
}

/// Whether `source`, the text of `path`, is a declaration file `unplugin-vue-components` generated: knip looks for its
/// header in the first 300 characters.
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)
}

/// Whether the components `package` registers may be used in files skif records no tags for, so its declaration
/// files credit every component they register: the package has `.tsx` or `.jsx` project files, a `.vue` file whose
/// template or script skif reads no tags in ([`has_untagged_block`]), or a VitePress or `VuePress` site (whose `.md`
/// pages Vue compiles), or a config passes the plugin an `include` that names more than `.vue` files, or options skif
/// cannot read.
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))
    })
}

/// Whether the `.vue` `source` has a block whose component uses skif records no tags for: a `<template>` in a language
/// other than HTML (`lang="pug"`) or a `<script>` in TSX or JSX, whose render functions name components as JSX.
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,
            }
        })
    })
}

/// The value of the `lang` attribute among a tag's `attributes`.
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()
    })
}

/// A bundler config the plugin may be passed in, by its path in the package.
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))
}

/// Whether a call of the plugin in the config `source` passes an `include` that names more than `.vue` files, or
/// options skif cannot read. Without options or an `include`, the plugin transforms `.vue` files only.
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,
        })
}

/// Whether the `include` value of the plugin's options matches only `.vue` files: regular expressions each of whose
/// alternatives names `.vue` (`/\.vue$/`, `/\.vue\?vue/`) and globs ending in `.vue`.
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,
    }
}

/// The import of the component `name` a template using it gets: the default export of its first file, as knip's
/// `import { default as AppButton }`; a further file the name is registered from is imported for its side effects.
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"));
    }
}