From f389b2ddfc2cc695cc167ccafcc330b4e0820ad9 Mon Sep 17 00:00:00 2001 From: Bernhard Schuster Date: Fri, 19 Jun 2020 18:45:14 +0200 Subject: [PATCH] refactor/traverse: introduce unit tests for module traversal --- demo/src/main.rs | 1 + src/literalset.rs | 2 +- src/traverse/iter.rs | 200 ++++++++++++ src/{traverse.rs => traverse/mod.rs} | 471 +++++++++++++-------------- 4 files changed, 429 insertions(+), 245 deletions(-) create mode 100644 src/traverse/iter.rs rename src/{traverse.rs => traverse/mod.rs} (55%) diff --git a/demo/src/main.rs b/demo/src/main.rs index 66113fd6..a9ca9f80 100644 --- a/demo/src/main.rs +++ b/demo/src/main.rs @@ -1,3 +1,4 @@ +//! Just a lil somethin somethin mod lib; pub mod nested; diff --git a/src/literalset.rs b/src/literalset.rs index cdf145e6..a86abd09 100644 --- a/src/literalset.rs +++ b/src/literalset.rs @@ -256,7 +256,7 @@ fn find_coverage<'a>( /// A set of consecutive literals. /// /// Provides means to render them as a code block -#[derive(Clone, Default, Debug, PartialEq, Eq)] +#[derive(Clone, Default, Debug, Hash, PartialEq, Eq)] pub struct LiteralSet { /// consecutive set of literals mapped by line number literals: Vec, diff --git a/src/traverse/iter.rs b/src/traverse/iter.rs new file mode 100644 index 00000000..74b5fd1a --- /dev/null +++ b/src/traverse/iter.rs @@ -0,0 +1,200 @@ +use super::*; +use crate::Documentation; + +use std::fs; + +use log::{trace, warn}; + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Error, Result}; + +/// An iterator traversing module hierarchies yielding paths +#[derive(Debug, Clone)] +pub struct TraverseModulesIter { + /// state for enqueuing child files and the depth at which they are found + queue: VecDeque<(PathBuf, usize)>, + /// zero limits to the provided path, if it is a directory, all children are collected + max_depth: usize, +} + +impl Default for TraverseModulesIter { + fn default() -> Self { + Self { + max_depth: usize::MAX, + queue: VecDeque::with_capacity(128), + } + } +} + +impl TraverseModulesIter { + fn add_initial_path

