mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-08-03 17:58:17 +00:00
feat: dynamic analysis on import from dynamic expressions (#233)
* feat: dynamic analysis on import from dynamic expressions * dev: adds more fixture
This commit is contained in:
parent
46f524de57
commit
abb89ed3e8
12 changed files with 308 additions and 158 deletions
|
@ -1,17 +1,12 @@
|
|||
//! Static analysis for def-use relations.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ops::{Deref, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::HashMap, ops::Range, sync::Arc};
|
||||
|
||||
use ecow::EcoVec;
|
||||
use log::info;
|
||||
|
||||
use super::{prelude::*, ImportInfo};
|
||||
use crate::adt::snapshot_map::SnapshotMap;
|
||||
use crate::syntax::find_source_by_import_path;
|
||||
|
||||
/// The type namespace of def-use relations
|
||||
///
|
||||
|
@ -118,7 +113,7 @@ pub(super) fn get_def_use_inner(
|
|||
ctx: &mut AnalysisContext,
|
||||
source: Source,
|
||||
e: EcoVec<LexicalHierarchy>,
|
||||
_m: Arc<ImportInfo>,
|
||||
import: Arc<ImportInfo>,
|
||||
) -> Option<Arc<DefUseInfo>> {
|
||||
let current_id = source.id();
|
||||
|
||||
|
@ -127,6 +122,7 @@ pub(super) fn get_def_use_inner(
|
|||
info: DefUseInfo::default(),
|
||||
id_scope: SnapshotMap::default(),
|
||||
label_scope: SnapshotMap::default(),
|
||||
import,
|
||||
|
||||
current_id,
|
||||
ext_src: None,
|
||||
|
@ -143,6 +139,7 @@ struct DefUseCollector<'a, 'w> {
|
|||
info: DefUseInfo,
|
||||
label_scope: SnapshotMap<String, DefId>,
|
||||
id_scope: SnapshotMap<String, DefId>,
|
||||
import: Arc<ImportInfo>,
|
||||
|
||||
current_id: TypstFileId,
|
||||
ext_src: Option<Source>,
|
||||
|
@ -203,7 +200,6 @@ impl<'a, 'w> DefUseCollector<'a, 'w> {
|
|||
for e in e {
|
||||
match &e.info.kind {
|
||||
LexicalKind::Heading(..) => unreachable!(),
|
||||
LexicalKind::Mod(LexicalModKind::PathInclude) => {}
|
||||
LexicalKind::Var(LexicalVarKind::Label) => {
|
||||
self.insert(Ns::Label, e);
|
||||
}
|
||||
|
@ -212,6 +208,37 @@ impl<'a, 'w> DefUseCollector<'a, 'w> {
|
|||
| LexicalKind::Var(LexicalVarKind::Variable) => {
|
||||
self.insert(Ns::Value, e);
|
||||
}
|
||||
LexicalKind::Var(LexicalVarKind::ValRef) => self.insert_ref(Ns::Value, e),
|
||||
LexicalKind::Block => {
|
||||
if let Some(e) = &e.children {
|
||||
self.enter(|this| this.scan(e.as_slice()))?;
|
||||
}
|
||||
}
|
||||
|
||||
LexicalKind::Mod(LexicalModKind::Module(..)) => {
|
||||
let mut src = self.import.imports.get(&e.info.range)?.clone();
|
||||
info!("check import: {info:?} => {src:?}", info = e.info);
|
||||
std::mem::swap(&mut self.ext_src, &mut src);
|
||||
|
||||
// todo: process import star
|
||||
if let Some(e) = &e.children {
|
||||
self.scan(e.as_slice())?;
|
||||
}
|
||||
|
||||
std::mem::swap(&mut self.ext_src, &mut src);
|
||||
}
|
||||
LexicalKind::Mod(LexicalModKind::Star) => {
|
||||
if let Some(source) = &self.ext_src {
|
||||
info!("diving source for def use: {:?}", source.id());
|
||||
let (_, external_info) =
|
||||
Some(source.id()).zip(self.ctx.def_use(source.clone()))?;
|
||||
|
||||
for ext_id in &external_info.exports_refs {
|
||||
self.import_from(&external_info, *ext_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
LexicalKind::Mod(LexicalModKind::PathInclude) => {}
|
||||
LexicalKind::Mod(LexicalModKind::PathVar)
|
||||
| LexicalKind::Mod(LexicalModKind::ModuleAlias) => self.insert_module(Ns::Value, e),
|
||||
LexicalKind::Mod(LexicalModKind::Ident) => match self.import_name(&e.info.name) {
|
||||
|
@ -245,43 +272,6 @@ impl<'a, 'w> DefUseCollector<'a, 'w> {
|
|||
}
|
||||
}
|
||||
}
|
||||
LexicalKind::Var(LexicalVarKind::ValRef) => self.insert_ref(Ns::Value, e),
|
||||
LexicalKind::Block => {
|
||||
if let Some(e) = &e.children {
|
||||
self.enter(|this| this.scan(e.as_slice()))?;
|
||||
}
|
||||
}
|
||||
LexicalKind::Mod(LexicalModKind::Module(p)) => {
|
||||
match p {
|
||||
ModSrc::Expr(_) => {}
|
||||
ModSrc::Path(p) => {
|
||||
let src = find_source_by_import_path(
|
||||
self.ctx.world(),
|
||||
self.current_id,
|
||||
p.deref(),
|
||||
);
|
||||
self.ext_src = src;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: process import star
|
||||
if let Some(e) = &e.children {
|
||||
self.scan(e.as_slice())?;
|
||||
}
|
||||
|
||||
self.ext_src = None;
|
||||
}
|
||||
LexicalKind::Mod(LexicalModKind::Star) => {
|
||||
if let Some(source) = &self.ext_src {
|
||||
info!("diving source for def use: {:?}", source.id());
|
||||
let (_, external_info) =
|
||||
Some(source.id()).zip(self.ctx.def_use(source.clone()))?;
|
||||
|
||||
for ext_id in &external_info.exports_refs {
|
||||
self.import_from(&external_info, *ext_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -40,8 +40,9 @@ use crate::{
|
|||
#[derive(Default)]
|
||||
pub struct ModuleAnalysisCache {
|
||||
source: OnceCell<FileResult<Source>>,
|
||||
top_level_eval: OnceCell<Option<Arc<TypeCheckInfo>>>,
|
||||
import_info: OnceCell<Option<Arc<ImportInfo>>>,
|
||||
def_use: OnceCell<Option<Arc<DefUseInfo>>>,
|
||||
type_check: OnceCell<Option<Arc<TypeCheckInfo>>>,
|
||||
}
|
||||
|
||||
impl ModuleAnalysisCache {
|
||||
|
@ -52,6 +53,19 @@ impl ModuleAnalysisCache {
|
|||
.clone()
|
||||
}
|
||||
|
||||
/// Try to get the def-use information of a file.
|
||||
pub fn import_info(&self) -> Option<Arc<ImportInfo>> {
|
||||
self.import_info.get().cloned().flatten()
|
||||
}
|
||||
|
||||
/// Compute the def-use information of a file.
|
||||
pub(crate) fn compute_import(
|
||||
&self,
|
||||
f: impl FnOnce() -> Option<Arc<ImportInfo>>,
|
||||
) -> Option<Arc<ImportInfo>> {
|
||||
self.import_info.get_or_init(f).clone()
|
||||
}
|
||||
|
||||
/// Try to get the def-use information of a file.
|
||||
pub fn def_use(&self) -> Option<Arc<DefUseInfo>> {
|
||||
self.def_use.get().cloned().flatten()
|
||||
|
@ -65,17 +79,17 @@ impl ModuleAnalysisCache {
|
|||
self.def_use.get_or_init(f).clone()
|
||||
}
|
||||
|
||||
/// Try to get the top-level evaluation information of a file.
|
||||
/// Try to get the type check information of a file.
|
||||
pub(crate) fn type_check(&self) -> Option<Arc<TypeCheckInfo>> {
|
||||
self.top_level_eval.get().cloned().flatten()
|
||||
self.type_check.get().cloned().flatten()
|
||||
}
|
||||
|
||||
/// Compute the top-level evaluation information of a file.
|
||||
/// Compute the type check information of a file.
|
||||
pub(crate) fn compute_type_check(
|
||||
&self,
|
||||
f: impl FnOnce() -> Option<Arc<TypeCheckInfo>>,
|
||||
) -> Option<Arc<TypeCheckInfo>> {
|
||||
self.top_level_eval.get_or_init(f).clone()
|
||||
self.type_check.get_or_init(f).clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -593,6 +607,41 @@ impl<'w> AnalysisContext<'w> {
|
|||
res
|
||||
}
|
||||
|
||||
/// Get the import information of a source file.
|
||||
pub fn import_info(&mut self, source: Source) -> Option<Arc<ImportInfo>> {
|
||||
let fid = source.id();
|
||||
|
||||
if let Some(res) = self.caches.modules.entry(fid).or_default().import_info() {
|
||||
return Some(res);
|
||||
}
|
||||
|
||||
let cache = self.at_module(fid);
|
||||
let l = cache
|
||||
.def_use_lexical_hierarchy
|
||||
.compute(source.clone(), |_before, after| {
|
||||
cache.signatures.clear();
|
||||
crate::syntax::get_lexical_hierarchy(after, crate::syntax::LexicalScopeKind::DefUse)
|
||||
})
|
||||
.ok()
|
||||
.flatten()?;
|
||||
|
||||
let res = cache
|
||||
.import
|
||||
.clone()
|
||||
.compute(l.clone(), |_before, after| {
|
||||
crate::analysis::get_import_info(self, source, after)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
self.caches
|
||||
.modules
|
||||
.entry(fid)
|
||||
.or_default()
|
||||
.compute_import(|| res.clone());
|
||||
res
|
||||
}
|
||||
|
||||
/// Get the def-use information of a source file.
|
||||
pub fn def_use(&mut self, source: Source) -> Option<Arc<DefUseInfo>> {
|
||||
let fid = source.id();
|
||||
|
@ -611,15 +660,7 @@ impl<'w> AnalysisContext<'w> {
|
|||
.ok()
|
||||
.flatten()?;
|
||||
|
||||
let source2 = source.clone();
|
||||
let m = cache
|
||||
.import
|
||||
.clone()
|
||||
.compute(l.clone(), |_before, after| {
|
||||
crate::analysis::get_import_info(self, source2, after)
|
||||
})
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let m = self.import_info(source.clone())?;
|
||||
|
||||
let cache = self.at_module(fid);
|
||||
let res = cache
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
//! Import analysis
|
||||
|
||||
use ecow::EcoVec;
|
||||
use typst::{
|
||||
foundations::Value,
|
||||
syntax::{LinkedNode, SyntaxKind},
|
||||
};
|
||||
|
||||
use crate::syntax::find_source_by_import_path;
|
||||
use crate::syntax::resolve_id_by_path;
|
||||
|
||||
use super::analyze_import;
|
||||
pub use super::prelude::*;
|
||||
|
||||
/// The import information of a source file.
|
||||
#[derive(Default)]
|
||||
pub struct ImportInfo {
|
||||
/// The source files that this source file depends on.
|
||||
pub deps: EcoVec<TypstFileId>,
|
||||
/// The source file that this source file imports.
|
||||
pub imports: indexmap::IndexMap<Range<usize>, Option<Source>>,
|
||||
}
|
||||
|
@ -28,16 +35,28 @@ pub(super) fn get_import_info(
|
|||
e: EcoVec<LexicalHierarchy>,
|
||||
) -> Option<Arc<ImportInfo>> {
|
||||
let current_id = source.id();
|
||||
let root = LinkedNode::new(source.root());
|
||||
|
||||
let mut collector = ImportCollector {
|
||||
ctx,
|
||||
info: ImportInfo::default(),
|
||||
|
||||
current_id,
|
||||
root,
|
||||
};
|
||||
|
||||
collector.scan(&e);
|
||||
|
||||
let mut deps: Vec<_> = collector
|
||||
.info
|
||||
.imports
|
||||
.values()
|
||||
.filter_map(|x| x.as_ref().map(|x| x.id()))
|
||||
.collect();
|
||||
deps.sort();
|
||||
deps.dedup();
|
||||
collector.info.deps = deps.into();
|
||||
|
||||
Some(Arc::new(collector.info))
|
||||
}
|
||||
|
||||
|
@ -46,6 +65,7 @@ struct ImportCollector<'a, 'w> {
|
|||
info: ImportInfo,
|
||||
|
||||
current_id: TypstFileId,
|
||||
root: LinkedNode<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'w> ImportCollector<'a, 'w> {
|
||||
|
@ -67,18 +87,60 @@ impl<'a, 'w> ImportCollector<'a, 'w> {
|
|||
| LexicalModKind::Alias { .. }
|
||||
| LexicalModKind::Star,
|
||||
) => {}
|
||||
LexicalKind::Mod(LexicalModKind::Module(p)) => match p {
|
||||
ModSrc::Expr(_) => {}
|
||||
ModSrc::Path(p) => {
|
||||
let src = find_source_by_import_path(
|
||||
self.ctx.world(),
|
||||
self.current_id,
|
||||
p.deref(),
|
||||
);
|
||||
self.info.imports.insert(e.info.range.clone(), src);
|
||||
}
|
||||
},
|
||||
LexicalKind::Mod(LexicalModKind::Module(p)) => {
|
||||
let id = match p {
|
||||
ModSrc::Expr(exp) => {
|
||||
let exp = find_import_expr(self.root.leaf_at(exp.range.end));
|
||||
let val = exp
|
||||
.as_ref()
|
||||
.and_then(|exp| analyze_import(self.ctx.world(), exp));
|
||||
|
||||
match val {
|
||||
Some(Value::Module(m)) => {
|
||||
log::debug!(
|
||||
"current id {:?} exp {:?} => id: {:?}",
|
||||
self.current_id,
|
||||
exp,
|
||||
m.file_id()
|
||||
);
|
||||
m.file_id()
|
||||
}
|
||||
Some(Value::Str(m)) => resolve_id_by_path(
|
||||
self.ctx.world(),
|
||||
self.current_id,
|
||||
m.as_str(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
ModSrc::Path(p) => {
|
||||
resolve_id_by_path(self.ctx.world(), self.current_id, p.deref())
|
||||
}
|
||||
};
|
||||
log::debug!(
|
||||
"current id {:?} range {:?} => id: {:?}",
|
||||
self.current_id,
|
||||
e.info.range,
|
||||
id
|
||||
);
|
||||
let source = id.and_then(|id| self.ctx.source_by_id(id).ok());
|
||||
self.info.imports.insert(e.info.range.clone(), source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_import_expr(end: Option<LinkedNode>) -> Option<LinkedNode> {
|
||||
let mut node = end?;
|
||||
while let Some(parent) = node.parent() {
|
||||
if matches!(
|
||||
parent.kind(),
|
||||
SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude
|
||||
) {
|
||||
return Some(node);
|
||||
}
|
||||
node = parent.clone();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
// path: base.typ
|
||||
#let f() = 1;
|
||||
-----
|
||||
// path: derive.typ
|
||||
#import "base.typ"
|
||||
-----
|
||||
#import "derive.typ": *
|
||||
#import base: *
|
||||
#f()
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
source: crates/tinymist-query/src/analysis.rs
|
||||
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
|
||||
input_file: crates/tinymist-query/src/fixtures/lexical_hierarchy/import_by_ident.typ
|
||||
---
|
||||
{
|
||||
"base@8..18@derive.typ": {
|
||||
"def": {
|
||||
"kind": {
|
||||
"Mod": "PathVar"
|
||||
},
|
||||
"name": "base",
|
||||
"range": "8:18"
|
||||
},
|
||||
"refs": [
|
||||
"base@32..36"
|
||||
]
|
||||
},
|
||||
"f@5..6@base.typ": {
|
||||
"def": {
|
||||
"kind": {
|
||||
"Var": "Function"
|
||||
},
|
||||
"name": "f",
|
||||
"range": "5:6"
|
||||
},
|
||||
"refs": [
|
||||
"f@41..42"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
source: crates/tinymist-query/src/analysis.rs
|
||||
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
|
||||
input_file: crates/tinymist-query/src/fixtures/lexical_hierarchy/import_by_ident.typ
|
||||
---
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"kind": {
|
||||
"Mod": "Star"
|
||||
},
|
||||
"name": "*",
|
||||
"range": "22:23"
|
||||
}
|
||||
],
|
||||
"kind": {
|
||||
"Mod": {
|
||||
"Module": {
|
||||
"Path": "derive.typ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "",
|
||||
"range": "1:23"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"kind": {
|
||||
"Var": "ValRef"
|
||||
},
|
||||
"name": "base",
|
||||
"range": "32:36"
|
||||
},
|
||||
{
|
||||
"kind": {
|
||||
"Mod": "Star"
|
||||
},
|
||||
"name": "*",
|
||||
"range": "38:39"
|
||||
}
|
||||
],
|
||||
"kind": {
|
||||
"Mod": {
|
||||
"Module": {
|
||||
"Expr": "@32..36"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "",
|
||||
"range": "25:39"
|
||||
},
|
||||
{
|
||||
"kind": {
|
||||
"Var": "ValRef"
|
||||
},
|
||||
"name": "f",
|
||||
"range": "41:42"
|
||||
}
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
// path: base.typ
|
||||
#let tmpl(content, authors: (), font: none, class: "article") = {
|
||||
if class != "article" and class != "letter" {
|
||||
panic("")
|
||||
}
|
||||
|
||||
set document(author: authors)
|
||||
set text(font: font)
|
||||
|
||||
set page(paper: "a4") if class == "article"
|
||||
set page(paper: "us-letter") if class == "letter"
|
||||
|
||||
content
|
||||
}
|
||||
-----
|
||||
#import "base.typ": *
|
||||
|
||||
#tmpl(class: /* position */ )[]
|
|
@ -1,6 +1,7 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
fn resolve_id_by_path(
|
||||
/// Resolve a file id by its import path.
|
||||
pub fn resolve_id_by_path(
|
||||
world: &dyn World,
|
||||
current: TypstFileId,
|
||||
import_path: &str,
|
||||
|
@ -28,17 +29,6 @@ fn resolve_id_by_path(
|
|||
Some(TypstFileId::new(current.package().cloned(), vpath))
|
||||
}
|
||||
|
||||
/// Find a source instance by its import path.
|
||||
pub fn find_source_by_import_path(
|
||||
world: &dyn World,
|
||||
current: TypstFileId,
|
||||
import_path: &str,
|
||||
) -> Option<Source> {
|
||||
world
|
||||
.source(resolve_id_by_path(world, current, import_path)?)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Find a source instance by its import node.
|
||||
pub fn find_source_by_expr(
|
||||
world: &dyn World,
|
||||
|
@ -47,63 +37,9 @@ pub fn find_source_by_expr(
|
|||
) -> Option<Source> {
|
||||
// todo: this could be valid: import("path.typ"), where v is parenthesized
|
||||
match e {
|
||||
ast::Expr::Str(s) => find_source_by_import_path(world, current, s.get().as_str()),
|
||||
ast::Expr::Str(s) => world
|
||||
.source(resolve_id_by_path(world, current, s.get().as_str())?)
|
||||
.ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all static imports in a source.
|
||||
pub fn find_imports(world: &dyn World, source: &Source) -> EcoVec<TypstFileId> {
|
||||
let root = LinkedNode::new(source.root());
|
||||
|
||||
let mut worker = ImportWorker {
|
||||
world,
|
||||
current: source.id(),
|
||||
imports: EcoVec::new(),
|
||||
};
|
||||
|
||||
worker.analyze(root);
|
||||
let res = worker.imports;
|
||||
|
||||
let mut res: Vec<TypstFileId> = res.into_iter().map(|(id, _)| id).collect();
|
||||
res.sort();
|
||||
res.dedup();
|
||||
res.into_iter().collect()
|
||||
}
|
||||
|
||||
struct ImportWorker<'a> {
|
||||
world: &'a dyn World,
|
||||
current: TypstFileId,
|
||||
imports: EcoVec<(TypstFileId, LinkedNode<'a>)>,
|
||||
}
|
||||
|
||||
impl<'a> ImportWorker<'a> {
|
||||
fn analyze(&mut self, node: LinkedNode<'a>) -> Option<()> {
|
||||
match node.kind() {
|
||||
SyntaxKind::ModuleImport => {
|
||||
let i = node.cast::<ast::ModuleImport>().unwrap();
|
||||
let src = i.source();
|
||||
match src {
|
||||
ast::Expr::Str(s) => {
|
||||
// todo: source in packages
|
||||
let s = s.get();
|
||||
let id = resolve_id_by_path(self.world, self.current, s.as_str())?;
|
||||
|
||||
self.imports.push((id, node));
|
||||
}
|
||||
// todo: handle dynamic import
|
||||
ast::Expr::FieldAccess(..) | ast::Expr::Ident(..) => {}
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
SyntaxKind::ModuleInclude => {}
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children() {
|
||||
self.analyze(child);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,7 +98,7 @@ impl<'de> Deserialize<'de> for IdentRef {
|
|||
/// A flat and transient reference to some symbol in a source file.
|
||||
///
|
||||
/// See [`IdentRef`] for definition of a "transient" reference.
|
||||
#[derive(Serialize, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IdentDef {
|
||||
/// The name of the symbol.
|
||||
pub name: String,
|
||||
|
|
|
@ -3,7 +3,6 @@ use std::sync::Once;
|
|||
use once_cell::sync::Lazy;
|
||||
use regex::RegexSet;
|
||||
|
||||
use super::find_imports;
|
||||
use crate::prelude::*;
|
||||
|
||||
/// The dependency information of a module (file).
|
||||
|
@ -37,14 +36,17 @@ pub fn construct_module_dependencies(
|
|||
};
|
||||
|
||||
let file_id = source.id();
|
||||
let deps = find_imports(ctx.world(), &source);
|
||||
let Some(import) = ctx.import_info(source) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
dependencies
|
||||
.entry(file_id)
|
||||
.or_insert_with(|| ModuleDependency {
|
||||
dependencies: deps.clone(),
|
||||
dependencies: import.deps.clone(),
|
||||
dependents: EcoVec::default(),
|
||||
});
|
||||
for dep in deps {
|
||||
for dep in import.deps.clone() {
|
||||
dependents
|
||||
.entry(dep)
|
||||
.or_insert_with(EcoVec::new)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue