diff --git a/src/documentation/wiki-package-database.ts b/src/documentation/wiki-package-database.ts index 1ecdea058b9..f2c15277216 100644 --- a/src/documentation/wiki-package-database.ts +++ b/src/documentation/wiki-package-database.ts @@ -32,7 +32,7 @@ export class WikiPackageDatabase extends DocMaker<'wiki/Package Database.md'> { flowR ships a database of CRAN package exports so it can resolve calls into the packages you load. After \`library(ggplot2)\`, a call to \`ggplot()\` resolves to \`ggplot2::ggplot\`. This also backs qualified names (a bare \`map()\` after \`library(purrr)\` is \`purrr::map\`, not the \`maps\` plot), the -${ctx.linkPage('wiki/Query API', 'dependencies and call-context queries')}, and the ${ctx.linkPage('wiki/Linter', '`undefined-symbol` rule')}. +${ctx.linkPage('wiki/Query API', 'dependencies and call-context queries')}, the ${ctx.linkPage('wiki/Linter', '`undefined-symbol` rule')}, and the ${ctx.linkPage('wiki/Linter', '`unused-import` rule')}. ## Configuration diff --git a/src/linter/linter-rules.ts b/src/linter/linter-rules.ts index f1a32f788b4..6a09c9f78a9 100644 --- a/src/linter/linter-rules.ts +++ b/src/linter/linter-rules.ts @@ -16,6 +16,7 @@ import { SOFTWARE_HAS_LICENSE } from './rules/software-has-license'; import { SOFTWARE_HAS_TESTS } from './rules/software-has-tests'; import { NO_LEAKED_CREDENTIALS } from './rules/no-leaked-credentials'; import { UNDEFINED_SYMBOL } from './rules/undefined-symbol'; +import { UNUSED_IMPORT } from './rules/unused-import'; /** * The registry of currently supported linting rules. @@ -38,7 +39,8 @@ export const LintingRules = { 'software-has-license': SOFTWARE_HAS_LICENSE, 'software-has-tests': SOFTWARE_HAS_TESTS, 'no-leaked-credentials': NO_LEAKED_CREDENTIALS, - 'undefined-symbol': UNDEFINED_SYMBOL + 'undefined-symbol': UNDEFINED_SYMBOL, + 'unused-import': UNUSED_IMPORT } as const; export type LintingRuleNames = keyof typeof LintingRules; diff --git a/src/linter/rules/unused-import.ts b/src/linter/rules/unused-import.ts new file mode 100644 index 00000000000..6414da33e8f --- /dev/null +++ b/src/linter/rules/unused-import.ts @@ -0,0 +1,114 @@ +import { + LintingPrettyPrintContext, + type LintingResult, + LintingResultCertainty, + type LintingRule, + LintingRuleCertainty +} from '../linter-format'; +import { SourceLocation } from '../../util/range'; +import type { MergeableRecord } from '../../util/objects'; +import { Q } from '../../search/flowr-search-builder'; +import { LintingRuleTag } from '../linter-tags'; +import { isNotUndefined } from '../../util/assert'; +import type { Writable } from 'ts-essentials'; +import { getOriginInDfg, OriginType } from '../../dataflow/origin/dfg-get-origin'; +import { DfEdge, EdgeType } from '../../dataflow/graph/edge'; +import type { NodeId } from '../../r-bridge/lang-4.x/ast/model/processing/node-id'; +import type { PkgDb } from '../../project/plugins/package-version-plugins/pkgdb'; +import { Enrichment } from '../../search/search-executor/search-enrichers'; +import { DependencyInfo } from '../../queries/catalog/dependencies-query/dependencies-query-format'; + +export interface UnusedImportResult extends LintingResult{ + readonly version: [string, string] +} + +export interface UnusedImportConfig extends MergeableRecord { + /* Packages that only work on load and should therefore not be considered */ + whitelist: string[] +}; + +export type UnusedImportMetadata = MergeableRecord; + +/** + * Flags imported functions that are not required for the code to run. We assume this applies to packages that do not have + * an ingoing reads-edge. We only consider packages that could be resolved from the `flowr-pkgdb` database. + */ +export const UNUSED_IMPORT = { + createSearch: () => Q.fromQuery({ type: 'dependencies', 'enabledCategories': ['library'] }), + processSearchResult: async(elements, config, data) => { + const dataflow = await data.dataflow(); + // needs a package database to compute + if(data.inspectContext().deps.loadedPackageDatabases().length === 0){ + return { results: [], '.meta': {} }; + } + const dependencyToVersion = Object.entries((config.pkgDb as PkgDb).pkgs).reduce((map, entry) => { + map.set(entry[0], entry[1][0]); + return map; + }, new Map()); + const whitelist = new Set(config.whitelist); + const unknownIds = new Set(dataflow.graph.unknownSideEffects.values().map(e => { return typeof e === 'object' && 'id' in e ? e.id : e })); + let uncalledLib = elements.getElements().filter(element => { + if(unknownIds.has(element.node.info.id)){ + return false; + } + const origins = getOriginInDfg(dataflow.graph, element.node.info.id); + if(isNotUndefined(origins)) { + const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin); + if(!builtIn){ + return false; + } + } + return true; + }); + //todo:idetifier get namespace + //packagedb kann :: erkennen, so kann man die libraries direkt rausfiltern + //dependency query additionalAnalysis function + // -> eigene catergorie mit den drei von additionalAnalysis und dann bekommt man die mit den punkten gar nicht überhaupt + //Todo: über identifier gehen um zu gucken ob es sich um einen identifier handel anstatt:: + //all NodeIds that have an ingoing read-edge + const readEdges = new Set(dataflow.graph.edges().flatMap(e => e[1].entries()).filter(entry => { + //nodes with "::" are definitely read + if(typeof entry[0] === 'string' && entry[0].includes('::')/*Identifier.getNamespace(entry[1] as unknown as Identifier*/){ + return true; + } else if(DfEdge.includesType(entry[1], EdgeType.Reads)){ + return true; + } else{ + return false; + } + }).map(e => e[0])); + + uncalledLib = uncalledLib.filter(element => !readEdges.has(element.node.info.id)); + const uncalledLibSet = new Set(uncalledLib.map(element => element.node.info.id)); + const idToDependecyName = (elements.enrichmentContent(Enrichment.QueryData).queries as { dependencies: { library: DependencyInfo[] } }).dependencies.library.filter(element => uncalledLibSet.has(element.nodeId) && isNotUndefined(element.value) && !whitelist.has(element.value)) + .reduce((map, element) => { + map.set(element.nodeId, element.value); + return map; + }, new Map()); + return { + results: + uncalledLib.filter(element => { + //check that lib not whitelisted + return idToDependecyName.has(element.node.info.id); + }).map(element => ({ + certainty: LintingResultCertainty.Uncertain, + involvedId: element.node.info.id, + loc: SourceLocation.fromNode(element.node), + version: [idToDependecyName.get(element.node.info.id), dependencyToVersion.get(idToDependecyName.get(element.node.info.id))] + })).filter(element => isNotUndefined(element.loc)) as Writable[], + '.meta': {} + }; + }, + prettyPrint: { + [LintingPrettyPrintContext.Query]: result => `Import at ${SourceLocation.format(result.loc)}`, + [LintingPrettyPrintContext.Full]: result => `Import at ${SourceLocation.format(result.loc)} is unused. Used version is ${result.version.join()}.` + }, + info: { + name: 'Unused Import', + tags: [LintingRuleTag.Smell, LintingRuleTag.Readability], + certainty: LintingRuleCertainty.BestEffort, + description: 'Flags imported packages that are not required for the code to run. Packages that are only used on load might be mistaken as such and should therefore be added to the whitelist in the configuration.', + defaultConfig: { + whitelist: [] + } + } +} as const satisfies LintingRule; \ No newline at end of file diff --git a/test/functionality/linter/lint-unused-import.test.ts b/test/functionality/linter/lint-unused-import.test.ts new file mode 100644 index 00000000000..be0187d9537 --- /dev/null +++ b/test/functionality/linter/lint-unused-import.test.ts @@ -0,0 +1,61 @@ +import { describe } from 'vitest'; +import { assertLinter } from '../_helper/linter'; +import { withTreeSitter } from '../_helper/shell'; +import { PkgDbBuilder } from '../../../src/project/plugins/package-version-plugins/pkgdb'; +import { LintingResultCertainty } from '../../../src/linter/linter-format'; + +const b = new PkgDbBuilder(); +b.addPackage('ggplot2', { latest: '3.5.1' }); +b.addVersion('ggplot2', '3.5.1', { exported: ['ggplot', 'aes', 'geom_point'], internal: [], deprecated: [], cran: true }); +b.addPackage('random1', { latest: '1.1.0' }); +b.addVersion('random1', '1.0.0', { exported: ['test1', 'test2'], internal: [], deprecated: [], cran: true }); +b.addVersion('random1', '1.1.0', { exported: ['test1', 'test3'], internal: [], deprecated: [], cran: true }); +b.addPackage('p', { latest: '1.0' }); +b.addVersion('p', '1.0', { exported: ['f'], internal: [], deprecated: [], cran: true }); +const pkg = b.build('all', { version: 1, date: '2026-05-23', generated: 0 }); + + +describe('flowR linter', withTreeSitter(parser => { + describe('unused import', () => { + assertLinter('Unused Import', parser, 'library(ggplot2)', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [1, 1, 1, 16], + version: ['ggplot2', '3.5.1'] + }, + ], undefined, { pkgDb: pkg }); + assertLinter('Used and unused imports', parser, 'library(p)\nlibrary(ggplot2)\nlibrary(random1)\nggplot()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [1, 1, 1, 10], + version: ['p', '1.0'] + }, + { + certainty: LintingResultCertainty.Uncertain, + loc: [3, 1, 3, 16], + version: ['random1', '1.1.0'] + }, + ], undefined, { pkgDb: pkg }); + assertLinter('Used and unused imports with require', parser, 'require(ggplot2)\nrequire(random1)\naes()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [2, 1, 2, 16], + version: ['random1', '1.1.0'] + }, + ], undefined, { pkgDb: pkg }); + assertLinter('Not in package database', parser, 'library(ggplot2)\nlibrary(random1)\nlibrary(notInDb)\naes()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [2, 1, 2, 16], + version: ['random1', '1.1.0'] + } + ], undefined, { pkgDb: pkg }); + assertLinter('Whitelisted package', parser, 'require(p)\nrequire(ggplot2)\nrequire(random1)\naes()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [1, 1, 1, 10], + version: ['p', '1.0'] + }, + ], undefined, { pkgDb: pkg, whitelist: ['random1'] }); + }); +})); \ No newline at end of file