1use std::path::Path;
25use std::sync::LazyLock;
26
27use crate::parser_warn as warn;
28use md5::{Digest, Md5};
29use packageurl::PackageUrl;
30use regex::Regex;
31
32use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
33use crate::parsers::PackageParser;
34use crate::parsers::license_normalization::normalize_spdx_declared_license;
35use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
36
37pub struct PodspecParser;
50
51impl PackageParser for PodspecParser {
52 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
53
54 fn is_match(path: &Path) -> bool {
55 path.extension().is_some_and(|ext| {
56 ext == "podspec"
57 && path
58 .file_name()
59 .is_some_and(|name| !name.to_string_lossy().ends_with(".json.podspec"))
60 })
61 }
62
63 fn extract_packages(path: &Path) -> Vec<PackageData> {
64 let content = match read_file_to_string(path, None) {
65 Ok(c) => c,
66 Err(e) => {
67 warn!("Failed to read {:?}: {}", path, e);
68 return vec![default_package_data()];
69 }
70 };
71
72 let name = extract_field(&content, &NAME_PATTERN).map(truncate_field);
73 let version = extract_field(&content, &VERSION_PATTERN).map(truncate_field);
74 let summary = extract_field(&content, &SUMMARY_PATTERN).map(truncate_field);
75 let description =
76 merge_summary_and_description(summary.as_deref(), extract_description(&content))
77 .map(truncate_field);
78 let homepage_url = extract_field(&content, &HOMEPAGE_PATTERN).map(truncate_field);
79 let license = extract_license_statement(&content).map(truncate_field);
80 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
81 normalize_podspec_declared_license(&content, license.as_deref());
82 let source = extract_source_url(&content).map(truncate_field);
83 let authors = extract_authors(&content);
84
85 let parties = authors
86 .into_iter()
87 .map(|(name, email)| Party {
88 r#type: Some("person".to_string()),
89 name: Some(truncate_field(name)),
90 email: email.map(truncate_field),
91 url: None,
92 role: Some("author".to_string()),
93 organization: None,
94 organization_url: None,
95 timezone: None,
96 })
97 .collect();
98
99 let dependencies = extract_dependencies(&content);
100 let mut extra_data = serde_json::Map::new();
101 if let Some(raw_license) = extract_field(&content, &LICENSE_PATTERN)
102 && let Some(license_file) = extract_ruby_hash_file(&raw_license)
103 {
104 extra_data.insert(
105 "license_file".to_string(),
106 serde_json::Value::String(license_file),
107 );
108 }
109 let repository_homepage_url = name
110 .as_ref()
111 .map(|n| format!("https://cocoapods.org/pods/{}", n));
112 let repository_download_url = match (source.as_deref(), version.as_deref()) {
113 (Some(vcs_url), Some(version_str)) => get_repo_base_url(vcs_url)
114 .map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str)),
115 _ => None,
116 };
117 let code_view_url = match (source.as_deref(), version.as_deref()) {
118 (Some(vcs_url), Some(version_str)) => {
119 get_repo_base_url(vcs_url).map(|base| format!("{}/tree/{}", base, version_str))
120 }
121 _ => None,
122 };
123 let bug_tracking_url = source
124 .as_deref()
125 .and_then(get_repo_base_url)
126 .map(|base| format!("{}/issues/", base));
127 let api_data_url = match (name.as_deref(), version.as_deref()) {
128 (Some(name_str), Some(version_str)) => get_hashed_path(name_str).map(|hashed| {
129 format!(
130 "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
131 hashed, name_str, version_str, name_str
132 )
133 }),
134 _ => None,
135 };
136 let purl = if let Some(name_str) = &name {
137 let purl_result = PackageUrl::new(Self::PACKAGE_TYPE.as_str(), name_str)
138 .or_else(|_| PackageUrl::new("generic", name_str));
139 match purl_result {
140 Ok(mut purl) => {
141 if let Some(version_str) = &version {
142 let _ = purl.with_version(version_str);
143 }
144 Some(truncate_field(purl.to_string()))
145 }
146 Err(_) => None,
147 }
148 } else {
149 None
150 };
151
152 vec![PackageData {
153 package_type: Some(Self::PACKAGE_TYPE),
154 namespace: None,
155 name,
156 version,
157 qualifiers: None,
158 subpath: None,
159 primary_language: Some("Objective-C".to_string()),
160 description,
161 release_date: None,
162 parties,
163 keywords: Vec::new(),
164 homepage_url,
165 download_url: None,
166 size: None,
167 sha1: None,
168 md5: None,
169 sha256: None,
170 sha512: None,
171 bug_tracking_url,
172 code_view_url,
173 vcs_url: source,
174 copyright: None,
175 holder: None,
176 declared_license_expression,
177 declared_license_expression_spdx,
178 license_detections,
179 other_license_expression: None,
180 other_license_expression_spdx: None,
181 other_license_detections: Vec::new(),
182 extracted_license_statement: license,
183 notice_text: None,
184 source_packages: Vec::new(),
185 file_references: Vec::new(),
186 extra_data: (!extra_data.is_empty()).then_some(extra_data.into_iter().collect()),
187 dependencies,
188 repository_homepage_url,
189 repository_download_url,
190 api_data_url,
191 datasource_id: Some(DatasourceId::CocoapodsPodspec),
192 purl,
193 is_private: false,
194 is_virtual: false,
195 }]
196 }
197}
198
199fn default_package_data() -> PackageData {
200 PackageData {
201 package_type: Some(PodspecParser::PACKAGE_TYPE),
202 primary_language: Some("Objective-C".to_string()),
203 datasource_id: Some(DatasourceId::CocoapodsPodspec),
204 ..Default::default()
205 }
206}
207
208static NAME_PATTERN: LazyLock<Regex> =
209 LazyLock::new(|| Regex::new(r"\.name\s*=\s*(.+)").expect("valid regex"));
210static VERSION_PATTERN: LazyLock<Regex> =
211 LazyLock::new(|| Regex::new(r"\.version\s*=\s*(.+)").expect("valid regex"));
212static SUMMARY_PATTERN: LazyLock<Regex> =
213 LazyLock::new(|| Regex::new(r"\.summary\s*=\s*(.+)").expect("valid regex"));
214static DESCRIPTION_PATTERN: LazyLock<Regex> =
215 LazyLock::new(|| Regex::new(r"\.description\s*=\s*(.+)").expect("valid regex"));
216static HOMEPAGE_PATTERN: LazyLock<Regex> =
217 LazyLock::new(|| Regex::new(r"\.homepage\s*=\s*(.+)").expect("valid regex"));
218static LICENSE_PATTERN: LazyLock<Regex> =
219 LazyLock::new(|| Regex::new(r"\.license\s*=\s*(.+)").expect("valid regex"));
220static SOURCE_PATTERN: LazyLock<Regex> =
221 LazyLock::new(|| Regex::new(r"\.source\s*=\s*(.+)").expect("valid regex"));
222static AUTHOR_PATTERN: LazyLock<Regex> =
223 LazyLock::new(|| Regex::new(r"\.authors?\s*=\s*(.+)").expect("valid regex"));
224static SOURCE_GIT_PATTERN: LazyLock<Regex> =
225 LazyLock::new(|| Regex::new(r#":git\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
226static SOURCE_HTTP_PATTERN: LazyLock<Regex> =
227 LazyLock::new(|| Regex::new(r#":http\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
228
229static DEPENDENCY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
230 Regex::new(
231 r#"(?:s\.)?(?:dependency|add_dependency|add_(?:runtime|development)_dependency)\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?"#
232).expect("valid regex")
233});
234
235fn extract_license_statement(content: &str) -> Option<String> {
236 extract_field(content, &LICENSE_PATTERN).map(|value| normalize_ruby_hash_literal(&value))
237}
238
239fn normalize_podspec_declared_license(
240 content: &str,
241 extracted_license_statement: Option<&str>,
242) -> (
243 Option<String>,
244 Option<String>,
245 Vec<crate::models::LicenseDetection>,
246) {
247 let Some(raw_license) = extract_field(content, &LICENSE_PATTERN) else {
248 return super::license_normalization::empty_declared_license_data();
249 };
250 let normalized_candidate = if raw_license.contains("=>") || raw_license.contains('=') {
251 extract_ruby_hash_type(&raw_license)
252 .map(|license_type| canonicalize_cocoapods_license_type(&license_type))
253 } else {
254 extracted_license_statement.map(canonicalize_cocoapods_license_type)
255 };
256
257 normalize_spdx_declared_license(normalized_candidate.as_deref())
258}
259
260fn extract_ruby_hash_file(raw_license: &str) -> Option<String> {
261 let normalized = raw_license.replace("=>", "=");
262 let file_regex = Regex::new(r#":file\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
263 file_regex
264 .captures(&normalized)
265 .and_then(|caps| caps.get(1))
266 .map(|value| value.as_str().trim().to_string())
267 .filter(|value| !value.is_empty())
268}
269
270fn canonicalize_cocoapods_license_type(value: &str) -> String {
271 match value.trim() {
272 "Apache License, Version 2.0" => "Apache-2.0".to_string(),
273 other => other.to_string(),
274 }
275}
276
277fn extract_ruby_hash_type(raw_license: &str) -> Option<String> {
278 let normalized = raw_license.replace("=>", "=");
279 let type_regex = Regex::new(r#":type\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
280 type_regex
281 .captures(&normalized)
282 .and_then(|caps| caps.get(1))
283 .map(|value| value.as_str().trim().to_string())
284 .filter(|value| !value.is_empty())
285}
286
287fn normalize_ruby_hash_literal(value: &str) -> String {
288 if !value.contains('=') && !value.contains("=>") {
289 return value.to_string();
290 }
291
292 value
293 .replace("=>", "=")
294 .replace(['\'', '"'], "")
295 .split_whitespace()
296 .collect::<Vec<_>>()
297 .join(" ")
298}
299
300fn extract_field(content: &str, pattern: &Regex) -> Option<String> {
302 for line in content.lines().take(MAX_ITERATION_COUNT) {
303 let cleaned_line = pre_process(line);
304 if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
305 return Some(clean_string(value.as_str()));
306 }
307 }
308 None
309}
310
311fn extract_description(content: &str) -> Option<String> {
313 let lines: Vec<&str> = content.lines().take(MAX_ITERATION_COUNT).collect();
314
315 for (i, line) in lines.iter().enumerate() {
316 let cleaned = pre_process(line);
317 if let Some(value) = DESCRIPTION_PATTERN
318 .captures(&cleaned)
319 .and_then(|caps| caps.get(1))
320 {
321 let value_str = value.as_str();
322
323 if value_str.contains("<<-") {
324 return extract_multiline_description(&lines, i);
325 } else {
326 return Some(clean_string(value_str));
327 }
328 }
329 }
330 None
331}
332
333fn merge_summary_and_description(
334 summary: Option<&str>,
335 description: Option<String>,
336) -> Option<String> {
337 match (
338 summary.map(str::trim).filter(|s| !s.is_empty()),
339 description,
340 ) {
341 (Some(summary), Some(description)) if description.starts_with(summary) => Some(description),
342 (Some(summary), Some(description)) => Some(format!("{}\n{}", summary, description)),
343 (Some(summary), None) => Some(summary.to_string()),
344 (None, description) => description,
345 }
346}
347
348fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
350 let start_line = lines.get(start_index)?;
351
352 let delimiter = start_line
354 .split("<<-")
355 .nth(1)?
356 .trim()
357 .trim_matches(|c| c == '"' || c == '\'');
358
359 let mut description_lines = Vec::new();
360 let mut found_start = false;
361
362 for line in lines.iter().take(MAX_ITERATION_COUNT).skip(start_index) {
363 if !found_start && line.contains("<<-") {
364 found_start = true;
365 continue;
366 }
367
368 if found_start {
369 let trimmed = line.trim();
370 if trimmed == delimiter {
371 break;
372 }
373 description_lines.push(*line);
374 }
375 }
376
377 if description_lines.is_empty() {
378 None
379 } else {
380 Some(description_lines.join("\n").trim().to_string())
381 }
382}
383
384fn extract_authors(content: &str) -> Vec<(String, Option<String>)> {
386 let mut authors = Vec::new();
387
388 for line in content.lines().take(MAX_ITERATION_COUNT) {
389 let cleaned_line = pre_process(line);
390 if let Some(value) = AUTHOR_PATTERN
391 .captures(&cleaned_line)
392 .and_then(|caps| caps.get(1))
393 {
394 let value_str = value.as_str();
395
396 if value_str.contains("=>") {
397 for part in value_str.split(',') {
398 if let Some((name, email)) = parse_author_hash_entry(part) {
399 authors.push((name, Some(email)));
400 }
401 }
402 } else {
403 let cleaned = clean_string(value_str);
404 let (name, email) = parse_author_string(&cleaned);
405 authors.push((name, email));
406 }
407 }
408 }
409
410 authors
411}
412
413fn extract_source_url(content: &str) -> Option<String> {
414 for line in content.lines().take(MAX_ITERATION_COUNT) {
415 let cleaned_line = pre_process(line);
416 let Some(value) = SOURCE_PATTERN
417 .captures(&cleaned_line)
418 .and_then(|caps| caps.get(1))
419 .map(|m| m.as_str())
420 else {
421 continue;
422 };
423
424 if let Some(caps) = SOURCE_GIT_PATTERN.captures(value)
425 && let Some(url) = caps.get(1)
426 {
427 return Some(clean_string(url.as_str()));
428 }
429
430 if let Some(caps) = SOURCE_HTTP_PATTERN.captures(value)
431 && let Some(url) = caps.get(1)
432 {
433 return Some(clean_string(url.as_str()));
434 }
435
436 return Some(clean_string(value));
437 }
438
439 None
440}
441
442fn parse_author_hash_entry(entry: &str) -> Option<(String, String)> {
444 let parts: Vec<&str> = entry.split("=>").collect();
445 if parts.len() == 2 {
446 let name = clean_string(parts[0].trim())
447 .trim()
448 .trim_matches(['\'', '"'])
449 .to_string();
450 let email = clean_string(parts[1].trim())
451 .trim()
452 .trim_matches(['\'', '"'])
453 .to_string();
454 Some((name, email))
455 } else {
456 None
457 }
458}
459
460fn parse_author_string(author: &str) -> (String, Option<String>) {
462 if let Some(email_start) = author.find('<')
463 && let Some(email_end) = author.find('>')
464 {
465 let name = author[..email_start].trim().to_string();
466 let email = author[email_start + 1..email_end].trim().to_string();
467 return (name, Some(email));
468 }
469 (author.to_string(), None)
470}
471
472fn extract_dependencies(content: &str) -> Vec<Dependency> {
474 let mut dependencies = Vec::new();
475
476 for line in content.lines().take(MAX_ITERATION_COUNT) {
477 let cleaned_line = pre_process(line);
478 if let Some(caps) = DEPENDENCY_PATTERN.captures(&cleaned_line) {
479 let method = caps.get(0).map(|m| m.as_str()).unwrap_or("");
480 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
481 let version_req = caps.get(2).map(|m| clean_string(m.as_str()));
482
483 if let Some(dep) = create_dependency(name, version_req, method) {
484 dependencies.push(dep);
485 }
486 }
487 }
488
489 dependencies
490}
491
492fn create_dependency(name: &str, version_req: Option<String>, method: &str) -> Option<Dependency> {
494 if name.is_empty() {
495 return None;
496 }
497
498 let purl = PackageUrl::new("cocoapods", name).ok()?;
499
500 let is_pinned = version_req
502 .as_ref()
503 .map(|v| !v.contains(&['~', '>', '<', '='][..]))
504 .unwrap_or(false);
505
506 let is_development = method.contains("add_development_dependency");
507
508 Some(Dependency {
509 purl: Some(truncate_field(purl.to_string())),
510 extracted_requirement: version_req.map(truncate_field),
511 scope: Some(
512 if is_development {
513 "development"
514 } else {
515 "runtime"
516 }
517 .to_string(),
518 ),
519 is_runtime: Some(!is_development),
520 is_optional: Some(is_development),
521 is_pinned: Some(is_pinned),
522 is_direct: Some(true),
523 resolved_package: None,
524 extra_data: None,
525 })
526}
527
528fn pre_process(line: &str) -> String {
530 let line = if let Some(comment_pos) = line.find('#') {
531 &line[..comment_pos]
532 } else {
533 line
534 };
535 line.trim().to_string()
536}
537
538fn clean_string(s: &str) -> String {
540 let after_removing_special_patterns = s.trim().replace("%q", "").replace(".freeze", "");
541
542 after_removing_special_patterns
543 .trim_matches(|c| {
544 c == '\''
545 || c == '"'
546 || c == '{'
547 || c == '}'
548 || c == '['
549 || c == ']'
550 || c == '<'
551 || c == '>'
552 })
553 .trim()
554 .to_string()
555}
556
557fn get_repo_base_url(vcs_url: &str) -> Option<String> {
558 if vcs_url.is_empty() {
559 return None;
560 }
561
562 if vcs_url.ends_with(".git") {
563 Some(vcs_url.trim_end_matches(".git").to_string())
564 } else {
565 Some(vcs_url.to_string())
566 }
567}
568
569fn get_hashed_path(name: &str) -> Option<String> {
570 if name.is_empty() {
571 return None;
572 }
573
574 let mut hasher = Md5::new();
575 hasher.update(name.as_bytes());
576 let hash_str = hex::encode(hasher.finalize());
577
578 Some(format!(
579 "{}/{}/{}",
580 &hash_str[0..1],
581 &hash_str[1..2],
582 &hash_str[2..3]
583 ))
584}
585
586crate::register_parser!(
587 "CocoaPods podspec file",
588 &["**/*.podspec"],
589 "cocoapods",
590 "Objective-C",
591 Some("https://guides.cocoapods.org/syntax/podspec.html"),
592);
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn test_is_match() {
600 assert!(PodspecParser::is_match(Path::new("AFNetworking.podspec")));
601 assert!(PodspecParser::is_match(Path::new("project/MyLib.podspec")));
602 assert!(!PodspecParser::is_match(Path::new(
603 "AFNetworking.podspec.json"
604 )));
605 assert!(!PodspecParser::is_match(Path::new("Podfile")));
606 assert!(!PodspecParser::is_match(Path::new("Podfile.lock")));
607 }
608
609 #[test]
610 fn test_clean_string() {
611 assert_eq!(clean_string("'AFNetworking'"), "AFNetworking");
612 assert_eq!(clean_string("\"AFNetworking\""), "AFNetworking");
613 assert_eq!(clean_string("'test'.freeze"), "test");
614 assert_eq!(clean_string("%q{test}"), "test");
615 }
616
617 #[test]
618 fn test_extract_simple_field() {
619 let content = r#"
620Pod::Spec.new do |s|
621 s.name = "AFNetworking"
622 s.version = "4.0.1"
623end
624"#;
625 assert_eq!(
626 extract_field(content, &NAME_PATTERN),
627 Some("AFNetworking".to_string())
628 );
629 assert_eq!(
630 extract_field(content, &VERSION_PATTERN),
631 Some("4.0.1".to_string())
632 );
633 }
634
635 #[test]
636 fn test_extract_multiline_description() {
637 let content = r#"
638Pod::Spec.new do |s|
639 s.description = <<-DESC
640 A delightful networking library.
641 Features include:
642 - Modern API
643 DESC
644end
645"#;
646 let desc = extract_description(content);
647 assert!(desc.is_some());
648 let desc_text = desc.unwrap();
649 assert!(desc_text.contains("delightful networking"));
650 assert!(desc_text.contains("Modern API"));
651 }
652
653 #[test]
654 fn test_extract_dependency() {
655 let content = r#"
656Pod::Spec.new do |s|
657 s.dependency "AFNetworking", "~> 4.0"
658 s.dependency "Alamofire"
659end
660"#;
661 let deps = extract_dependencies(content);
662 assert_eq!(deps.len(), 2);
663
664 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
665 assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
666 assert_eq!(deps[0].is_pinned, Some(false)); assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
669 assert_eq!(deps[1].extracted_requirement, None);
670 }
671
672 #[test]
673 fn test_extract_runtime_and_development_dependency_scopes() {
674 let content = r#"
675Pod::Spec.new do |s|
676 s.add_dependency 'AFNetworking', '~> 4.0'
677 s.add_runtime_dependency 'Alamofire', '~> 5.0'
678 s.add_development_dependency 'Quick', '~> 7.0'
679end
680"#;
681
682 let deps = extract_dependencies(content);
683 assert_eq!(deps.len(), 3);
684
685 assert_eq!(deps[0].scope.as_deref(), Some("runtime"));
686 assert_eq!(deps[0].is_runtime, Some(true));
687 assert_eq!(deps[0].is_optional, Some(false));
688
689 assert_eq!(deps[1].scope.as_deref(), Some("runtime"));
690 assert_eq!(deps[1].is_runtime, Some(true));
691 assert_eq!(deps[1].is_optional, Some(false));
692
693 assert_eq!(deps[2].scope.as_deref(), Some("development"));
694 assert_eq!(deps[2].is_runtime, Some(false));
695 assert_eq!(deps[2].is_optional, Some(true));
696 }
697
698 #[test]
699 fn test_parse_author_string() {
700 assert_eq!(
701 parse_author_string("John Doe <john@example.com>"),
702 ("John Doe".to_string(), Some("john@example.com".to_string()))
703 );
704 assert_eq!(
705 parse_author_string("Jane Smith"),
706 ("Jane Smith".to_string(), None)
707 );
708 }
709
710 #[test]
711 fn test_normalize_podspec_license_string() {
712 let content = r#"
713Pod::Spec.new do |s|
714 s.license = 'Apache License, Version 2.0'
715end
716"#;
717
718 let extracted = extract_license_statement(content);
719 let (declared, declared_spdx, detections) =
720 normalize_podspec_declared_license(content, extracted.as_deref());
721
722 assert_eq!(declared.as_deref(), Some("apache-2.0"));
723 assert_eq!(declared_spdx.as_deref(), Some("Apache-2.0"));
724 assert_eq!(detections.len(), 1);
725 }
726
727 #[test]
728 fn test_normalize_podspec_hash_type_only() {
729 let content = r#"
730Pod::Spec.new do |s|
731 s.license = { :type => 'MIT', :file => 'LICENSE' }
732end
733"#;
734
735 let extracted = extract_license_statement(content);
736 let (declared, declared_spdx, detections) =
737 normalize_podspec_declared_license(content, extracted.as_deref());
738
739 assert_eq!(declared.as_deref(), Some("mit"));
740 assert_eq!(declared_spdx.as_deref(), Some("MIT"));
741 assert_eq!(detections.len(), 1);
742 }
743
744 #[test]
745 fn test_podspec_license_hash_preserves_license_file_reference() {
746 let content = r#"
747Pod::Spec.new do |s|
748 s.name = "Demo"
749 s.version = "1.0.0"
750 s.license = { :type => 'MIT', :file => 'LICENSE.txt' }
751end
752"#;
753
754 let temp_dir = tempfile::tempdir().unwrap();
755 let file_path = temp_dir.path().join("Demo.podspec");
756 std::fs::write(&file_path, content).unwrap();
757
758 let package_data = PodspecParser::extract_first_package(&file_path);
759 assert_eq!(package_data.license_detections.len(), 1);
760 assert_eq!(
761 package_data.license_detections[0].matches[0]
762 .referenced_filenames
763 .as_ref(),
764 Some(&vec!["LICENSE.txt".to_string()])
765 );
766 }
767}