Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow `<Version>` in
`src/XppLens/XppLens.csproj`; release tags are `vX.Y.Z`.

## 1.3.2 — 2026-10-26

### Fixed
- The `model` filter of `xpp_find`, `xpp_refs`, `xpp_changed` and `property=` listings took one name only: a list
such as `model="Contoso;Fabrikam*"` matched nothing and the answer was empty. All of them now take names separated
by `;` with `*` and `?`, like `xpp_grep` already did.
- `xpp_find "Table.Extension.Field"` split the name at the first dot and found nothing; a field added in a named
extension (`CustTable.ContosoExtension.MyField`) is now found.
- `xpp_method` with only `lines` (no method) returned nothing; it now shows those lines of the object's file and the
methods they belong to — the way to read a spot named by a build error or a grep hit.

### Changed
- `xpp_extensions` is several times faster for objects with many extension classes (measured warm: `SalesTable`
9.2 s → 0.5 s, `VendTable` 5.1 s → 1.8 s): the Chain of Command details of each extension class were looked up
by a column without an index, which read the whole reference table once per extension class.
- `xpp_join` reads the table relations from a small index of its own instead of the whole member table (the first
call of a server and every CLI call: 1.3 s → 0.65 s); the index is built once, in a few seconds.

## 1.3.1 — 2026-10-26

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Useful parameters:
```powershell
.\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory # instead of auto-detection
.\install.ps1 -Languages en-US,de -DisplayLanguage de # label languages
.\install.ps1 -FullModels XPL,XPLRetail # force models into the full index
.\install.ps1 -FullModels ContosoCore,ContosoRetail # force models into the full index
.\install.ps1 -StandardModels HugeIsvModel # push a model down to the standard tier
.\install.ps1 -NoStandard # custom models only (fast, small index)
.\install.ps1 -First -NoBuild -NoRegister # non-interactive / CI
Expand All @@ -73,7 +73,7 @@ New distribution package: `pack.ps1` (add `-FrameworkDependent` if you prefer 5
```powershell
xpplens config # show settings
xpplens config --add-language de # add a label language
xpplens config --add-full-model XPL # model/package into the full index
xpplens config --add-full-model Contoso # model/package into the full index
xpplens config --add-standard-model ContosoIsv # model into the standard tier
xpplens config --add-standard-publisher "Contoso"
xpplens config --packages-dir K:\AosService\PackagesLocalDirectory
Expand Down
4 changes: 2 additions & 2 deletions README.pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Przydatne parametry:
```powershell
.\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory # zamiast wykrywania
.\install.ps1 -Languages en-US,de -DisplayLanguage de # języki etykiet
.\install.ps1 -FullModels XPL,XPLRetail # wymuszenie pełnego indeksu
.\install.ps1 -FullModels ContosoCore,ContosoRetail # wymuszenie pełnego indeksu
.\install.ps1 -StandardModels HugeIsvModel # zepchnięcie modelu do poziomu standardowego
.\install.ps1 -NoStandard # tylko modele własne (szybko, mały indeks)
.\install.ps1 -First -NoBuild -NoRegister # tryb nieinteraktywny / CI
Expand All @@ -73,7 +73,7 @@ Nowy pakiet dystrybucyjny: `pack.ps1` (dodaj `-FrameworkDependent`, jeśli wolis
```powershell
xpplens config # pokaż ustawienia
xpplens config --add-language de # dołóż język etykiet
xpplens config --add-full-model XPL # model/pakiet do pełnego indeksu
xpplens config --add-full-model Contoso # model/pakiet do pełnego indeksu
xpplens config --add-standard-model ContosoIsv # model do poziomu standardowego
xpplens config --add-standard-publisher "Contoso"
xpplens config --packages-dir K:\AosService\PackagesLocalDirectory
Expand Down
4 changes: 2 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
.EXAMPLE
.\install.ps1
.EXAMPLE
.\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory -Languages en-US,de -FullModels XPL,XPLCore
.\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory -Languages en-US,de -FullModels ContosoCore,ContosoRetail

.NOTES
Running it again over an existing installation (an update) keeps its settings: languages and the
Expand Down Expand Up @@ -209,6 +209,6 @@ if ($desktop.Count -gt 0) {
}
}
Say "Claude Code in a terminal / VS Code: '/mcp' -> reconnect xpp-lens, or start a new session."
Say "Change settings later: `"$exe`" config --add-language de --add-full-model XPL"
Say "Change settings later: `"$exe`" config --add-language de --add-full-model Contoso"
Say "Index status: `"$exe`" status"
Say "Uninstall: $InstallDir\uninstall.ps1"
4 changes: 3 additions & 1 deletion release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ if (-not $Pre) {
$changelog = [IO.File]::ReadAllText((Resolve-Path 'CHANGELOG.md'))
$sectionRx = "(?ms)^## $([regex]::Escape($Version))\b.*?(?=^## |\z)"
$section = [regex]::Match($changelog, $sectionRx)
if (-not $section.Success) { Fail "CHANGELOG.md has no '## $Version' section" }
# A dry run also serves as the scan before an ordinary push to dev, so it goes on without the section.
if (-not $section.Success -and $DryRun) { Write-Host " WARNING: CHANGELOG.md has no '## $Version' section (needed for the release)" -ForegroundColor Yellow }
elseif (-not $section.Success) { Fail "CHANGELOG.md has no '## $Version' section" }
Ok "CHANGELOG section found, tag is free, $work is up to date"

# ---------------------------------------------------------------- 2. version
Expand Down
2 changes: 1 addition & 1 deletion src/XppLens/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ xpplens config show settings
xpplens config --packages-dir <path> | --index-path <path>
--add-language pl | --remove-language pl | --languages en-US,pl
--display-language en-US
--add-full-model XPL | --remove-full-model XPL | --full-models A,B
--add-full-model Contoso | --remove-full-model Contoso | --full-models A,B
--add-standard-model X | --remove-standard-model X
--add-standard-publisher "Contoso" | --standard-publishers Microsoft
--index-standard true|false | --standard-code true|false
Expand Down
2 changes: 1 addition & 1 deletion src/XppLens/Queries.Chains.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ List<ChainRow> ChainedRefs(Store s, string member, string kinds, string? modelLi
FROM refs r JOIN objects so ON so.id = r.object_id JOIN files f ON f.id = r.file_id JOIN models md ON md.id = f.model_id
LEFT JOIN methods sm ON sm.id = r.method_id
WHERE r.target IS NULL AND r.member = $m AND r.kind IN ({kinds}) AND r.via LIKE 'ret:%'
AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!')
AND xl_model($model, md.name, md.package)
ORDER BY md.tier DESC, so.name, sm.name, r.line LIMIT 5000
""", ("$m", member), ("$model", modelLike))
.Select(r => (Kind: r.GetString(0), Via: r.GetString(1), Line: r.GetInt32(2), Type: r.GetString(3), Obj: r.GetString(4),
Expand Down
6 changes: 3 additions & 3 deletions src/XppLens/Queries.Tools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,10 @@ public string Changed(string? since, string? model, string? type, int limit) =>
var from = ParseSince(since, DateTime.UtcNow);
if (from == null) return $"cannot read since='{since}' — use e.g. 24h, 3d, 2026-10-01 or '2026-10-01 14:00'.";
var tp = TypePattern(type);
var modelLike = PatOrNull(model);
var modelLike = ModelArg(model);
var where = $"""
f.mtime > $t AND md.tier >= 0
AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') {(tp != null ? "AND o.type LIKE $tp" : "")}
AND xl_model($model, md.name, md.package) {(tp != null ? "AND o.type LIKE $tp" : "")}
""";
var args = new (string, object?)[] { ("$t", from.Value.Ticks), ("$model", modelLike), ("$tp", tp), ("$limit", limit) };
const string from_ = "FROM files f JOIN objects o ON o.file_id = f.id JOIN models md ON md.id = f.model_id";
Expand All @@ -336,7 +336,7 @@ public string Changed(string? since, string? model, string? type, int limit) =>
.Select(r => $"{Tag(r.GetString(0), r.GetString(1), r.GetInt32(2))} {r.GetInt64(3)}").ToList();
var labels = s.Query("""
SELECT lf.label_file, lf.lang, lf.mtime, md.package, md.name, md.tier FROM label_files lf JOIN models md ON md.id = lf.model_id
WHERE lf.mtime > $t AND md.tier >= 0 AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!')
WHERE lf.mtime > $t AND md.tier >= 0 AND xl_model($model, md.name, md.package)
ORDER BY lf.mtime DESC LIMIT 50
""", args).Select(r => $" {new DateTime(r.GetInt64(2), DateTimeKind.Utc).ToLocalTime():MM-dd HH:mm} labels {r.GetString(0)} ({r.GetString(1)}) {Tag(r.GetString(3), r.GetString(4), r.GetInt32(5))}").ToList();

Expand Down
63 changes: 52 additions & 11 deletions src/XppLens/Queries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ static string Tag(string package, string model, int tier)
static string Pat(string q) => Esc(q).Replace('*', '%').Replace('?', '_');
static string Like(string q) => HasWildcard(q) ? Pat(q) : $"%{Esc(q)}%";
static string? PatOrNull(string? q) => string.IsNullOrWhiteSpace(q) ? null : Pat(q.Trim());

/// <summary>The model filter as the SQL function xl_model takes it (see Store.ModelMatches): names separated by ';', * ? wildcards.</summary>
static string? ModelArg(string? model) => string.IsNullOrWhiteSpace(model) ? null : model.Trim();
static bool HasWildcard(string q) => q.Contains('*') || q.Contains('?');

/// <summary>
Expand Down Expand Up @@ -262,7 +265,7 @@ string ListProperties(string query, string? type, string? model, string property
return eq > 0 ? (Name: p[..eq].Trim(), Filter: NameDirectory.WholeName(p[(eq + 1)..].Trim())) : (Name: p.Trim(), Filter: (Regex?)null);
}).ToList();
var tp = TypePattern(type);
var args = new List<(string, object?)> { ("$tp", tp), ("$model", PatOrNull(model)) };
var args = new List<(string, object?)> { ("$tp", tp), ("$model", ModelArg(model)) };
var names = SplitList(query).Where(q => q.Trim('*').Length > 0).ToList();
if (names.Count == 0 && tp == null && string.IsNullOrWhiteSpace(model))
return "listing properties needs a scope: a name pattern, type (e.g. entity) or model.";
Expand All @@ -272,7 +275,7 @@ string ListProperties(string query, string? type, string? model, string property
var rows = s.Query($"""
SELECT o.id, o.file_id, o.type, o.name, o.target, o.extends, o.header, o.props, f.path, md.package, md.name, md.tier
FROM models md JOIN files f ON f.model_id = md.id JOIN objects o ON o.file_id = f.id
WHERE ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!')
WHERE xl_model($model, md.name, md.package)
AND {nameSql} {(tp != null ? "AND o.type LIKE $tp" : "")}
ORDER BY md.tier DESC, o.type, o.name
""", [.. args]).Select(r => ReadObj(r)).ToList();
Expand Down Expand Up @@ -318,7 +321,17 @@ string FindOne(string query, string? kind, string? type, string? model, int limi
string? objPart = query, memPart = null;
int sep = query.IndexOf("::", StringComparison.Ordinal);
int sepLen = 2;
if (sep < 0) { sep = query.IndexOf('.'); sepLen = 1; }
if (sep < 0)
{
sep = query.IndexOf('.');
sepLen = 1;
// "Table.Extension.Field": the extension object is named "Table.Extension", so the member follows the
// last dot when the part before it is an object.
int last = query.LastIndexOf('.');
if (last > sep && !HasWildcard(query[..last])
&& s.Scalar("SELECT 1 FROM objects WHERE name = $n LIMIT 1", ("$n", query[..last])) != null)
sep = last;
}
if (sep >= 0)
{
objPart = query[..sep].Trim();
Expand All @@ -327,8 +340,8 @@ string FindOne(string query, string? kind, string? type, string? model, int limi
if (memPart.Length == 0) memPart = null;
}
var tp = TypePattern(type);
var modelLike = PatOrNull(model);
const string modelFilter = "AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!')";
var modelLike = ModelArg(model);
const string modelFilter = "AND xl_model($model, md.name, md.package)";

// Extension objects are named "Base.Model", so a dot is not always "Object.member".
bool dotted = sep >= 0 && sepLen == 1;
Expand Down Expand Up @@ -663,10 +676,12 @@ string ExtensionDetail(Store s, ObjRow e)
{
if (e.Type == "AxClass")
{
var wraps = s.Query("SELECT DISTINCT member FROM refs WHERE object_id = $id AND kind = 'coc' AND via = 'next' AND member IS NOT NULL ORDER BY member",
("$id", e.Id)).Select(r => r.GetString(0)).ToList();
var of = s.Query("SELECT via, member FROM refs WHERE object_id = $id AND kind = 'coc' AND method_id = (SELECT MIN(id) FROM methods WHERE object_id = $id AND name = 'classDeclaration') LIMIT 1",
("$id", e.Id)).Select(r => $"{r.GetString(0)}{(r.IsDBNull(1) ? "" : ":" + r.GetString(1))}").FirstOrDefault();
// file_id first: refs has no index on object_id, but one on file_id (an object lives in one file) —
// without it each extension class meant a scan of the whole refs table.
var wraps = s.Query("SELECT DISTINCT member FROM refs WHERE file_id = $fid AND object_id = $id AND kind = 'coc' AND via = 'next' AND member IS NOT NULL ORDER BY member",
("$fid", e.FileId), ("$id", e.Id)).Select(r => r.GetString(0)).ToList();
var of = s.Query("SELECT via, member FROM refs WHERE file_id = $fid AND object_id = $id AND kind = 'coc' AND method_id = (SELECT MIN(id) FROM methods WHERE object_id = $id AND name = 'classDeclaration') LIMIT 1",
("$fid", e.FileId), ("$id", e.Id)).Select(r => $"{r.GetString(0)}{(r.IsDBNull(1) ? "" : ":" + r.GetString(1))}").FirstOrDefault();
var parts = new List<string>();
if (of != null && !of.StartsWith("classStr", StringComparison.OrdinalIgnoreCase) && !of.StartsWith("tableStr", StringComparison.OrdinalIgnoreCase)) parts.Add(of);
if (wraps.Count > 0) parts.Add("wraps " + string.Join(", ", wraps));
Expand All @@ -684,10 +699,36 @@ string ExtensionDetail(Store s, ObjRow e)
/// One or more methods. method may list several names ("a;b") of the same object; without method,
/// objectName may list "Object.method" / "Object::method" items. match/lines return a numbered fragment.
/// </summary>
/// <summary>Lines "from-to" (at most 300) of an object's XML file, numbered as in the file.</summary>
string FileLines(string objectName, string? type, string lines) => svc.Read(s =>
{
var m = Regex.Match(lines.Trim(), @"^(\d+)\s*-\s*(\d+)$");
if (!m.Success) return $"lines '{lines}': give a range like 120-180.";
int from = int.Parse(m.Groups[1].Value), to = int.Parse(m.Groups[2].Value);
if (to < from) (from, to) = (to, from);
to = Math.Min(to, from + 299);
var o = ObjectRows(s, objectName, type).FirstOrDefault();
if (o == null) return NotFound(s, objectName, type);
if (o.Compiled || !File.Exists(o.Path)) return $"{o.Type} {o.Name} {o.Tag}: no source file (compiled package).";
var sb = new StringBuilder($"{o.Type} {o.Name} {o.Tag}\n{o.Path}:{from}-{to}\n");
var methodsHere = s.Query("SELECT owner, name, start_line, end_line FROM methods WHERE object_id = $id AND start_line <= $to AND end_line >= $from ORDER BY start_line",
("$id", o.Id), ("$from", from), ("$to", to))
.Select(r => $"{(r.IsDBNull(0) || r.GetString(0).Length == 0 ? "" : r.GetString(0) + "/")}{r.GetString(1)} L{r.GetInt32(2)}-{r.GetInt32(3)}").ToList();
if (methodsHere.Count > 0) sb.AppendLine("in: " + string.Join(", ", methodsHere));
foreach (var (no, text) in File.ReadLines(o.Path).Skip(from - 1).Take(to - from + 1).Select((t, i) => (from + i, t)))
sb.AppendLine($"{no,6}: {text}");
return Finish(sb);
});

public string Method(string objectName, string? method, string? type, string? match = null, string? lines = null, int context = 3)
{
var frag = FragmentSpec.Create(match, lines, context);
var pairs = new List<(string Obj, string Method)>();
if (method is "null" or "undefined") method = null;
// Only a line range of one object (e.g. from a build error or a grep hit): those lines of its file.
if (string.IsNullOrWhiteSpace(method) && !string.IsNullOrWhiteSpace(lines) && SplitList(objectName) is [var single]
&& !single.Contains("::", StringComparison.Ordinal) && !single.Contains('.'))
return FileLines(single, type, lines);
var methods = SplitList(method);
if (methods.Count > 0)
{
Expand Down Expand Up @@ -1008,7 +1049,7 @@ string Chain(string via, string? member)
public string Refs(string name, string? member, string? kind, string? model, int limit) => svc.Read(s =>
{
limit = Math.Clamp(limit, 1, 1000);
var modelLike = PatOrNull(model);
var modelLike = ModelArg(model);
name = name.Trim();
string? readAs = null;
if (string.IsNullOrWhiteSpace(member) && SplitMemberName(s, name) is ({ } objectPart, { } memberPart))
Expand All @@ -1019,7 +1060,7 @@ public string Refs(string name, string? member, string? kind, string? model, int
}
var where = """
r.target = $n AND ($mem IS NULL OR r.member = $mem) AND ($kind IS NULL OR r.kind = $kind)
AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!')
AND xl_model($model, md.name, md.package)
""";
var args = new (string, object?)[] { ("$n", name), ("$mem", string.IsNullOrWhiteSpace(member) ? null : member),
("$kind", string.IsNullOrWhiteSpace(kind) ? null : kind), ("$model", modelLike), ("$limit", limit) };
Expand Down
Loading
Loading