Skip to content

Add support for resolveFromAST in plugins #1005

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 8, 2025
Merged

Add support for resolveFromAST in plugins #1005

merged 15 commits into from
Apr 8, 2025

Conversation

webpro
Copy link
Member

@webpro webpro commented Mar 24, 2025

Add support for resolveFromAST in plugins so we can use the source file AST directly (i.e. without loading the file and receiving the resolved localConfig).

Updated plugin guide: https://feat-plugin-ast.knip.pages.dev/guides/writing-a-plugin#example-4-use-the-ast-directly

New plugins:

Extended plugins:

  • Astro (new plugin: Starlight)
  • Next.js

For the existing resolveEntryPaths and resolveFromConfig functions that plugins can implement, Knip loads the tool's configuration file and passes the resulting object to this plugin function. This works well for most cases, but has some drawbacks:

  • Potentially slow as it may import lots of dependencies itself
  • Error-prone as the config files may depend on context such as environment variables being set
  • Information may be missing once resolved i.e. the returning object might no longer contain references we want

An example vite.config.ts:

import { defineConfig } from 'vite';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';

export default defineConfig({
  plugins: [
    TanStackRouterVite({
      routesDirectory: './routes',
      generatedRouteTree: './gen/routeTree.gen.ts',
    }),
  ],
});

We want to know the values of routesDirectory and generatedRouteTree, but if we import('vite.config.ts') then this information is missing from the default-exported value that we pass on to resolveEntryPaths and resolveFromConfig.

The vite.config.ts is already part of the module graph and it's import statements need to be parsed anyway (so we know vite and @tanstack/router-plugin/vite are dependencies that should be listed in package.json), so why not make this available to plugins as well?

This way, plugins can create a custom function to extract the information, in this case the (new) TanStack Router plugin can return non-default entry paths (./routes/**/*.{ts,tsx}) and a non-default path for generatedRouteTree so users don't need to configure such entry patterns manually in knip.json. This was previously impossible!

A downside is that the "DSL" here is the TS API, which is powerful but also tedious. Another downside is that the plugins can start using typescript dependency which has a large API surface and reduces their compactness and simplicity, which in turn requires more governance.

Here's example code to do what we just described:

import ts from 'typescript';

const CONFIG_KEYS = new Set([
  'routeFilePrefix',
  'routeFileIgnorePrefix',
  'routeFileIgnorePattern',
  'routesDirectory',
  'generatedRouteTree',
]);

const FUNCTIONS = new Set(['TanStackRouterVite', 'TanStackRouterRspack', 'TanStackRouterWebpack']);

export const getCustomConfig = (sourceFile: ts.SourceFile) => {
  const config: Record<string, string> = {};

  function visit(node: ts.Node) {
    if (ts.isCallExpression(node)) {
      const callee = node.expression;
      if (ts.isIdentifier(callee) && FUNCTIONS.has(callee.text)) {
        const firstArg = node.arguments[0];
        if (ts.isObjectLiteralExpression(firstArg)) {
          for (const prop of firstArg.properties) {
            if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
              if (CONFIG_KEYS.has(prop.name.text)) {
                if (ts.isStringLiteral(prop.initializer)) {
                  config[prop.name.text] = prop.initializer.text;
                }
              }
            }
          }
        }
      }
    }

    ts.forEachChild(node, visit);
  }

  visit(sourceFile);

  return config;
};

Also see the other resolveFromAST.ts files in this PR for more examples.

There are no utilities for this type of querying yet, but I can imagine we'll often want to do things like "get the string values of certain literal object properties" so that could be a next step once we figure out our needs better.

Edit: added a few helpers already 😇 so also the example above can be implemented more concise now

Edit 2: also see #1009 that has the same plugins using tsquery

Copy link

pkg-pr-new bot commented Mar 24, 2025

Open in StackBlitz

npm i https://pkg.pr.new/knip@1005

commit: 8c0a7fe

