Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/documentation/wiki-package-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/linter/linter-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down
110 changes: 110 additions & 0 deletions src/linter/rules/unused-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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';

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(!(config.pkgDb !== null && typeof config.pkgDb === 'object' && 'format' in config.pkgDb && config.pkgDb.format === 'flowr-pkgdb' && 'pkgs' in config.pkgDb && (config.pkgDb as PkgDb).pkgs !== null)){
return { results: [], '.meta': {} };
}
const whitelist = new Set(config.whitelist);
const unknownIds = new Set<NodeId>();
for(const e of dataflow.graph.unknownSideEffects) {
unknownIds.add(typeof e === 'object' && 'id' in e ? e.id : e);
}
const libraryCalls = elements.enrichmentContent(Enrichment.QueryData).queries['dependencies'].library
.filter(element => {
//packages that could not be resolved from package database
if(unknownIds.has(element.nodeId)){
return false;
}
const origins = getOriginInDfg(dataflow.graph, element.nodeId);
if(isNotUndefined(origins)) {
const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin);
if(!builtIn){
return false;
}
}
return true;
});
//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('::')){
return true;
} else if(DfEdge.includesType(entry[1], EdgeType.Reads)){
return true;
} else{
return false;
}
}).map(e => e[0]));

const dependencyToVersion = Object.entries((config.pkgDb as PkgDb).pkgs).reduce((map, entry) => {
map.set(entry[0], entry[1][0]);
return map;
}, new Map());
const idToDependecyName = libraryCalls.filter(element => !readEdges.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:
elements.getElements().filter(element => {
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<UnusedImportResult>[],
'.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<UnusedImportResult, UnusedImportMetadata, UnusedImportConfig>;
61 changes: 61 additions & 0 deletions test/functionality/linter/lint-unused-import.test.ts
Original file line number Diff line number Diff line change
@@ -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'] });
});
}));
Loading