use crate::Error;
use crate::morphology::{Morpheme, Morphology};
use crate::text::Sentence;
use super::{Finding, Span, Suggestion, make_span};
pub(super) const CONTENT_POS: &[&str] = &["名詞", "代名詞", "形状詞", "動詞", "形容詞", "副詞"];
const TRANSITIVE_SMELL_VERBS: &[&str] = &[
"もたらす",
"示す",
"意味する",
"証明する",
"生み出す",
"反映する",
"示唆する",
"物語る",
"浮き彫りにする",
"後押しする",
];
const ABSTRACT_METAPHOR_NOUNS: &[&str] = &[
"地図",
"羅針盤",
"\u{5951}\u{7d04}",
"道標",
"土台",
"架け橋",
];
const ABSTRACT_CONTEXT_NOUNS: &[&str] = &[
"実装",
"判断",
"設計",
"仕様",
"方針",
"計画",
"戦略",
"議論",
"思考",
"理解",
"運用",
"開発",
"組織",
"事業",
"変革",
"成長",
"課題",
"解決",
"意思決定",
];
#[derive(Clone, Debug)]
pub(super) struct TokenizedSentence {
pub(super) line: usize,
pub(super) text: String,
pub(super) raw_text: String,
pub(super) end_mark: Option<char>,
pub(super) line_byte_start: usize,
pub(super) tokens: Vec<Morpheme>,
}
impl TokenizedSentence {
pub(super) fn span(
&self,
raw_lines: &[&str],
byte_start: usize,
byte_end: usize,
) -> Option<Span> {
make_span(
raw_lines,
self.line,
self.line_byte_start + byte_start,
self.line,
self.line_byte_start + byte_end,
)
}
pub(super) fn excerpt(&self, byte_start: usize, byte_end: usize) -> String {
self.raw_text
.get(byte_start..byte_end)
.unwrap_or(&self.text[byte_start..byte_end])
.to_owned()
}
}
pub(super) fn tokenize(
split: &[Sentence],
morphology: &Morphology,
) -> Result<Vec<TokenizedSentence>, Error> {
split
.iter()
.map(|sentence| {
Ok(TokenizedSentence {
line: sentence.line,
text: sentence.text.clone(),
raw_text: sentence.raw_text.clone(),
end_mark: sentence.end_mark,
line_byte_start: sentence.line_byte_start,
tokens: morphology.tokenize(&sentence.text)?,
})
})
.collect()
}
pub(super) fn significant_tokens(tokens: &[Morpheme]) -> &[Morpheme] {
let start = tokens
.iter()
.position(|token| !matches!(token.pos(0), "記号" | "補助記号" | "空白"))
.unwrap_or(tokens.len());
&tokens[start..]
}
pub(super) fn punctuation_between(tokens: &[Morpheme], first: usize, second: usize) -> bool {
tokens[first + 1..second]
.iter()
.any(|token| matches!(token.pos(0), "記号" | "補助記号"))
}
pub(super) fn noun_ended(tokens: &[Morpheme]) -> bool {
tokens
.iter()
.rev()
.find(|token| !matches!(token.pos(0), "記号" | "補助記号" | "空白"))
.is_some_and(|token| matches!(token.pos(0), "名詞" | "代名詞"))
}
pub(super) fn buried_list(tokens: &[Morpheme]) -> Option<(usize, usize, usize)> {
let mut bounds = Vec::new();
let mut start = 0;
for (index, token) in tokens.iter().enumerate() {
if token.surface == "、" {
bounds.push((start, index));
start = index + 1;
}
}
bounds.push((start, tokens.len()));
let mut run = Vec::new();
let mut best = None;
for (index, (start, end)) in bounds.iter().copied().enumerate() {
if end > start && noun_ended(&tokens[start..end]) {
run.push((start, end));
} else {
run.clear();
}
if run.len() >= 2 && index + 1 < bounds.len() {
let items = run.len() + 1;
if best.is_none_or(|(_, _, best_items)| items > best_items) {
best = Some((run[0].0, bounds[index + 1].1, items));
}
}
}
best
}
pub(super) fn mora_length(tokens: &[Morpheme]) -> usize {
tokens
.iter()
.map(|token| {
token
.reading()
.chars()
.filter(|ch| {
!matches!(
ch,
'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ'
)
})
.count()
})
.sum()
}
pub(super) fn translationese_morph_findings(
tokenized: &[TokenizedSentence],
raw_lines: &[&str],
) -> Vec<Finding> {
let mut findings = Vec::new();
for sentence in tokenized {
for (index, token) in sentence.tokens.iter().enumerate() {
let Some(particle) = sentence.tokens.get(index + 1) else {
continue;
};
let Some(verb) = sentence.tokens.get(index + 2) else {
continue;
};
let causative = index > 0
&& matches!(
sentence.tokens[index - 1].dictionary_form(),
"せる" | "させる"
);
if token.surface == "こと"
&& token.pos(0) == "名詞"
&& particle.pos(0) == "助詞"
&& particle.surface == "が"
&& !causative
&& verb.pos(0) == "動詞"
&& verb.surface.starts_with("でき")
{
let start = sentence.tokens[index.saturating_sub(4)].byte_start;
let mut finding = Finding::new(
sentence.line,
"translationese_morph",
sentence.excerpt(start, verb.byte_end),
"info",
"品詞列マッチ: 名詞/動詞+こと+が/は+できる型の翻訳調構文",
);
finding.span = sentence.span(raw_lines, start, verb.byte_end);
finding.suggestion = suru_koto_ga_suggestion(sentence, raw_lines, index, particle);
findings.push(finding);
}
}
}
findings
}
fn suru_koto_ga_suggestion(
sentence: &TokenizedSentence,
raw_lines: &[&str],
koto_index: usize,
particle: &Morpheme,
) -> Option<Suggestion> {
if koto_index == 0 || particle.surface != "が" {
return None;
}
let suru = sentence.tokens.get(koto_index - 1)?;
if suru.pos(0) != "動詞" || suru.dictionary_form() != "する" {
return None;
}
let koto = &sentence.tokens[koto_index];
let expected = format!("{}{}{}", suru.surface, koto.surface, particle.surface);
let line_start = sentence.line_byte_start + suru.byte_start;
let line_end = sentence.line_byte_start + particle.byte_end;
let matches_raw = raw_lines
.get(sentence.line - 1)
.and_then(|raw_line| raw_line.get(line_start..line_end))
.is_some_and(|slice| slice == expected);
if !matches_raw {
return None;
}
Some(Suggestion {
span: sentence.span(raw_lines, suru.byte_start, particle.byte_end)?,
preimage: expected,
replacement: String::new(),
})
}
pub(super) fn redundant_light_verb_findings(
tokenized: &[TokenizedSentence],
raw_lines: &[&str],
) -> Vec<Finding> {
let mut findings = Vec::new();
for sentence in tokenized {
for (index, noun) in sentence.tokens.iter().enumerate() {
let Some(particle) = sentence.tokens.get(index + 1) else {
continue;
};
let Some(verb) = sentence.tokens.get(index + 2) else {
continue;
};
let verbal_noun = noun.pos(0) == "名詞"
&& (noun.pos(2) == "サ変可能" || noun.pos(2) == "サ変形状詞可能");
if !verbal_noun
|| particle.pos(0) != "助詞"
|| particle.surface != "を"
|| verb.pos(0) != "動詞"
|| !matches!(verb.dictionary_form(), "行う" | "行なう")
{
continue;
}
let passive_or_causative = sentence.tokens.get(index + 3).is_some_and(|next| {
matches!(
next.dictionary_form(),
"れる" | "られる" | "せる" | "させる"
)
});
if passive_or_causative {
continue;
}
let mut finding = Finding::new(
sentence.line,
"redundant_light_verb",
sentence.excerpt(noun.byte_start, verb.byte_end),
"info",
format!(
"サ変名詞+を+行う型の冗長候補: 「{}を{}」は「{}する」へ畳める。名詞の動作性を活かす方が簡潔(意図的な文体なら維持する)",
noun.surface, verb.surface, noun.surface
),
);
finding.span = sentence.span(raw_lines, noun.byte_start, verb.byte_end);
finding.suggestion = light_verb_suggestion(sentence, raw_lines, particle, verb);
findings.push(finding);
}
}
findings
}
pub(super) fn abstract_metaphor_findings(
tokenized: &[TokenizedSentence],
raw_lines: &[&str],
) -> Vec<Finding> {
let mut findings = Vec::new();
for sentence in tokenized {
for (index, token) in sentence.tokens.iter().enumerate() {
if token.pos(0) != "名詞" || !ABSTRACT_METAPHOR_NOUNS.contains(&token.dictionary_form())
{
continue;
}
let abstract_genitive = index >= 2
&& sentence.tokens[index - 1].surface == "の"
&& sentence.tokens[index - 2].pos(0) == "名詞"
&& is_abstract_context_noun(&sentence.tokens[index - 2]);
let predicate_end = metaphor_predicate_end(&sentence.tokens, index);
if !abstract_genitive
&& (predicate_end.is_none()
|| !has_abstract_context_before(&sentence.tokens, index))
{
continue;
}
let byte_start = if index >= 2 && sentence.tokens[index - 1].surface == "の" {
sentence.tokens[index - 2].byte_start
} else {
token.byte_start
};
let byte_end = predicate_end
.map(|end| sentence.tokens[end].byte_end)
.unwrap_or(token.byte_end);
let mut finding = Finding::new(
sentence.line,
"abstract_metaphor",
sentence.excerpt(byte_start, byte_end),
"info",
format!(
"抽象比喩の可能性: 「{}」。判断対象・判断基準・具体的な効果を明記してください",
token.surface
),
);
finding.span = sentence.span(raw_lines, byte_start, byte_end);
findings.push(finding);
}
}
findings
}
fn is_abstract_context_noun(token: &Morpheme) -> bool {
ABSTRACT_CONTEXT_NOUNS.iter().any(|candidate| {
token.dictionary_form() == *candidate || token.surface.ends_with(candidate)
})
}
fn has_abstract_context_before(tokens: &[Morpheme], noun_index: usize) -> bool {
tokens[..noun_index]
.iter()
.rev()
.take_while(|token| !matches!(token.pos(0), "記号" | "補助記号"))
.take(12)
.any(|token| token.pos(0) == "名詞" && is_abstract_context_noun(token))
}
fn metaphor_predicate_end(tokens: &[Morpheme], noun_index: usize) -> Option<usize> {
let first = tokens.get(noun_index + 1)?;
if matches!(first.dictionary_form(), "だ" | "です") {
return Some(noun_index + 1);
}
let second = tokens.get(noun_index + 2)?;
if matches!(first.surface.as_str(), "に" | "と") && second.dictionary_form() == "なる" {
return Some(noun_index + 2);
}
if first.surface == "で" && second.dictionary_form() == "ある" {
return Some(noun_index + 2);
}
if first.surface == "と" && second.dictionary_form() == "する" {
let third = tokens.get(noun_index + 3)?;
if third.surface == "て" {
return Some(noun_index + 3);
}
}
None
}
fn light_verb_suggestion(
sentence: &TokenizedSentence,
raw_lines: &[&str],
particle: &Morpheme,
verb: &Morpheme,
) -> Option<Suggestion> {
let replacement = match verb.surface.as_str() {
"行う" | "行なう" => "する",
"行い" | "行ない" => "し",
"行っ" | "行なっ" => "し",
_ => return None,
};
let expected = format!("{}{}", particle.surface, verb.surface);
let line_start = sentence.line_byte_start + particle.byte_start;
let line_end = sentence.line_byte_start + verb.byte_end;
let matches_raw = raw_lines
.get(sentence.line - 1)
.and_then(|raw_line| raw_line.get(line_start..line_end))
.is_some_and(|slice| slice == expected);
if !matches_raw {
return None;
}
Some(Suggestion {
span: sentence.span(raw_lines, particle.byte_start, verb.byte_end)?,
preimage: expected,
replacement: replacement.to_owned(),
})
}
pub(super) fn inanimate_morph_findings(
tokenized: &[TokenizedSentence],
raw_lines: &[&str],
) -> Vec<Finding> {
let mut findings = Vec::new();
for sentence in tokenized {
let mut skip_until = None;
for index in 0..sentence.tokens.len() {
if skip_until.is_some_and(|skip| index <= skip) {
continue;
}
let token = &sentence.tokens[index];
let mut subject_end = index;
let mut abstract_subject =
matches!(token.surface.as_str(), "これ" | "それ" | "あれ" | "それら")
|| (token.pos(0) == "名詞"
&& matches!(token.surface.as_str(), "こと" | "事実"));
if !abstract_subject && let Some(next) = sentence.tokens.get(index + 1) {
let phrase = format!("{}{}", token.surface, next.surface);
if matches!(phrase.as_str(), "この事実" | "そのこと") {
abstract_subject = true;
subject_end = index + 1;
}
}
if !abstract_subject {
continue;
}
skip_until = Some(subject_end);
let Some(particle) = sentence.tokens.get(subject_end + 1) else {
continue;
};
if particle.pos(0) != "助詞" || !matches!(particle.surface.as_str(), "が" | "は") {
continue;
}
let verb = sentence.tokens[subject_end + 2..].iter().find(|candidate| {
candidate.pos(0) == "動詞"
&& TRANSITIVE_SMELL_VERBS.contains(&candidate.dictionary_form())
});
let Some(verb) = verb else {
continue;
};
let byte_start = sentence.tokens[index.saturating_sub(3)].byte_start;
let subject = sentence.tokens[index..=subject_end]
.iter()
.map(|token| token.surface.as_str())
.collect::<String>();
let mut finding = Finding::new(
sentence.line,
"inanimate_subject_morph",
sentence.excerpt(byte_start, verb.byte_end),
"info",
format!(
"品詞列マッチ: 抽象主語「{subject}」+ {} + 他動詞的述語「{}」(英語統語の直訳調の疑い)",
particle.surface,
verb.dictionary_form()
),
);
finding.span = sentence.span(raw_lines, byte_start, verb.byte_end);
findings.push(finding);
}
}
findings
}