Skip to content

Commit 48e60b7

Browse files
ctruedenclaude
andcommitted
Add explicit positioning for coordinate parsing
Implement strict mode parsing that uses empty strings (consecutive colons) to disambiguate coordinate components, avoiding heuristic ambiguity. When empty strings are detected (e.g., g:a::c), the parser switches to strict positional format: G:A:V:C:P:S (groupId, artifactId, version, classifier, packaging, scope). Examples: - org.example:my-lib:1.0.0: → version=1.0.0, classifier=None (explicit) - org.example:my-lib::sources → version=None, classifier=sources - org.example:my-lib:1.0::pom → version=1.0, packaging=pom, skip classifier Features: - Maintains backward compatibility (heuristics still work without empty strings) - Validates scope values in strict mode - Works with existing features (raw flag !, placement suffixes) Changes: - src/jgo/parse/coordinate.py: Added strict mode parsing logic - tests/test_coordinate.py: Added 15 comprehensive test cases - README.md: Documented explicit positioning syntax 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent c65151a commit 48e60b7

File tree

3 files changed

+242
-0
lines changed

3 files changed

+242
-0
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ Endpoint Format:
181181
Specify main class: org.scijava:scijava-common@ScriptREPL
182182
Auto-completion: Use simple class name (e.g., @ScriptREPL) and it will be auto-completed
183183
184+
Explicit Positioning (Advanced):
185+
Use empty strings (consecutive colons) to avoid heuristic parsing:
186+
- g:a:1.0: → version=1.0, classifier=None (explicit)
187+
- g:a::sources → version=None, classifier=sources (explicit)
188+
- g:a:v::jar → version=v, packaging=jar, skip classifier
189+
190+
Format: groupId:artifactId:version:classifier:packaging:scope
191+
Empty positions default to None. Useful when heuristics fail.
192+
184193
Full documentation: jgo --help
185194
```
186195

src/jgo/parse/coordinate.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@
1212
- G:A:P:C:V:S (full format with scope)
1313
- Other orderings of these - see the Coordinate.parse function for details
1414
15+
Explicit Positioning (Strict Mode):
16+
To avoid heuristic ambiguity, use empty strings (consecutive colons) to specify
17+
exact positions. When empty strings are detected, the parser uses strict positional
18+
format: G:A:V:C:P:S
19+
20+
Examples:
21+
- "org.example:my-lib:1.0.0:" → version=1.0.0, classifier=None (explicit)
22+
- "org.example:my-lib::sources" → version=None, classifier=sources (explicit)
23+
- "org.example:my-lib:1.0::pom" → version=1.0, packaging=pom (skip classifier)
24+
- "junit:junit:4.13:::test" → version=4.13, scope=test (skip C and P)
25+
1526
The Coordinate class is a simple data structure for holding parsed coordinate
1627
components. It does not perform Maven resolution, version interpolation, or
1728
dependency management - for that, see the jgo.maven subpackage.
@@ -296,6 +307,11 @@ def _parse_coordinate_dict(coordinate: str) -> dict[str, str | None | bool]:
296307
297308
This is the internal implementation that returns a dict.
298309
External callers should use Coordinate.parse() instead.
310+
311+
Supports two modes:
312+
1. Heuristic mode (default): Uses pattern matching to infer component types
313+
2. Strict mode: When empty strings are present (e.g., "g:a::c"), uses
314+
fixed positions G:A:V:C:P:S to avoid ambiguity
299315
"""
300316
# Check for raw/unmanaged flag (! or \! suffix) on the entire coordinate
301317
# Handle both ! and \! (shell escaped) before splitting
@@ -329,6 +345,9 @@ def _parse_coordinate_dict(coordinate: str) -> dict[str, str | None | bool]:
329345
"Invalid coordinate string: must have at least groupId and artifactId"
330346
)
331347

348+
# Check if we're in strict mode (empty strings present, indicating explicit positions)
349+
has_empty_strings = any(part == "" for part in parts[2:]) # Skip G and A positions
350+
332351
# Handle " (optional)" suffix early - it's typically attached to the last part
333352
optional = False
334353
if parts[-1].endswith(" (optional)"):
@@ -363,6 +382,43 @@ def _parse_coordinate_dict(coordinate: str) -> dict[str, str | None | bool]:
363382

364383
num_parts = len(parts)
365384

385+
# STRICT MODE: When empty strings are present, use fixed positions G:A:V:C:P:S
386+
# This allows explicit disambiguation without relying on heuristics
387+
if has_empty_strings:
388+
if num_parts >= 3:
389+
version = parts[2] if parts[2] else None
390+
if num_parts >= 4:
391+
classifier = parts[3] if parts[3] else None
392+
if num_parts >= 5:
393+
packaging = parts[4] if parts[4] else None
394+
if num_parts >= 6:
395+
scope = parts[5] if parts[5] else None
396+
if num_parts >= 7:
397+
# More than 6 positions (G:A:V:C:P:S) is an error
398+
raise ValueError(
399+
f"Too many parts in strict mode coordinate: {coordinate}. "
400+
"Expected at most G:A:V:C:P:S (6 positions total)."
401+
)
402+
403+
# In strict mode, validate that scope is actually a scope if provided
404+
if scope and scope not in scope_values:
405+
raise ValueError(
406+
f"Invalid scope '{scope}' in strict mode. "
407+
f"Must be one of: {', '.join(sorted(scope_values))}"
408+
)
409+
410+
return {
411+
"groupId": groupId,
412+
"artifactId": artifactId,
413+
"packaging": packaging,
414+
"classifier": classifier,
415+
"version": version,
416+
"scope": scope,
417+
"optional": optional,
418+
"raw": raw,
419+
"placement": placement,
420+
}
421+
366422
if num_parts == 2:
367423
# G:A - minimal coordinate
368424
pass

tests/test_coordinate.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,180 @@ def test_roundtrip_with_scope():
374374
original = "junit:junit:jar:4.13.2:test"
375375
coord = Coordinate.parse(original)
376376
assert str(coord) == original
377+
378+
379+
# =============================================================================
380+
# Strict mode tests (explicit positioning with empty strings)
381+
# =============================================================================
382+
383+
384+
def test_strict_mode_version_explicit():
385+
"""Test G:A:V: format - explicit version, no classifier (strict mode)."""
386+
result = Coordinate.parse("org.example:my-artifact:1.2.3:")
387+
assert result.groupId == "org.example"
388+
assert result.artifactId == "my-artifact"
389+
assert result.version == "1.2.3"
390+
assert result.classifier is None
391+
assert result.packaging is None
392+
assert result.scope is None
393+
394+
395+
def test_strict_mode_classifier_without_version():
396+
"""Test G:A::C format - no version, explicit classifier (strict mode)."""
397+
result = Coordinate.parse("org.example:my-lib::sources")
398+
assert result.groupId == "org.example"
399+
assert result.artifactId == "my-lib"
400+
assert result.version is None
401+
assert result.classifier == "sources"
402+
assert result.packaging is None
403+
404+
405+
def test_strict_mode_version_and_classifier():
406+
"""Test G:A:V:C: format - explicit version and classifier (strict mode)."""
407+
result = Coordinate.parse("org.lwjgl:lwjgl:3.3.1:natives-linux:")
408+
assert result.groupId == "org.lwjgl"
409+
assert result.artifactId == "lwjgl"
410+
assert result.version == "3.3.1"
411+
assert result.classifier == "natives-linux"
412+
assert result.packaging is None
413+
assert result.scope is None
414+
415+
416+
def test_strict_mode_version_skip_classifier_packaging():
417+
"""Test G:A:V::P format - version and packaging, skip classifier (strict mode)."""
418+
result = Coordinate.parse("org.example:my-lib:1.0.0::pom")
419+
assert result.groupId == "org.example"
420+
assert result.artifactId == "my-lib"
421+
assert result.version == "1.0.0"
422+
assert result.classifier is None
423+
assert result.packaging == "pom"
424+
assert result.scope is None
425+
426+
427+
def test_strict_mode_skip_version_packaging():
428+
"""Test G:A:::P format - skip version and classifier, specify packaging (strict mode)."""
429+
result = Coordinate.parse("org.example:my-webapp:::pom")
430+
assert result.groupId == "org.example"
431+
assert result.artifactId == "my-webapp"
432+
assert result.version is None
433+
assert result.classifier is None
434+
assert result.packaging == "pom"
435+
436+
437+
def test_strict_mode_full_with_scope():
438+
"""Test G:A:V:C:P:S format - full strict specification (strict mode)."""
439+
# Use empty classifier to trigger strict mode while specifying all positions
440+
result = Coordinate.parse("org.example:my-lib:1.2.3::jar:compile")
441+
assert result.groupId == "org.example"
442+
assert result.artifactId == "my-lib"
443+
assert result.version == "1.2.3"
444+
assert result.classifier is None
445+
assert result.packaging == "jar"
446+
assert result.scope == "compile"
447+
448+
449+
def test_strict_mode_only_scope():
450+
"""Test G:A::::S format - only scope specified (strict mode)."""
451+
result = Coordinate.parse("org.example:my-lib::::test")
452+
assert result.groupId == "org.example"
453+
assert result.artifactId == "my-lib"
454+
assert result.version is None
455+
assert result.classifier is None
456+
assert result.packaging is None
457+
assert result.scope == "test"
458+
459+
460+
def test_strict_mode_version_and_scope():
461+
"""Test G:A:V:::S format - version and scope, skip middle parts (strict mode)."""
462+
result = Coordinate.parse("junit:junit:4.13.2:::test")
463+
assert result.groupId == "junit"
464+
assert result.artifactId == "junit"
465+
assert result.version == "4.13.2"
466+
assert result.classifier is None
467+
assert result.packaging is None
468+
assert result.scope == "test"
469+
470+
471+
def test_strict_mode_invalid_scope():
472+
"""Test that strict mode validates scope values."""
473+
try:
474+
Coordinate.parse("org.example:my-lib::::invalid-scope")
475+
assert False, "Should have raised ValueError for invalid scope"
476+
except ValueError as e:
477+
assert "Invalid scope" in str(e)
478+
assert "invalid-scope" in str(e)
479+
480+
481+
def test_strict_mode_too_many_parts():
482+
"""Test that strict mode rejects more than 6 positions (7+ parts)."""
483+
try:
484+
# Need empty string to trigger strict mode, and 7+ parts
485+
Coordinate.parse("g:a:v:c:p:s:extra:")
486+
assert False, "Should have raised ValueError for too many parts"
487+
except ValueError as e:
488+
assert "Too many parts" in str(e)
489+
assert "at most G:A:V:C:P:S" in str(e)
490+
491+
492+
def test_strict_mode_disambiguate_version_vs_classifier():
493+
"""
494+
Test strict mode disambiguates ambiguous cases.
495+
496+
Without trailing colon, "sources" could be version or classifier (heuristic decides).
497+
With trailing colon, position is explicit.
498+
"""
499+
# Heuristic mode: "sources" detected as classifier
500+
heuristic = Coordinate.parse("org.example:my-lib:sources")
501+
assert heuristic.classifier == "sources"
502+
assert heuristic.version is None
503+
504+
# Strict mode: "sources" in position 2 = version
505+
strict = Coordinate.parse("org.example:my-lib:sources:")
506+
assert strict.version == "sources" # Unusual but explicit
507+
assert strict.classifier is None
508+
509+
510+
def test_strict_mode_disambiguate_packaging_vs_version():
511+
"""
512+
Test strict mode disambiguates packaging vs version.
513+
514+
"jar" could be packaging (heuristic) or version (if used as version string).
515+
"""
516+
# Heuristic mode: "jar" detected as packaging
517+
heuristic = Coordinate.parse("org.example:my-lib:jar")
518+
assert heuristic.packaging == "jar"
519+
assert heuristic.version is None
520+
521+
# Strict mode: "jar" in position 2 = version (unusual but explicit)
522+
strict = Coordinate.parse("org.example:my-lib:jar:")
523+
assert strict.version == "jar"
524+
assert strict.packaging is None
525+
526+
527+
def test_strict_mode_empty_version_string():
528+
"""Test G:A:: format - both version and classifier are explicitly empty."""
529+
result = Coordinate.parse("org.example:my-lib::")
530+
assert result.groupId == "org.example"
531+
assert result.artifactId == "my-lib"
532+
assert result.version is None
533+
assert result.classifier is None
534+
assert result.packaging is None
535+
536+
537+
def test_strict_mode_with_raw_flag():
538+
"""Test that strict mode works with raw flag (! suffix)."""
539+
result = Coordinate.parse("org.example:my-lib:1.0.0:!")
540+
assert result.groupId == "org.example"
541+
assert result.artifactId == "my-lib"
542+
assert result.version == "1.0.0"
543+
assert result.classifier is None
544+
assert result.raw is True
545+
546+
547+
def test_strict_mode_preserves_special_versions():
548+
"""Test that strict mode preserves RELEASE, LATEST, MANAGED versions."""
549+
result = Coordinate.parse("org.example:my-lib:RELEASE:")
550+
assert result.groupId == "org.example"
551+
assert result.artifactId == "my-lib"
552+
assert result.version == "RELEASE"
553+
assert result.classifier is None

0 commit comments

Comments
 (0)