fix(project-view): classify large Java and Kotlin files cut off in their body (#1075)

File icons read the first 64 KiB of a JVM file; a larger file cut off inside its body no longer parsed as a declaration and showed the plain Java file icon. When that happens, the declaration header (up to the first type's opening brace, skipping comments, literals and parenthesised arguments) is classified instead.

Fixes #754
This commit is contained in:
l0ng-ai
2026-11-04 01:26:44 +08:00
committed by GitHub
parent bd424a3805
commit a35e434376
4 changed files with 107 additions and 10 deletions
+40 -8
View File
@@ -1,7 +1,7 @@
//! Bounded, read-only JVM presentation for visible files, also served remotely.
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use ide_syntax::{JvmPresentation, Snapshot};
use ide_syntax::{JvmPresentation, Snapshot, StructureService, jvm_type_header};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
@@ -38,6 +38,27 @@ fn prefix(path: &PathBuf) -> std::io::Result<String> {
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// The presentation of the file at `path` from its first [`PREFIX_BYTES`].
fn read_presentation(
structure: &StructureService,
path: &PathBuf,
language: &LanguageId,
) -> IdeResult<JvmPresentation> {
let source = prefix(path)?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let present =
|source: &str| structure.jvm_presentation(&Snapshot::text(language, source), stem, &CancellationToken::new());
let presentation = present(&source)?;
// Cut off inside its body, a large file doesn't parse as a declaration: its header does.
if presentation.kind.is_none()
&& source.len() as u64 >= PREFIX_BYTES
&& let Some(header) = jvm_type_header(&source)
{
return Ok(present(&format!("{header}}}"))?);
}
Ok(presentation)
}
pub async fn jvm_file_presentations(
host: &Host,
window: &str,
@@ -67,13 +88,7 @@ pub async fn jvm_file_presentations(
let presentation = if let Some(hit) = hit {
hit.presentation
} else {
let Ok(source) = prefix(&path) else { continue };
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let presentation = project.structure.jvm_presentation(
&Snapshot::text(&language, &source),
stem,
&CancellationToken::new(),
)?;
let Ok(presentation) = read_presentation(&project.structure, &path, &language) else { continue };
let mut cache = cache.lock().unwrap();
if cache.len() >= MAX_CACHE {
cache.clear();
@@ -99,4 +114,21 @@ mod tests {
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
std::fs::remove_file(path).unwrap();
}
#[test]
fn large_files_cut_off_in_their_body_keep_their_kind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Foo.java");
let mut text = String::from("package a;\n\n@Service\npublic class Foo implements IFoo {\n");
while text.len() < PREFIX_BYTES as usize + 1024 {
// The prefix ends inside one of these comments.
text.push_str(" /**\n * Does something.\n */\n public void m() { call(\"x\"); }\n");
}
std::fs::write(&path, &text).unwrap();
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = LanguageId::new("java");
let presentation = read_presentation(&structure, &path, &java).unwrap();
assert_eq!(presentation.kind, Some(ide_syntax::JvmTypeKind::Class));
}
}
+38
View File
@@ -101,6 +101,44 @@ fn members(node: Node<'_>) -> Vec<Node<'_>> {
out
}
/// The source up to and including the opening brace of the first type's body, skipping
/// comments, string and character literals and parenthesised arguments (annotations, Kotlin
/// constructors). A file cut off somewhere in its body does not parse as a declaration; this
/// header, closed with `}`, still tells what it declares.
pub fn jvm_type_header(source: &str) -> Option<&str> {
let b = source.as_bytes();
let find = |from: usize, needle: &str| source.get(from..)?.find(needle).map(|i| from + i);
let (mut i, mut parens, mut seen_type) = (0, 0usize, false);
while i < b.len() {
match b[i] {
b'/' if b.get(i + 1) == Some(&b'/') => i = find(i, "\n")?,
b'/' if b.get(i + 1) == Some(&b'*') => i = find(i + 2, "*/")? + 1,
b'"' if source[i..].starts_with("\"\"\"") => i = find(i + 3, "\"\"\"")? + 2,
q @ (b'"' | b'\'') => {
i += 1;
while i < b.len() && b[i] != q && b[i] != b'\n' {
i += if b[i] == b'\\' { 2 } else { 1 };
}
}
b'(' => parens += 1,
b')' => parens = parens.saturating_sub(1),
b'{' if parens == 0 && seen_type => return Some(&source[..=i]),
c if c.is_ascii_alphabetic() || c == b'_' => {
let start = i;
while i + 1 < b.len() && (b[i + 1].is_ascii_alphanumeric() || matches!(b[i + 1], b'_' | b'$')) {
i += 1;
}
let word = &source[start..=i];
let declares = matches!(word, "class" | "interface" | "enum" | "record" | "object");
seen_type |= parens == 0 && declares && (start == 0 || b[start - 1] != b'.');
}
_ => {}
}
i += 1;
}
None
}
fn is_type(node: Node) -> bool {
matches!(
node.kind(),
+1 -1
View File
@@ -29,7 +29,7 @@ mod types;
#[cfg(test)]
mod tests;
pub use jvm::{JvmPresentation, JvmTypeKind};
pub use jvm::{JvmPresentation, JvmTypeKind, jvm_type_header};
pub use types::*;
use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range};
+28 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use crate::{JvmPresentation, JvmTypeKind::*};
use crate::{JvmPresentation, JvmTypeKind::*, jvm_type_header};
fn presentation(language: &str, text: &str) -> JvmPresentation {
service().jvm_presentation(&Snapshot::text(&lang(language), text), "Foo", &none()).unwrap()
@@ -76,3 +76,30 @@ fn runnable_methods() {
.runnable
);
}
#[test]
fn a_cut_off_file_is_classified_by_its_type_header() {
for (source, header) in [
(
"package a.record;\n/* class { */\n@Ann({\"}\"}) // interface {\npublic class Foo implements IFoo {\n void m() {",
"package a.record;\n/* class { */\n@Ann({\"}\"}) // interface {\npublic class Foo implements IFoo {",
),
("@interface Foo { String value() default \"{\";", "@interface Foo {"),
(
"class Foo(val a: Map<String, () -> Unit> = mapOf()) : Base() {\n fun f() {",
"class Foo(val a: Map<String, () -> Unit> = mapOf()) : Base() {",
),
(
"record Foo(int x) implements Comparable<Foo> {\n static String S = \"\"\"\n {",
"record Foo(int x) implements Comparable<Foo> {",
),
] {
assert_eq!(jvm_type_header(source), Some(header), "{source}");
}
assert_eq!(jvm_type_header("package a;\nimport b.C;\n/* unterminated class {"), None);
let cut = "@Service\npublic abstract class Foo extends Base {\n void m() {\n /*\n * doc";
assert_eq!(presentation("java", cut).kind, None, "the cut-off text itself doesn't parse");
let closed = format!("{}}}", jvm_type_header(cut).unwrap());
assert_eq!(presentation("java", &closed).kind, Some(AbstractClass));
}