feat(dictionary): a German verb is found with its particle at the clause's end

"Ich rufe dich morgen an": where a clause ends in a separable particle,
each word before it is also asked for with the particle joined on, as a
subordinate clause writes the verb (anrufe), which Wiktionary lists among
anrufen's forms. The verb is offered beside its stem and its particle,
as the longer words running over a Japanese word are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 12:39:58 +02:00
commit 93c33ce079
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 168 additions and 4 deletions

View file

@ -0,0 +1,108 @@
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
/// The particles German separates from their verbs, which end a main
/// clause (`Ich rufe dich morgen an`: anrufen).
const _particles = {
'ab', 'an', 'auf', 'aus', 'bei', 'dabei', 'dar', 'davon', 'ein', //
'empor', 'entgegen', 'entlang', 'fest', 'fort', 'frei', 'gegenüber', //
'heim', 'her', 'herab', 'heran', 'herauf', 'heraus', 'herbei', //
'herein', 'herüber', 'herum', 'herunter', 'hervor', 'hin', 'hinab', //
'hinauf', 'hinaus', 'hinein', 'hinüber', 'hinunter', 'hinzu', 'kennen', //
'los', 'mit', 'nach', 'nieder', 'statt', 'teil', 'um', 'vor', 'voran', //
'voraus', 'vorbei', 'vorüber', 'weg', 'weiter', 'wieder', 'zu', //
'zurecht', 'zurück', 'zusammen',
};
/// A word as German text writes it, and where a clause ends.
final _word = RegExp(r"[\p{L}\p{M}]+(?:['’\-][\p{L}\p{M}]+)*", unicode: true);
final _clauseEnd = RegExp('[.,;:!?…–—()"„“”«»]');
/// Each clause of [sentence] that ends in a separable particle: its words
/// before it (from where they start), and the particle with where it
/// starts.
Iterable<({List<Match> before, Match particle})> _separated(
String sentence,
) sync* {
var from = 0;
for (final end in [
for (final match in _clauseEnd.allMatches(sentence)) match.start,
sentence.length,
]) {
final words = _word.allMatches(sentence.substring(from, end)).toList();
final offset = from;
from = end + 1;
if (words.length < 2 ||
!_particles.contains(words.last[0]!.toLowerCase())) {
continue;
}
// The matches are of the clause alone: moved to the sentence's places.
final inSentence = [
for (final word in words)
_word.matchAsPrefix(sentence, offset + word.start)!,
];
yield (
before: inSentence.sublist(0, inSentence.length - 1),
particle: inSentence.last,
);
}
}
/// The key of a separated verb's form, its particle joined before it as a
/// subordinate clause writes it (`rufe` … `an`: `anrufe`), which the
/// dictionary has among the verb's forms.
String _joined(Match particle, Match verb) =>
'${particle[0]!}${verb[0]!}'.toLowerCase();
/// Every key a dictionary is asked for to cut German [sentence]: each word
/// as it stands ([spacedSentenceKeys]), and where a clause ends in a
/// separable particle, each word before it with the particle joined on.
Set<String> germanSentenceKeys(String sentence) => {
...spacedSentenceKeys(sentence),
for (final (:before, :particle) in _separated(sentence))
for (final verb in before) _joined(particle, verb),
};
/// German [sentence] cut into its words ([spacedSentenceParts]), a
/// separated verb (`rufe` … `an`) offered as the verb it is a form of
/// (anrufen) beside its stem and its particle, as the longer words running
/// over a word are ([SentencePart.wider]).
List<SentencePart> germanSentenceParts(
String sentence,
Map<String, List<DictionaryItem>> found,
) {
// Where each separated verb's stem and particle start, with the verb.
final verbs = <int, List<SentencePart>>{};
for (final (:before, :particle) in _separated(sentence)) {
for (final stem in before) {
final entries = [
for (final item in found[_joined(particle, stem)] ?? const [])
if (item is DictionaryWord) item,
];
if (entries.isEmpty) {
continue;
}
final verb = SentencePart(
text: '${stem[0]!} … ${particle[0]!}',
start: stem.start,
entries: entries,
);
(verbs[stem.start] ??= []).add(verb);
(verbs[particle.start] ??= []).add(verb);
}
}
return [
for (final part in spacedSentenceParts(sentence, found))
if (verbs[part.start] case final wider? when part.isWord)
SentencePart(
text: part.text,
start: part.start,
entries: part.entries,
steps: part.steps,
wider: [...wider, ...part.wider].take(widerWords).toList(),
)
else
part,
];
}

View file

@ -1,5 +1,6 @@
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/chinese_words.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/chinese_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/german_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/korean_words.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/korean_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
@ -11,11 +12,15 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.
/// `sentence_words.dart`; Chinese, which doesn't inflect, /// `sentence_words.dart`; Chinese, which doesn't inflect,
/// `chinese_words.dart`; decided 2026-10-14). Korean is cut at its spaces, /// `chinese_words.dart`; decided 2026-10-14). Korean is cut at its spaces,
/// its particles taken off a word the dictionary lacks as it stands /// its particles taken off a word the dictionary lacks as it stands
/// (`korean_words.dart`). /// (`korean_words.dart`); German at its spaces, a verb whose particle ends
/// the clause found with it (`german_words.dart`).
enum WordCutting() { enum WordCutting() {
/// At the spaces, each word looked up as it stands. /// At the spaces, each word looked up as it stands.
spaces, spaces,
/// At the spaces, a verb's separated particle joined on (German).
spacesGerman,
/// At the spaces, a word's particles taken off where it needs (Korean). /// At the spaces, a word's particles taken off where it needs (Korean).
spacesKorean, spacesKorean,
@ -31,12 +36,14 @@ enum WordCutting() {
'ja' => longestJapanese, 'ja' => longestJapanese,
'zh' || 'yue' => longestChinese, 'zh' || 'yue' => longestChinese,
'ko' => spacesKorean, 'ko' => spacesKorean,
'de' => spacesGerman,
_ => spaces, _ => spaces,
}; };
/// The keys a dictionary is asked for to cut [sentence] into words. /// The keys a dictionary is asked for to cut [sentence] into words.
Set<String> keysOf(String sentence) => switch (this) { Set<String> keysOf(String sentence) => switch (this) {
spaces => spacedSentenceKeys(sentence), spaces => spacedSentenceKeys(sentence),
spacesGerman => germanSentenceKeys(sentence),
spacesKorean => koreanSentenceKeys(sentence), spacesKorean => koreanSentenceKeys(sentence),
longestJapanese => sentenceKeys(sentence), longestJapanese => sentenceKeys(sentence),
longestChinese => chineseSentenceKeys(sentence), longestChinese => chineseSentenceKeys(sentence),
@ -48,6 +55,7 @@ enum WordCutting() {
Map<String, List<DictionaryItem>> found, Map<String, List<DictionaryItem>> found,
) => switch (this) { ) => switch (this) {
spaces => spacedSentenceParts(sentence, found), spaces => spacedSentenceParts(sentence, found),
spacesGerman => germanSentenceParts(sentence, found),
spacesKorean => koreanSentenceParts(sentence, found), spacesKorean => koreanSentenceParts(sentence, found),
longestJapanese => sentenceParts(sentence, found), longestJapanese => sentenceParts(sentence, found),
longestChinese => chineseSentenceParts(sentence, found), longestChinese => chineseSentenceParts(sentence, found),
@ -56,7 +64,7 @@ enum WordCutting() {
/// The keys a dictionary is asked for to look up [text], a stretch of a /// The keys a dictionary is asked for to look up [text], a stretch of a
/// line chosen by hand, as one word. /// line chosen by hand, as one word.
Set<String> stretchKeysOf(String text) => switch (this) { Set<String> stretchKeysOf(String text) => switch (this) {
spaces => spacedStretchKeys(text), spaces || spacesGerman => spacedStretchKeys(text),
spacesKorean => koreanStretchKeys(text), spacesKorean => koreanStretchKeys(text),
longestJapanese => stretchKeys(text), longestJapanese => stretchKeys(text),
longestChinese => chineseStretchKeys(text), longestChinese => chineseStretchKeys(text),
@ -69,7 +77,7 @@ enum WordCutting() {
int start, int start,
Map<String, List<DictionaryItem>> found, Map<String, List<DictionaryItem>> found,
) => switch (this) { ) => switch (this) {
spaces => spacedStretchPart(text, start, found), spaces || spacesGerman => spacedStretchPart(text, start, found),
spacesKorean => koreanStretchPart(text, start, found), spacesKorean => koreanStretchPart(text, start, found),
longestJapanese => stretchPart(text, start, found), longestJapanese => stretchPart(text, start, found),
longestChinese => chineseStretchPart(text, start, found), longestChinese => chineseStretchPart(text, start, found),

View file

@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/german_words.dart';
import '../../support/fake_dictionary_gateway.dart';
void main() {
final anrufen = germanWord(
'anrufen',
pos: 'verb',
glosses: ['to call'],
forms: ['anrufe', 'anruft', 'anrief'],
);
final rufen = germanWord('rufen', pos: 'verb', glosses: ['to shout']);
final an = germanWord('an', pos: 'preposition', glosses: ['at']);
test('a clause ending in a separable particle asks for each word before '
'it with the particle joined on, as subordinate clauses write it', () {
final keys = germanSentenceKeys('Ich rufe dich morgen an.');
expect(keys, containsAll(['ich', 'rufe', 'an', 'anrufe', 'anich']));
// Only where a particle ends the clause.
expect(germanSentenceKeys('Ich rufe dich.'), isNot(contains('anrufe')));
});
test('the separated verb is offered beside its stem and its particle; '
'clauses each on their own', () {
final found = <String, List<DictionaryItem>>{
'rufe': [rufen],
'an': [an],
'anrufe': [anrufen],
};
final parts = germanSentenceParts(
'Ich rufe dich morgen an, dann gehe ich.',
found,
);
final stem = parts.singleWhere((part) => part.text == 'rufe');
expect(stem.entries, [rufen]);
expect(stem.wider.single.entries, [anrufen]);
expect(stem.wider.single.text, 'rufe … an');
final particle = parts.singleWhere((part) => part.text == 'an');
expect(particle.wider.single.entries, [anrufen]);
// The particle's place in the line is where it stands.
expect(particle.start, 'Ich rufe dich morgen '.length);
});
}

View file

@ -114,7 +114,7 @@ void main() {
test('Japanese is cut by its longest words, the rest at their spaces', () { test('Japanese is cut by its longest words, the rest at their spaces', () {
expect(WordCutting.of('ja'), WordCutting.longestJapanese); expect(WordCutting.of('ja'), WordCutting.longestJapanese);
expect(WordCutting.of('de'), WordCutting.spaces); expect(WordCutting.of('de'), WordCutting.spacesGerman);
expect(WordCutting.of('el'), WordCutting.spaces); expect(WordCutting.of('el'), WordCutting.spaces);
expect(WordCutting.of('en-GB'), WordCutting.spaces); expect(WordCutting.of('en-GB'), WordCutting.spaces);
}); });