mirror of
https://github.com/rust-lang/rust-analyzer.git
synced 2025-10-02 14:51:48 +00:00
Merge #10358
10358: internal: Remove inherent methods from ast nodes that do non-syntactic complex tasks r=Veykril a=Veykril Co-authored-by: Lukas Wirth <lukastw97@gmail.com>
This commit is contained in:
commit
c51a3c78cf
15 changed files with 392 additions and 365 deletions
|
@ -1,15 +1,17 @@
|
|||
//! A module with ide helpers for high-level ide features.
|
||||
pub mod famous_defs;
|
||||
pub mod generated_lints;
|
||||
pub mod import_assets;
|
||||
pub mod insert_use;
|
||||
pub mod merge_imports;
|
||||
pub mod node_ext;
|
||||
pub mod rust_doc;
|
||||
pub mod generated_lints;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use base_db::FileId;
|
||||
use either::Either;
|
||||
use hir::{Crate, Enum, ItemInNs, MacroDef, Module, ModuleDef, Name, ScopeDef, Semantics, Trait};
|
||||
use hir::{ItemInNs, MacroDef, ModuleDef, Name, Semantics};
|
||||
use syntax::{
|
||||
ast::{self, make, LoopBodyOwner},
|
||||
AstNode, Direction, SyntaxElement, SyntaxKind, SyntaxToken, TokenAtOffset, WalkEvent, T,
|
||||
|
@ -17,6 +19,8 @@ use syntax::{
|
|||
|
||||
use crate::RootDatabase;
|
||||
|
||||
pub use self::famous_defs::FamousDefs;
|
||||
|
||||
pub fn item_name(db: &RootDatabase, item: ItemInNs) -> Option<Name> {
|
||||
match item {
|
||||
ItemInNs::Types(module_def_id) => ModuleDef::from(module_def_id).name(db),
|
||||
|
@ -27,7 +31,7 @@ pub fn item_name(db: &RootDatabase, item: ItemInNs) -> Option<Name> {
|
|||
|
||||
/// Resolves the path at the cursor token as a derive macro if it inside a token tree of a derive attribute.
|
||||
pub fn try_resolve_derive_input_at(
|
||||
sema: &Semantics<RootDatabase>,
|
||||
sema: &hir::Semantics<RootDatabase>,
|
||||
derive_attr: &ast::Attr,
|
||||
cursor: &SyntaxToken,
|
||||
) -> Option<MacroDef> {
|
||||
|
@ -113,123 +117,6 @@ pub fn visit_file_defs(
|
|||
module.impl_defs(db).into_iter().for_each(|impl_| cb(Either::Right(impl_)));
|
||||
}
|
||||
|
||||
/// Helps with finding well-know things inside the standard library. This is
|
||||
/// somewhat similar to the known paths infra inside hir, but it different; We
|
||||
/// want to make sure that IDE specific paths don't become interesting inside
|
||||
/// the compiler itself as well.
|
||||
///
|
||||
/// Note that, by default, rust-analyzer tests **do not** include core or std
|
||||
/// libraries. If you are writing tests for functionality using [`FamousDefs`],
|
||||
/// you'd want to include minicore (see `test_utils::MiniCore`) declaration at
|
||||
/// the start of your tests:
|
||||
///
|
||||
/// ```
|
||||
/// //- minicore: iterator, ord, derive
|
||||
/// ```
|
||||
pub struct FamousDefs<'a, 'b>(pub &'a Semantics<'b, RootDatabase>, pub Option<Crate>);
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
impl FamousDefs<'_, '_> {
|
||||
pub fn std(&self) -> Option<Crate> {
|
||||
self.find_crate("std")
|
||||
}
|
||||
|
||||
pub fn core(&self) -> Option<Crate> {
|
||||
self.find_crate("core")
|
||||
}
|
||||
|
||||
pub fn core_cmp_Ord(&self) -> Option<Trait> {
|
||||
self.find_trait("core:cmp:Ord")
|
||||
}
|
||||
|
||||
pub fn core_convert_From(&self) -> Option<Trait> {
|
||||
self.find_trait("core:convert:From")
|
||||
}
|
||||
|
||||
pub fn core_convert_Into(&self) -> Option<Trait> {
|
||||
self.find_trait("core:convert:Into")
|
||||
}
|
||||
|
||||
pub fn core_option_Option(&self) -> Option<Enum> {
|
||||
self.find_enum("core:option:Option")
|
||||
}
|
||||
|
||||
pub fn core_result_Result(&self) -> Option<Enum> {
|
||||
self.find_enum("core:result:Result")
|
||||
}
|
||||
|
||||
pub fn core_default_Default(&self) -> Option<Trait> {
|
||||
self.find_trait("core:default:Default")
|
||||
}
|
||||
|
||||
pub fn core_iter_Iterator(&self) -> Option<Trait> {
|
||||
self.find_trait("core:iter:traits:iterator:Iterator")
|
||||
}
|
||||
|
||||
pub fn core_iter_IntoIterator(&self) -> Option<Trait> {
|
||||
self.find_trait("core:iter:traits:collect:IntoIterator")
|
||||
}
|
||||
|
||||
pub fn core_iter(&self) -> Option<Module> {
|
||||
self.find_module("core:iter")
|
||||
}
|
||||
|
||||
pub fn core_ops_Deref(&self) -> Option<Trait> {
|
||||
self.find_trait("core:ops:Deref")
|
||||
}
|
||||
|
||||
fn find_trait(&self, path: &str) -> Option<Trait> {
|
||||
match self.find_def(path)? {
|
||||
hir::ScopeDef::ModuleDef(hir::ModuleDef::Trait(it)) => Some(it),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_enum(&self, path: &str) -> Option<Enum> {
|
||||
match self.find_def(path)? {
|
||||
hir::ScopeDef::ModuleDef(hir::ModuleDef::Adt(hir::Adt::Enum(it))) => Some(it),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_module(&self, path: &str) -> Option<Module> {
|
||||
match self.find_def(path)? {
|
||||
hir::ScopeDef::ModuleDef(hir::ModuleDef::Module(it)) => Some(it),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_crate(&self, name: &str) -> Option<Crate> {
|
||||
let krate = self.1?;
|
||||
let db = self.0.db;
|
||||
let res =
|
||||
krate.dependencies(db).into_iter().find(|dep| dep.name.to_string() == name)?.krate;
|
||||
Some(res)
|
||||
}
|
||||
|
||||
fn find_def(&self, path: &str) -> Option<ScopeDef> {
|
||||
let db = self.0.db;
|
||||
let mut path = path.split(':');
|
||||
let trait_ = path.next_back()?;
|
||||
let std_crate = path.next()?;
|
||||
let std_crate = self.find_crate(std_crate)?;
|
||||
let mut module = std_crate.root_module(db);
|
||||
for segment in path {
|
||||
module = module.children(db).find_map(|child| {
|
||||
let name = child.name(db)?;
|
||||
if name.to_string() == segment {
|
||||
Some(child)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let def =
|
||||
module.scope(db, None).into_iter().find(|(name, _def)| name.to_string() == trait_)?.1;
|
||||
Some(def)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SnippetCap {
|
||||
_private: (),
|
||||
|
|
121
crates/ide_db/src/helpers/famous_defs.rs
Normal file
121
crates/ide_db/src/helpers/famous_defs.rs
Normal file
|
@ -0,0 +1,121 @@
|
|||
//! See [`FamousDefs`].
|
||||
use hir::{Crate, Enum, Module, ScopeDef, Semantics, Trait};
|
||||
|
||||
use crate::RootDatabase;
|
||||
|
||||
/// Helps with finding well-know things inside the standard library. This is
|
||||
/// somewhat similar to the known paths infra inside hir, but it different; We
|
||||
/// want to make sure that IDE specific paths don't become interesting inside
|
||||
/// the compiler itself as well.
|
||||
///
|
||||
/// Note that, by default, rust-analyzer tests **do not** include core or std
|
||||
/// libraries. If you are writing tests for functionality using [`FamousDefs`],
|
||||
/// you'd want to include minicore (see `test_utils::MiniCore`) declaration at
|
||||
/// the start of your tests:
|
||||
///
|
||||
/// ```
|
||||
/// //- minicore: iterator, ord, derive
|
||||
/// ```
|
||||
pub struct FamousDefs<'a, 'b>(pub &'a Semantics<'b, RootDatabase>, pub Option<Crate>);
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
impl FamousDefs<'_, '_> {
|
||||
pub fn std(&self) -> Option<Crate> {
|
||||
self.find_crate("std")
|
||||
}
|
||||
|
||||
pub fn core(&self) -> Option<Crate> {
|
||||
self.find_crate("core")
|
||||
}
|
||||
|
||||
pub fn core_cmp_Ord(&self) -> Option<Trait> {
|
||||
self.find_trait("core:cmp:Ord")
|
||||
}
|
||||
|
||||
pub fn core_convert_From(&self) -> Option<Trait> {
|
||||
self.find_trait("core:convert:From")
|
||||
}
|
||||
|
||||
pub fn core_convert_Into(&self) -> Option<Trait> {
|
||||
self.find_trait("core:convert:Into")
|
||||
}
|
||||
|
||||
pub fn core_option_Option(&self) -> Option<Enum> {
|
||||
self.find_enum("core:option:Option")
|
||||
}
|
||||
|
||||
pub fn core_result_Result(&self) -> Option<Enum> {
|
||||
self.find_enum("core:result:Result")
|
||||
}
|
||||
|
||||
pub fn core_default_Default(&self) -> Option<Trait> {
|
||||
self.find_trait("core:default:Default")
|
||||
}
|
||||
|
||||
pub fn core_iter_Iterator(&self) -> Option<Trait> {
|
||||
self.find_trait("core:iter:traits:iterator:Iterator")
|
||||
}
|
||||
|
||||
pub fn core_iter_IntoIterator(&self) -> Option<Trait> {
|
||||
self.find_trait("core:iter:traits:collect:IntoIterator")
|
||||
}
|
||||
|
||||
pub fn core_iter(&self) -> Option<Module> {
|
||||
self.find_module("core:iter")
|
||||
}
|
||||
|
||||
pub fn core_ops_Deref(&self) -> Option<Trait> {
|
||||
self.find_trait("core:ops:Deref")
|
||||
}
|
||||
|
||||
fn find_trait(&self, path: &str) -> Option<Trait> {
|
||||
match self.find_def(path)? {
|
||||
hir::ScopeDef::ModuleDef(hir::ModuleDef::Trait(it)) => Some(it),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_enum(&self, path: &str) -> Option<Enum> {
|
||||
match self.find_def(path)? {
|
||||
hir::ScopeDef::ModuleDef(hir::ModuleDef::Adt(hir::Adt::Enum(it))) => Some(it),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_module(&self, path: &str) -> Option<Module> {
|
||||
match self.find_def(path)? {
|
||||
hir::ScopeDef::ModuleDef(hir::ModuleDef::Module(it)) => Some(it),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_crate(&self, name: &str) -> Option<Crate> {
|
||||
let krate = self.1?;
|
||||
let db = self.0.db;
|
||||
let res =
|
||||
krate.dependencies(db).into_iter().find(|dep| dep.name.to_string() == name)?.krate;
|
||||
Some(res)
|
||||
}
|
||||
|
||||
fn find_def(&self, path: &str) -> Option<ScopeDef> {
|
||||
let db = self.0.db;
|
||||
let mut path = path.split(':');
|
||||
let trait_ = path.next_back()?;
|
||||
let std_crate = path.next()?;
|
||||
let std_crate = self.find_crate(std_crate)?;
|
||||
let mut module = std_crate.root_module(db);
|
||||
for segment in path {
|
||||
module = module.children(db).find_map(|child| {
|
||||
let name = child.name(db)?;
|
||||
if name.to_string() == segment {
|
||||
Some(child)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let def =
|
||||
module.scope(db, None).into_iter().find(|(name, _def)| name.to_string() == trait_)?.1;
|
||||
Some(def)
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ use syntax::{
|
|||
ted,
|
||||
};
|
||||
|
||||
use crate::helpers::node_ext::vis_eq;
|
||||
|
||||
/// What type of merges are allowed.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MergeBehavior {
|
||||
|
@ -292,7 +294,7 @@ fn path_segment_cmp(a: &ast::PathSegment, b: &ast::PathSegment) -> Ordering {
|
|||
pub fn eq_visibility(vis0: Option<ast::Visibility>, vis1: Option<ast::Visibility>) -> bool {
|
||||
match (vis0, vis1) {
|
||||
(None, None) => true,
|
||||
(Some(vis0), Some(vis1)) => vis0.is_eq_to(&vis1),
|
||||
(Some(vis0), Some(vis1)) => vis_eq(&vis0, &vis1),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
212
crates/ide_db/src/helpers/node_ext.rs
Normal file
212
crates/ide_db/src/helpers/node_ext.rs
Normal file
|
@ -0,0 +1,212 @@
|
|||
//! Various helper functions to work with SyntaxNodes.
|
||||
use syntax::{
|
||||
ast::{self, PathSegmentKind, VisibilityKind},
|
||||
AstNode, WalkEvent,
|
||||
};
|
||||
|
||||
pub fn expr_as_name_ref(expr: &ast::Expr) -> Option<ast::NameRef> {
|
||||
if let ast::Expr::PathExpr(expr) = expr {
|
||||
let path = expr.path()?;
|
||||
let segment = path.segment()?;
|
||||
let name_ref = segment.name_ref()?;
|
||||
if path.qualifier().is_none() {
|
||||
return Some(name_ref);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn block_as_lone_tail(block: &ast::BlockExpr) -> Option<ast::Expr> {
|
||||
block.statements().next().is_none().then(|| block.tail_expr()).flatten()
|
||||
}
|
||||
/// Preorder walk all the expression's child expressions.
|
||||
pub fn walk_expr(expr: &ast::Expr, cb: &mut dyn FnMut(ast::Expr)) {
|
||||
preorder_expr(expr, &mut |ev| {
|
||||
if let WalkEvent::Enter(expr) = ev {
|
||||
cb(expr);
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// Preorder walk all the expression's child expressions preserving events.
|
||||
/// If the callback returns true on an [`WalkEvent::Enter`], the subtree of the expression will be skipped.
|
||||
/// Note that the subtree may already be skipped due to the context analysis this function does.
|
||||
pub fn preorder_expr(expr: &ast::Expr, cb: &mut dyn FnMut(WalkEvent<ast::Expr>) -> bool) {
|
||||
let mut preorder = expr.syntax().preorder();
|
||||
while let Some(event) = preorder.next() {
|
||||
let node = match event {
|
||||
WalkEvent::Enter(node) => node,
|
||||
WalkEvent::Leave(node) => {
|
||||
if let Some(expr) = ast::Expr::cast(node) {
|
||||
cb(WalkEvent::Leave(expr));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match ast::Stmt::cast(node.clone()) {
|
||||
// recursively walk the initializer, skipping potential const pat expressions
|
||||
// let statements aren't usually nested too deeply so this is fine to recurse on
|
||||
Some(ast::Stmt::LetStmt(l)) => {
|
||||
if let Some(expr) = l.initializer() {
|
||||
preorder_expr(&expr, cb);
|
||||
}
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
// Don't skip subtree since we want to process the expression child next
|
||||
Some(ast::Stmt::ExprStmt(_)) => (),
|
||||
// This might be an expression
|
||||
Some(ast::Stmt::Item(ast::Item::MacroCall(mcall))) => {
|
||||
cb(WalkEvent::Enter(ast::Expr::MacroCall(mcall)));
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
// skip inner items which might have their own expressions
|
||||
Some(ast::Stmt::Item(_)) => preorder.skip_subtree(),
|
||||
None => {
|
||||
// skip const args, those expressions are a different context
|
||||
if ast::GenericArg::can_cast(node.kind()) {
|
||||
preorder.skip_subtree();
|
||||
} else if let Some(expr) = ast::Expr::cast(node) {
|
||||
let is_different_context = match &expr {
|
||||
ast::Expr::EffectExpr(effect) => {
|
||||
matches!(
|
||||
effect.effect(),
|
||||
ast::Effect::Async(_) | ast::Effect::Try(_) | ast::Effect::Const(_)
|
||||
)
|
||||
}
|
||||
ast::Expr::ClosureExpr(_) => true,
|
||||
_ => false,
|
||||
};
|
||||
let skip = cb(WalkEvent::Enter(expr));
|
||||
if skip || is_different_context {
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preorder walk all the expression's child patterns.
|
||||
pub fn walk_patterns_in_expr(expr: &ast::Expr, cb: &mut dyn FnMut(ast::Pat)) {
|
||||
let mut preorder = expr.syntax().preorder();
|
||||
while let Some(event) = preorder.next() {
|
||||
let node = match event {
|
||||
WalkEvent::Enter(node) => node,
|
||||
WalkEvent::Leave(_) => continue,
|
||||
};
|
||||
match ast::Stmt::cast(node.clone()) {
|
||||
Some(ast::Stmt::LetStmt(l)) => {
|
||||
if let Some(pat) = l.pat() {
|
||||
walk_pat(&pat, cb);
|
||||
}
|
||||
if let Some(expr) = l.initializer() {
|
||||
walk_patterns_in_expr(&expr, cb);
|
||||
}
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
// Don't skip subtree since we want to process the expression child next
|
||||
Some(ast::Stmt::ExprStmt(_)) => (),
|
||||
// skip inner items which might have their own patterns
|
||||
Some(ast::Stmt::Item(_)) => preorder.skip_subtree(),
|
||||
None => {
|
||||
// skip const args, those are a different context
|
||||
if ast::GenericArg::can_cast(node.kind()) {
|
||||
preorder.skip_subtree();
|
||||
} else if let Some(expr) = ast::Expr::cast(node.clone()) {
|
||||
let is_different_context = match &expr {
|
||||
ast::Expr::EffectExpr(effect) => match effect.effect() {
|
||||
ast::Effect::Async(_) | ast::Effect::Try(_) | ast::Effect::Const(_) => {
|
||||
true
|
||||
}
|
||||
ast::Effect::Unsafe(_) | ast::Effect::Label(_) => false,
|
||||
},
|
||||
ast::Expr::ClosureExpr(_) => true,
|
||||
_ => false,
|
||||
};
|
||||
if is_different_context {
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
} else if let Some(pat) = ast::Pat::cast(node) {
|
||||
preorder.skip_subtree();
|
||||
walk_pat(&pat, cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preorder walk all the pattern's sub patterns.
|
||||
pub fn walk_pat(pat: &ast::Pat, cb: &mut dyn FnMut(ast::Pat)) {
|
||||
let mut preorder = pat.syntax().preorder();
|
||||
while let Some(event) = preorder.next() {
|
||||
let node = match event {
|
||||
WalkEvent::Enter(node) => node,
|
||||
WalkEvent::Leave(_) => continue,
|
||||
};
|
||||
let kind = node.kind();
|
||||
match ast::Pat::cast(node) {
|
||||
Some(pat @ ast::Pat::ConstBlockPat(_)) => {
|
||||
preorder.skip_subtree();
|
||||
cb(pat);
|
||||
}
|
||||
Some(pat) => {
|
||||
cb(pat);
|
||||
}
|
||||
// skip const args
|
||||
None if ast::GenericArg::can_cast(kind) => {
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preorder walk all the type's sub types.
|
||||
pub fn walk_ty(ty: &ast::Type, cb: &mut dyn FnMut(ast::Type)) {
|
||||
let mut preorder = ty.syntax().preorder();
|
||||
while let Some(event) = preorder.next() {
|
||||
let node = match event {
|
||||
WalkEvent::Enter(node) => node,
|
||||
WalkEvent::Leave(_) => continue,
|
||||
};
|
||||
let kind = node.kind();
|
||||
match ast::Type::cast(node) {
|
||||
Some(ty @ ast::Type::MacroType(_)) => {
|
||||
preorder.skip_subtree();
|
||||
cb(ty)
|
||||
}
|
||||
Some(ty) => {
|
||||
cb(ty);
|
||||
}
|
||||
// skip const args
|
||||
None if ast::ConstArg::can_cast(kind) => {
|
||||
preorder.skip_subtree();
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vis_eq(this: &ast::Visibility, other: &ast::Visibility) -> bool {
|
||||
match (this.kind(), other.kind()) {
|
||||
(VisibilityKind::In(this), VisibilityKind::In(other)) => {
|
||||
stdx::iter_eq_by(this.segments(), other.segments(), |lhs, rhs| {
|
||||
lhs.kind().zip(rhs.kind()).map_or(false, |it| match it {
|
||||
(PathSegmentKind::CrateKw, PathSegmentKind::CrateKw)
|
||||
| (PathSegmentKind::SelfKw, PathSegmentKind::SelfKw)
|
||||
| (PathSegmentKind::SuperKw, PathSegmentKind::SuperKw) => true,
|
||||
(PathSegmentKind::Name(lhs), PathSegmentKind::Name(rhs)) => {
|
||||
lhs.text() == rhs.text()
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
(VisibilityKind::PubSelf, VisibilityKind::PubSelf)
|
||||
| (VisibilityKind::PubSuper, VisibilityKind::PubSuper)
|
||||
| (VisibilityKind::PubCrate, VisibilityKind::PubCrate)
|
||||
| (VisibilityKind::Pub, VisibilityKind::Pub) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
|
@ -34,6 +34,7 @@ use text_edit::{TextEdit, TextEditBuilder};
|
|||
|
||||
use crate::{
|
||||
defs::Definition,
|
||||
helpers::node_ext::expr_as_name_ref,
|
||||
search::FileReference,
|
||||
source_change::{FileSystemEdit, SourceChange},
|
||||
RootDatabase,
|
||||
|
@ -339,7 +340,7 @@ fn source_edit_from_name_ref(
|
|||
if let Some(record_field) = ast::RecordExprField::for_name_ref(name_ref) {
|
||||
let rcf_name_ref = record_field.name_ref();
|
||||
let rcf_expr = record_field.expr();
|
||||
match &(rcf_name_ref, rcf_expr.and_then(|it| it.name_ref())) {
|
||||
match &(rcf_name_ref, rcf_expr.and_then(|it| expr_as_name_ref(&it))) {
|
||||
// field: init-expr, check if we can use a field init shorthand
|
||||
(Some(field_name), Some(init)) => {
|
||||
if field_name == name_ref {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue