Skip to main content

textus_core/
i18n.rs

1//! 언어 식별자와 문서 경로를 검증하는 순수 함수 모음.
2//!
3//! 파일명 접미사 방식과 명시적 디렉토리 방식이 같은 언어 표를 사용한다.
4//! 경로 검증은 문자열 규칙 검사이며 파일 존재 여부나 심볼릭 링크 대상은 검사하지 않는다.
5
6/// 실제 배정된 ISO 639-1 코드를 공백으로 구분해 정렬한 검증용 표.
7///
8/// `iso-codes`의 `iso_639-2.json`에서 `alpha_2` 필드를 추출한 데이터를 포함한다.
9/// 형식만 맞는 미배정 코드를 허용하지 않기 위해 외부 조회 없이 이 표와 비교한다.
10/// 출처는 미국 의회도서관 ISO 639-2 등록 기관의 두 글자 코드 대응표다.
11/// <https://www.loc.gov/standards/iso639-2/php/code_list.php>
12/// 갱신 시 데이터의 출처와 변경 내용을 함께 검토해야 한다.
13const LANGUAGE_CODES: &str = include_str!("iso-639-1.txt");
14
15/// 입력이 실제 배정된 소문자 ISO 639-1 코드인지 검사한다.
16///
17/// `ko`와 `en`은 허용하지만 `KO`, `kor`, `ko-KR`, 미배정 코드 `zz`는 거부한다.
18/// 대소문자 변환이나 지역 태그 축약을 하지 않아 사용자 입력의 의미를 조용히 바꾸지 않는다.
19/// 프로젝트의 지원 언어 등록 여부는 별도 검사이며 여기서는 언어 표의 유효성만 판단한다.
20///
21/// # 오류
22///
23/// 표에 없는 값이면 잘못된 코드와 허용 형식의 예시를 포함한 메시지를 반환한다.
24pub fn validate_language(language: &str) -> Result<(), String> {
25    if LANGUAGE_CODES
26        .split_whitespace()
27        .any(|code| code == language)
28    {
29        Ok(())
30    } else {
31        Err(format!(
32            "invalid ISO 639-1 language code {language:?}; use a lowercase code such as ko or en"
33        ))
34    }
35}
36
37/// 기본 파일의 마지막 확장자 앞에 언어 코드를 삽입한 경로를 반환한다.
38///
39/// `docs/api.guide.md`와 `Some("ko")`는 `docs/api.guide.ko.md`가 된다.
40/// `None`이면 검증한 기본 경로를 그대로 반환한다. 파일을 읽지 않으므로 이 결과만으로
41/// 문서 존재 여부는 알 수 없다. 호출자는 패키지 매니페스트 위치를 기준으로 해석한다.
42///
43/// # 오류
44///
45/// 빈 경로, 절대 경로, 역슬래시·콜론, 빈 경로 구간, `..`, 파일명 또는 확장자 누락을
46/// 거부한다. 언어가 주어지면 코드도 검증한다. 유효한 파일이 있는 다른 언어를 탐색하거나
47/// 자동 대체하는 정책은 포함하지 않는다.
48pub fn document_path(path: &str, language: Option<&str>) -> Result<String, String> {
49    if path.is_empty()
50        || path.starts_with('/')
51        || path.contains(['\\', ':'])
52        || path.split('/').any(|part| part.is_empty() || part == "..")
53    {
54        return Err(
55            "document path must be a nonempty package-relative path using /, without ..".into(),
56        );
57    }
58    let filename = path.rsplit('/').next().unwrap_or_default();
59    let (stem, extension) = filename
60        .rsplit_once('.')
61        .filter(|(stem, extension)| !stem.is_empty() && !extension.is_empty())
62        .ok_or("document path must have a filename and extension, for example docs/guide.md")?;
63    match language {
64        None => Ok(path.to_owned()),
65        Some(language) => {
66            validate_language(language)?;
67            let directory = &path[..path.len() - filename.len()];
68            Ok(format!("{directory}{stem}.{language}.{extension}"))
69        }
70    }
71}
72
73/// 기본 파일 또는 언어에 명시적으로 연결된 디렉토리의 동일 파일명을 선택한다.
74///
75/// `directories`는 `(언어 코드, 패키지 기준 디렉토리)` 쌍의 목록이다.
76/// `docs/api/guide.md`와 `("ko", "translations/korean/")`을 사용하면 한국어
77/// 선택 결과는 `translations/korean/guide.md`다. 기본 경로의 상위 디렉토리는
78/// 복사하지 않으며 디렉토리 끝의 `/` 하나를 제거한 뒤 파일명 전체를 붙인다.
79///
80/// 언어가 없으면 기본 파일을 선택하지만 모든 매핑의 유효성을 먼저 확인한다.
81/// 이렇게 하면 언어를 전환해야만 설정 오타가 드러나는 일을 줄일 수 있다.
82/// 선택하지 않은 파일의 존재 여부를 포함해 파일 시스템은 검사하지 않는다.
83///
84/// # 오류
85///
86/// 기본 경로는 [`document_path`]와 같은 규칙을 따른다. 빈 매핑 목록, 미배정 코드,
87/// 중복 키, 빈 디렉토리, 절대 경로, 역슬래시·콜론, 빈 구간과 `..`를 거부한다.
88/// 요청 언어의 유효성과 매핑 존재 여부는 별개로 검사하며, 유효하지만 매핑이 없는
89/// 언어에는 등록 누락 오류를 반환한다. 기본 파일이나 다른 언어로 대체하지 않는다.
90pub fn directory_document_path(
91    path: &str,
92    directories: &[(&str, &str)],
93    language: Option<&str>,
94) -> Result<String, String> {
95    document_path(path, None)?;
96    if directories.is_empty() {
97        return Err("at least one language directory mapping is required".into());
98    }
99    for (index, &(code, directory)) in directories.iter().enumerate() {
100        validate_language(code)?;
101        if directories[..index]
102            .iter()
103            .any(|&(previous, _)| previous == code)
104        {
105            return Err(format!("duplicate language directory mapping for {code:?}"));
106        }
107        // 디렉토리임을 표현하는 마지막 슬래시 하나만 허용하고 중복 슬래시는 거부한다.
108        let directory = directory.strip_suffix('/').unwrap_or(directory);
109        if directory.is_empty()
110            || directory.starts_with('/')
111            || directory.contains(['\\', ':'])
112            || directory
113                .split('/')
114                .any(|part| part.is_empty() || part == "..")
115        {
116            return Err(
117                "language directory must be a nonempty package-relative path using /, without .."
118                    .into(),
119            );
120        }
121    }
122    let Some(language) = language else {
123        return Ok(path.to_owned());
124    };
125    validate_language(language)?;
126    let directory = directories
127        .iter()
128        .find_map(|&(code, directory)| (code == language).then_some(directory))
129        .ok_or_else(|| format!("no document directory registered for language {language:?}"))?;
130    let filename = path.rsplit('/').next().unwrap();
131    Ok(format!(
132        "{}/{filename}",
133        directory.strip_suffix('/').unwrap_or(directory)
134    ))
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn accepts_assigned_codes_only() {
143        for code in ["ko", "en", "ja", "zh", "zu"] {
144            assert!(validate_language(code).is_ok());
145        }
146        for code in ["", "zz", "KO", "kor", "ko-KR", "../ko"] {
147            assert!(validate_language(code).is_err(), "{code}");
148        }
149    }
150
151    #[test]
152    fn table_is_sorted_unique_and_has_two_letter_codes() {
153        let codes: Vec<_> = LANGUAGE_CODES.split_whitespace().collect();
154        assert!(codes.len() > 180);
155        assert!(codes.windows(2).all(|pair| pair[0] < pair[1]));
156        assert!(
157            codes
158                .iter()
159                .all(|code| code.len() == 2 && code.bytes().all(|byte| byte.is_ascii_lowercase()))
160        );
161    }
162
163    #[test]
164    fn selects_only_the_filename_suffix() {
165        assert_eq!(
166            document_path("docs/guide.md", None).unwrap(),
167            "docs/guide.md"
168        );
169        assert_eq!(
170            document_path("docs.v1/api.guide.md", Some("ko")).unwrap(),
171            "docs.v1/api.guide.ko.md"
172        );
173        assert_eq!(
174            document_path("docs/안내.md", Some("ja")).unwrap(),
175            "docs/안내.ja.md"
176        );
177    }
178
179    #[test]
180    fn rejects_ambiguous_or_nonportable_paths() {
181        for path in [
182            "",
183            "/a.md",
184            "../a.md",
185            "docs/../a.md",
186            "a",
187            ".md",
188            "a.",
189            "a//b.md",
190            "C:/a.md",
191            "docs\\a.md",
192        ] {
193            assert!(document_path(path, Some("ko")).is_err(), "{path}");
194        }
195        assert!(document_path("a.md", Some("zz")).is_err());
196    }
197}
198
199#[cfg(test)]
200mod directory_tests {
201    use super::directory_document_path as select;
202
203    #[test]
204    fn selects_basename_and_explicit_directory() {
205        let mappings = [("ko", "docs/ko/"), ("en", "translations/english")];
206        assert_eq!(
207            select("docs/nested/api.guide.md", &mappings, None).unwrap(),
208            "docs/nested/api.guide.md"
209        );
210        assert_eq!(
211            select("docs/nested/api.guide.md", &mappings, Some("ko")).unwrap(),
212            "docs/ko/api.guide.md"
213        );
214        assert_eq!(
215            select("docs/안내.md", &mappings, Some("en")).unwrap(),
216            "translations/english/안내.md"
217        );
218        assert!(
219            select("a.md", &mappings, Some("ja"))
220                .unwrap_err()
221                .contains("no document directory registered")
222        );
223        assert!(
224            select("a.md", &mappings, Some("zz"))
225                .unwrap_err()
226                .contains("invalid ISO 639-1")
227        );
228    }
229
230    #[test]
231    fn validates_all_mappings_even_without_language_selection() {
232        assert!(select("a.md", &[], None).is_err());
233        assert!(select("a.md", &[("ko", "ko"), ("ko", "other")], None).is_err());
234        for code in ["zz", "KO", "kor", "ko-KR"] {
235            assert!(select("a.md", &[(code, "ko")], None).is_err());
236        }
237        for directory in [
238            "",
239            "/",
240            "/ko",
241            "../ko",
242            "docs/../ko",
243            "docs//ko",
244            "ko//",
245            "C:/ko",
246            "docs\\ko",
247        ] {
248            assert!(
249                select("a.md", &[("ko", directory)], None).is_err(),
250                "{directory}"
251            );
252        }
253        assert!(select("../a.md", &[("ko", "ko")], None).is_err());
254    }
255}