This was referenced Mar 24, 2025
Comment on lines 364 to 387
if (plugin.resolveEntryPaths) {
if (!loadedConfig) loadedConfig = await loadConfigForPlugin(configFilePath, plugin, opts, pluginName);
if (loadedConfig) {
const inputs = await plugin.resolveEntryPaths(loadedConfig, opts);
for (const input of inputs) addInput(input, configFilePath);
}
}

if (plugin.resolveConfig) {
if (!loadedConfig) loadedConfig = await loadConfigForPlugin(configFilePath, plugin, opts, pluginName);
if (loadedConfig) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing that both plugin.resolveEntryPaths and plugin.resolveConfig attempt to define loadedConfig makes me think they are mutually exclusive properties (don't make sense when specified together in plugin). Is that true? If so, maybe it could be helpful to model the type of plugin to eliminate the invalid state where both can be passed on the same time. I'm just spitballing here really and could be completely off, also this could not matter at all :) I kind of lack the bigger picture. I'll comment on everything where I have some thought. Feel free to disregard any of my comments

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveEntryPaths and resolveConfig can both be implemented as they server different purposes. They might get merged one day now that we have all the "inputs" (onEntry etc), but that'd be for another day. So what I was trying to do here is "load if need, and skip if not".


// Unbound sourceFile.imports isn't available, maybe create helper
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
Copy link
Contributor

@tryggvigy tryggvigy Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we want to care about require call expressions here?

import('foo') seems to be a ImportKeyword in TS AST as well. ts.isImportDeclaration probably doesn't catch those

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is specific to SST, so probably not.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well SST can be used with require or with import...

Copy link
Member Author

@webpro webpro Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One does not generally use CommonJS syntax in TS files (sst.config.ts). Or is e.g. sst.config.cjs also supported?

Would be easy to add, though :)

Comment on lines +39 to +27
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'stack'
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's another dep which is not great but I wonder if you want something like https://github.com/phenomnomnominal/tsquery to make the AST branching logic a bit more terse.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When building queries https://astexplorer.net/ and https://tsquery-playground.firebaseapp.com/
are very useful.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've actually looked at tsquery, but what we need is a few matchers, so at this point I'm thinking to create those as we go (indeed, without pulling in another dep).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for sharing those, I often use https://ts-ast-viewer.com

Copy link

cloudflare-workers-and-pages bot commented Mar 26, 2025

Deploying knip with  Cloudflare Pages  Cloudflare Pages

Latest commit: 079263b
Status: ✅  Deploy successful!
Preview URL: https://2ee6f1f4.knip.pages.dev
Branch Preview URL: https://feat-plugin-ast.knip.pages.dev

View logs

@webpro webpro force-pushed the feat/plugin-ast branch 2 times, most recently from d654708 to 6b79f5a Compare April 2, 2025 07:49
@webpro webpro force-pushed the feat/plugin-ast branch from 6b79f5a to ce5a619 Compare April 8, 2025 07:01
@webpro webpro force-pushed the feat/plugin-ast branch from ce5a619 to 079263b Compare April 8, 2025 07:09
@webpro webpro marked this pull request as ready for review April 8, 2025 07:11
@webpro webpro merged commit 17a6f3c into main Apr 8, 2025
47 checks passed
@webpro webpro deleted the feat/plugin-ast branch April 8, 2025 07:26
@webpro
Copy link
Member Author

webpro commented Apr 8, 2025

Confident to go in this direction, hence the merge to main.

@webpro
Copy link
Member Author

webpro commented Apr 9, 2025

🚀 This pull request is included in v5.48.0. See Release 5.48.0 for release notes.

Using Knip in a commercial project? Please consider becoming a sponsor.

webpro added a commit that referenced this pull request Apr 9, 2025
# Conflicts:
#	packages/knip/src/graph/build.ts
#	packages/knip/src/plugins/ava/index.ts
#	packages/knip/src/plugins/jest/index.ts
#	packages/knip/src/plugins/storybook/index.ts
#	packages/knip/src/util/plugin.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants