From 83532b4e35aa0c6bcf2ac0b14bda535815759428 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 1 May 2026 11:20:54 +0200 Subject: [PATCH] TOML: structured DottedKey AST type and path-lookup utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dotted TOML key like `physical.color` was previously flattened into a single `Toml.Identifier` whose `name` was the joined string of all child tokens. That representation cannot distinguish `site."google.com"` (two segments, the second containing a literal dot) from `site.google.com` (three bare segments) — both became the string `site.google.com`. Recipes wanting to find or modify a value by logical key path each ended up doing ad-hoc traversal that handled only some of the equivalent authoring forms. Add `Toml.DottedKey implements TomlKey` with an ordered list of `Toml.Identifier` segments wrapped in `TomlRightPadded`. Each segment preserves its own prefix/source for round-tripping, and the right-padding holds the whitespace before the following dot. The dots themselves are emitted by the printer between segments rather than stored. `Toml.Table.name` widens from `TomlRightPadded` to `TomlRightPadded` so headers can carry either shape. `TomlKey` gains a `getPath()` default returning the canonical list of unquoted segment names — singleton for a simple `Identifier`, N-element for a `DottedKey`. A `getName()` default returns those segments joined with `.`, matching the existing `Identifier.getName()` semantics so consumers that compare names as strings keep working unchanged. `TomlPaths` is a new static utility offering `findKeyValue` and `findTable` over a `Document`. The finder walks the document and matches a target path regardless of whether the document expressed it as a flat dotted key (`a.b.c.x = 1`), nested headers (`[a] [a.b] [a.b.c] x = 1`), `[a.b.c] x = 1`, `[a.b] c.x = 1`, or nested inline tables. Quoted segments containing literal dots are treated as one segment. Also: `TomlVisitor.visitTable` now visits the table name so subclasses that transform identifiers/dotted keys see headers as well as key-value keys; previously the name was silently skipped. `SemanticallyEqual.keyEquals` and `TomlPathMatcher` are simplified to use `getPath()` directly. `PythonDependencyParser.indexTables` is adjusted so dotted-header tables (e.g. `[tool.uv]`) keep being indexed. --- .../internal/PythonDependencyParser.java | 5 +- .../openrewrite/toml/SemanticallyEqual.java | 30 ++-- .../org/openrewrite/toml/TomlIsoVisitor.java | 5 + .../org/openrewrite/toml/TomlPathMatcher.java | 32 ++-- .../java/org/openrewrite/toml/TomlPaths.java | 123 ++++++++++++++++ .../org/openrewrite/toml/TomlVisitor.java | 12 ++ .../toml/internal/TomlParserVisitor.java | 39 +++-- .../toml/internal/TomlPrinter.java | 8 + .../java/org/openrewrite/toml/tree/Toml.java | 85 ++++++++++- .../org/openrewrite/toml/tree/TomlKey.java | 38 +++++ .../org/openrewrite/toml/TomlParserTest.java | 31 +++- .../org/openrewrite/toml/TomlPathsTest.java | 137 ++++++++++++++++++ 12 files changed, 481 insertions(+), 64 deletions(-) create mode 100644 rewrite-toml/src/main/java/org/openrewrite/toml/TomlPaths.java create mode 100644 rewrite-toml/src/test/java/org/openrewrite/toml/TomlPathsTest.java diff --git a/rewrite-python/src/main/java/org/openrewrite/python/internal/PythonDependencyParser.java b/rewrite-python/src/main/java/org/openrewrite/python/internal/PythonDependencyParser.java index 0f62479a441..9a2a42ebdce 100644 --- a/rewrite-python/src/main/java/org/openrewrite/python/internal/PythonDependencyParser.java +++ b/rewrite-python/src/main/java/org/openrewrite/python/internal/PythonDependencyParser.java @@ -103,9 +103,8 @@ private static Map indexTables(Toml.Document doc) { for (TomlValue value : doc.getValues()) { if (value instanceof Toml.Table) { Toml.Table table = (Toml.Table) value; - Toml.Identifier nameId = table.getName(); - if (nameId != null) { - tables.put(nameId.getName(), table); + if (table.getName() != null) { + tables.put(table.getName().getName(), table); } } } diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/SemanticallyEqual.java b/rewrite-toml/src/main/java/org/openrewrite/toml/SemanticallyEqual.java index 17b0d8e2925..4802db0db5b 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/SemanticallyEqual.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/SemanticallyEqual.java @@ -175,6 +175,21 @@ public Toml visitIdentifier(Toml.Identifier identifier, Toml other) { return null; } + @Override + public Toml visitDottedKey(Toml.DottedKey dottedKey, Toml other) { + if (dottedKey == other) { + return null; + } + if (!(other instanceof Toml.DottedKey)) { + areEqual = false; + return null; + } + if (!dottedKey.getPath().equals(((Toml.DottedKey) other).getPath())) { + areEqual = false; + } + return null; + } + @Override public Toml visitEmpty(Toml.Empty empty, Toml other) { if (empty == other) { @@ -214,18 +229,9 @@ private boolean keyEquals(@Nullable TomlKey key1, @Nullable TomlKey key2) { if (key1 == null || key2 == null) { return false; } - - // Both keys must be of the same type - if (key1.getClass() != key2.getClass()) { - return false; - } - - // Compare identifier keys - if (key1 instanceof Toml.Identifier && key2 instanceof Toml.Identifier) { - return ((Toml.Identifier) key1).getName().equals(((Toml.Identifier) key2).getName()); - } - - return false; + // Same canonical path counts as equal regardless of whether the key + // was authored as a simple key or a dotted key. + return key1.getPath().equals(key2.getPath()); } } } diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlIsoVisitor.java b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlIsoVisitor.java index 850c76832a1..815ccdd220d 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlIsoVisitor.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlIsoVisitor.java @@ -39,6 +39,11 @@ public Toml.Identifier visitIdentifier(Toml.Identifier identifier, P p) { return (Toml.Identifier) super.visitIdentifier(identifier, p); } + @Override + public Toml.DottedKey visitDottedKey(Toml.DottedKey dottedKey, P p) { + return (Toml.DottedKey) super.visitDottedKey(dottedKey, p); + } + @Override public Toml.KeyValue visitKeyValue(Toml.KeyValue keyValue, P p) { return (Toml.KeyValue) super.visitKeyValue(keyValue, p); diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPathMatcher.java b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPathMatcher.java index b960adda184..f7572a10dea 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPathMatcher.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPathMatcher.java @@ -18,7 +18,6 @@ import org.jspecify.annotations.Nullable; import org.openrewrite.Cursor; import org.openrewrite.toml.tree.Toml; -import org.openrewrite.toml.tree.TomlKey; import java.util.ArrayList; import java.util.List; @@ -95,29 +94,20 @@ private List buildPath(Cursor cursor) { if (value instanceof Toml.KeyValue) { Toml.KeyValue kv = (Toml.KeyValue) value; - TomlKey key = kv.getKey(); - if (key instanceof Toml.Identifier) { - String keyName = ((Toml.Identifier) key).getName(); - Cursor parent = current.getParent(); - while (parent != null) { - Object parentValue = parent.getValue(); - if (parentValue instanceof Toml.Table) { - Toml.Table table = (Toml.Table) parentValue; - if (table.getName() != null) { - String tableName = table.getName().getName(); - // Split dotted names: [tool.poetry] - String[] parts = tableName.split("\\."); - for (int i = parts.length - 1; i >= 0; i--) { - path.add(0, parts[i].trim()); - } - } - break; + Cursor parent = current.getParent(); + while (parent != null) { + Object parentValue = parent.getValue(); + if (parentValue instanceof Toml.Table) { + Toml.Table table = (Toml.Table) parentValue; + if (table.getName() != null) { + path.addAll(table.getName().getPath()); } - parent = parent.getParent(); + break; } - path.add(keyName); - return path; + parent = parent.getParent(); } + path.addAll(kv.getKey().getPath()); + return path; } current = current.getParent(); diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPaths.java b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPaths.java new file mode 100644 index 00000000000..43876407f02 --- /dev/null +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlPaths.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.toml; + +import org.jspecify.annotations.Nullable; +import org.openrewrite.toml.marker.ArrayTable; +import org.openrewrite.toml.marker.InlineTable; +import org.openrewrite.toml.tree.Toml; +import org.openrewrite.toml.tree.TomlValue; + +import java.util.List; + +/** + * Path-based lookup over a {@link Toml.Document}. Resolves a logical key path + * (a list of unquoted segment names) to the AST node for that key, regardless + * of whether the document expressed it as + *

    + *
  • a flat dotted key ({@code a.b.c.x = 1}),
  • + *
  • nested table headers ({@code [a.b.c] x = 1}),
  • + *
  • nested inline tables ({@code a = {b = {c = {x = 1}}}}), or
  • + *
  • any combination of the above (e.g., {@code [a.b] c.x = 1}).
  • + *
+ * + *

Quoted segments containing literal dots are treated as a single segment, + * so {@code site."google.com"} resolves at path {@code ["site", "google.com"]} + * (length 2), distinct from {@code site.google.com} which resolves at + * {@code ["site", "google", "com"]} (length 3). + * + *

Array tables ({@code [[products]]}) are not searched: there is no way to + * disambiguate which element of the array a path refers to. + */ +public final class TomlPaths { + + private TomlPaths() { + } + + /** + * Find the {@link Toml.KeyValue} at the given logical key path, or + * {@code null} if no such key exists. + */ + public static Toml.@Nullable KeyValue findKeyValue(Toml.Document doc, List path) { + if (path.isEmpty()) { + return null; + } + return findKeyValueIn(doc.getValues(), path); + } + + /** + * Find a standard (non-array, non-inline) {@link Toml.Table} whose header + * matches the given logical key path, or {@code null} if no such table + * exists. Implicit tables defined only via dotted keys (e.g. {@code [a.b]} + * implicitly defines {@code [a]}) are not returned — only tables that are + * explicitly written as {@code [path]} are matched. + */ + public static Toml.@Nullable Table findTable(Toml.Document doc, List path) { + if (path.isEmpty()) { + return null; + } + for (TomlValue value : doc.getValues()) { + if (!(value instanceof Toml.Table)) { + continue; + } + Toml.Table table = (Toml.Table) value; + if (isStandardTable(table) && table.getName() != null && path.equals(table.getName().getPath())) { + return table; + } + } + return null; + } + + private static Toml.@Nullable KeyValue findKeyValueIn(List elements, List targetSuffix) { + for (Toml element : elements) { + if (element instanceof Toml.KeyValue) { + Toml.KeyValue kv = (Toml.KeyValue) element; + List kvPath = kv.getKey().getPath(); + if (kvPath.equals(targetSuffix)) { + return kv; + } + if (kv.getValue() instanceof Toml.Table) { + Toml.KeyValue found = recurseAtPrefix(kvPath, ((Toml.Table) kv.getValue()).getValues(), targetSuffix); + if (found != null) { + return found; + } + } + } else if (element instanceof Toml.Table) { + Toml.Table table = (Toml.Table) element; + if (!isStandardTable(table) || table.getName() == null) { + continue; + } + Toml.KeyValue found = recurseAtPrefix(table.getName().getPath(), table.getValues(), targetSuffix); + if (found != null) { + return found; + } + } + } + return null; + } + + private static Toml.@Nullable KeyValue recurseAtPrefix(List prefix, List children, List targetSuffix) { + if (prefix.size() >= targetSuffix.size() || !targetSuffix.subList(0, prefix.size()).equals(prefix)) { + return null; + } + return findKeyValueIn(children, targetSuffix.subList(prefix.size(), targetSuffix.size())); + } + + private static boolean isStandardTable(Toml.Table table) { + return !table.getMarkers().findFirst(InlineTable.class).isPresent() && + !table.getMarkers().findFirst(ArrayTable.class).isPresent(); + } +} diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlVisitor.java b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlVisitor.java index 9fe054c0f59..c18d1387545 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/TomlVisitor.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/TomlVisitor.java @@ -22,6 +22,7 @@ import org.openrewrite.internal.ListUtils; import org.openrewrite.toml.tree.Space; import org.openrewrite.toml.tree.Toml; +import org.openrewrite.toml.tree.TomlKey; import org.openrewrite.toml.tree.TomlRightPadded; import org.openrewrite.toml.tree.TomlValue; @@ -64,6 +65,13 @@ public Toml visitIdentifier(Toml.Identifier identifier, P p) { return i.withMarkers(visitMarkers(i.getMarkers(), p)); } + public Toml visitDottedKey(Toml.DottedKey dottedKey, P p) { + Toml.DottedKey d = dottedKey; + d = d.withPrefix(visitSpace(d.getPrefix(), p)); + d = d.withMarkers(visitMarkers(d.getMarkers(), p)); + return d.getPadding().withNames(ListUtils.map(d.getPadding().getNames(), n -> visitRightPadded(n, p))); + } + public Toml visitKeyValue(Toml.KeyValue keyValue, P p) { Toml.KeyValue kv = keyValue; kv = kv.withPrefix(visitSpace(kv.getPrefix(), p)); @@ -86,6 +94,10 @@ public Toml visitTable(Toml.Table table, P p) { Toml.Table t = table; t = t.withPrefix(visitSpace(t.getPrefix(), p)); t = t.withMarkers(visitMarkers(t.getMarkers(), p)); + TomlRightPadded name = t.getPadding().getName(); + if (name != null) { + t = t.getPadding().withName(visitRightPadded(name, p)); + } return t.withValues(ListUtils.map(t.getValues(), v -> visit(v, p))); } diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlParserVisitor.java b/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlParserVisitor.java index 7503d1f80a5..e9a5e164a5d 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlParserVisitor.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlParserVisitor.java @@ -128,8 +128,11 @@ public Toml.Document visitDocument(TomlParser.DocumentContext ctx) { } @Override - public Toml.Identifier visitKey(TomlParser.KeyContext ctx) { - return (Toml.Identifier) super.visitKey(ctx); + public TomlKey visitKey(TomlParser.KeyContext ctx) { + if (ctx.simpleKey() != null) { + return visitSimpleKey(ctx.simpleKey()); + } + return visitDottedKey(ctx.dottedKey()); } @Override @@ -147,22 +150,16 @@ public Toml.Identifier visitSimpleKey(TomlParser.SimpleKeyContext ctx) { } @Override - public Toml.Identifier visitDottedKey(TomlParser.DottedKeyContext ctx) { + public Toml.DottedKey visitDottedKey(TomlParser.DottedKeyContext ctx) { Space prefix = prefix(ctx); - StringBuilder text = new StringBuilder(); - StringBuilder key = new StringBuilder(); - for (ParseTree child : ctx.children) { - Space space = sourceBefore(child.getText()); - text.append(space.getWhitespace()).append(child.getText()); - key.append(child.getText()); + List simpleKeys = ctx.simpleKey(); + List> segments = new ArrayList<>(simpleKeys.size()); + for (int i = 0; i < simpleKeys.size(); i++) { + Toml.Identifier segment = visitSimpleKey(simpleKeys.get(i)); + Space after = i < simpleKeys.size() - 1 ? sourceBefore(".") : Space.EMPTY; + segments.add(TomlRightPadded.build(segment).withAfter(after)); } - return new Toml.Identifier( - randomId(), - prefix, - Markers.EMPTY, - text.toString(), - key.toString() - ); + return new Toml.DottedKey(randomId(), prefix, Markers.EMPTY, segments); } /** @@ -192,7 +189,7 @@ public Toml.KeyValue visitKeyValue(TomlParser.KeyValueContext ctx) { randomId(), prefix, Markers.EMPTY, - TomlRightPadded.build((TomlKey) visitKey(c.key())).withAfter(sourceBefore("=")), + TomlRightPadded.build(visitKey(c.key())).withAfter(sourceBefore("=")), visitValue(c.value()) )); } @@ -413,8 +410,8 @@ public Toml visitInlineTable(TomlParser.InlineTableContext ctx) { public Toml visitStandardTable(TomlParser.StandardTableContext ctx) { return convert(ctx, (c, prefix) -> { sourceBefore("["); - Toml.Identifier tableName = visitKey(c.key()); - TomlRightPadded nameRightPadded = TomlRightPadded.build(tableName).withAfter(sourceBefore("]")); + TomlKey tableName = visitKey(c.key()); + TomlRightPadded nameRightPadded = TomlRightPadded.build(tableName).withAfter(sourceBefore("]")); List values = c.keyValue(); List> elements = new ArrayList<>(); @@ -436,8 +433,8 @@ public Toml visitStandardTable(TomlParser.StandardTableContext ctx) { public Toml visitArrayTable(TomlParser.ArrayTableContext ctx) { return convert(ctx, (c, prefix) -> { sourceBefore("[["); - Toml.Identifier tableName = visitKey(c.key()); - TomlRightPadded nameRightPadded = TomlRightPadded.build(tableName).withAfter(sourceBefore("]]")); + TomlKey tableName = visitKey(c.key()); + TomlRightPadded nameRightPadded = TomlRightPadded.build(tableName).withAfter(sourceBefore("]]")); List values = c.keyValue(); List> elements = new ArrayList<>(); diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlPrinter.java b/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlPrinter.java index b0bdeceed5f..f7ff4238e17 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlPrinter.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/internal/TomlPrinter.java @@ -66,6 +66,14 @@ public Toml visitIdentifier(Toml.Identifier identifier, PrintOutputCapture

p) return identifier; } + @Override + public Toml visitDottedKey(Toml.DottedKey dottedKey, PrintOutputCapture

p) { + beforeSyntax(dottedKey, p); + visitRightPadded(dottedKey.getPadding().getNames(), ".", p); + afterSyntax(dottedKey, p); + return dottedKey; + } + @Override public Toml visitKeyValue(Toml.KeyValue keyValue, PrintOutputCapture

p) { beforeSyntax(keyValue, p); diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/tree/Toml.java b/rewrite-toml/src/main/java/org/openrewrite/toml/tree/Toml.java index f429cd99f56..03c46690ace 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/tree/Toml.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/tree/Toml.java @@ -199,6 +199,81 @@ public String toString() { } } + /** + * A dotted key such as {@code physical.color} or {@code site."google.com"}. + * Each segment is a {@link Identifier}; the dots between them are emitted + * by the printer and not stored in the AST. {@code TomlRightPadded.after} on + * each segment captures whitespace before the following dot (empty for the + * last segment). + */ + @Value + @EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true) + @RequiredArgsConstructor + @AllArgsConstructor(access = AccessLevel.PRIVATE) + class DottedKey implements TomlKey { + @Nullable + @NonFinal + transient WeakReference padding; + + @With + @EqualsAndHashCode.Include + UUID id; + + @With + Space prefix; + + @With + Markers markers; + + List> names; + + public List getNames() { + return TomlRightPadded.getElements(names); + } + + public DottedKey withNames(List names) { + return getPadding().withNames(TomlRightPadded.withElements(this.names, names)); + } + + @Override + public

Toml acceptToml(TomlVisitor

v, P p) { + return v.visitDottedKey(this, p); + } + + @Override + public String toString() { + return "DottedKey{prefix=" + prefix + ", path=" + getPath() + "}"; + } + + public Padding getPadding() { + Padding p; + if (this.padding == null) { + p = new Padding(this); + this.padding = new WeakReference<>(p); + } else { + p = this.padding.get(); + if (p == null || p.t != this) { + p = new Padding(this); + this.padding = new WeakReference<>(p); + } + } + return p; + } + + @RequiredArgsConstructor + public static class Padding { + private final DottedKey t; + + public List> getNames() { + return t.names; + } + + public DottedKey withNames(List> names) { + return t.names == names ? t : new DottedKey(t.id, t.prefix, t.markers, names); + } + } + } + @Value @EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true) @RequiredArgsConstructor @@ -315,13 +390,13 @@ class Table implements TomlValue { Markers markers; @Nullable - TomlRightPadded name; + TomlRightPadded name; - public Toml.@Nullable Identifier getName() { + public @Nullable TomlKey getName() { return name != null ? name.getElement() : null; } - public Table withName(Toml.@Nullable Identifier name) { + public Table withName(@Nullable TomlKey name) { return getPadding().withName(TomlRightPadded.withElement(this.name, name)); } @@ -367,11 +442,11 @@ public Table withValues(List> values) { return t.values == values ? t : new Table(t.id, t.prefix, t.markers, t.name, values); } - public @Nullable TomlRightPadded getName() { + public @Nullable TomlRightPadded getName() { return t.name; } - public Table withName(@Nullable TomlRightPadded name) { + public Table withName(@Nullable TomlRightPadded name) { return t.name == name ? t : new Table(t.id, t.prefix, t.markers, name, t.values); } } diff --git a/rewrite-toml/src/main/java/org/openrewrite/toml/tree/TomlKey.java b/rewrite-toml/src/main/java/org/openrewrite/toml/tree/TomlKey.java index bdf63eb1f0e..c2793f6f2c1 100644 --- a/rewrite-toml/src/main/java/org/openrewrite/toml/tree/TomlKey.java +++ b/rewrite-toml/src/main/java/org/openrewrite/toml/tree/TomlKey.java @@ -15,5 +15,43 @@ */ package org.openrewrite.toml.tree; +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.singletonList; + public interface TomlKey extends Toml { + + /** + * The canonical key path: the list of unquoted segment names. A simple + * {@link Toml.Identifier} returns a singleton list; a {@link Toml.DottedKey} + * returns one entry per segment. Quoted segments are returned without their + * surrounding quotes, so {@code site."google.com"} yields + * {@code ["site", "google.com"]} (length 2) and is distinguishable from + * {@code site.google.com} which yields {@code ["site", "google", "com"]} + * (length 3). + */ + default List getPath() { + if (this instanceof Toml.DottedKey) { + List> segments = ((Toml.DottedKey) this).getPadding().getNames(); + List path = new ArrayList<>(segments.size()); + for (TomlRightPadded segment : segments) { + path.add(segment.getElement().getName()); + } + return path; + } + return singletonList(((Toml.Identifier) this).getName()); + } + + /** + * The dot-joined unquoted segment names. For a simple {@link Toml.Identifier} + * this is the bare or unquoted name; for a {@link Toml.DottedKey} the segments + * are joined with {@code '.'}. This loses the distinction between a quoted + * segment containing a dot ({@code site."google.com"}) and a sequence of bare + * segments ({@code site.google.com}); use {@link #getPath()} when that + * distinction matters. + */ + default String getName() { + return String.join(".", getPath()); + } } diff --git a/rewrite-toml/src/test/java/org/openrewrite/toml/TomlParserTest.java b/rewrite-toml/src/test/java/org/openrewrite/toml/TomlParserTest.java index e5c74239e2d..9c304e00619 100644 --- a/rewrite-toml/src/test/java/org/openrewrite/toml/TomlParserTest.java +++ b/rewrite-toml/src/test/java/org/openrewrite/toml/TomlParserTest.java @@ -336,7 +336,24 @@ void dottedKeys() { physical.color = "orange" physical.shape = "round" site."google.com" = true - """ + site.google.com = false + """, + spec -> spec.afterRecipe(doc -> { + assertThat(doc.getValues()).hasSize(4); + Toml.KeyValue first = (Toml.KeyValue) doc.getValues().get(0); + assertThat(first.getKey()).isInstanceOf(Toml.DottedKey.class); + assertThat(first.getKey().getPath()).containsExactly("physical", "color"); + + // quoted segment with literal dot is one segment + Toml.KeyValue quoted = (Toml.KeyValue) doc.getValues().get(2); + assertThat(quoted.getKey()).isInstanceOf(Toml.DottedKey.class); + assertThat(quoted.getKey().getPath()).containsExactly("site", "google.com"); + + // bare three-segment form is distinct + Toml.KeyValue bare = (Toml.KeyValue) doc.getValues().get(3); + assertThat(bare.getKey()).isInstanceOf(Toml.DottedKey.class); + assertThat(bare.getKey().getPath()).containsExactly("site", "google", "com"); + }) ) ); } @@ -363,7 +380,17 @@ void extraWhitespaceTable() { [ d.e.f ] # same as [d.e.f] [ g . h . i ] # same as [g.h.i] [ j . "ʞ" . 'l' ] # same as [j."ʞ".'l'] - """ + """, + spec -> spec.afterRecipe(doc -> { + assertThat(doc.getValues()).hasSize(4); + Toml.Table abc = (Toml.Table) doc.getValues().get(0); + assertThat(abc.getName()).isInstanceOf(Toml.DottedKey.class); + assertThat(abc.getName().getPath()).containsExactly("a", "b", "c"); + + Toml.Table jkl = (Toml.Table) doc.getValues().get(3); + assertThat(jkl.getName()).isInstanceOf(Toml.DottedKey.class); + assertThat(jkl.getName().getPath()).containsExactly("j", "ʞ", "l"); + }) ) ); } diff --git a/rewrite-toml/src/test/java/org/openrewrite/toml/TomlPathsTest.java b/rewrite-toml/src/test/java/org/openrewrite/toml/TomlPathsTest.java new file mode 100644 index 00000000000..d2c2c14bf19 --- /dev/null +++ b/rewrite-toml/src/test/java/org/openrewrite/toml/TomlPathsTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.toml; + +import org.junit.jupiter.api.Test; +import org.openrewrite.toml.tree.Toml; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TomlPathsTest { + + private static Toml.Document parse(String src) { + return (Toml.Document) new TomlParser().parse(src).findFirst().orElseThrow(); + } + + private static List path(String... segments) { + return Arrays.asList(segments); + } + + @Test + void findsKeyAtFlatDottedPath() { + Toml.Document doc = parse("a.b.c.x = 1\n"); + Toml.KeyValue found = TomlPaths.findKeyValue(doc, path("a", "b", "c", "x")); + assertThat(found).isNotNull(); + assertThat(((Toml.Literal) found.getValue()).getValue()).isEqualTo(1L); + } + + @Test + void findsKeyInsideStandardTable() { + Toml.Document doc = parse("[a.b.c]\nx = 1\n"); + Toml.KeyValue found = TomlPaths.findKeyValue(doc, path("a", "b", "c", "x")); + assertThat(found).isNotNull(); + assertThat(((Toml.Literal) found.getValue()).getValue()).isEqualTo(1L); + } + + @Test + void findsKeyInsideNestedHeaders() { + Toml.Document doc = parse( + "[a]\n" + + "[a.b]\n" + + "[a.b.c]\n" + + "x = 1\n"); + Toml.KeyValue found = TomlPaths.findKeyValue(doc, path("a", "b", "c", "x")); + assertThat(found).isNotNull(); + assertThat(((Toml.Literal) found.getValue()).getValue()).isEqualTo(1L); + } + + @Test + void findsKeyWithMixedTableAndDottedKey() { + Toml.Document doc = parse("[a.b]\nc.x = 1\n"); + Toml.KeyValue found = TomlPaths.findKeyValue(doc, path("a", "b", "c", "x")); + assertThat(found).isNotNull(); + assertThat(((Toml.Literal) found.getValue()).getValue()).isEqualTo(1L); + } + + @Test + void findsKeyInsideInlineTable() { + Toml.Document doc = parse("a = {b = {c = {x = 1}}}\n"); + Toml.KeyValue found = TomlPaths.findKeyValue(doc, path("a", "b", "c", "x")); + assertThat(found).isNotNull(); + assertThat(((Toml.Literal) found.getValue()).getValue()).isEqualTo(1L); + } + + @Test + void quotedSegmentWithLiteralDotIsOneSegment() { + Toml.Document doc = parse("site.\"google.com\" = true\n"); + Toml.KeyValue twoSeg = TomlPaths.findKeyValue(doc, path("site", "google.com")); + assertThat(twoSeg).isNotNull(); + assertThat(((Toml.Literal) twoSeg.getValue()).getValue()).isEqualTo(true); + + Toml.KeyValue threeSeg = TomlPaths.findKeyValue(doc, path("site", "google", "com")); + assertThat(threeSeg).isNull(); + } + + @Test + void bareThreeSegmentDoesNotMatchTwoSegment() { + Toml.Document doc = parse("site.google.com = true\n"); + assertThat(TomlPaths.findKeyValue(doc, path("site", "google.com"))).isNull(); + assertThat(TomlPaths.findKeyValue(doc, path("site", "google", "com"))).isNotNull(); + } + + @Test + void returnsNullForMissingPath() { + Toml.Document doc = parse("[a.b]\nc = 1\n"); + assertThat(TomlPaths.findKeyValue(doc, path("a", "b", "missing"))).isNull(); + assertThat(TomlPaths.findKeyValue(doc, path("nonexistent"))).isNull(); + } + + @Test + void emptyPathReturnsNull() { + Toml.Document doc = parse("a = 1\n"); + assertThat(TomlPaths.findKeyValue(doc, path())).isNull(); + } + + @Test + void findTableMatchesExplicitHeader() { + Toml.Document doc = parse( + "[tool.poetry]\n" + + "name = \"x\"\n"); + Toml.Table found = TomlPaths.findTable(doc, path("tool", "poetry")); + assertThat(found).isNotNull(); + assertThat(found.getName().getPath()).containsExactly("tool", "poetry"); + } + + @Test + void findTableDoesNotMatchImplicitParent() { + // [a.b] implicitly defines [a], but findTable only matches explicit headers + Toml.Document doc = parse("[a.b]\nx = 1\n"); + assertThat(TomlPaths.findTable(doc, path("a"))).isNull(); + assertThat(TomlPaths.findTable(doc, path("a", "b"))).isNotNull(); + } + + @Test + void findKeyValueIgnoresArrayTables() { + Toml.Document doc = parse( + "[[products]]\n" + + "name = \"Hammer\"\n"); + // Array tables are not searched — there's no way to disambiguate which element + assertThat(TomlPaths.findKeyValue(doc, path("products", "name"))).isNull(); + } +}