mirror of
https://github.com/Noratrieb/cargo-minimize.git
synced 2026-01-14 16:35:01 +01:00
aaa
This commit is contained in:
parent
002bad34ae
commit
a9e488f3e3
11 changed files with 433 additions and 40 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -155,6 +155,8 @@ dependencies = [
|
|||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustfix",
|
||||
"serde_json",
|
||||
"syn",
|
||||
"walkdir",
|
||||
]
|
||||
|
|
@ -965,9 +967,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.46"
|
||||
version = "1.0.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b"
|
||||
checksum = "e9d89e5dba24725ae5678020bf8f1357a9aa7ff10736b551adbcd3f8d17d766f"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
|
@ -1134,9 +1136,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.85"
|
||||
version = "1.0.90"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44"
|
||||
checksum = "8778cc0b528968fe72abec38b5db5a20a70d148116cd9325d2bc5f5180ca3faf"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ anyhow = "1.0.65"
|
|||
cargo = "0.65.0"
|
||||
clap = { version = "4.0.29", features = ["derive"] }
|
||||
prettyplease = "0.1.19"
|
||||
proc-macro2 = "1.0.46"
|
||||
proc-macro2 = { version = "1.0.48", features = ["span-locations"] }
|
||||
quote = "1.0.21"
|
||||
rustfix = "0.6.1"
|
||||
serde_json = "1.0.90"
|
||||
syn = { version = "1.0.101", features = ["full", "visit", "visit-mut"] }
|
||||
walkdir = "2.3.2"
|
||||
|
|
|
|||
54
src/build.rs
54
src/build.rs
|
|
@ -1,5 +1,6 @@
|
|||
use anyhow::{Context, Result};
|
||||
use std::{fmt::Display, path::PathBuf};
|
||||
use rustfix::diagnostics::Diagnostic;
|
||||
use std::{collections::HashSet, fmt::Display, path::PathBuf};
|
||||
|
||||
use crate::Options;
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ use crate::Options;
|
|||
pub struct Build {
|
||||
mode: BuildMode,
|
||||
input_path: PathBuf,
|
||||
no_verify: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -28,6 +30,7 @@ impl Build {
|
|||
Self {
|
||||
mode,
|
||||
input_path: options.path.clone(),
|
||||
no_verify: options.no_verify,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,19 +64,58 @@ impl Build {
|
|||
}
|
||||
};
|
||||
|
||||
Ok(BuildResult { reproduces_issue })
|
||||
Ok(BuildResult {
|
||||
reproduces_issue,
|
||||
no_verify: self.no_verify,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_suggestions(&self) -> Result<(Vec<Diagnostic>, Vec<rustfix::Suggestion>)> {
|
||||
match self.mode {
|
||||
BuildMode::Cargo => {
|
||||
todo!();
|
||||
}
|
||||
BuildMode::Script(_) => todo!(),
|
||||
BuildMode::Rustc => {
|
||||
let mut cmd = std::process::Command::new("rustc");
|
||||
cmd.args(["--edition", "2018", "--error-format=json"]);
|
||||
cmd.arg(&self.input_path);
|
||||
|
||||
let output = cmd.output()?.stderr;
|
||||
let output = String::from_utf8(output)?;
|
||||
|
||||
let diags = serde_json::Deserializer::from_str(&output).into_iter::<Diagnostic>().collect::<Result<_, _>>()?;
|
||||
|
||||
let suggestions = rustfix::get_suggestions_from_json(
|
||||
&output,
|
||||
&HashSet::new(),
|
||||
rustfix::Filter::Everything,
|
||||
)
|
||||
.context("reading output as rustfix suggestions")?;
|
||||
|
||||
Ok((diags, suggestions))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BuildResult {
|
||||
pub reproduces_issue: bool,
|
||||
reproduces_issue: bool,
|
||||
no_verify: bool,
|
||||
}
|
||||
|
||||
impl BuildResult {
|
||||
pub fn reproduces_issue(&self) -> bool {
|
||||
self.reproduces_issue || self.no_verify
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for BuildResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.reproduces_issue {
|
||||
true => f.write_str("yes"),
|
||||
false => f.write_str("no"),
|
||||
match (self.reproduces_issue, self.no_verify) {
|
||||
(true, _) => f.write_str("yes"),
|
||||
(false, true) => f.write_str("no (ignore)"),
|
||||
(false, false) => f.write_str("no"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
src/lib.rs
14
src/lib.rs
|
|
@ -8,11 +8,11 @@ mod expand;
|
|||
mod privatize;
|
||||
mod processor;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use processor::Minimizer;
|
||||
|
||||
use crate::{everybody_loops::EverybodyLoops, processor::Processor};
|
||||
use crate::{everybody_loops::EverybodyLoops, processor::Processor, privatize::Privatize};
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
pub struct Options {
|
||||
|
|
@ -20,6 +20,8 @@ pub struct Options {
|
|||
verify_error_path: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
cargo: bool,
|
||||
#[arg(long)]
|
||||
no_verify: bool,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -28,15 +30,19 @@ pub fn minimize() -> Result<()> {
|
|||
|
||||
let build = build::Build::new(&options);
|
||||
|
||||
let mut minimizer = Minimizer::new_glob_dir(&options.path, build);
|
||||
let mut minimizer = Minimizer::new_glob_dir(&options.path, build, &options);
|
||||
|
||||
println!("{minimizer:?}");
|
||||
|
||||
minimizer.delete_dead_code().context("deleting dead code")?;
|
||||
|
||||
minimizer.run_passes([
|
||||
//Box::new(Privarize::default()) as Box<dyn Processor>,
|
||||
Box::new(Privatize::default()) as Box<dyn Processor>,
|
||||
Box::new(EverybodyLoops::default()) as Box<dyn Processor>,
|
||||
])?;
|
||||
|
||||
minimizer.delete_dead_code().context("deleting dead code")?;
|
||||
|
||||
/*
|
||||
let file = expand::expand(&dir).context("during expansion")?;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ impl VisitMut for Visitor {
|
|||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Privarize {}
|
||||
pub struct Privatize {}
|
||||
|
||||
impl Processor for Privarize {
|
||||
impl Processor for Privatize {
|
||||
fn process_file(&mut self, krate: &mut syn::File, _: &mut ProcessChecker) -> bool {
|
||||
let mut visitor = Visitor::new();
|
||||
visitor.visit_file_mut(krate);
|
||||
|
|
|
|||
80
src/processor/files.rs
Normal file
80
src/processor/files.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use anyhow::{Context, Result};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SourceFile {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Changes {
|
||||
any_change: bool,
|
||||
}
|
||||
|
||||
pub struct FileChange<'a, 'b> {
|
||||
pub path: &'a Path,
|
||||
content: String,
|
||||
changes: &'b mut Changes,
|
||||
has_written_change: bool,
|
||||
}
|
||||
|
||||
impl FileChange<'_, '_> {
|
||||
pub fn before_content(&self) -> &str {
|
||||
&self.content
|
||||
}
|
||||
|
||||
pub fn write(&mut self, new: &str) -> Result<()> {
|
||||
self.has_written_change = true;
|
||||
fs::write(&self.path, new).with_context(|| format!("writing file {}", self.path.display()))
|
||||
}
|
||||
|
||||
pub fn rollback(mut self) -> Result<()> {
|
||||
assert!(self.has_written_change);
|
||||
self.has_written_change = false;
|
||||
fs::write(self.path, &self.content)
|
||||
.with_context(|| format!("writing file {}", self.path.display()))
|
||||
}
|
||||
|
||||
pub fn commit(mut self) {
|
||||
assert!(self.has_written_change);
|
||||
self.has_written_change = false;
|
||||
self.changes.any_change = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileChange<'_, '_> {
|
||||
fn drop(&mut self) {
|
||||
if self.has_written_change {
|
||||
fs::write(&self.path, self.before_content()).ok();
|
||||
if !std::thread::panicking() {
|
||||
panic!("File contains unsaved changes!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceFile {
|
||||
pub fn try_change<'file, 'change>(
|
||||
&'file self,
|
||||
changes: &'change mut Changes,
|
||||
) -> Result<FileChange<'file, 'change>> {
|
||||
let path = &self.path;
|
||||
Ok(FileChange {
|
||||
path,
|
||||
changes,
|
||||
has_written_change: false,
|
||||
content: fs::read_to_string(path)
|
||||
.with_context(|| format!("opening file {}", path.display()))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Changes {
|
||||
pub fn had_changes(&self) -> bool {
|
||||
self.any_change
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
mod files;
|
||||
mod reaper;
|
||||
|
||||
use std::{ffi::OsStr, path::Path};
|
||||
|
||||
use anyhow::{ensure, Context, Result};
|
||||
|
||||
use crate::build::Build;
|
||||
use crate::{build::Build, processor::files::Changes, Options};
|
||||
|
||||
use self::files::SourceFile;
|
||||
|
||||
pub trait Processor {
|
||||
fn process_file(&mut self, krate: &mut syn::File, checker: &mut ProcessChecker) -> bool;
|
||||
|
|
@ -15,12 +17,13 @@ pub trait Processor {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct Minimizer {
|
||||
files: Vec<PathBuf>,
|
||||
files: Vec<SourceFile>,
|
||||
build: Build,
|
||||
no_verify: bool,
|
||||
}
|
||||
|
||||
impl Minimizer {
|
||||
pub fn new_glob_dir(path: &Path, build: Build) -> Self {
|
||||
pub fn new_glob_dir(path: &Path, build: Build, options: &Options) -> Self {
|
||||
let walk = walkdir::WalkDir::new(path);
|
||||
|
||||
let files = walk
|
||||
|
|
@ -33,10 +36,16 @@ impl Minimizer {
|
|||
}
|
||||
})
|
||||
.filter(|entry| entry.path().extension() == Some(OsStr::new("rs")))
|
||||
.map(|entry| entry.into_path())
|
||||
.map(|entry| SourceFile {
|
||||
path: entry.into_path(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { files, build }
|
||||
Self {
|
||||
files,
|
||||
build,
|
||||
no_verify: options.no_verify,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_passes<'a>(
|
||||
|
|
@ -45,23 +54,24 @@ impl Minimizer {
|
|||
) -> Result<()> {
|
||||
let inital_build = self.build.build()?;
|
||||
println!("Initial build: {}", inital_build);
|
||||
if !self.no_verify {
|
||||
ensure!(
|
||||
inital_build.reproduces_issue,
|
||||
inital_build.reproduces_issue(),
|
||||
"Initial build must reproduce issue"
|
||||
);
|
||||
}
|
||||
|
||||
for mut pass in passes {
|
||||
'pass: loop {
|
||||
println!("Starting a round of {}", pass.name());
|
||||
let mut any_change = false;
|
||||
let mut changes = Changes::default();
|
||||
|
||||
for file in &self.files {
|
||||
let file_display = file.display();
|
||||
let file_display = file.path.display();
|
||||
|
||||
let before_string = std::fs::read_to_string(file)
|
||||
.with_context(|| format!("opening file {file_display}"))?;
|
||||
let mut change = file.try_change(&mut changes)?;
|
||||
|
||||
let mut krate = syn::parse_file(&before_string)
|
||||
let mut krate = syn::parse_file(change.before_content())
|
||||
.with_context(|| format!("parsing file {file_display}"))?;
|
||||
|
||||
let has_made_change = pass.process_file(&mut krate, &mut ProcessChecker {});
|
||||
|
|
@ -69,23 +79,23 @@ impl Minimizer {
|
|||
if has_made_change {
|
||||
let result = prettyplease::unparse(&krate);
|
||||
|
||||
std::fs::write(file, &result)?;
|
||||
change.write(&result)?;
|
||||
|
||||
let after = self.build.build()?;
|
||||
|
||||
println!("{file_display}: After {}: {after}", pass.name());
|
||||
|
||||
if after.reproduces_issue {
|
||||
any_change = true;
|
||||
if after.reproduces_issue() {
|
||||
change.commit();
|
||||
} else {
|
||||
std::fs::write(file, before_string)?;
|
||||
change.rollback()?;
|
||||
}
|
||||
} else {
|
||||
println!("{file_display}: After {}: no change", pass.name());
|
||||
}
|
||||
}
|
||||
|
||||
if !any_change {
|
||||
if !changes.had_changes() {
|
||||
println!("Finished {}", pass.name());
|
||||
break 'pass;
|
||||
}
|
||||
229
src/processor/reaper.rs
Normal file
229
src/processor/reaper.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
//! Deletes dead code.
|
||||
|
||||
use super::{files::Changes, Minimizer, Processor};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use proc_macro2::Span;
|
||||
use rustfix::{diagnostics::Diagnostic, Suggestion};
|
||||
use std::{collections::HashMap, ops::Range, path::Path};
|
||||
use syn::{visit_mut::VisitMut, ImplItem, Item};
|
||||
|
||||
fn file_for_suggestion(suggestion: &Suggestion) -> &str {
|
||||
&suggestion.solutions[0].replacements[0].snippet.file_name
|
||||
}
|
||||
|
||||
impl Minimizer {
|
||||
pub fn delete_dead_code(&mut self) -> Result<()> {
|
||||
let inital_build = self.build.build()?;
|
||||
println!("Before reaper: {}", inital_build);
|
||||
if !self.no_verify {
|
||||
ensure!(
|
||||
inital_build.reproduces_issue(),
|
||||
"Initial build must reproduce issue"
|
||||
);
|
||||
}
|
||||
|
||||
let (diags, suggestions) = self
|
||||
.build
|
||||
.get_suggestions()
|
||||
.context("getting suggestions from rustc")?;
|
||||
|
||||
let mut suggestions_for_file = HashMap::<_, Vec<_>>::new();
|
||||
for suggestion in &suggestions {
|
||||
suggestions_for_file
|
||||
.entry(file_for_suggestion(suggestion))
|
||||
.or_default()
|
||||
.push(suggestion);
|
||||
}
|
||||
|
||||
// Always unconditionally apply unused imports.
|
||||
self.apply_unused_imports(&suggestions_for_file)?;
|
||||
|
||||
self.run_passes([Box::new(DeleteUnusedFunctions(diags)) as Box<dyn Processor>])
|
||||
.context("deleting unused functions")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_unused_imports<'a>(
|
||||
&mut self,
|
||||
suggestions: &HashMap<&str, Vec<&Suggestion>>,
|
||||
) -> Result<()> {
|
||||
for (file, suggestions) in suggestions {
|
||||
let file = self
|
||||
.files
|
||||
.iter()
|
||||
.find(|source| source.path == Path::new(file))
|
||||
.expect("unknown file");
|
||||
|
||||
let mut changes = &mut Changes::default();
|
||||
|
||||
let mut change = file.try_change(&mut changes)?;
|
||||
|
||||
let desired_suggestions = suggestions
|
||||
.iter()
|
||||
.filter(|sugg| sugg.message.contains("unused import"))
|
||||
.cloned()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let result = rustfix::apply_suggestions(change.before_content(), &desired_suggestions)?;
|
||||
|
||||
change.write(&result)?;
|
||||
|
||||
let after = self.build.build()?;
|
||||
|
||||
println!("{}: After reaper: {after}", file.path.display());
|
||||
|
||||
if after.reproduces_issue() {
|
||||
change.commit();
|
||||
} else {
|
||||
change.rollback()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DeleteUnusedFunctions(Vec<Diagnostic>);
|
||||
|
||||
impl Processor for DeleteUnusedFunctions {
|
||||
fn process_file(&mut self, krate: &mut syn::File, _: &mut super::ProcessChecker) -> bool {
|
||||
let mut visitor = FindUnusedFunction::new(self.0.iter());
|
||||
visitor.visit_file_mut(krate);
|
||||
|
||||
visitor.has_change
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"delete-unused-functions"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Unused {
|
||||
name: String,
|
||||
line: usize,
|
||||
column: Range<usize>,
|
||||
}
|
||||
|
||||
impl Unused {
|
||||
fn span_matches(&self, ident_span: Span) -> bool {
|
||||
let (start, end) = (ident_span.start(), ident_span.end());
|
||||
|
||||
assert_eq!(start.line, end.line);
|
||||
|
||||
let line_matches = self.line == start.line;
|
||||
let column_matches = self.column.start <= start.column && self.column.end >= end.column;
|
||||
|
||||
line_matches && column_matches
|
||||
}
|
||||
}
|
||||
|
||||
struct FindUnusedFunction {
|
||||
unused_functions: Vec<Unused>,
|
||||
has_change: bool,
|
||||
}
|
||||
|
||||
impl FindUnusedFunction {
|
||||
fn new<'a>(diags: impl Iterator<Item = &'a Diagnostic>) -> Self {
|
||||
let unused_functions = diags
|
||||
.filter_map(|diag| {
|
||||
// FIXME: use `code` correctly
|
||||
if diag
|
||||
.code
|
||||
.as_ref()
|
||||
.map_or(false, |code| code.code != "dead_code")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if !diag.message.contains("function") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = diag.message.split("`").nth(1)?.to_owned();
|
||||
let span = &diag.spans[0];
|
||||
|
||||
assert_eq!(
|
||||
span.line_start, span.line_end,
|
||||
"encountered multiline span in dead_code"
|
||||
);
|
||||
|
||||
Some(Unused {
|
||||
name,
|
||||
line: span.line_start,
|
||||
column: (span.column_start - 1)..(span.column_end - 1),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
unused_functions,
|
||||
has_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_retain_item(&mut self, span: Span) -> bool {
|
||||
let span_matches = self
|
||||
.unused_functions
|
||||
.iter()
|
||||
.map(|a| a.span_matches(span))
|
||||
.filter(|&matches| matches)
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
span_matches < 2,
|
||||
"multiple dead_code spans matched identifier: {span_matches}"
|
||||
);
|
||||
|
||||
if span_matches == 1 {
|
||||
self.has_change = true;
|
||||
}
|
||||
|
||||
span_matches == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl VisitMut for FindUnusedFunction {
|
||||
fn visit_item_impl_mut(&mut self, item_impl: &mut syn::ItemImpl) {
|
||||
item_impl.items.retain(|item| match item {
|
||||
ImplItem::Method(method) => {
|
||||
let span = method.sig.ident.span();
|
||||
|
||||
self.should_retain_item(span)
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
|
||||
syn::visit_mut::visit_item_impl_mut(self, item_impl);
|
||||
}
|
||||
|
||||
fn visit_file_mut(&mut self, krate: &mut syn::File) {
|
||||
krate.items.retain(|item| match item {
|
||||
Item::Fn(func) => {
|
||||
let span = func.sig.ident.span();
|
||||
|
||||
self.should_retain_item(span)
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
|
||||
syn::visit_mut::visit_file_mut(self, krate);
|
||||
}
|
||||
|
||||
fn visit_item_mod_mut(&mut self, module: &mut syn::ItemMod) {
|
||||
if let Some((_, content)) = &mut module.content {
|
||||
content.retain(|item| match item {
|
||||
Item::Fn(func) => {
|
||||
let span = func.sig.ident.span();
|
||||
|
||||
self.should_retain_item(span)
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
syn::visit_mut::visit_item_mod_mut(self, module);
|
||||
}
|
||||
}
|
||||
7
test-cases/unused-code/Cargo.lock
generated
Normal file
7
test-cases/unused-code/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "hello-world"
|
||||
version = "0.1.0"
|
||||
10
test-cases/unused-code/Cargo.toml
Normal file
10
test-cases/unused-code/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "hello-world"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
5
test-cases/unused-code/src/main.rs
Normal file
5
test-cases/unused-code/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
fn unused() {}
|
||||
|
||||
fn main() {
|
||||
unused();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue