mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-26 20:10:09 +00:00
Add a lint rule to enforce noqa validity (#253)
This commit is contained in:
parent
32e62d9209
commit
732f208e47
7 changed files with 199 additions and 32 deletions
|
@ -209,6 +209,7 @@ Beyond rule-set parity, ruff suffers from the following limitations vis-à-vis F
|
||||||
| F901 | RaiseNotImplemented | `raise NotImplemented` should be `raise NotImplementedError` |
|
| F901 | RaiseNotImplemented | `raise NotImplemented` should be `raise NotImplementedError` |
|
||||||
| R001 | UselessObjectInheritance | Class `...` inherits from object |
|
| R001 | UselessObjectInheritance | Class `...` inherits from object |
|
||||||
| R002 | NoAssertEquals | `assertEquals` is deprecated, use `assertEqual` instead |
|
| R002 | NoAssertEquals | `assertEquals` is deprecated, use `assertEqual` instead |
|
||||||
|
| M001 | UnusedNOQA | Unused `noqa` directive |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
16
resources/test/fixtures/M001.py
vendored
Normal file
16
resources/test/fixtures/M001.py
vendored
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
def f() -> None:
|
||||||
|
# Valid
|
||||||
|
a = 1 # noqa
|
||||||
|
|
||||||
|
# Valid
|
||||||
|
b = 2 # noqa: F841
|
||||||
|
|
||||||
|
# Invalid
|
||||||
|
c = 1 # noqa
|
||||||
|
print(c)
|
||||||
|
|
||||||
|
# Invalid
|
||||||
|
d = 1 # noqa: E501
|
||||||
|
|
||||||
|
# Invalid
|
||||||
|
d = 1 # noqa: F841, E501
|
|
@ -1,6 +1,7 @@
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use rustpython_parser::ast::Location;
|
use rustpython_parser::ast::Location;
|
||||||
|
|
||||||
use crate::checks::{Check, CheckCode, CheckKind};
|
use crate::checks::{extract_noqa_directive, Check, CheckCode, CheckKind, Directive};
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
/// Whether the given line is too long and should be reported.
|
/// Whether the given line is too long and should be reported.
|
||||||
|
@ -21,15 +22,31 @@ fn should_enforce_line_length(line: &str, length: usize, limit: usize) -> bool {
|
||||||
|
|
||||||
pub fn check_lines(checks: &mut Vec<Check>, contents: &str, settings: &Settings) {
|
pub fn check_lines(checks: &mut Vec<Check>, contents: &str, settings: &Settings) {
|
||||||
let enforce_line_too_long = settings.select.contains(&CheckCode::E501);
|
let enforce_line_too_long = settings.select.contains(&CheckCode::E501);
|
||||||
|
let enforce_noqa = settings.select.contains(&CheckCode::M001);
|
||||||
|
|
||||||
let mut line_checks = vec![];
|
let mut line_checks = vec![];
|
||||||
let mut ignored = vec![];
|
let mut ignored = vec![];
|
||||||
for (row, line) in contents.lines().enumerate() {
|
for (row, line) in contents.lines().enumerate() {
|
||||||
|
let noqa_directive = Lazy::new(|| extract_noqa_directive(line));
|
||||||
|
let mut line_ignored: Vec<&str> = vec![];
|
||||||
|
|
||||||
// Remove any ignored checks.
|
// Remove any ignored checks.
|
||||||
// TODO(charlie): Only validate checks for the current line.
|
// TODO(charlie): Only validate checks for the current line.
|
||||||
for (index, check) in checks.iter().enumerate() {
|
for (index, check) in checks.iter().enumerate() {
|
||||||
if check.location.row() == row + 1 && check.is_inline_ignored(line) {
|
if check.location.row() == row + 1 {
|
||||||
ignored.push(index);
|
match &*noqa_directive {
|
||||||
|
Directive::All(_) => {
|
||||||
|
line_ignored.push(check.kind.code().as_str());
|
||||||
|
ignored.push(index)
|
||||||
|
}
|
||||||
|
Directive::Codes(_, codes) => {
|
||||||
|
if codes.contains(&check.kind.code().as_str()) {
|
||||||
|
line_ignored.push(check.kind.code().as_str());
|
||||||
|
ignored.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Directive::None => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,11 +58,46 @@ pub fn check_lines(checks: &mut Vec<Check>, contents: &str, settings: &Settings)
|
||||||
CheckKind::LineTooLong(line_length, settings.line_length),
|
CheckKind::LineTooLong(line_length, settings.line_length),
|
||||||
Location::new(row + 1, settings.line_length + 1),
|
Location::new(row + 1, settings.line_length + 1),
|
||||||
);
|
);
|
||||||
if !check.is_inline_ignored(line) {
|
match &*noqa_directive {
|
||||||
line_checks.push(check);
|
Directive::All(_) => {
|
||||||
|
line_ignored.push(check.kind.code().as_str());
|
||||||
|
}
|
||||||
|
Directive::Codes(_, codes) => {
|
||||||
|
if codes.contains(&check.kind.code().as_str()) {
|
||||||
|
line_ignored.push(check.kind.code().as_str());
|
||||||
|
} else {
|
||||||
|
line_checks.push(check);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Directive::None => line_checks.push(check),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce that the noqa was actually used.
|
||||||
|
if enforce_noqa {
|
||||||
|
match &*noqa_directive {
|
||||||
|
Directive::All(column) => {
|
||||||
|
if line_ignored.is_empty() {
|
||||||
|
line_checks.push(Check::new(
|
||||||
|
CheckKind::UnusedNOQA(None),
|
||||||
|
Location::new(row + 1, column + 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Directive::Codes(column, codes) => {
|
||||||
|
for code in codes {
|
||||||
|
if !line_ignored.contains(code) {
|
||||||
|
line_checks.push(Check::new(
|
||||||
|
CheckKind::UnusedNOQA(Some(code.to_string())),
|
||||||
|
Location::new(row + 1, column + 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Directive::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ignored.sort();
|
ignored.sort();
|
||||||
for index in ignored.iter().rev() {
|
for index in ignored.iter().rev() {
|
||||||
|
|
113
src/checks.rs
113
src/checks.rs
|
@ -6,7 +6,7 @@ use regex::Regex;
|
||||||
use rustpython_parser::ast::Location;
|
use rustpython_parser::ast::Location;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const ALL_CHECK_CODES: [CheckCode; 44] = [
|
pub const DEFAULT_CHECK_CODES: [CheckCode; 42] = [
|
||||||
CheckCode::E402,
|
CheckCode::E402,
|
||||||
CheckCode::E501,
|
CheckCode::E501,
|
||||||
CheckCode::E711,
|
CheckCode::E711,
|
||||||
|
@ -49,6 +49,52 @@ pub const ALL_CHECK_CODES: [CheckCode; 44] = [
|
||||||
CheckCode::F831,
|
CheckCode::F831,
|
||||||
CheckCode::F841,
|
CheckCode::F841,
|
||||||
CheckCode::F901,
|
CheckCode::F901,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const ALL_CHECK_CODES: [CheckCode; 45] = [
|
||||||
|
CheckCode::E402,
|
||||||
|
CheckCode::E501,
|
||||||
|
CheckCode::E711,
|
||||||
|
CheckCode::E712,
|
||||||
|
CheckCode::E713,
|
||||||
|
CheckCode::E714,
|
||||||
|
CheckCode::E721,
|
||||||
|
CheckCode::E722,
|
||||||
|
CheckCode::E731,
|
||||||
|
CheckCode::E741,
|
||||||
|
CheckCode::E742,
|
||||||
|
CheckCode::E743,
|
||||||
|
CheckCode::E902,
|
||||||
|
CheckCode::E999,
|
||||||
|
CheckCode::F401,
|
||||||
|
CheckCode::F402,
|
||||||
|
CheckCode::F403,
|
||||||
|
CheckCode::F404,
|
||||||
|
CheckCode::F405,
|
||||||
|
CheckCode::F406,
|
||||||
|
CheckCode::F407,
|
||||||
|
CheckCode::F541,
|
||||||
|
CheckCode::F601,
|
||||||
|
CheckCode::F602,
|
||||||
|
CheckCode::F621,
|
||||||
|
CheckCode::F622,
|
||||||
|
CheckCode::F631,
|
||||||
|
CheckCode::F632,
|
||||||
|
CheckCode::F633,
|
||||||
|
CheckCode::F634,
|
||||||
|
CheckCode::F701,
|
||||||
|
CheckCode::F702,
|
||||||
|
CheckCode::F704,
|
||||||
|
CheckCode::F706,
|
||||||
|
CheckCode::F707,
|
||||||
|
CheckCode::F722,
|
||||||
|
CheckCode::F821,
|
||||||
|
CheckCode::F822,
|
||||||
|
CheckCode::F823,
|
||||||
|
CheckCode::F831,
|
||||||
|
CheckCode::F841,
|
||||||
|
CheckCode::F901,
|
||||||
|
CheckCode::M001,
|
||||||
CheckCode::R001,
|
CheckCode::R001,
|
||||||
CheckCode::R002,
|
CheckCode::R002,
|
||||||
];
|
];
|
||||||
|
@ -99,6 +145,7 @@ pub enum CheckCode {
|
||||||
F901,
|
F901,
|
||||||
R001,
|
R001,
|
||||||
R002,
|
R002,
|
||||||
|
M001,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for CheckCode {
|
impl FromStr for CheckCode {
|
||||||
|
@ -150,6 +197,7 @@ impl FromStr for CheckCode {
|
||||||
"F901" => Ok(CheckCode::F901),
|
"F901" => Ok(CheckCode::F901),
|
||||||
"R001" => Ok(CheckCode::R001),
|
"R001" => Ok(CheckCode::R001),
|
||||||
"R002" => Ok(CheckCode::R002),
|
"R002" => Ok(CheckCode::R002),
|
||||||
|
"M001" => Ok(CheckCode::M001),
|
||||||
_ => Err(anyhow::anyhow!("Unknown check code: {s}")),
|
_ => Err(anyhow::anyhow!("Unknown check code: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -202,13 +250,14 @@ impl CheckCode {
|
||||||
CheckCode::F901 => "F901",
|
CheckCode::F901 => "F901",
|
||||||
CheckCode::R001 => "R001",
|
CheckCode::R001 => "R001",
|
||||||
CheckCode::R002 => "R002",
|
CheckCode::R002 => "R002",
|
||||||
|
CheckCode::M001 => "M001",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The source for the check (either the AST, the filesystem, or the physical lines).
|
/// The source for the check (either the AST, the filesystem, or the physical lines).
|
||||||
pub fn lint_source(&self) -> &'static LintSource {
|
pub fn lint_source(&self) -> &'static LintSource {
|
||||||
match self {
|
match self {
|
||||||
CheckCode::E501 => &LintSource::Lines,
|
CheckCode::E501 | CheckCode::M001 => &LintSource::Lines,
|
||||||
CheckCode::E902 | CheckCode::E999 => &LintSource::FileSystem,
|
CheckCode::E902 | CheckCode::E999 => &LintSource::FileSystem,
|
||||||
_ => &LintSource::AST,
|
_ => &LintSource::AST,
|
||||||
}
|
}
|
||||||
|
@ -259,6 +308,7 @@ impl CheckCode {
|
||||||
CheckCode::F831 => CheckKind::DuplicateArgumentName,
|
CheckCode::F831 => CheckKind::DuplicateArgumentName,
|
||||||
CheckCode::F841 => CheckKind::UnusedVariable("...".to_string()),
|
CheckCode::F841 => CheckKind::UnusedVariable("...".to_string()),
|
||||||
CheckCode::F901 => CheckKind::RaiseNotImplemented,
|
CheckCode::F901 => CheckKind::RaiseNotImplemented,
|
||||||
|
CheckCode::M001 => CheckKind::UnusedNOQA(None),
|
||||||
CheckCode::R001 => CheckKind::UselessObjectInheritance("...".to_string()),
|
CheckCode::R001 => CheckKind::UselessObjectInheritance("...".to_string()),
|
||||||
CheckCode::R002 => CheckKind::NoAssertEquals,
|
CheckCode::R002 => CheckKind::NoAssertEquals,
|
||||||
}
|
}
|
||||||
|
@ -280,6 +330,7 @@ pub enum RejectedCmpop {
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum CheckKind {
|
pub enum CheckKind {
|
||||||
|
UnusedNOQA(Option<String>),
|
||||||
AmbiguousClassName(String),
|
AmbiguousClassName(String),
|
||||||
AmbiguousFunctionName(String),
|
AmbiguousFunctionName(String),
|
||||||
AmbiguousVariableName(String),
|
AmbiguousVariableName(String),
|
||||||
|
@ -376,6 +427,7 @@ impl CheckKind {
|
||||||
CheckKind::UnusedVariable(_) => "UnusedVariable",
|
CheckKind::UnusedVariable(_) => "UnusedVariable",
|
||||||
CheckKind::UselessObjectInheritance(_) => "UselessObjectInheritance",
|
CheckKind::UselessObjectInheritance(_) => "UselessObjectInheritance",
|
||||||
CheckKind::YieldOutsideFunction => "YieldOutsideFunction",
|
CheckKind::YieldOutsideFunction => "YieldOutsideFunction",
|
||||||
|
CheckKind::UnusedNOQA(_) => "UnusedNOQA",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -399,8 +451,8 @@ impl CheckKind {
|
||||||
CheckKind::IfTuple => &CheckCode::F634,
|
CheckKind::IfTuple => &CheckCode::F634,
|
||||||
CheckKind::ImportShadowedByLoopVar(_, _) => &CheckCode::F402,
|
CheckKind::ImportShadowedByLoopVar(_, _) => &CheckCode::F402,
|
||||||
CheckKind::ImportStarNotPermitted(_) => &CheckCode::F406,
|
CheckKind::ImportStarNotPermitted(_) => &CheckCode::F406,
|
||||||
CheckKind::ImportStarUsed(_) => &CheckCode::F403,
|
|
||||||
CheckKind::ImportStarUsage(_, _) => &CheckCode::F405,
|
CheckKind::ImportStarUsage(_, _) => &CheckCode::F405,
|
||||||
|
CheckKind::ImportStarUsed(_) => &CheckCode::F403,
|
||||||
CheckKind::InvalidPrintSyntax => &CheckCode::F633,
|
CheckKind::InvalidPrintSyntax => &CheckCode::F633,
|
||||||
CheckKind::IsLiteral => &CheckCode::F632,
|
CheckKind::IsLiteral => &CheckCode::F632,
|
||||||
CheckKind::LateFutureImport => &CheckCode::F404,
|
CheckKind::LateFutureImport => &CheckCode::F404,
|
||||||
|
@ -423,6 +475,7 @@ impl CheckKind {
|
||||||
CheckKind::UndefinedLocal(_) => &CheckCode::F823,
|
CheckKind::UndefinedLocal(_) => &CheckCode::F823,
|
||||||
CheckKind::UndefinedName(_) => &CheckCode::F821,
|
CheckKind::UndefinedName(_) => &CheckCode::F821,
|
||||||
CheckKind::UnusedImport(_) => &CheckCode::F401,
|
CheckKind::UnusedImport(_) => &CheckCode::F401,
|
||||||
|
CheckKind::UnusedNOQA(_) => &CheckCode::M001,
|
||||||
CheckKind::UnusedVariable(_) => &CheckCode::F841,
|
CheckKind::UnusedVariable(_) => &CheckCode::F841,
|
||||||
CheckKind::UselessObjectInheritance(_) => &CheckCode::R001,
|
CheckKind::UselessObjectInheritance(_) => &CheckCode::R001,
|
||||||
CheckKind::YieldOutsideFunction => &CheckCode::F704,
|
CheckKind::YieldOutsideFunction => &CheckCode::F704,
|
||||||
|
@ -556,6 +609,10 @@ impl CheckKind {
|
||||||
CheckKind::YieldOutsideFunction => {
|
CheckKind::YieldOutsideFunction => {
|
||||||
"a `yield` or `yield from` statement outside of a function/method".to_string()
|
"a `yield` or `yield from` statement outside of a function/method".to_string()
|
||||||
}
|
}
|
||||||
|
CheckKind::UnusedNOQA(code) => match code {
|
||||||
|
None => "Unused `noqa` directive".to_string(),
|
||||||
|
Some(code) => format!("Unused `noqa` directive for {code}"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -584,10 +641,37 @@ pub struct Check {
|
||||||
}
|
}
|
||||||
|
|
||||||
static NO_QA_REGEX: Lazy<Regex> = Lazy::new(|| {
|
static NO_QA_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||||
Regex::new(r"(?i)# noqa(?::\s?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?").expect("Invalid regex")
|
Regex::new(r"(?i)(?P<noqa># noqa(?::\s?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?)")
|
||||||
|
.expect("Invalid regex")
|
||||||
});
|
});
|
||||||
static SPLIT_COMMA_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[,\s]").expect("Invalid regex"));
|
static SPLIT_COMMA_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[,\s]").expect("Invalid regex"));
|
||||||
|
|
||||||
|
pub enum Directive<'a> {
|
||||||
|
None,
|
||||||
|
All(usize),
|
||||||
|
Codes(usize, Vec<&'a str>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_noqa_directive(line: &str) -> Directive {
|
||||||
|
match NO_QA_REGEX.captures(line) {
|
||||||
|
Some(caps) => match caps.name("noqa") {
|
||||||
|
Some(noqa) => match caps.name("codes") {
|
||||||
|
Some(codes) => Directive::Codes(
|
||||||
|
noqa.start(),
|
||||||
|
SPLIT_COMMA_REGEX
|
||||||
|
.split(codes.as_str())
|
||||||
|
.map(|code| code.trim())
|
||||||
|
.filter(|code| !code.is_empty())
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
None => Directive::All(noqa.start()),
|
||||||
|
},
|
||||||
|
None => Directive::None,
|
||||||
|
},
|
||||||
|
None => Directive::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Check {
|
impl Check {
|
||||||
pub fn new(kind: CheckKind, location: Location) -> Self {
|
pub fn new(kind: CheckKind, location: Location) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
@ -600,25 +684,4 @@ impl Check {
|
||||||
pub fn amend(&mut self, fix: Fix) {
|
pub fn amend(&mut self, fix: Fix) {
|
||||||
self.fix = Some(fix);
|
self.fix = Some(fix);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_inline_ignored(&self, line: &str) -> bool {
|
|
||||||
match NO_QA_REGEX.captures(line) {
|
|
||||||
Some(caps) => match caps.name("codes") {
|
|
||||||
Some(codes) => {
|
|
||||||
for code in SPLIT_COMMA_REGEX
|
|
||||||
.split(codes.as_str())
|
|
||||||
.map(|code| code.trim())
|
|
||||||
.filter(|code| !code.is_empty())
|
|
||||||
{
|
|
||||||
if code == self.kind.code().as_str() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
None => true,
|
|
||||||
},
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -578,6 +578,18 @@ mod tests {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m001() -> Result<()> {
|
||||||
|
let mut checks = check_path(
|
||||||
|
Path::new("./resources/test/fixtures/M001.py"),
|
||||||
|
&settings::Settings::for_rules(vec![CheckCode::M001, CheckCode::F841]),
|
||||||
|
&fixer::Mode::Generate,
|
||||||
|
)?;
|
||||||
|
checks.sort_by_key(|check| check.location);
|
||||||
|
insta::assert_yaml_snapshot!(checks);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn r001() -> Result<()> {
|
fn r001() -> Result<()> {
|
||||||
let mut checks = check_path(
|
let mut checks = check_path(
|
||||||
|
|
|
@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
|
||||||
use glob::Pattern;
|
use glob::Pattern;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use crate::checks::{CheckCode, ALL_CHECK_CODES};
|
use crate::checks::{CheckCode, DEFAULT_CHECK_CODES};
|
||||||
use crate::fs;
|
use crate::fs;
|
||||||
use crate::pyproject::load_config;
|
use crate::pyproject::load_config;
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ impl Settings {
|
||||||
select: if let Some(select) = config.select {
|
select: if let Some(select) = config.select {
|
||||||
BTreeSet::from_iter(select)
|
BTreeSet::from_iter(select)
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::from_iter(ALL_CHECK_CODES)
|
BTreeSet::from_iter(DEFAULT_CHECK_CODES)
|
||||||
},
|
},
|
||||||
pyproject,
|
pyproject,
|
||||||
project_root,
|
project_root,
|
||||||
|
|
23
src/snapshots/ruff__linter__tests__m001.snap
Normal file
23
src/snapshots/ruff__linter__tests__m001.snap
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
source: src/linter.rs
|
||||||
|
expression: checks
|
||||||
|
---
|
||||||
|
- kind:
|
||||||
|
UnusedNOQA: ~
|
||||||
|
location:
|
||||||
|
row: 9
|
||||||
|
column: 12
|
||||||
|
fix: ~
|
||||||
|
- kind:
|
||||||
|
UnusedNOQA: E501
|
||||||
|
location:
|
||||||
|
row: 13
|
||||||
|
column: 12
|
||||||
|
fix: ~
|
||||||
|
- kind:
|
||||||
|
UnusedNOQA: E501
|
||||||
|
location:
|
||||||
|
row: 16
|
||||||
|
column: 12
|
||||||
|
fix: ~
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue