diff --git a/docs/usage/commands/compile.md b/docs/usage/commands/compile.md index 05abbf5d..e0b3400e 100644 --- a/docs/usage/commands/compile.md +++ b/docs/usage/commands/compile.md @@ -12,9 +12,12 @@ jo compile [--sast ] ... [--lib ]... jo compile --python|--ruby|--js [--sast ] ... \ [--lib ]... [--link-lib ]... [--link =]... -o -# Generate documentation from source files (experimental) +# Generate HTML documentation from source files (experimental) jo compile --doc [--out ] [--title ] [--readme ] \ [--include-private] [--include-source] ... + +# Query API information as JSON (experimental) +jo compile --query [...] [--lib ]... ``` Without a backend flag, the compiler type-checks only. With a backend flag, it produces an executable or script. `--sast ` is optional in both cases — if present, `.sast` files are written to `` alongside the primary output. @@ -43,13 +46,36 @@ Experimental. | Flag | Description | |------|-------------| -| `--doc` | Generate documentation instead of normal compile output | +| `--doc` | Generate HTML documentation instead of normal compile output | | `--out ` | Documentation output directory | | `--title ` | Documentation title | | `--readme ` | Markdown file to use as the generated documentation home page | | `--include-private` | Include private symbols | | `--include-source` | Embed source code in output | +### Query + +Experimental. + +| Flag | Description | +|------|-------------| +| `--query ` | Query comma-separated symbols or source files and write a JSON array to stdout | +| `--fields ` | Select comma-separated query output fields (default: `name,signature,doc`) | + +Selectors may name symbols or source files with `file:`. +Symbol selectors are dot-separated names resolved from the root namespace, such +as `jo.List.map`. Positional source files are optional. With no source files, +the query searches the loaded SAST libraries, including stdlib, any `--lib` +paths, and the runtime API selected by `--use-runtime-api`. + +A file selector may use the recorded source path, an absolute path, or a basename: + +```sh +jo compile --query file:Byte.jo +``` + +Query output includes public symbols only. + ### App compilation | Flag | Description | @@ -104,6 +130,30 @@ jo compile --doc lib/Core.jo lib/List.jo \ --title "Jo Standard Library" ``` +Query selected symbols from source: + +```sh +jo compile --query 'MyAPI,jo.List' src/API.jo +``` + +Query stdlib API information from SAST files only: + +```sh +jo compile --query jo.List.map +``` + +Select output fields: + +```sh +jo compile --query jo.List.map --fields name,signature,loc,doc +``` + +Query a standard-library source file by basename: + +```sh +jo compile --query file:Byte.jo +``` + ## Notes `jo compile` is a low-level escape hatch for scripts, CI pipelines, or experiments that don't fit the standard project layout. For normal project workflows, prefer the higher-level commands such as `jo build`, `jo run `, and [`jo doc`](doc.md). diff --git a/docs/usage/reference/compiler-options.md b/docs/usage/reference/compiler-options.md index 775f7513..e12f61f6 100644 --- a/docs/usage/reference/compiler-options.md +++ b/docs/usage/reference/compiler-options.md @@ -28,11 +28,13 @@ Backend selectors are handled before the shared compiler options are parsed. | `--ruby` | Compile a Ruby application. | | `--python` | Compile a Python application. | | `--js` | Compile a JavaScript application. Experimental. | +| `--doc` | Generate HTML documentation from source files. Experimental. | +| `--query ` | Query API information and write JSON to stdout. Experimental. | ## Common Options Common options are accepted by type-check-only compilation, app backend compilation, -and experimental documentation generation. +documentation generation, and API queries. | Option | Form | Description | |--------|------|-------------| @@ -99,3 +101,28 @@ above plus these documentation-specific options. | `--readme ` | single value | Markdown file to use as the generated documentation home page. | | `--include-private` | flag | Include private symbols. | | `--include-source` | flag | Embed source code in the generated documentation. | + +Documentation output is always HTML. + +## Query Options + +`jo compile --query ` is an experimental API-query backend. It +accepts common compiler options and optional positional source files, and writes +a bare JSON array to stdout. Selectors are comma-separated: + +| Selector | Meaning | +|----------|---------| +| `` | Dot-separated symbol name resolved from the root namespace, such as `jo.List.map`. Aggregate symbols are emitted structurally with their members. | +| `file:` | Public symbols whose recorded source file matches the normalized path, absolute path, or basename. | + +Positional source files are optional. With no source files, the query searches +the loaded SAST libraries: stdlib by default, runtime +API libraries selected by `--use-runtime-api`, and any `--lib` paths. + +Query output includes public symbols only. +Each symbol includes `name`, `signature`, and `doc` by default. Use +`--fields` to select any combination of `name`, `kind`, `signature`, `loc`, +`visibility`, `flags`, `annotations`, and `doc`. Field order in the option does +not affect JSON output order. Classes with views include `views` automatically. + +Kind-qualified selectors such as `def:` are reserved for future use. diff --git a/stack-lang/cli/Main.scala b/stack-lang/cli/Main.scala index 57fa71f4..8d1e8f25 100644 --- a/stack-lang/cli/Main.scala +++ b/stack-lang/cli/Main.scala @@ -120,6 +120,7 @@ object Main: case Some(backend) => backend match case Backend.Doc => doc.Compiler.main(flags.args) + case Backend.Query => query.Compiler.main(flags.args) case Backend.Ruby => ruby.Compiler.main(flags.args) case Backend.Python => python.Compiler.main(flags.args) case Backend.JS => js.Compiler.main(flags.args) @@ -203,6 +204,7 @@ object Main: enum Backend: case Doc + case Query case Ruby case Python case JS @@ -220,30 +222,45 @@ object Main: var remaining = List.empty[String] var i = 0 + def selectBackend(next: Backend): Unit = + backend match + case None => + backend = Some(next) + case Some(current) => + System.err.println( + s"Error: compile backend already selected: ${current.toString.toLowerCase}; cannot select ${next.toString.toLowerCase}" + ) + System.exit(1) + while i < args.length do args(i) match case "--doc" => - backend = Some(Backend.Doc) + selectBackend(Backend.Doc) + i += 1 + + case "--query" => + selectBackend(Backend.Query) + remaining = remaining :+ args(i) i += 1 case "--ruby" => - backend = Some(Backend.Ruby) + selectBackend(Backend.Ruby) i += 1 case "--python" => - backend = Some(Backend.Python) + selectBackend(Backend.Python) i += 1 case "--js" => - backend = Some(Backend.JS) + selectBackend(Backend.JS) i += 1 case "--stack" => - backend = Some(Backend.LinuxX86Stack) + selectBackend(Backend.LinuxX86Stack) i += 1 case "--reg" => - backend = Some(Backend.LinuxX86Reg) + selectBackend(Backend.LinuxX86Reg) i += 1 case other => @@ -274,7 +291,8 @@ object Main: | jo versions use Switch the active compiler version | jo versions remove Remove an installed compiler version | jo compile [options] Compile application or library - | jo compile --doc [options] Generate documentation from source files + | jo compile --query [files...] Query documentation from source files or SAST libraries + | jo compile --doc [options] Generate HTML documentation from source files | jo doc [module] Generate module documentation | jo help Show this help message | diff --git a/stack-lang/doc/Compiler.scala b/stack-lang/doc/Compiler.scala index 3a1624e8..cd3438c7 100644 --- a/stack-lang/doc/Compiler.scala +++ b/stack-lang/doc/Compiler.scala @@ -1,7 +1,7 @@ package doc import sast.* - +import sast.Trees.FileUnit import typing.Typer import reporting.Reporter import reporting.Config @@ -26,6 +26,8 @@ object Compiler: val (config, sources) = cli.OptionParser.parseConfig(args, docOptions) + given Config = config + if sources.isEmpty then println("Usage: jo doc [options]") println() @@ -37,10 +39,7 @@ object Compiler: println() println("Examples:") println(" jo doc lib/Core.jo lib/List.jo --out site/api") - println(" jo doc src/main.jo --out docs --title MyProject") - return - - given Config = config + System.exit(1) Reporter.monitor(): compile(sources) @@ -53,12 +52,12 @@ object Compiler: // Parse and type check val (units, _) = sources |> Typer.parseStep |> Typer.typeStep - if rp.hasErrors then - println("Errors occurred during type checking. Documentation not generated.") - return + if rp.hasErrors then return - given Definitions = lazyDefn.value + given defn: Definitions = lazyDefn.value + generateHtmlDoc(units) + def generateHtmlDoc(units: List[FileUnit])(using Config, Definitions): Unit = val outputPath = Paths.get(outputDir.value) val includePrivateVal = includePrivate.value diff --git a/stack-lang/pickle/Decoder.scala b/stack-lang/pickle/Decoder.scala index 34b4db0a..2f293822 100644 --- a/stack-lang/pickle/Decoder.scala +++ b/stack-lang/pickle/Decoder.scala @@ -185,7 +185,7 @@ object Decoder: FileUnit(owner, imports, members, source) - LazyFileUnit(owner, state.accessed, delayed) + LazyFileUnit(owner, source.file, state.accessed, delayed) /** Decode all imports for a file unit */ private def decodeImports diff --git a/stack-lang/pickle/LazyFileUnit.scala b/stack-lang/pickle/LazyFileUnit.scala index a56345f4..eb862855 100644 --- a/stack-lang/pickle/LazyFileUnit.scala +++ b/stack-lang/pickle/LazyFileUnit.scala @@ -17,6 +17,7 @@ import scala.collection.mutable */ class LazyFileUnit( val owner: Symbol, + val sourceFile: String, private val accessed: java.util.concurrent.atomic.AtomicBoolean, private val delayed: () => FileUnit ): diff --git a/stack-lang/query/Compiler.scala b/stack-lang/query/Compiler.scala new file mode 100644 index 00000000..d33e4b08 --- /dev/null +++ b/stack-lang/query/Compiler.scala @@ -0,0 +1,70 @@ +package query + +import sast.* +import sast.Trees.FileUnit + +import typing.Typer +import reporting.{Config, Reporter} + +import java.io.{OutputStreamWriter, PrintWriter} +import java.nio.charset.StandardCharsets + +object Compiler: + val selectors: Config.StringSetting = + Config.StringSetting("--query", "", "comma-separated documentation selectors") + + val fields: Config.StringSetting = + Config.StringSetting("--fields", "name,signature,doc", "comma-separated query output fields") + + val queryOptions: List[cli.OptionParser.Setting[?]] = + selectors :: fields :: Config.commonOptions + + def main(args: Array[String]): Unit = + given Reporter = Reporter.createReporter() + + val (config, sources) = cli.OptionParser.parseConfig(args, queryOptions) + given Config = config + + val queryText = selectors.value.trim + if queryText.isEmpty then + println("Usage: jo compile --query [files...] [options]") + println() + println("Options:") + println(" --query Select symbols or source files, e.g. jo.List.map,file:Byte.jo") + println(" --fields Select output fields (default: name,signature,doc)") + System.exit(1) + + Reporter.monitor(): + val selectedFields = Query.parseFields(fields.value) + if !summon[Reporter].hasErrors then compile(queryText, selectedFields, sources) + + def compile(queryText: String, fields: Set[String], sources: List[String])(using rp: Reporter, config: Config): Unit = + val rootNameTable = new NameTable + given lazyDefn: Definitions.Lazy = Definitions.Lazy(rootNameTable) + + if sources.isEmpty && Config.libPaths.value.isEmpty then + Query.reportNoMatches(queryText) + return + + val (units, delayedUnits) = sources |> Typer.parseStep |> Typer.typeStep + if rp.hasErrors then return + + given defn: Definitions = lazyDefn.value + val filter = Query.parse(queryText, defn.rootNameTable) + if rp.hasErrors then return + + delayedUnits.forceIf: unit => + filter.selectsFile(unit.sourceFile) || + filter.symbols.exists: querySymbol => + unit.owner.containedIn(querySymbol) || querySymbol.containedIn(unit.owner) + + val libraryUnits = delayedUnits.force() + val filteredUnits = Query.filterUnits(units, libraryUnits, filter) + if rp.hasErrors then return + + writeJson(filteredUnits, filter, fields) + + private def writeJson(units: List[FileUnit], filter: Query.Filter, fields: Set[String])(using Reporter, Definitions): Unit = + val out = new PrintWriter(new OutputStreamWriter(System.out, StandardCharsets.UTF_8)) + Query.emitJson(units, filter, fields, false, out) + out.flush() diff --git a/stack-lang/query/JsonUtil.scala b/stack-lang/query/JsonUtil.scala new file mode 100644 index 00000000..f1fa8afb --- /dev/null +++ b/stack-lang/query/JsonUtil.scala @@ -0,0 +1,23 @@ +package query + +import java.io.PrintWriter + +object JsonUtil: + def string(s: String): String = + val sb = new StringBuilder("\"") + for c <- s do + c match + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\b' => sb.append("\\b") + case '\f' => sb.append("\\f") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + case c if c < 32 => sb.append("\\u%04x".format(c.toInt)) + case c => sb.append(c) + sb.append("\"") + sb.toString + + def emitString(s: String, out: PrintWriter): Unit = + out.print(string(s)) diff --git a/stack-lang/query/Query.scala b/stack-lang/query/Query.scala new file mode 100644 index 00000000..66227d2e --- /dev/null +++ b/stack-lang/query/Query.scala @@ -0,0 +1,651 @@ +package query + +import reporting.Reporter +import sast.* +import sast.Symbols.* +import sast.Trees.* +import sast.Types.* +import sast.Denotations.* +import sast.Flags +import ast.Positions.Span + +import java.io.PrintWriter +import java.nio.file.Paths +import scala.collection.mutable + +object Query: + private val outputFields = + List("name", "kind", "signature", "loc", "visibility", "flags", "annotations", "doc") + + private val availableFields = outputFields.mkString(",") + + private case class EmitContext(fields: Set[String], out: PrintWriter, indent: String): + def indented: EmitContext = copy(indent = indent + " ") + + def parseFields(rawFields: String)(using Reporter): Set[String] = + val fields = rawFields.split(",").map(_.trim).filter(_.nonEmpty).toSet + if fields.isEmpty then + Reporter.error(s"Option --fields requires at least one field. Available fields: $availableFields") + + for field <- fields.diff(outputFields.toSet).toList.sorted do + Reporter.error(s"Unknown query field: $field. Available fields: $availableFields") + + fields + + case class Filter(files: List[String], symbols: List[Symbol]): + def isEmpty: Boolean = + files.isEmpty && symbols.isEmpty + + def selectsFile(sourceFile: String): Boolean = + files.exists(file => matchesFile(sourceFile, file)) + + def resolveSymbol(nameTable: NameTable, parts: List[String])(using Definitions): List[Symbol] = + parts match + case Nil => Nil + + case name :: Nil => + nameTable.resolve(name) + + case name :: rest => + val containerMatches = + nameTable.resolveContainer(name).toList.flatMap: sym => + resolveSymbol(sym.nameTable, rest) + + val memberMatches = + rest match + case memberName :: Nil => + nameTable.resolveType(name).toList.flatMap: sym => + if sym.isOneOf(Flags.Class | Flags.Interface) then + sym.classInfo.getMemberSymbol(memberName).toList + else Nil + + case _ => Nil + + (containerMatches ++ memberMatches).distinct + + def filterUnits(sourceUnits: List[FileUnit], libraryUnits: List[FileUnit], filter: Filter)(using Reporter): List[FileUnit] = + if filter.isEmpty then + sourceUnits + else + val allUnits = sourceUnits ++ libraryUnits + val fileUnits = filter.files.flatMap: file => + val matches = allUnits.filter(unit => matchesFile(unit.source.file, file)) + if matches.isEmpty then Reporter.error(s"No documentation entries match file selector `file:$file`") + matches + + val symbolUnits = + allUnits.filter: unit => + filter.symbols.exists: sym => + unit.owner.containedIn(sym) || sym.containedIn(unit.owner) + + distinctUnits(fileUnits ++ symbolUnits) + + def reportNoMatches(rawQuery: String)(using Reporter): Unit = + rawQuery.split(",").map(_.trim).filter(_.nonEmpty).foreach: selector => + if selector.startsWith("file:") then + Reporter.error(s"No documentation entries match file selector `$selector`") + else + Reporter.error(s"No documentation entries match symbol selector `${symbolName(selector)}`") + + def parse(rawQuery: String, nameTable: NameTable)(using Reporter, Definitions): Filter = + val files = mutable.ArrayBuffer.empty[String] + val symbols = mutable.ArrayBuffer.empty[Symbol] + + rawQuery.split(",").map(_.trim).filter(_.nonEmpty).foreach: selector => + if selector.startsWith("file:") then + files += selector.stripPrefix("file:") + else + val name = symbolName(selector) + val resolved = resolveSymbol(nameTable, name.split('.').toList) + if resolved.isEmpty then Reporter.error(s"No documentation entries match symbol selector `$name`") + else symbols ++= resolved + + Filter(files.toList, symbols.toList) + + private def symbolName(selector: String): String = + if selector.endsWith(".*") then selector.stripSuffix(".*") + else selector + + private def matchesFile(sourceFile: String, rawFile: String): Boolean = + val rawNormalized = normalize(rawFile) + val sourceNormalized = normalize(sourceFile) + rawFile == sourceFile || + rawNormalized == sourceNormalized || + (Paths.get(rawFile).getParent == null && + Paths.get(sourceFile).getFileName == Paths.get(rawFile).getFileName) || + absolute(rawFile) == absolute(sourceFile) + + private def normalize(path: String): String = + Paths.get(path).normalize().toString + + private def absolute(path: String): String = + Paths.get(path).toAbsolutePath.normalize().toString + + private def distinctUnits(units: List[FileUnit]): List[FileUnit] = + val seen = mutable.HashSet.empty[FileUnit] + units.filter: unit => + if seen.contains(unit) then false + else + seen += unit + true + + def emitJson( + units: List[FileUnit], + filter: Filter, + fields: Set[String], + includePrivate: Boolean, + out: PrintWriter, + )(using Reporter, Definitions): Unit = + val trimmedUnits = trimUnits(units, filter, includePrivate) + val sortedTargets = sortRoots(jsonRoots(trimmedUnits, filter)) + given EmitContext = EmitContext(fields, out, " ") + out.println("[") + emitRootList(sortedTargets) + if sortedTargets.nonEmpty then out.println() + out.println("]") + + private def trimUnits(units: List[FileUnit], filter: Filter, includePrivate: Boolean)(using Reporter, Definitions): List[FileUnit] = + if filter.isEmpty then + return units.flatMap: unit => + val defs = visibleDefs(unit.defs, includePrivate) + if defs.isEmpty then None else Some(unit.copy(defs = defs)) + + val requested = filter.symbols.toSet + val matched = mutable.HashSet.empty[Symbol] + val trimmed = units.flatMap: unit => + if emitsNamespace(unit, filter) then + if requested.contains(unit.owner) then matched += unit.owner + val defs = visibleDefs(unit.defs, includePrivate) + markMatchesInDefs(defs, requested, includePrivate, matched) + Some(unit.copy(defs = defs)) + else + val defs = trimDefs(unit.defs, requested, includePrivate, matched) + if defs.isEmpty then None else Some(unit.copy(defs = defs)) + + for sym <- filter.symbols do + val matchedByAncestor = + sym.ownersIterator.exists(matched.contains) + if !matched.contains(sym) && !matchedByAncestor then + Reporter.error(s"No documentation entries match symbol selector `${sym.fullName}`") + + distinctUnits(trimmed) + + private def trimDefs( + defs: List[Def], + requested: Set[Symbol], + includePrivate: Boolean, + matched: mutable.Set[Symbol], + )(using Definitions): List[Def] = + defs.flatMap: defn => + docSymbolForDef(defn, includePrivate) match + case Some(sym) if requested.contains(sym) => + matched += sym + visibleDef(defn, includePrivate).toList + + case Some(_) => + defn match + case cd: ClassDef => + trimClass(cd, requested, includePrivate, matched) + + case id: InterfaceDef => + trimInterface(id, requested, includePrivate, matched) + + case sec: Section => + trimSection(sec, requested, includePrivate, matched) + + case _ => + Nil + + case None => + Nil + + private def trimClass( + cd: ClassDef, + requested: Set[Symbol], + includePrivate: Boolean, + matched: mutable.Set[Symbol], + )(using Definitions): List[Def] = + val fields = cd.vals.filter: field => + val keep = requested.contains(field.symbol) && visible(field.symbol, includePrivate) + if keep then matched += field.symbol + keep + + val methods = cd.funs.filter: fun => + docSymbolForDef(fun, includePrivate) match + case Some(sym) if requested.contains(sym) => + matched += sym + true + case _ => + false + + if fields.isEmpty && methods.isEmpty then + Nil + else if fields.isEmpty && methods.size == 1 then + methods + else + List(ClassDef(cd.symbol, cd.self, cd.tparams, fields, methods, cd.views)(cd.annots, cd.span)) + + private def trimInterface( + id: InterfaceDef, + requested: Set[Symbol], + includePrivate: Boolean, + matched: mutable.Set[Symbol], + )(using Definitions): List[Def] = + val methods = id.methods.filter: fun => + docSymbolForDef(fun, includePrivate) match + case Some(sym) if requested.contains(sym) => + matched += sym + true + case _ => + false + + if methods.isEmpty then + Nil + else if methods.size == 1 then + methods + else + List(InterfaceDef(id.symbol, id.self, id.tparams, methods)(id.annots, id.span)) + + private def trimSection( + sec: Section, + requested: Set[Symbol], + includePrivate: Boolean, + matched: mutable.Set[Symbol], + )(using Definitions): List[Def] = + val defs = trimDefs(sec.defs, requested, includePrivate, matched) + if defs.isEmpty then + Nil + else if defs.size == 1 then + defs + else + List(Section(sec.symbol, defs)(sec.annots, sec.span)) + + private def visibleDefs(defs: List[Def], includePrivate: Boolean)(using Definitions): List[Def] = + defs.flatMap(visibleDef(_, includePrivate)) + + private def visibleDef(defn: Def, includePrivate: Boolean)(using Definitions): Option[Def] = + docSymbolForDef(defn, includePrivate).map: _ => + defn match + case cd: ClassDef => + val fields = cd.vals.filter(field => visible(field.symbol, includePrivate)) + val methods = visibleFunDefs(cd.funs, includePrivate) + ClassDef(cd.symbol, cd.self, cd.tparams, fields, methods, cd.views)(cd.annots, cd.span) + + case id: InterfaceDef => + val methods = visibleFunDefs(id.methods, includePrivate) + InterfaceDef(id.symbol, id.self, id.tparams, methods)(id.annots, id.span) + + case sec: Section => + Section(sec.symbol, visibleDefs(sec.defs, includePrivate))(sec.annots, sec.span) + + case _ => + defn + + private def visibleFunDefs(defs: List[FunDef], includePrivate: Boolean)(using Definitions): List[FunDef] = + defs.filter(fun => docSymbolForDef(fun, includePrivate).isDefined) + + private def markMatchesInDefs( + defs: List[Def], + requested: Set[Symbol], + includePrivate: Boolean, + matched: mutable.Set[Symbol], + )(using Definitions): Unit = + defs.foreach: defn => + docSymbolForDef(defn, includePrivate).foreach: sym => + if requested.contains(sym) then matched += sym + + defn match + case cd: ClassDef => + cd.vals.foreach: field => + if requested.contains(field.symbol) && visible(field.symbol, includePrivate) then + matched += field.symbol + markMatchesInDefs(cd.funs, requested, includePrivate, matched) + + case id: InterfaceDef => + markMatchesInDefs(id.methods, requested, includePrivate, matched) + + case sec: Section => + markMatchesInDefs(sec.defs, requested, includePrivate, matched) + + case _ => + + private def docSymbolForDef(defn: Def, includePrivate: Boolean)(using Definitions): Option[Symbol] = + if !visible(defn.symbol, includePrivate) then None + else + defn match + case fd: FunDef if fd.symbol.is(Flags.Object) => + None + + case pd: PatDef if pd.resultType.tpe.isSingletonObjectType => + None + + case _ => + Some(defn.symbol) + + def visible(sym: Symbol, includePrivate: Boolean): Boolean = + includePrivate || !sym.isPrivate + + private def emitsNamespace(unit: FileUnit, filter: Filter): Boolean = + !filter.isEmpty && ( + filter.symbols.contains(unit.owner) || + filter.files.exists(file => matchesFile(unit.source.file, file)) + ) + + private def jsonRoots(units: List[FileUnit], filter: Filter): List[FileUnit | Def] = + if filter.isEmpty then + units.flatMap(_.defs) + else + val namespaces = mutable.LinkedHashMap.empty[Symbol, mutable.ArrayBuffer[FileUnit]] + val defs = mutable.ArrayBuffer.empty[Def] + + for unit <- units do + if emitsNamespace(unit, filter) then + namespaces.getOrElseUpdate(unit.owner, mutable.ArrayBuffer.empty) += unit + else + defs ++= unit.defs + + val namespaceRoots: List[FileUnit | Def] = namespaces.toList.map: + case (owner, ownerUnits) => + val sorted = ownerUnits.toList.sortBy(_.source.file) + FileUnit(owner, sorted.flatMap(_.imports).distinct, sorted.flatMap(_.defs), sorted.head.source) + + namespaceRoots ++ defs.toList.map(defn => defn: FileUnit | Def) + + private def sortRoots(roots: List[FileUnit | Def]): List[FileUnit | Def] = + roots.sortBy: + case unit: FileUnit => sortKey(unit.owner, "namespace") + case defn: Def => sortKey(defn.symbol, kind(defn)) + + private def sortMembers(members: List[Def | FieldDecl]): List[Def | FieldDecl] = + members.sortBy: + case defn: Def => sortKey(defn.symbol, kind(defn)) + case field: FieldDecl => sortKey(field.symbol, "field") + + private def sortKey(sym: Symbol, kind: String): (String, Int, String, String) = + val source = sourceLoc(sym) + val file = source.map(_.file).getOrElse("") + val line = source.map(_.line).getOrElse(0) + (file, line, kind, sym.fullName) + + private def emitRootList(roots: List[FileUnit | Def])(using ctx: EmitContext, defn: Definitions): Unit = + var first = true + for root <- roots do + if !first then ctx.out.println(",") + first = false + root match + case unit: FileUnit => emitNamespace(unit) + case tree: Def => emitDef(tree) + + private def emitMemberList(members: List[Def | FieldDecl])(using ctx: EmitContext, defn: Definitions): Unit = + var first = true + for member <- members do + if !first then ctx.out.println(",") + first = false + member match + case tree: Def => emitDef(tree) + case field: FieldDecl => emitFieldDecl(field) + + private def emitNamespace(unit: FileUnit)(using EmitContext, Definitions): Unit = + val members = sortMembers(unit.defs.map(defn => defn: Def | FieldDecl)) + emitSymbol(unit.owner, "namespace", "namespace " + unit.owner.fullName, Nil, members, sourceLoc(unit.owner)) + + private def emitDef(tree: Def)(using EmitContext, Definitions): Unit = + val members = sortMembers(memberNodes(tree)) + val views = tree match + case cd: ClassDef => cd.views.map(_.tpe.show) + case _ => Nil + emitSymbol(tree.symbol, kind(tree), signature(tree), views, members, sourceLoc(tree.symbol, tree.span)) + + private def emitFieldDecl(field: FieldDecl)(using EmitContext, Definitions): Unit = + emitSymbol(field.symbol, "field", fieldSignature(field), Nil, Nil, sourceLoc(field.symbol, field.span)) + + private def emitSymbol( + sym: Symbol, + kind: String, + signature: String, + views: List[String], + members: List[Def | FieldDecl], + source: Option[SourceLoc], + )(using ctx: EmitContext, defn: Definitions): Unit = + val nested = ctx.indented + ctx.out.println(ctx.indent + "{") + val entries = mutable.ArrayBuffer.empty[(String, String)] + if ctx.fields.contains("name") then entries += "name" -> JsonUtil.string(sym.fullName) + if ctx.fields.contains("kind") then entries += "kind" -> JsonUtil.string(kind) + if ctx.fields.contains("signature") then entries += "signature" -> JsonUtil.string(signature) + if ctx.fields.contains("loc") then entries += "loc" -> sourceJson(source) + if ctx.fields.contains("visibility") then entries += "visibility" -> JsonUtil.string(visibility(sym)) + if ctx.fields.contains("flags") then entries += "flags" -> stringArray(Flags.flagStrings(sym.flags)) + if ctx.fields.contains("annotations") then entries += "annotations" -> annotationsJson(sym) + if ctx.fields.contains("doc") then entries += "doc" -> docs(sym).map(JsonUtil.string).getOrElse("null") + if views.nonEmpty then entries += "views" -> stringArray(views) + + for ((name, value), index) <- entries.zipWithIndex do + emitField(name, value, comma = index < entries.size - 1 || members.nonEmpty)(using nested) + + if members.nonEmpty then + ctx.out.println(nested.indent + JsonUtil.string("members") + ": [") + emitMemberList(members)(using nested.indented, defn) + ctx.out.println() + ctx.out.println(nested.indent + "]") + + ctx.out.print(ctx.indent + "}") + + private def memberNodes(defn: Def): List[Def | FieldDecl] = + defn match + case sec: Section => + sec.defs.map(defn => defn: Def | FieldDecl) + + case cd: ClassDef => + cd.vals.map(field => field: Def | FieldDecl) ++ + cd.funs.map(fun => fun: Def | FieldDecl) + + case id: InterfaceDef => + id.methods.map(fun => fun: Def | FieldDecl) + + case _ => + Nil + + private def kind(defn: Def): String = + defn match + case _: ParamDef => + "param" + + case _: TypeDef => + "type" + + case fd: FunDef => + if fd.symbol.is(Flags.Constructor) then "constructor" else "def" + + case _: PatDef => + "pattern" + + case _: ClassDef | _: InterfaceDef => + "class" + + case _: Section => + "section" + + private def signature(defn: Def)(using Definitions): String = + defn match + case pd: ParamDef => + "param " + pd.name + ": " + pd.tpt.tpe.show + + case td: TypeDef => + typeSignature(td) + + case fd: FunDef => + funSignature(fd) + + case pd: PatDef => + patternSignature(pd) + + case cd: ClassDef => + classSignature(cd) + + case id: InterfaceDef => + interfaceSignature(id) + + case sec: Section => + "section " + sec.symbol.name + + private def sourceLoc(sym: Symbol): Option[SourceLoc] = + if sym.sourcePos == null then None + else Some(SourceLoc(sym.source.file, sym.sourcePos.startLine + 1, sym.sourcePos.endLine + 1)) + + private def sourceLoc(sym: Symbol, span: Span): Option[SourceLoc] = + if sym.sourcePos == null then None + else + val pos = span.toPos(using sym.source) + Some(SourceLoc(sym.source.file, pos.startLine + 1, pos.endLine + 1)) + + private case class SourceLoc(file: String, line: Int, end: Int) + + private def sourceJson(source: Option[SourceLoc]): String = + source match + case Some(SourceLoc(file, line, end)) => + s"""{ "file": ${JsonUtil.string(file)}, "line": $line, "end": $end }""" + case None => + "null" + + private def visibility(sym: Symbol): String = + sym.visibility match + case Visibility.Default => "public" + case Visibility.Private(within) => + if within == sym.owner then "private" + else s"private[${within.fullName}]" + + private def docs(sym: Symbol)(using Definitions): Option[String] = + val lines = summon[Definitions].index.docComment(sym) + if lines.isEmpty then None else Some(lines.mkString("\n")) + + private def annotationsJson(sym: Symbol)(using Definitions): String = + sym.annotations.map { annot => + s"""{ "name": ${JsonUtil.string(annot.symbol.fullName)}, "args": ${valuesJson(annot.args)} }""" + }.mkString("[", ", ", "]") + + private def valuesJson(values: List[Constant]): String = + values.map(constantJson).mkString("[", ", ", "]") + + private def constantJson(value: Constant): String = + value match + case Constant.String(v) => JsonUtil.string(v) + case Constant.Int(v) => v.toString + case Constant.Float(v) => v.toString + case Constant.Bool(v) => v.toString + + private def stringArray(values: List[String]): String = + values.map(JsonUtil.string).mkString("[", ", ", "]") + + private def emitField(name: String, value: String, comma: Boolean)(using ctx: EmitContext): Unit = + ctx.out.print(ctx.indent) + ctx.out.print(JsonUtil.string(name)) + ctx.out.print(": ") + ctx.out.print(value) + if comma then ctx.out.print(",") + ctx.out.println() + + private def typeParams(params: List[Symbol]): String = + if params.isEmpty then "" else params.map(_.name).mkString("[", ", ", "]") + + private def defaultValue(value: DefaultValue): String = + value match + case DefaultValue.Lit(const) => const match + case Constant.Bool(v) => v.toString + case Constant.Int(v) => v.toString + case Constant.Float(v) => v.toString + case Constant.String(v) => JsonUtil.string(v) + case DefaultValue.Ref(sym) => sym.name + + private def paramSignature(sym: Symbol, default: Option[DefaultValue] = None)(using Definitions): String = + val base = sym.name + ": " + sym.tpe.show + default match + case Some(value) => base + " = " + defaultValue(value) + case None => base + + private def autoCandidateSignature(candidate: AutoCandidate)(using Definitions): String = + candidate.show + + private def autoSignature(sym: Symbol, candidates: List[AutoCandidate])(using Definitions): String = + val candidateText = + if candidates.isEmpty then "" + else " with [" + candidates.map(autoCandidateSignature).mkString(", ") + "]" + paramSignature(sym) + candidateText + + private def receivesSignature(procType: ProcType)(using Definitions): String = + def showEffects(effects: List[Symbol]): String = + if effects.isEmpty then " receives none" + else " receives " + effects.map(_.name).mkString(", ") + + procType.receivesInfo match + case sym: Symbol => + summon[Definitions].index.effectEngine.getKnownEffects(sym) match + case Some(effects) => showEffects(effects) + case None => "" + case effects: List[Symbol] => + showEffects(effects) + + private def procSignature(fd: FunDef)(using Definitions): String = + val procType = fd.symbol.tpe.asProcType + val tparamText = + if fd.tparams.isEmpty then "" + else fd.tparams.map(_.name).mkString("[", ", ", "]") + + val preParamText = + if procType.preParamCount == 0 then "" + else fd.params.take(procType.preParamCount).map(paramSignature(_)).mkString("(", ", ", ")") + + val postParams = fd.params.drop(procType.preParamCount) + val defaultCount = procType.defaults.size + val postParamText = + val split = postParams.size - defaultCount + val withoutDefaults = postParams.take(split).map(paramSignature(_)) + val withDefaults = postParams.drop(split).zip(procType.defaults).map: + case (param, default) => paramSignature(param, Some(default)) + (withoutDefaults ++ withDefaults).mkString("(", ", ", ")") + + val autoText = + if fd.autos.isEmpty then "" + else fd.autos.zip(fd.candidates).map(autoSignature).mkString("(auto ", ", ", ")") + + tparamText + preParamText + postParamText + autoText + ": " + fd.resultType.tpe.show + receivesSignature(procType) + + private def funSignature(fd: FunDef)(using Definitions): String = + val sym = fd.symbol + if sym.is(Flags.Annotation) then + "annotation " + sym.name + procSignature(fd).stripSuffix(": void receives none").stripSuffix(": void") + else if sym.is(Flags.Constructor) then + "def " + sym.owner.name + procSignature(fd) + else + "def " + sym.name + procSignature(fd) + + private def patternSignature(pd: PatDef)(using Definitions): String = + "pattern " + pd.symbol.name + pd.symbol.tpe.asProcType.show + + private def typeSignature(td: TypeDef)(using Definitions): String = + val sym = td.symbol + val info = sym.info + val rhs = + if sym.is(Flags.Defer) then "" + else + info match + case TypeOperatorInfo(_, body, _) => " = " + body.show + case tp: Type => " = " + tp.show + case other => " = " + other.toString + "type " + sym.name + typeParams(td.tparams) + rhs + + private def classSignature(cd: ClassDef): String = + val sym = cd.symbol + val prefix = + if sym.is(Flags.Object) then "object" + else if sym.isInterface then "interface" + else "class" + prefix + " " + sym.name + typeParams(cd.tparams) + + private def interfaceSignature(id: InterfaceDef): String = + "interface " + id.symbol.name + typeParams(id.tparams) + + private def fieldSignature(field: FieldDecl)(using Definitions): String = + val sym = field.symbol + val prefix = if sym.isMutable then "var " else "val " + prefix + sym.name + ": " + field.tpt.tpe.show diff --git a/tests/custom/doc-query/api.jo b/tests/custom/doc-query/api.jo new file mode 100644 index 00000000..47f0b50f --- /dev/null +++ b/tests/custom/doc-query/api.jo @@ -0,0 +1,58 @@ +namespace DocQueryAPI + +//[ Default limit used by context-aware helpers. //] +param defaultLimit: Int + +//[ File path alias used in host calls. //] +type Path = String + +//[ Match positive integers. //] +pattern Positive(value: Int): Partial[Int] = + case value if value > 0 + +//[ Host-backed operations grouped in a section. //] +section HostOps + //[ Return a remote file size. //] + defer def remoteSize(path: Path): Int receives defaultLimit + + //[ Clamp a caller supplied limit. //] + def normalizeLimit(value: Int): Int receives defaultLimit = + if value > 0 then value else defaultLimit +end + +//[ Render a value with auto formatting and context. //] +def describe[T](value: T)(auto show: Show[T] with [[T].toString]): String receives defaultLimit = + show.show(value) + ":" + defaultLimit.toString() + +//[ Shared singleton helpers. //] +object Singleton + //[ Return the singleton label. //] + def label: String = "singleton" +end + +//[ File operations exposed to a Python-backed host. //] +@py.interop +interface FileLike + //[ Read a text file with an optional limit. //] + @py.targetName("read_text") + def readText(path: String, limit: Int = 10): String +end + +//[ A small documented class. //] +class Box + //[ Stored display name. //] + val name: String + + //[ Create a box with a name. //] + def Box(name: String): Box = + this.name = name + + //[ Render a label. //] + def label(prefix: String = "box"): String = prefix + ":" + name +end + +//[ Public helper. //] +def helper(value: Int): Int = value + 1 + +//[ Hidden helper. //] +private def hidden: Int = 42 diff --git a/tests/custom/doc-query/class-views.json.check b/tests/custom/doc-query/class-views.json.check new file mode 100644 index 00000000..dece76ad --- /dev/null +++ b/tests/custom/doc-query/class-views.json.check @@ -0,0 +1,15 @@ +[ + { + "name": "DocQueryCompanion.TaggedValue", + "signature": "class TaggedValue", + "doc": null, + "views": ["Tagged"], + "members": [ + { + "name": "DocQueryCompanion.TaggedValue.", + "signature": "def TaggedValue(): TaggedValue receives none", + "doc": null + } + ] + } +] diff --git a/tests/custom/doc-query/companion-member.json.check b/tests/custom/doc-query/companion-member.json.check new file mode 100644 index 00000000..3962c3ee --- /dev/null +++ b/tests/custom/doc-query/companion-member.json.check @@ -0,0 +1,7 @@ +[ + { + "name": "DocQueryCompanion.Value.label", + "signature": "def label(): String receives none", + "doc": "Return the value label." + } +] diff --git a/tests/custom/doc-query/companion.jo b/tests/custom/doc-query/companion.jo new file mode 100644 index 00000000..08cf8216 --- /dev/null +++ b/tests/custom/doc-query/companion.jo @@ -0,0 +1,20 @@ +namespace DocQueryCompanion + +//[ A value with instance operations. //] +class Value + //[ Return the value label. //] + def label: String = "value" +end + +//[ Companion operations for values. //] +section Value + //[ Create a value. //] + def create: Value = new Value() +end + +interface Tagged +end + +class TaggedValue + view Tagged +end diff --git a/tests/custom/doc-query/describe.json.check b/tests/custom/doc-query/describe.json.check new file mode 100644 index 00000000..4e73a074 --- /dev/null +++ b/tests/custom/doc-query/describe.json.check @@ -0,0 +1,7 @@ +[ + { + "name": "DocQueryAPI.describe", + "signature": "def describe[T](value: T)(auto show: Show[T] with [[T].toString]): String receives defaultLimit", + "doc": "Render a value with auto formatting and context." + } +] diff --git a/tests/custom/doc-query/exact-field.json.check b/tests/custom/doc-query/exact-field.json.check new file mode 100644 index 00000000..8d3ccf25 --- /dev/null +++ b/tests/custom/doc-query/exact-field.json.check @@ -0,0 +1,14 @@ +[ + { + "name": "DocQueryAPI.Box", + "signature": "class Box", + "doc": "A small documented class.", + "members": [ + { + "name": "DocQueryAPI.Box.name", + "signature": "val name: String", + "doc": "Stored display name." + } + ] + } +] diff --git a/tests/custom/doc-query/exact-member.json.check b/tests/custom/doc-query/exact-member.json.check new file mode 100644 index 00000000..e44c8f43 --- /dev/null +++ b/tests/custom/doc-query/exact-member.json.check @@ -0,0 +1,7 @@ +[ + { + "name": "DocQueryAPI.Box.label", + "signature": "def label(prefix: String = \"box\"): String receives none", + "doc": "Render a label." + } +] diff --git a/tests/custom/doc-query/extra.jo b/tests/custom/doc-query/extra.jo new file mode 100644 index 00000000..6b28e34e --- /dev/null +++ b/tests/custom/doc-query/extra.jo @@ -0,0 +1,4 @@ +namespace DocQueryExtra + +//[ Extra documented value used to exercise file selector filtering. //] +def extraValue: Int = 7 diff --git a/tests/custom/doc-query/fields.json.check b/tests/custom/doc-query/fields.json.check new file mode 100644 index 00000000..85330bf4 --- /dev/null +++ b/tests/custom/doc-query/fields.json.check @@ -0,0 +1,6 @@ +[ + { + "name": "DocQueryAPI.describe", + "loc": { "file": "tests/custom/doc-query/api.jo", "line": 24, "end": 25 } + } +] diff --git a/tests/custom/doc-query/interface-member.json.check b/tests/custom/doc-query/interface-member.json.check new file mode 100644 index 00000000..781b2683 --- /dev/null +++ b/tests/custom/doc-query/interface-member.json.check @@ -0,0 +1,7 @@ +[ + { + "name": "DocQueryAPI.FileLike.readText", + "signature": "def readText(path: String, limit: Int = 10): String receives none", + "doc": "Read a text file with an optional limit." + } +] diff --git a/tests/custom/doc-query/lib-exact-member.json.check b/tests/custom/doc-query/lib-exact-member.json.check new file mode 100644 index 00000000..e44c8f43 --- /dev/null +++ b/tests/custom/doc-query/lib-exact-member.json.check @@ -0,0 +1,7 @@ +[ + { + "name": "DocQueryAPI.Box.label", + "signature": "def label(prefix: String = \"box\"): String receives none", + "doc": "Render a label." + } +] diff --git a/tests/custom/doc-query/lib-query.json.check b/tests/custom/doc-query/lib-query.json.check new file mode 100644 index 00000000..28cc3c03 --- /dev/null +++ b/tests/custom/doc-query/lib-query.json.check @@ -0,0 +1,102 @@ +[ + { + "name": "DocQueryAPI", + "signature": "namespace DocQueryAPI", + "doc": null, + "members": [ + { + "name": "DocQueryAPI.defaultLimit", + "signature": "param defaultLimit: Int", + "doc": "Default limit used by context-aware helpers." + }, + { + "name": "DocQueryAPI.Path", + "signature": "type Path = String", + "doc": "File path alias used in host calls." + }, + { + "name": "DocQueryAPI.Positive", + "signature": "pattern Positive(value: Int): Partial[Int] receives none", + "doc": "Match positive integers." + }, + { + "name": "DocQueryAPI.HostOps", + "signature": "section HostOps", + "doc": "Host-backed operations grouped in a section.", + "members": [ + { + "name": "DocQueryAPI.HostOps.remoteSize", + "signature": "def remoteSize(path: Path): Int receives defaultLimit", + "doc": "Return a remote file size." + }, + { + "name": "DocQueryAPI.HostOps.normalizeLimit", + "signature": "def normalizeLimit(value: Int): Int receives defaultLimit", + "doc": "Clamp a caller supplied limit." + } + ] + }, + { + "name": "DocQueryAPI.describe", + "signature": "def describe[T](value: T)(auto show: Show[T] with [[T].toString]): String receives defaultLimit", + "doc": "Render a value with auto formatting and context." + }, + { + "name": "DocQueryAPI.Singleton", + "signature": "object Singleton", + "doc": "Shared singleton helpers.", + "members": [ + { + "name": "DocQueryAPI.Singleton.", + "signature": "def Singleton(): Singleton receives none", + "doc": null + }, + { + "name": "DocQueryAPI.Singleton.label", + "signature": "def label(): String receives none", + "doc": "Return the singleton label." + } + ] + }, + { + "name": "DocQueryAPI.FileLike", + "signature": "interface FileLike", + "doc": "File operations exposed to a Python-backed host.", + "members": [ + { + "name": "DocQueryAPI.FileLike.readText", + "signature": "def readText(path: String, limit: Int = 10): String receives none", + "doc": "Read a text file with an optional limit." + } + ] + }, + { + "name": "DocQueryAPI.Box", + "signature": "class Box", + "doc": "A small documented class.", + "members": [ + { + "name": "DocQueryAPI.Box.name", + "signature": "val name: String", + "doc": "Stored display name." + }, + { + "name": "DocQueryAPI.Box.", + "signature": "def Box(name: String): Box receives none", + "doc": "Create a box with a name." + }, + { + "name": "DocQueryAPI.Box.label", + "signature": "def label(prefix: String = \"box\"): String receives none", + "doc": "Render a label." + } + ] + }, + { + "name": "DocQueryAPI.helper", + "signature": "def helper(value: Int): Int receives none", + "doc": "Public helper." + } + ] + } +] diff --git a/tests/custom/doc-query/structural.json.check b/tests/custom/doc-query/structural.json.check new file mode 100644 index 00000000..28cc3c03 --- /dev/null +++ b/tests/custom/doc-query/structural.json.check @@ -0,0 +1,102 @@ +[ + { + "name": "DocQueryAPI", + "signature": "namespace DocQueryAPI", + "doc": null, + "members": [ + { + "name": "DocQueryAPI.defaultLimit", + "signature": "param defaultLimit: Int", + "doc": "Default limit used by context-aware helpers." + }, + { + "name": "DocQueryAPI.Path", + "signature": "type Path = String", + "doc": "File path alias used in host calls." + }, + { + "name": "DocQueryAPI.Positive", + "signature": "pattern Positive(value: Int): Partial[Int] receives none", + "doc": "Match positive integers." + }, + { + "name": "DocQueryAPI.HostOps", + "signature": "section HostOps", + "doc": "Host-backed operations grouped in a section.", + "members": [ + { + "name": "DocQueryAPI.HostOps.remoteSize", + "signature": "def remoteSize(path: Path): Int receives defaultLimit", + "doc": "Return a remote file size." + }, + { + "name": "DocQueryAPI.HostOps.normalizeLimit", + "signature": "def normalizeLimit(value: Int): Int receives defaultLimit", + "doc": "Clamp a caller supplied limit." + } + ] + }, + { + "name": "DocQueryAPI.describe", + "signature": "def describe[T](value: T)(auto show: Show[T] with [[T].toString]): String receives defaultLimit", + "doc": "Render a value with auto formatting and context." + }, + { + "name": "DocQueryAPI.Singleton", + "signature": "object Singleton", + "doc": "Shared singleton helpers.", + "members": [ + { + "name": "DocQueryAPI.Singleton.", + "signature": "def Singleton(): Singleton receives none", + "doc": null + }, + { + "name": "DocQueryAPI.Singleton.label", + "signature": "def label(): String receives none", + "doc": "Return the singleton label." + } + ] + }, + { + "name": "DocQueryAPI.FileLike", + "signature": "interface FileLike", + "doc": "File operations exposed to a Python-backed host.", + "members": [ + { + "name": "DocQueryAPI.FileLike.readText", + "signature": "def readText(path: String, limit: Int = 10): String receives none", + "doc": "Read a text file with an optional limit." + } + ] + }, + { + "name": "DocQueryAPI.Box", + "signature": "class Box", + "doc": "A small documented class.", + "members": [ + { + "name": "DocQueryAPI.Box.name", + "signature": "val name: String", + "doc": "Stored display name." + }, + { + "name": "DocQueryAPI.Box.", + "signature": "def Box(name: String): Box receives none", + "doc": "Create a box with a name." + }, + { + "name": "DocQueryAPI.Box.label", + "signature": "def label(prefix: String = \"box\"): String receives none", + "doc": "Render a label." + } + ] + }, + { + "name": "DocQueryAPI.helper", + "signature": "def helper(value: Int): Int receives none", + "doc": "Public helper." + } + ] + } +] diff --git a/tests/custom/doc-query/test.sh b/tests/custom/doc-query/test.sh new file mode 100755 index 00000000..8dd8110e --- /dev/null +++ b/tests/custom/doc-query/test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$DIR/../../.." && pwd)" +TEST_NAME="$(basename "$DIR")" +REL_DIR="tests/custom/doc-query" +API_FILE="$REL_DIR/api.jo" +EXTRA_FILE="$REL_DIR/extra.jo" +COMPANION_FILE="$REL_DIR/companion.jo" +SAST_DIR="${TMPDIR:-/tmp}/jo-doc-query-sast-$$" +SAST_LOG="$SAST_DIR.log" +FAIL_LOG="$DIR/fail.log" + +cleanup() { + rm -f "$DIR"/*.json + rm -rf "$SAST_DIR" "$SAST_LOG" "$FAIL_LOG" +} + +finish() { + local status=$? + if [ "$status" -eq 0 ]; then + cleanup + else + echo " failed; temporary JSON/log files were left for inspection" + fi +} + +trap finish EXIT + +echo "Testing $TEST_NAME" + +cleanup + +run_query() { + "$PROJECT_ROOT/bin/jo" compile --use-runtime-api python "$@" +} + +run_plain_query() { + "$PROJECT_ROOT/bin/jo" compile "$@" +} + +expect_fail() { + local out="$1" + shift + if "$@" > "$out" 2>&1; then + echo "Expected command to fail: $*" + exit 1 + fi +} + +cd "$PROJECT_ROOT" + +"$PROJECT_ROOT/bin/jo" compile --sast "$SAST_DIR" --use-runtime-api python "$API_FILE" > "$SAST_LOG" + +run_query --query "DocQueryAPI.*,DocQueryAPI.FileLike.readText" "$API_FILE" > "$DIR/structural.json" +run_query --query "file:$API_FILE" "$API_FILE" "$EXTRA_FILE" > "$DIR/file.json" +run_query --query "file:$PROJECT_ROOT/$API_FILE" "$API_FILE" "$EXTRA_FILE" > "$DIR/file-absolute.json" +run_query --query "DocQueryAPI.describe" "$API_FILE" > "$DIR/describe.json" +run_query --query "DocQueryAPI.describe" --fields loc,name "$API_FILE" > "$DIR/fields.json" +run_query --query "DocQueryAPI.Box.label" "$API_FILE" > "$DIR/exact-member.json" +run_query --query "DocQueryAPI.FileLike.readText" "$API_FILE" > "$DIR/interface-member.json" +run_query --query "DocQueryAPI.Box.name" "$API_FILE" > "$DIR/exact-field.json" +run_query --query "DocQueryCompanion.Value.label" "$COMPANION_FILE" > "$DIR/companion-member.json" +run_query --query "DocQueryCompanion.TaggedValue" "$COMPANION_FILE" > "$DIR/class-views.json" +run_query --query "DocQueryAPI,DocQueryAPI.FileLike.readText" "$API_FILE" > "$DIR/query-implies-json.json" +run_query --lib "$SAST_DIR" --query "DocQueryAPI" > "$DIR/lib-query.json" +run_query --lib "$SAST_DIR" --query "DocQueryAPI.Box.label" > "$DIR/lib-exact-member.json" +run_query --lib "$SAST_DIR" --query "file:api.jo" > "$DIR/lib-file-query.json" + +diff -u "$DIR/structural.json.check" "$DIR/structural.json" +diff -u "$DIR/structural.json.check" "$DIR/file.json" +diff -u "$DIR/structural.json.check" "$DIR/file-absolute.json" +diff -u "$DIR/structural.json.check" "$DIR/query-implies-json.json" +diff -u "$DIR/lib-query.json.check" "$DIR/lib-query.json" +diff -u "$DIR/lib-query.json.check" "$DIR/lib-file-query.json" +diff -u "$DIR/lib-exact-member.json.check" "$DIR/lib-exact-member.json" +diff -u "$DIR/describe.json.check" "$DIR/describe.json" +diff -u "$DIR/fields.json.check" "$DIR/fields.json" +diff -u "$DIR/exact-member.json.check" "$DIR/exact-member.json" +diff -u "$DIR/interface-member.json.check" "$DIR/interface-member.json" +diff -u "$DIR/exact-field.json.check" "$DIR/exact-field.json" +diff -u "$DIR/companion-member.json.check" "$DIR/companion-member.json" +diff -u "$DIR/class-views.json.check" "$DIR/class-views.json" + +expect_fail "$FAIL_LOG" run_plain_query --query "NoSuchSymbol" +grep -q -- "No documentation entries match symbol selector" "$FAIL_LOG" + +expect_fail "$FAIL_LOG" run_plain_query --no-stdlib --query "NoSuchSymbol" +grep -q -- "No documentation entries match symbol selector" "$FAIL_LOG" + +expect_fail "$FAIL_LOG" run_query --query "DocQueryAPI.describe" --fields unknown "$API_FILE" +grep -q -- "Unknown query field: unknown" "$FAIL_LOG" +grep -q -- "Available fields: name,kind,signature,loc,visibility,flags,annotations,doc" "$FAIL_LOG" + +expect_fail "$FAIL_LOG" run_query --query "DocQueryAPI.describe" --fields views "$API_FILE" +grep -q -- "Unknown query field: views" "$FAIL_LOG" + +expect_fail "$FAIL_LOG" run_query --query "DocQueryAPI.describe" --fields , "$API_FILE" +grep -q -- "Option --fields requires at least one field" "$FAIL_LOG" +grep -q -- "Available fields: name,kind,signature,loc,visibility,flags,annotations,doc" "$FAIL_LOG" + +echo " ✓ All tests passed for $TEST_NAME"