(&mut self, path: P, level: usize) -> Result<()> + where + P: AsRef, + { + let path = path.as_ref(); + let path = path.canonicalize().unwrap(); + let meta = path.metadata().unwrap(); + if meta.is_file() { + self.queue.push_back((path, level)); + } else if meta.is_dir() { + walkdir::WalkDir::new(path) + .max_depth(1) + .same_file_system(true) + .into_iter() + .filter_map(|entry| { + entry + .ok() + .filter(|entry| entry.file_type().is_file()) + .map(|x| x.path().to_owned()) + }) + .filter(|path: &PathBuf| { + path.to_str() + .map(|x| x.to_owned()) + .filter(|path| path.ends_with(".rs")) + .is_some() + }) + .try_for_each::<_, Result<()>>(|path| { + self.queue.push_back((path, level)); + Ok(()) + })?; + } + Ok(()) + } + + #[allow(unused)] + pub fn with_multi(entries: I) -> Result + where + P: AsRef, + J: Iterator, + I: IntoIterator, + { + let mut me = Self::default(); + for path in entries.into_iter() { + me.add_initial_path(path, 0)?; + } + Ok(me) + } + + pub fn with_depth_limit>(path: P, max_depth: usize) -> Result { + let mut me = Self { + max_depth, + ..Default::default() + }; + me.add_initial_path(path, 0)?; + Ok(me) + } + + pub fn new>(path: P) -> Result { + Self::with_depth_limit(path, usize::MAX) + } + + pub fn collect_modules(&mut self, path: &Path, level: usize) -> Result<()> { + if path.is_file() { + trace!("collecting mods declared in file {}", path.display()); + self.queue.extend( + extract_modules_from_file(path)? + .into_iter() + .map(|item| (item, level)), + ); + } else { + warn!("Only dealing with files, dropping {}", path.display()); + } + Ok(()) + } +} + +impl Iterator for TraverseModulesIter { + type Item = PathBuf; + fn next(&mut self) -> Option { + if let Some((path, level)) = self.queue.pop_front() { + if level < self.max_depth { + // ignore the error here, there is nothing we can do really + // @todo potentially consider returning a result covering this + let _ = self.collect_modules(path.as_path(), level + 1); + } + Some(path) + } else { + None + } + } +} + +/// traverse path with a depth limit, if the path is a directory all its children will be collected +/// instead +pub(crate) fn traverse(path: &Path) -> Result> { + let it = TraverseModulesIter::new(path)? + .filter_map(|path: PathBuf| -> Option { + fs::read_to_string(&path) + .ok() + .and_then(|content: String| syn::parse_str(&content).ok()) + .map(|stream| Documentation::from((path, stream))) + }) + .filter(|documentation| !documentation.is_empty()); + Ok(it) +} + +/// traverse path with a depth limit, if the path is a directory all its children will be collected +/// as depth 0 instead +pub(crate) fn traverse_with_depth_limit( + path: &Path, + max_depth: usize, +) -> Result> { + let it = TraverseModulesIter::with_depth_limit(path, max_depth)? + .filter_map(|path: PathBuf| -> Option { + fs::read_to_string(&path) + .ok() + .and_then(|content: String| syn::parse_str(&content).ok()) + .map(|stream| Documentation::from((path, stream))) + }) + .filter(|documentation| !documentation.is_empty()); + Ok(it) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn demo_dir() -> PathBuf { + manifest_dir().join("demo") + } + + #[test] + fn traverse_main_rs() { + let manifest_path = demo_dir().join("src/main.rs"); + + let expect = indexmap::indexset! { + "src/main.rs", + "src/lib.rs", + "src/nested/mod.rs", + "src/nested/justone.rs", + "src/nested/justtwo.rs", + "src/nested/again/mod.rs", + "src/nested/fragments.rs", + "src/nested/fragments/enumerate.rs", + "src/nested/fragments/simple.rs", + } + .into_iter() + .map(|sub| demo_dir().join(sub)) + .collect::>(); + + let found = TraverseModulesIter::new(manifest_path.as_path()) + .expect("Must succeed to traverse file tree.") + .into_iter() + .collect::>(); + + let unexpected_files: Vec<_> = dbg!(&found) + .iter() + .filter(|found_path| !expect.contains(*found_path)) + .collect(); + assert_eq!(Vec::<&PathBuf>::new(), unexpected_files); + + let missing_files: Vec<_> = expect + .iter() + .filter(|expected_path| !found.contains(expected_path)) + .collect(); + assert_eq!(Vec::<&PathBuf>::new(), missing_files); + + assert_eq!(found.len(), expect.len()); + } +} diff --git a/src/traverse.rs b/src/traverse/mod.rs similarity index 55% rename from src/traverse.rs rename to src/traverse/mod.rs index ce197e16..dd75de56 100644 --- a/src/traverse.rs +++ b/src/traverse/mod.rs @@ -24,97 +24,8 @@ fn manifest_dir() -> PathBuf { use std::collections::VecDeque; -/// An iterator traversing module hierarchies yielding paths - -#[derive(Default, Debug, Clone)] -struct TraverseModulesIter { - queue: VecDeque, -} - -impl TraverseModulesIter { - #[allow(unused)] - pub fn with_multi(entries: I) -> Result - where - P: AsRef, - J: Iterator, - I: IntoIterator, - { - let mut me = Self::default(); - for path in entries.into_iter().map(|p| p.as_ref().to_owned()) { - me.queue.push_back(path); - } - Ok(me) - } - - pub fn new>(path: P) -> Result { - let mut me = Self::default(); - me.queue.push_back(path.as_ref().to_owned()); - Ok(me) - } - - pub fn collect_modules(&mut self, path: &Path) -> Result<()> { - if path.is_file() { - trace!("collecting mods declared in file {}", path.display()); - self.queue - .extend(extract_modules_from_file(path)?.into_iter()); - } else if path.is_dir() { - trace!("collecting mods declared in directory {}", path.display()); - walkdir::WalkDir::new(path) - .max_depth(1) - .same_file_system(true) - .into_iter() - .filter_map(|entry| { - entry - .ok() - .filter(|entry| entry.file_type().is_file()) - .map(|x| x.path().to_owned()) - }) - .filter(|path: &PathBuf| { - path.to_str() - .map(|x| x.to_owned()) - .filter(|path| path.ends_with(".rs")) - .is_some() - }) - .try_for_each::<_, Result<()>>(|path| { - self.queue - .extend(extract_modules_from_file(path)?.into_iter()); - Ok(()) - })?; - } else { - warn!( - "Only dealing with dirs or files, dropping {}", - path.display() - ); - } - Ok(()) - } -} - -impl Iterator for TraverseModulesIter { - type Item = PathBuf; - fn next(&mut self) -> Option { - if let Some(path) = self.queue.pop_front() { - // ignore the error here, there is nothing we can do really - // @todo potentially consider returning a result covering this - let _ = self.collect_modules(path.as_path()); - Some(path) - } else { - None - } - } -} - -pub(crate) fn traverse(path: &Path) -> Result> { - let it = TraverseModulesIter::new(path)? - .filter_map(|path: PathBuf| -> Option { - fs::read_to_string(&path) - .ok() - .and_then(|content: String| syn::parse_str(&content).ok()) - .map(|stream| Documentation::from((path, stream))) - }) - .filter(|documentation| !documentation.is_empty()); - Ok(it) -} +mod iter; +pub use iter::*; use proc_macro2::Spacing; use proc_macro2::TokenStream; @@ -191,7 +102,7 @@ fn extract_modules_inner>(path: P, stream: TokenStream) -> Result )) } _ => trace!( - "Neither file not dir with mod.rs {} / {} / {}", + "Neither file nor dir with mod.rs {} / {} / {}", path1.display(), path2.display(), path2.display() @@ -212,7 +123,7 @@ fn extract_modules_inner>(path: P, stream: TokenStream) -> Result } /// Read all `mod x;` declarations from a source file. -fn extract_modules_from_file>(path: P) -> Result> { +pub(crate) fn extract_modules_from_file>(path: P) -> Result> { let path: &Path = path.as_ref(); if let Some(path_str) = path.to_str() { let s = std::fs::read_to_string(path_str).map_err(|e| { @@ -227,7 +138,7 @@ fn extract_modules_from_file>(path: P) -> Result> { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum CheckItem { +pub enum CheckEntity { Markdown(PathBuf), Source(PathBuf), ManifestDescription(String), @@ -273,33 +184,9 @@ fn to_manifest_dir>(manifest_dir: P) -> Result { }) } -fn handle_manifest>(manifest_dir: P) -> Result> { - let manifest_dir = to_manifest_dir(manifest_dir)?; - trace!("Handle manifest in dir: {}", manifest_dir.display()); - - let manifest_dir = manifest_dir.as_path(); - let manifest = load_manifest(manifest_dir)?; - - let mut acc = extract_products(manifest_dir)?; - - if let Some(workspace) = manifest.workspace { - trace!("Handling manifest workspace"); - workspace - .members - .into_iter() - .try_for_each::<_, Result<()>>(|item| { - let d = manifest_dir.join(&item); - trace!("Handling manifest member {} -> {}", &item, d.display()); - acc.extend(extract_products(d)?.into_iter()); - Ok(()) - })?; - } - Ok(acc) -} - /// Extract all cargo manifest products / build targets. // @todo code with an enum to allow source and markdown files -fn extract_products>(manifest_dir: P) -> Result> { +fn extract_products>(manifest_dir: P) -> Result> { let manifest_dir = manifest_dir.as_ref(); let manifest = load_manifest(manifest_dir)?; @@ -309,16 +196,17 @@ fn extract_products>(manifest_dir: P) -> Result> { .chain(manifest.lib.into_iter().map(|x| x)); let mut items = iter + .inspect(|x| println!("manifest entries {:?}", &x)) .filter(|product| product.doctest) .filter_map(|product| product.path) - .map(|path_str| CheckItem::Source(manifest_dir.join(path_str))) - .collect::>(); + .map(|path_str| CheckEntity::Source(manifest_dir.join(path_str))) + .collect::>(); if let Some(package) = manifest.package { if let Some(readme) = package.readme { let readme = PathBuf::from(readme); if readme.is_file() { - items.push(CheckItem::Markdown(manifest_dir.join(readme))) + items.push(CheckEntity::Markdown(manifest_dir.join(readme))) } else { warn!( "README.md defined in Cargo.toml {} is not a file", @@ -327,10 +215,34 @@ fn extract_products>(manifest_dir: P) -> Result> { } } if let Some(description) = package.description { - items.push(CheckItem::ManifestDescription(description.to_owned())) + items.push(CheckEntity::ManifestDescription(description.to_owned())) } } - Ok(items) + Ok(dbg!(items)) +} + +fn handle_manifest>(manifest_dir: P) -> Result> { + let manifest_dir = to_manifest_dir(manifest_dir)?; + trace!("Handle manifest in dir: {}", manifest_dir.display()); + + let manifest_dir = manifest_dir.as_path(); + let manifest = load_manifest(manifest_dir)?; + + let mut acc = extract_products(manifest_dir)?; + + if let Some(workspace) = manifest.workspace { + trace!("Handling manifest workspace"); + workspace + .members + .into_iter() + .try_for_each::<_, Result<()>>(|item| { + let d = manifest_dir.join(&item); + trace!("Handling manifest member {} -> {}", &item, d.display()); + acc.extend(extract_products(d)?.into_iter()); + Ok(()) + })?; + } + Ok(acc) } /// Extract all chunks from @@ -355,45 +267,56 @@ pub(crate) fn extract( Markdown(PathBuf), } - // convert all `Cargo.toml` manifest files to their respective product files - // so after this conversion all of them are considered - let items: Vec<_> = paths - .into_iter() - .filter_map(|path_in| { - let path = if path_in.is_absolute() { - path_in.to_owned() - } else { - cwd.join(&path_in) - } - .canonicalize() - .unwrap(); - info!("Processing {} -> {}", path_in.display(), path.display()); - Some(if let Ok(meta) = path.metadata() { - if meta.is_file() { - match path.file_name().map(|x| x.to_str()).flatten() { - Some(file_name) if file_name == "Cargo.toml" => Extraction::Manifest(path), - Some(file_name) if file_name.ends_with(".md") => Extraction::Markdown(path), - Some(file_name) if file_name.ends_with(".rs") => Extraction::Source(path), - _ => { - warn!("Unexpected item made it into the items {}", path.display()); - return None; - } - } - } else if meta.is_dir() { - let cargo_toml = to_manifest_dir(path).unwrap().join("Cargo.toml"); - if cargo_toml.is_file() { - Extraction::Manifest(cargo_toml) - } else { - // @todo should we just collect all .rs files here instead? - Extraction::Missing(cargo_toml) + // stage 1 - obtain canonical paths + let mut flow = VecDeque::::with_capacity(32); + flow.extend(paths.into_iter().filter_map(|path_in| { + let path = if path_in.is_absolute() { + path_in.to_owned() + } else { + cwd.join(&path_in) + }; + info!("Processing {} -> {}", path_in.display(), path.display()); + path.canonicalize().ok() + })); + + // stage 2 - check for manifest, .rs , .md files and directories + let mut files_to_check = Vec::with_capacity(64); + while let Some(path) = flow.pop_front() { + files_to_check.push(if let Ok(meta) = path.metadata() { + if meta.is_file() { + match path.file_name().map(|x| x.to_str()).flatten() { + Some(file_name) if file_name == "Cargo.toml" => Extraction::Manifest(path), + Some(file_name) if file_name.ends_with(".md") => Extraction::Markdown(path), + Some(file_name) if file_name.ends_with(".rs") => Extraction::Source(path), + _ => { + warn!("Unexpected item made it into the items {}", path.display()); + continue; } + } + } else if meta.is_dir() { + let cargo_toml = to_manifest_dir(&path).unwrap().join("Cargo.toml"); + if cargo_toml.is_file() { + Extraction::Manifest(cargo_toml) } else { - Extraction::Missing(path) + // @todo should we just collect all .rs files here instead? + + // we know it's a directory, and we limit the entries to 0 levels, + // will cause to yield all "^.*\.rs$" files in that dir + // which is what we want in this case + flow.extend(TraverseModulesIter::with_depth_limit(&path, 0)?); + continue; } } else { Extraction::Missing(path) - }) + } + } else { + Extraction::Missing(path) }) + } + + // stage 3 - resolve the manifest products and workspaces, warn about missing + let files_to_check = files_to_check + .into_iter() .try_fold::, _, Result<_>>(Vec::with_capacity(64), |mut acc, tagged_path| { match tagged_path { Extraction::Manifest(ref cargo_toml_path) => { @@ -404,66 +327,36 @@ pub(crate) fn extract( "File passed as argument or listed in Cargo.toml manifest does not exist: {}", missing_path.display() ), - Extraction::Source(path) => acc.push(CheckItem::Source(path)), - Extraction::Markdown(path) => acc.push(CheckItem::Markdown(path)), + Extraction::Source(path) => acc.push(CheckEntity::Source(path)), + Extraction::Markdown(path) => acc.push(CheckEntity::Markdown(path)), } Ok(acc) })?; - let docs: Vec = if recurse { - let mut path_collection = indexmap::IndexSet::<_>::with_capacity(64); - - // @todo merge this with the `Documentation::from` to reduce parsing of the file twice - let mut dq = std::collections::VecDeque::::with_capacity(64); - dq.extend(items.into_iter()); - while let Some(item) = dq.pop_front() { - if let CheckItem::Source(path) = item { - let modules = extract_modules_from_file(&path)?; - if path_collection.insert(CheckItem::Source(path.to_owned())) { - dq.extend(modules.into_iter().map(CheckItem::Source)); - } else { - warn!("Already visited module"); - } - } - } - - trace!("Recursive"); - let n = path_collection.len(); - path_collection - .into_iter() - .try_fold::, _, Result>>( - Vec::with_capacity(n), - |mut acc, item| { - match item { - CheckItem::Source(path) => { + // stage 4 - expand from the passed source files, if recursive, recurse down the module train + let docs: Vec = files_to_check + .iter() + .try_fold::, _, Result>>( + Vec::with_capacity(files_to_check.len()), + |mut acc, item| { + match item { + CheckEntity::Source(path) => { + if recurse { + acc.extend(traverse(path.as_path())?) + } else { let content = fs::read_to_string(&path)?; let stream = syn::parse_str(&content)?; - acc.push(Documentation::from((path, stream))); + acc.push(Documentation::from((dbg!(path), stream))); } - _ => unimplemented!("Did not impl this just yet"), } - Ok(acc) - }, - )? - } else { - trace!("Single file"); - items - .iter() - .try_fold::, _, Result>>( - Vec::with_capacity(items.len()), - |mut acc, item| { - match item { - CheckItem::Source(path) => { - acc.extend(traverse(path)?); - } - _ => { - // @todo generate Documentation structs from non-file sources - } + other => { + warn!("Did not impl handling of {:?} type files", other); + // @todo generate Documentation structs from non-file sources } - Ok(acc) - }, - )? - }; + } + Ok(dbg!(acc)) + }, + )?; let combined = Documentation::combine(docs); @@ -499,10 +392,10 @@ mod tests { assert_eq!( extract_products(demo_dir()).expect("Must succeed"), vec![ - CheckItem::Source(demo_dir().join("src/main.rs")), - CheckItem::Source(demo_dir().join("src/lib.rs")), - CheckItem::Markdown(demo_dir().join("README.md")), - CheckItem::ManifestDescription( + CheckEntity::Source(demo_dir().join("src/main.rs")), + CheckEntity::Source(demo_dir().join("src/lib.rs")), + CheckEntity::Markdown(demo_dir().join("README.md")), + CheckEntity::ManifestDescription( "A silly demo with plenty of spelling mistakes for cargo-spellcheck demos and CI".to_string() ), ] @@ -513,42 +406,132 @@ mod tests { manifest_dir().join("demo") } - #[test] - fn traverse_main_rs() { - let manifest_path = demo_dir().join("src/main.rs"); + use std::collections::HashSet; + use std::hash::Hash; - let expect = indexmap::indexset! { - "src/main.rs", - "src/lib.rs", - "src/nested/mod.rs", - "src/nested/justone.rs", - "src/nested/justtwo.rs", - "src/nested/again/mod.rs", - "src/nested/fragments.rs", - "src/nested/fragments/enumerate.rs", - "src/nested/fragments/simple.rs", - } - .into_iter() - .map(|sub| demo_dir().join(sub)) - .collect::>(); + fn into_hashset(source: I) -> HashSet + where + I: IntoIterator, + J: Iterator, + T: Hash + Eq, + { + source.into_iter().collect::>() + } - let found = TraverseModulesIter::new(manifest_path.as_path()) - .expect("Must succeed to traverse file tree.") - .into_iter() - .collect::>(); + macro_rules! pathset { + ( $($x:expr),* $(,)? ) => { + { + let mut temp_set = HashSet::new(); + $( + temp_set.insert(PathBuf::from($x)); + )* + temp_set + } + }; + } + + macro_rules! extract_test { + + ($name:ident, [ $( $path:literal ),* $(,)?] + $recurse: expr => [ $( $file:literal ),* $(,)?] ) => { + + #[test] + fn $name() { + extract_test!([ $( $path ),* ] + $recurse => [ $( $file ),* ]); + } + }; - let unexpected_files: Vec<_> = dbg!(&found) - .iter() - .filter(|found_path| !expect.contains(*found_path)) - .collect(); - assert_eq!(Vec::<&PathBuf>::new(), unexpected_files); + ([ $( $path:literal ),* $(,)?] + $recurse: expr => [ $( $file:literal ),* $(,)?] ) => { + let _ = env_logger::from_env( + env_logger::Env::new().filter_or("CARGO_SPELLCHECK", "cargo_spellcheck=trace"), + ) + .is_test(true) + .try_init(); + let docs = extract( + vec![ + $( + demo_dir().join($path) + )* + ], + $recurse, + &Config::default(), + ) + .expect("Must be able to extract demo dir"); + assert_eq!( + into_hashset(docs.into_iter().map(|x| { + x.0.strip_prefix(demo_dir()).expect("Must have common prefix").to_owned() } + )), + pathset![ + $( + ($file).to_owned(), + )* + ] + ); - let missing_files: Vec<_> = expect - .iter() - .filter(|expected_path| !found.contains(expected_path)) - .collect(); - assert_eq!(Vec::<&PathBuf>::new(), missing_files); + }; + } - assert_eq!(found.len(), expect.len()); + #[test] + fn traverse_manifest_1() { + extract_test!(["Cargo.toml"] + false => [ + //"README.md", + "src/main.rs", + "src/lib.rs" + ]); } + + extract_test!(traverse_source_dir_1, ["src"] + false => [ + "src/lib.rs", + "src/main.rs"]); + + extract_test!(traverse_source_dir_rec, ["src"] + true => [ + "src/lib.rs", + "src/main.rs", + "src/nested/again/mod.rs", + "src/nested/fragments/enumerate.rs", + "src/nested/fragments/simple.rs", + "src/nested/fragments.rs", + "src/nested/justone.rs", + "src/nested/justtwo.rs", + "src/nested/mod.rs" + ]); + + extract_test!(traverse_manifest_dir_rec, ["."] + true => [ + //"README.md", + "src/lib.rs", + "src/main.rs", + "src/nested/again/mod.rs", + "src/nested/fragments/enumerate.rs", + "src/nested/fragments/simple.rs", + "src/nested/fragments.rs", + "src/nested/justone.rs", + "src/nested/justtwo.rs", + "src/nested/mod.rs", + ]); + + extract_test!(traverse_manifest_rec, ["Cargo.toml"] + true => [ + //"README.md", + "src/lib.rs", + "src/main.rs", + "src/nested/again/mod.rs", + "src/nested/fragments/enumerate.rs", + "src/nested/fragments/simple.rs", + "src/nested/fragments.rs", + "src/nested/justone.rs", + "src/nested/justtwo.rs", + "src/nested/mod.rs", + ]); + + extract_test!(traverse_nested_mod_rs_1, ["src/nested/mod.rs"] + false => [ + "src/nested/mod.rs" + ]); + + extract_test!(traverse_nested_mod_rs_rec, ["src/nested/mod.rs"] + true => [ + "src/nested/again/mod.rs", + "src/nested/fragments/enumerate.rs", + "src/nested/fragments/simple.rs", + "src/nested/fragments.rs", + "src/nested/justone.rs", + "src/nested/justtwo.rs", + "src/nested/mod.rs" + ]); }