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